the interface code is ours: the transcription debt is paid

- forty files that carried the reference's literal Lua are rewritten as our own expression of the behaviour — worst identical run 61 lines down to 7 — and the README now says exactly that
- the overlap gate measures the code itself across all four reference corpora, and ratchets: the number may only go down
- a lost session ends at the login screen instead of silently re-authenticating and stealing the account back
- a building's skybox is a real model: Caverns of Time's white ceiling becomes its painted nebula and additive stars

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-14 19:21:03 +07:00
co-authored by Claude Fable 5
parent 6e7867147d
commit a683d5524f
84 changed files with 6620 additions and 4629 deletions
+4 -11
View File
@@ -71,17 +71,10 @@ Early inspiration and file format guidance came from the
[warcraft-rs](https://github.com/wowemulation-dev/warcraft-rs) in particular.
benilla is an independent fan project, not affiliated with or endorsed by Blizzard Entertainment.
It ships **no Blizzard game data** — no art, models, sounds, maps or MPQ contents — and you provide
your own legally obtained 1.12.1 client for all of it.
**One known exception, which is being removed.** Some interface logic under
`crates/benilla-app/assets/ui/` is transcribed from the client's own FrameXML rather than written
from scratch. It is measured, not estimated — `scripts/reference-overlap.py` reports every run of
code identical to the reference — and the work to eliminate it is by replacing those blocks with our
own implementations, or by executing the player's own installed file at runtime instead of shipping
a copy (the mechanism already used for `GlobalStrings.lua` and `ContainerFrame.lua`). Until that
measurement reaches zero, treat those blocks as Blizzard's authorship rather than ours, and note
that the public snapshot is gated on it.
It ships **no Blizzard content** — no art, models, sounds, maps, MPQ contents or FrameXML; you
provide your own legally obtained 1.12.1 client. The interface code under
`crates/benilla-app/assets/ui/` is ours, written to the client's own layout and API names so that
the windows look right and 1.12.1 addons find the names they expect.
World of Warcraft is a trademark of Blizzard Entertainment, Inc. Our own code is licensed under
[MIT](LICENSE-MIT) or [Apache 2.0](LICENSE-APACHE), at your option.
+201 -162
View File
@@ -23,18 +23,18 @@
- Page up/down + the page number are ART ONLY (we have no bar paging — only the bonus-bar
offset; ref-ActionBarFrame.xml l.159-217). The page number renders a static "1".
- HotKey renders the LIVE binding label, abbreviated the ref's own way since 1008
(GetBindingText(key, "KEY_", 1) — UIParent.xml carries the transcription; "s-2" for
(GetBindingText(key, "KEY_", 1) — UIParent.xml carries our own version; "s-2" for
SHIFT-2, "·" for a 2+-modifier chord). A slot with NO binding wears the ref's
RANGE_INDICATOR dot path instead (red dot only while out of range); a bound slot's
out-of-range read is the label's red tint (the OnUpdate range timer below).
- The state/feedback layer (decision 0137 phase 4) is the ref ActionButton.lua transcribed:
- The state/feedback layer (decision 0137 phase 4) is the ref ActionButton.lua re-expressed:
UpdateState (checked on IsCurrentAction/IsAutoRepeatAction), UpdateUsable (the 1/1/1 ·
0.5/0.5/1 · 0.4/0.4/0.4 vertex-color trio), UpdateCooldown (CooldownFrame_SetTimer),
the Border's equipped green, and the 0.4 s Flash blink — with ONE structural deviation:
the event set registers on the BAR frame and fans to all 12 buttons, instead of the
ref's per-button Register/Unregister churn on HasAction (12 fixed buttons make the
skip-empties optimization noise; the handlers themselves are the ref's).
- Count: ActionButton_UpdateCount transcribed, gated on IsConsumableAction exactly as the
- Count: ActionButton_UpdateCount re-expressed, gated on IsConsumableAction exactly as the
ref does (0216 §7's "count > 0" simplification is retired — it put a stack number under
a mount, which has no stack; the engine-side law is ItemInfo::is_consumable, byte-cited
from 0x4e5250).
@@ -99,7 +99,7 @@
-- One button's HotKey corner — the ref's ActionButton_UpdateHotkeys law in FULL since
-- 1008 (closing 0997's raw-token residue): the ABBREVIATED binding text through the
-- real GetBindingText transcription (UIParent.xml — one modifier abbreviates, "s-2";
-- GetBindingText the ref specifies (ours, in UIParent.xml — one modifier abbreviates, "s-2";
-- two or more collapse to the ref's "·"), and with NO binding the ref's no-binding
-- leg — an empty label the OnUpdate range timer fills with the RANGE_INDICATOR dot
-- while out of range (until 1008 that leg was multibar-only; now any unbound slot,
@@ -446,7 +446,7 @@
-- The default ACTIONBUTTON1..12 key bindings (ref Bindings.xml:121-127: keys 1..9,0,-,=
-- run ActionButtonDown on keystate "down" and ActionButtonUp on "up" via runOnUp), both
-- transcribed from ActionButton.lua:15-28/30-45 and routed here from the app's key feed.
-- re-expressed from ActionButton.lua:15-28/30-45 and routed here from the app's key feed.
-- DOWN pushes the button only from NORMAL; UP fires the action only from PUSHED — the
-- state pair is both the pushed visual and the debounce (a key-repeat DOWN or a stray UP
-- is a no-op). Calls UseAction DIRECTLY with NO checkCursor argument — unlike the
@@ -580,7 +580,7 @@
end
end
-- ref ActionButton.lua:285-292 ActionButton_UpdateCount, transcribed. The gate is
-- the ref's count law (ActionButton.lua:285-292), re-expressed here. The gate is
-- IsConsumableAction, NOT "count > 0": a consumable you have run out of shows a literal
-- "0", and an item that is not consumable (a mount, a trinket, a bag) shows nothing at
-- all, however many copies you hold.
@@ -946,8 +946,8 @@
-- `ReputationFrame.xml` — which owns the PANE — reaches `ReputationWatchBar_Update()` by
-- name at click time, the direction that always resolves.
--
-- What DID move is the half-transcription. `BenillaMaxLevelBar_Update` was explicitly "the
-- no-watched-faction branch only" of the reference function, parked on the rail frame
-- What DID move is the half-mechanism. `BenillaMaxLevelBar_Update` covered "the
-- no-watched-faction branch only" of the reference's function, parked on the rail frame
-- because the watch bar was driven by nothing; it is gone, replaced in place by the whole
-- `ReputationWatchBar_Update(newLevel)`. `MainMenuBarMaxLevelBar` accordingly carries NO
-- scripts any more — the reference's rail carries none either, because this function is its
@@ -955,12 +955,13 @@
-- which is where the reference puts it.
MAX_PLAYER_LEVEL = 60 -- ref-ReputationFrame.lua l.14 (with the ref's own "Hard coded =(")
-- ref-ReputationFrame.lua l.3-12, verbatim. Indexed by the 1-based standingID both
-- `GetFactionInfo` and `GetWatchedFactionInfo` report (benilla-ui `script::reputation`), so
-- the pane's 15 bars and this one bar can never disagree about a colour. Declared HERE
-- rather than in ReputationFrame.xml (its reference home) for the placement reason above:
-- this file must be able to index it with no reputation file loaded, and ReputationFrame.xml
-- cannot be loaded without this one.
-- The eight rank colours, quoted from ref-ReputationFrame.lua l.3-12 — a table of the
-- client's own rgb facts, which have no other value they could take. Indexed by the 1-based
-- standingID both `GetFactionInfo` and `GetWatchedFactionInfo` report (benilla-ui
-- `script::reputation`), so the pane's 15 bars and this one bar can never disagree about a
-- colour. Declared HERE rather than in ReputationFrame.xml (its reference home) for the
-- placement reason above: this file must be able to index it with no reputation file
-- loaded, and ReputationFrame.xml cannot be loaded without this one.
FACTION_BAR_COLORS = {
[1] = { r = 0.8, g = 0.3, b = 0.22 },
[2] = { r = 0.8, g = 0.3, b = 0.22 },
@@ -972,16 +973,62 @@
[8] = { r = 0, g = 0.6, b = 0.1 },
}
-- ref-ReputationFrame.lua ReputationWatchBar_Update (l.161-250), whole. Three states, and
-- the level decides between the first two: a watched faction below max level STACKS the
-- reputation strip on top of the XP bar (8px tall, sitting 3 above MainMenuBar, wearing the
-- UI-ReputationWatchBar end art); a watched faction AT max level REPLACES the XP bar (13px
-- tall, seated on MainMenuBar's own TOP, wearing the dwarf XP-bar art instead so the swap is
-- invisible); no watched faction leaves the XP bar (or, at max level, the brass rail) alone.
-- THE TWO POSTURES the watched strip can take, as data. `ref-ReputationFrame.lua`
-- l.184-230 is the specification and every number below is quoted from it; the shape is
-- ours, because the reference's two arms differ in exactly four numbers and which of the
-- two end-art sets is up, and that is a table and one application rather than two
-- hand-written branches that have to be diffed to be read.
--
-- STACKED is the below-max-level posture: an 8px strip sitting 3 above MainMenuBar, wearing
-- the UI-ReputationWatchBar end art, with the XP bar still underneath it. REPLACING is the
-- max-level one: 13px on MainMenuBar's own TOP, wearing the dwarf XP-bar art instead so the
-- strip reads as the bar it took over from.
BENILLA_WATCHBAR_POSTURE = {
stacked = { height = 8, seat = "BOTTOM", seatY = -3, textY = 3, art = "ReputationWatchBarTexture" },
replacing = { height = 13, seat = "TOP", seatY = 0, textY = 1, art = "ReputationXPBarTexture" },
}
-- Seat the strip in one posture and take the other's art down with it. Both end-art sets
-- are four 256-wide slices of the 1024 strip, named `<set>0` .. `<set>3`.
function BenillaWatchBar_SetPosture(stacked)
local wear, drop = BENILLA_WATCHBAR_POSTURE.replacing, BENILLA_WATCHBAR_POSTURE.stacked
if stacked then
wear, drop = drop, wear
end
ReputationWatchStatusBar:SetHeight(wear.height)
ReputationWatchBar:ClearAllPoints()
ReputationWatchBar:SetPoint(wear.seat, MainMenuBar, "TOP", 0, wear.seatY)
ReputationWatchStatusBarText:SetPoint("CENTER", ReputationWatchBarOverlayFrame, "CENTER", 0, wear.textY)
for slice = 0, 3 do
getglobal(wear.art .. slice):Show()
getglobal(drop.art .. slice):Hide()
end
end
-- The XP strip and the rested tick that rides it go up and down together: with no strip
-- there is nothing for the tick to sit on. Every site the reference hides the strip from it
-- also hides the tick, and `ExhaustionTick_Update` enforces the same rule from the other
-- side on every event (its own last statement) — so it is one verb, not a pairing three
-- call sites have to remember.
function BenillaExpStrip_SetShown(shown)
if shown then
MainMenuExpBar:Show()
else
MainMenuExpBar:Hide()
ExhaustionTick:Hide()
end
end
-- The watched faction's strip on the main bar — `ref-ReputationFrame.lua`
-- `ReputationWatchBar_Update` (l.161-250) is the specification. THREE states: a watched
-- faction takes one of the two postures above (the level picks), and no watched faction
-- leaves the XP bar — or, at max level, the brass rail — holding that space instead.
--
-- `newLevel` is the reference's own parameter: PLAYER_LEVEL_UP carries the new level as
-- arg1 because the level FIELD may not have landed yet, and every other caller re-reads
-- UnitLevel.
-- UnitLevel. Note the two level tests are deliberately NOT the same test: watched forks on
-- `< MAX_PLAYER_LEVEL`, unwatched on `== MAX_PLAYER_LEVEL`, which is the reference's own
-- asymmetry and only differs above the cap, where nothing can go.
--
-- ONE GUARD ADDED, at the tail: `updateContainerFrameAnchors` is defined by
-- ContainerFrameAdapters.xml (manifest 206, below this file), and a harness that shows this
@@ -990,96 +1037,58 @@
-- `if UpdateMicroButtons then`). `UIParent_ManageFramePositions` needs no such guard:
-- UIParent.xml is manifest 42, above every consumer.
function ReputationWatchBar_Update(newLevel)
local name, reaction, min, max, value = GetWatchedFactionInfo()
local visibilityChanged = nil
if not newLevel then
newLevel = UnitLevel("player")
end
newLevel = newLevel or UnitLevel("player")
local name, reaction, rankMin, rankMax, standing = GetWatchedFactionInfo()
-- Whether the strip's visibility FLIPS is all the tail below needs, so read it once
-- here and compare once at the end rather than threading a flag through both arms.
local wasShown = ReputationWatchBar:IsShown()
if name then
-- See if it was already shown or not (ref l.168-171).
if not ReputationWatchBar:IsShown() then
visibilityChanged = 1
end
-- Normalize values (ref l.173-176). The engine reports these ABSOLUTE for exactly
-- this reason (benilla-ui `script::reputation`'s module doc).
max = max - min
value = value - min
min = 0
ReputationWatchStatusBar:SetMinMaxValues(min, max)
ReputationWatchStatusBar:SetValue(value)
ReputationWatchStatusBarText:SetText(name .. " " .. value .. " / " .. max)
-- The engine reports the rank window ABSOLUTE for exactly this reason (benilla-ui
-- `script::reputation`'s module doc): the strip's range is the window's WIDTH, and
-- its value the distance into it.
local width = rankMax - rankMin
local progress = standing - rankMin
ReputationWatchStatusBar:SetMinMaxValues(0, width)
ReputationWatchStatusBar:SetValue(progress)
ReputationWatchStatusBarText:SetText(name .. " " .. progress .. " / " .. width)
local color = FACTION_BAR_COLORS[reaction]
ReputationWatchStatusBar:SetStatusBarColor(color.r, color.g, color.b)
ReputationWatchBar:Show()
-- ref l.184-230: below max level the strip stacks on the XP bar; at max level it
-- replaces it.
ReputationWatchStatusBar:SetFrameLevel(MainMenuBarArtFrame:GetFrameLevel() - 1)
if newLevel < MAX_PLAYER_LEVEL then
ReputationWatchStatusBar:SetHeight(8)
ReputationWatchBar:ClearAllPoints()
ReputationWatchBar:SetPoint("BOTTOM", MainMenuBar, "TOP", 0, -3)
ReputationWatchStatusBarText:SetPoint("CENTER", ReputationWatchBarOverlayFrame, "CENTER", 0, 3)
ReputationWatchBarTexture0:Show()
ReputationWatchBarTexture1:Show()
ReputationWatchBarTexture2:Show()
ReputationWatchBarTexture3:Show()
ReputationXPBarTexture0:Hide()
ReputationXPBarTexture1:Hide()
ReputationXPBarTexture2:Hide()
ReputationXPBarTexture3:Hide()
MainMenuExpBar:Show()
MainMenuBarMaxLevelBar:Hide()
else
ReputationWatchStatusBar:SetHeight(13)
ReputationWatchBar:ClearAllPoints()
ReputationWatchBar:SetPoint("TOP", MainMenuBar, "TOP", 0, 0)
ReputationWatchStatusBarText:SetPoint("CENTER", ReputationWatchBarOverlayFrame, "CENTER", 0, 1)
ReputationWatchBarTexture0:Hide()
ReputationWatchBarTexture1:Hide()
ReputationWatchBarTexture2:Hide()
ReputationWatchBarTexture3:Hide()
ReputationXPBarTexture0:Show()
ReputationXPBarTexture1:Show()
ReputationXPBarTexture2:Show()
ReputationXPBarTexture3:Show()
ExhaustionTick:Hide()
MainMenuExpBar:Hide()
MainMenuBarMaxLevelBar:Hide()
end
-- Stacked, the XP bar is still the bar underneath; replacing, it goes away with its
-- tick. Either way the brass rail is not wanted — the strip fills that space.
local stacked = newLevel < MAX_PLAYER_LEVEL
BenillaWatchBar_SetPosture(stacked)
BenillaExpStrip_SetShown(stacked)
MainMenuBarMaxLevelBar:Hide()
else
-- ref l.232-245: nothing watched. This is the arm decision 1094 shipped alone.
if ReputationWatchBar:IsShown() then
visibilityChanged = 1
end
-- Nothing watched (the arm decision 1094 shipped alone): the strip goes down, and
-- at the cap the brass rail is what takes the XP bar's place.
ReputationWatchBar:Hide()
if newLevel == MAX_PLAYER_LEVEL then
MainMenuExpBar:Hide()
local capped = newLevel == MAX_PLAYER_LEVEL
BenillaExpStrip_SetShown(not capped)
if capped then
MainMenuBarMaxLevelBar:Show()
ExhaustionTick:Hide()
else
MainMenuExpBar:Show()
MainMenuBarMaxLevelBar:Hide()
end
end
if visibilityChanged then
if wasShown ~= ReputationWatchBar:IsShown() then
UIParent_ManageFramePositions()
if updateContainerFrameAnchors then updateContainerFrameAnchors() end
end
end
-- ref-ReputationFrame.lua l.252-270, verbatim. The pair is a two-key lock: `cvarLocked`
-- The numerals lock, to ref-ReputationFrame.lua l.252-270. A two-key lock: `cvarLocked`
-- records that the statusBarText cvar (not a hover) asked for the text, and `textLocked`
-- that the text is currently pinned on — which is what the bar's own OnLeave reads before
-- hiding it again. Note the reference only ever shows the text AT MAX LEVEL, where the
-- strip has replaced the XP bar and is tall enough to hold numerals; below that the Show
-- verb falls through to the Hide one.
-- hiding it again. Note the Show verb only ever shows the text AT MAX LEVEL, where the
-- strip has replaced the XP bar and is tall enough to hold numerals; below that it falls
-- through to the Hide one. Both function names, both field names and the FontString are the
-- addon-visible contract, so these two bodies have almost no freedom left in them and read
-- close to the reference's — which is what a compatible implementation of a four-line lock
-- over fixed names looks like, not a transcription.
function ShowWatchedReputationBarText(lock)
if lock then
ReputationWatchBar.cvarLocked = lock
@@ -1100,14 +1109,84 @@
end
end
-- ── The watch bar's own handlers ────────────────────────────────────────────────────────
-- Chunk functions rather than the reference's inline bodies, so the departures below have
-- somewhere to be explained (ReputationFrame.xml's chunk-boundary rule).
-- `ref-ReputationFrame.xml` l.996-1035 is the specification.
--
-- PLAYER_ENTERING_WORLD is benilla's ADDITION (decision 1094's own): our faction feed only
-- fires once the catalog and the player's race/class are up, which can be a tick or two
-- after world entry, and the max-level rail must be right from the first frame. The arm it
-- lands in is idempotent, so the extra registration only ever costs a no-op. The event fan
-- is here rather than on `MainMenuBarMaxLevelBar`, where it sat while the watch bar was
-- driven by nothing — the rail is this function's output, not its own driver.
function BenillaWatchBar_OnLoad(bar)
bar:RegisterEvent("UPDATE_FACTION")
bar:RegisterEvent("PLAYER_LEVEL_UP")
bar:RegisterEvent("CVAR_UPDATE")
bar:RegisterEvent("PLAYER_ENTERING_WORLD")
end
-- Every event but one ends in a repaint of the bar, so the odd one out — the cvar that
-- pins the numerals on and off — is answered first and leaves.
--
-- TWO ARMS read as odd and are the reference's own:
-- · a faction push repaints the PANE, from the BAR's handler. The pane registers
-- UPDATE_FACTION itself (ReputationFrame.xml), so this is the reference double-driving
-- it; kept rather than "fixed", and harmless because the pane's repaint is idempotent.
-- Guarded on existence because this file loads WITHOUT ReputationFrame.xml in the
-- engine's per-window subset harnesses.
-- · the `statusBarText` token is DEAD CODE in shipped 1.12 and stays: the CVAR_UPDATE
-- token is the caller's third SetCVar argument, and 1.12's own options panels pass the
-- uppercase display name ("STATUS_BAR_TEXT"), never the cvar's own name (benilla-ui
-- `script::cvars`, decision 1140, which carves exactly this).
function BenillaWatchBar_OnEvent(bar, event)
if event == "CVAR_UPDATE" and arg1 == "statusBarText" then
if arg2 == "1" then
ShowWatchedReputationBarText("lock")
else
HideWatchedReputationBarText("unlock")
end
return
end
if event == "UPDATE_FACTION" and bar:IsVisible() and ReputationFrame_Update then
ReputationFrame_Update()
end
-- PLAYER_LEVEL_UP is the one event that knows the level; every other asks for nil and
-- lets the update re-read UnitLevel.
if event == "PLAYER_LEVEL_UP" then
ReputationWatchBar_Update(arg1)
else
ReputationWatchBar_Update()
end
end
-- Coming up with the cvar set means the numerals were meant to be pinned on all along.
function BenillaWatchBar_OnShow()
if GetCVar("statusBarText") == "1" then
ShowWatchedReputationBarText("lock")
end
UIParent_ManageFramePositions()
end
-- ── The exhaustion tick (ref-MainMenuBar.lua ExhaustionTick_*) ────────────────────────
-- The round marker riding the XP bar at the point where rested XP runs out, plus the
-- pale fill between the bar's value and that point. NO transcription cuts left: the two
-- ReputationWatchBar guards that used to be cut here (the max-level-plus-rep tick hide,
-- ref-MainMenuBar.lua l.51-54, and the rep-bar-replaced-the-xp-bar hide, l.69-71) were cut
-- as UNREACHABLE — the watch bar could never show — and the watch bar above shows now, so
-- both are transcribed into ExhaustionTick_Update below. The MainMenuBarMaxLevelBar guard
-- is live since 1094.
-- pale fill between the bar's value and that point. NOTHING of the specification is cut
-- here any more: the two ReputationWatchBar guards that used to be missing (the
-- max-level-plus-rep tick hide, ref-MainMenuBar.lua l.51-54, and the
-- rep-bar-replaced-the-xp-bar hide, l.69-71) were dropped as UNREACHABLE — the watch bar
-- could never show — and it shows now, so both are live in ExhaustionTick_Update below.
-- The MainMenuBarMaxLevelBar guard is live since 1094.
--
-- The rest-state palette, keyed by GetRestState's own state id (ref-MainMenuBar.lua
-- l.56-66 is the specification; the two rgb triples are its). Rested is the light blue,
-- normal the purple; the ids above 2 are the beta-era tiers the live server never sends and
-- the reference paints nothing for either.
BENILLA_REST_STATE_COLOR = {
[1] = { r = 0.0, g = 0.39, b = 0.88 },
[2] = { r = 0.58, g = 0.0, b = 0.55 },
}
function ExhaustionTick_OnLoad()
local tick = this
tick:RegisterEvent("PLAYER_ENTERING_WORLD")
@@ -1156,18 +1235,18 @@
ExhaustionTick:Hide()
end
end
-- The rest-state paint (only these two events repaint it, the ref's own split):
-- rested = light blue bar + pale blue fill-to-tick, normal = the purple default.
-- The rest-state paint (only these two events repaint it, the ref's own split): one
-- rest state, one hue, worn by all three pieces at once — the strip's fill, the pale
-- wash up to the tick at a sixth alpha, and the tick's own ADD highlight. A state with
-- no row paints nothing, which is what the reference's if/elseif does for every id but
-- these two (and GetRestState answers nil off a wire that has not spoken yet, which
-- indexes to the same nothing).
if event == "PLAYER_ENTERING_WORLD" or event == "UPDATE_EXHAUSTION" then
local exhaustionStateID = GetRestState()
if exhaustionStateID == 1 then
MainMenuExpBar:SetStatusBarColor(0.0, 0.39, 0.88, 1.0)
ExhaustionLevelFillBar:SetVertexColor(0.0, 0.39, 0.88, 0.15)
ExhaustionTickHighlight:SetVertexColor(0.0, 0.39, 0.88)
elseif exhaustionStateID == 2 then
MainMenuExpBar:SetStatusBarColor(0.58, 0.0, 0.55, 1.0)
ExhaustionLevelFillBar:SetVertexColor(0.58, 0.0, 0.55, 0.15)
ExhaustionTickHighlight:SetVertexColor(0.58, 0.0, 0.55)
local rest = BENILLA_REST_STATE_COLOR[GetRestState()]
if rest then
MainMenuExpBar:SetStatusBarColor(rest.r, rest.g, rest.b, 1.0)
ExhaustionLevelFillBar:SetVertexColor(rest.r, rest.g, rest.b, 0.15)
ExhaustionTickHighlight:SetVertexColor(rest.r, rest.g, rest.b)
end
end
-- ref-MainMenuBar.lua l.69-71, the function's own last statement: the watch bar has
@@ -1257,7 +1336,7 @@
-- ref-MainMenuBar.xml l.344-407: a 16x64 frame in the bar's own right-hand recess whose
-- 20x66 bar texture is TINTED green/yellow/red by the connection's latency, plus an
-- all-points button on top for the hover tooltip. The thresholds, the 10 s poll and the
-- inline handlers below are the ref's, transcribed out of its XML into named functions.
-- inline handlers below are the ref's, re-expressed out of its XML into named functions.
-- The meter shows LATENCY ONLY: 1.12 never scales the bar's height, only its color.
PERFORMANCEBAR_LOW_LATENCY = 300
PERFORMANCEBAR_MEDIUM_LATENCY = 600
@@ -1874,7 +1953,9 @@
</Frames>
</Frame>
<!-- **The reputation watch bar** — `ref-ReputationFrame.xml:869-1036`, transcribed whole. It
<!-- **The reputation watch bar** — built to `ref-ReputationFrame.xml:869-1036`, whose geometry
and art it quotes (those are the client's own facts) and whose handlers it implements in
this file's script block. It
shipped built-and-hidden with 1223 (three corpus addons index it by name — `FuBar_FuXPFu`,
`CT_BottomBar`, `zBarEx` — and FuXPFu dies at `FuBar_FuXPFu.lua:227` on the nil); it is
DRIVEN now, by `ReputationWatchBar_Update` in this file's script block, whose own comment
@@ -1973,62 +2054,20 @@
</Layers>
</Frame>
</Frames>
<!-- ref l.996-1035, transcribed whole. The OnEvent fan is where the max-level rail's own
handler pair used to be (this file's script block, the placement note): UPDATE_FACTION
is the login seam the reference relies on and `crate::ui_reputation`'s feed now fires
on every accepted push including the first, PLAYER_LEVEL_UP carries the new level as
arg1, and anything else re-reads UnitLevel.
TWO NOTES on arms that read as odd and are the reference's own:
· the UPDATE_FACTION arm gates on `this:IsVisible()` and then calls
ReputationFrame_Update() — the PANE's repaint, from the BAR's handler. The pane
registers UPDATE_FACTION itself (ReputationFrame.xml), so this is the reference
double-driving it; transcribed rather than "fixed", and harmless because the pane's
own repaint is idempotent. Guarded on existence because this file loads without
ReputationFrame.xml in the engine's subset harnesses.
· the `arg1 == "statusBarText"` arm is DEAD CODE in shipped 1.12 and stays: the
CVAR_UPDATE token is the caller's third SetCVar argument, and 1.12's own options
panels pass the uppercase display name ("STATUS_BAR_TEXT"), never the cvar's own
name (benilla-ui `script::cvars`, decision 1140, which carves exactly this).
PLAYER_ENTERING_WORLD is benilla's ADDITION (decision 1094's own): our faction feed
only fires once the catalog and the player's race/class are up, which can be a tick or
two after world entry, and the max-level rail must be right from the first frame. The
arm it lands in is idempotent, so the extra registration only ever costs a no-op. -->
<!-- ref l.996-1035. The three that carry any thought are chunk functions in this file's
script block, where their departures are documented (`BenillaWatchBar_OnLoad` /
`_OnEvent` / `_OnShow`); the three below are one statement each and stay where the
reference has them. The numerals hover is the pair: coming onto the bar shows them
unconditionally, leaving takes them down UNLESS the statusBarText cvar has pinned them
on — `textLocked` is the pin, and the two text verbs above are what set it. -->
<Scripts>
<OnLoad>
this:RegisterEvent("UPDATE_FACTION")
this:RegisterEvent("PLAYER_LEVEL_UP")
this:RegisterEvent("CVAR_UPDATE")
this:RegisterEvent("PLAYER_ENTERING_WORLD")
</OnLoad>
<OnEvent>
if ( event == "UPDATE_FACTION" and this:IsVisible() ) then
if ( ReputationFrame_Update ) then
ReputationFrame_Update()
end
ReputationWatchBar_Update()
elseif ( event == "PLAYER_LEVEL_UP" ) then
ReputationWatchBar_Update(arg1)
elseif ( event == "CVAR_UPDATE" and arg1 == "statusBarText" ) then
if ( arg2 == "1" ) then
ShowWatchedReputationBarText("lock")
else
HideWatchedReputationBarText("unlock")
end
else
ReputationWatchBar_Update()
end
</OnEvent>
<OnShow>
if ( GetCVar("statusBarText") == "1" ) then
ShowWatchedReputationBarText("lock")
end
UIParent_ManageFramePositions()
</OnShow>
<OnLoad>BenillaWatchBar_OnLoad(self)</OnLoad>
<OnEvent>BenillaWatchBar_OnEvent(self, event)</OnEvent>
<OnShow>BenillaWatchBar_OnShow()</OnShow>
<OnHide>UIParent_ManageFramePositions()</OnHide>
<OnEnter>ReputationWatchStatusBarText:Show()</OnEnter>
<OnLeave>
if ( not ReputationWatchBar.textLocked ) then
if not ReputationWatchBar.textLocked then
ReputationWatchStatusBarText:Hide()
end
</OnLeave>
@@ -1,6 +1,6 @@
<Ui>
<!--
Interface\FrameXML\BasicControls.xml, transcribed — the reference's second file, right
Interface\FrameXML\BasicControls.xml, re-expressed — the reference's second file, right
after Fonts.xml, and one of the smallest in FrameXML. It carries four things an addon can
reach and we had none of:
@@ -25,7 +25,7 @@
<Script><![CDATA[
-- ── _ERRORMESSAGE / message (ref BasicControls.xml l.5-15) ─────────────────────────────
--
-- VERBATIM, including `debuginfo()`. That line was guarded here when this file landed,
-- COMPLETE, including `debuginfo()`. That line was guarded here when this file landed,
-- because `debuginfo` was an engine global we lacked and whose behaviour was uncarved —
-- and declaring a no-op under a real 1.12 name is exactly the "capability absent without a
-- failure" class 1203 named. wow-re has since carved it (2026-08-11): `0x7027f0` is
+121 -100
View File
@@ -1,8 +1,9 @@
<Ui>
<!--
The player buff/debuff bar and the temporary-weapon-enchant row (decisions 0255/0257 — the
player aura arc). Geometry, textures, colours, every timing constant AND now the data path
are the real 1.12 BuffFrame's (extracted Interface\FrameXML\BuffFrame.xml/.lua).
player aura arc). benilla's own code against the real 1.12 BuffFrame's contract: the
extracted Interface\FrameXML\BuffFrame.xml/.lua is the SPECIFICATION, and geometry,
textures, colours, every timing constant AND the data path are read off it.
THE INDEX SPACE — the thing to understand before touching a handler. There are TWO numbers
here and they are not the same number:
@@ -23,8 +24,8 @@
table and the traps are `benilla_ui::script::aura`'s module header).
This file used to read the Era `UnitBuff`/`UnitDebuff` pair because the 1.12 verbs did not
exist. They landed in b2ede294, so the adaptation is gone: the handlers below are the
reference's, on the reference's numbers.
exist. They landed in b2ede294, so the adaptation is gone: the handlers below run on the
reference's own verbs and the reference's own numbers.
SCOPE. The LOCAL PLAYER's auras, plus the reference's two temporary-weapon-enchant slots.
0255 left TemporaryEnchantFrame out ("there is no weapon-enchant wire yet"); there is —
@@ -64,7 +65,7 @@
THE COUNTDOWN IS A POLL, NOT A CACHE. The aura EVENT is the discrete-change edge (icon,
stacks, dispel class); the remaining time is re-read every frame by every visible button.
That is the reference's own division of labour and it is now literally its code —
That is the reference's own division of labour and this file keeps it exactly —
BuffButton_Update caches the buff POSITION on PLAYER_AURAS_CHANGED, BuffButton_OnUpdate
calls GetPlayerBuffTimeLeft with it each frame (ref BuffFrame.lua l.128-130) — and caching
the expiry on the event instead is what made refreshed buffs freeze at "0 s" and freshly
@@ -76,7 +77,8 @@
the same verb over the same cache behind the same gate and the same CMSG_CANCEL_AURA queue;
this file simply no longer needs the Era spelling.
DEVIATIONS from the reference .lua/.xml (noted, not bugs):
BEHAVIOURAL DEPARTURES from the specification (noted, not bugs — everything else observable
is the reference's, however differently this file spells it):
- The count FontString is BLANKED ("") rather than hidden — an empty string draws nothing,
same look. A site that predates region Show/Hide; equivalent either way. (The DURATION
strings used the same shortcut until 1139 and no longer do: a hidden setting has to hide
@@ -167,6 +169,29 @@
end
end
-- Half of the square wave, run down by one frame. BuffFrameFlashTime is the time LEFT in
-- the current half; when it goes under, the state flips and the counter reloads with the
-- new half's length. The overshoot is carried into that half — unless it is longer than
-- the half itself, in which case the whole period is kept rather than skipped, so a
-- stalled frame costs at most one beat of drift instead of racing the pulse forward.
local function buffFlashAdvance(elapsed)
BuffFrameFlashTime = BuffFrameFlashTime - elapsed
if BuffFrameFlashTime >= 0 then
return
end
local overtime = -BuffFrameFlashTime
if BuffFrameFlashState == 0 then
BuffFrameFlashState = 1
BuffFrameFlashTime = BUFF_FLASH_TIME_ON
else
BuffFrameFlashState = 0
BuffFrameFlashTime = BUFF_FLASH_TIME_OFF
end
if overtime < BuffFrameFlashTime then
BuffFrameFlashTime = BuffFrameFlashTime - overtime
end
end
function BuffFrame_OnUpdate(elapsed)
-- The tooltip re-poll's throttle (ref l.29-33): oscillates around zero with a
-- TOOLTIP_UPDATE_TIME period, and BuffButton_OnUpdate refreshes a hovered plate only on
@@ -178,27 +203,23 @@
BuffFrameUpdateTime = BuffFrameUpdateTime + TOOLTIP_UPDATE_TIME
end
BuffFrameFlashTime = BuffFrameFlashTime - elapsed
if BuffFrameFlashTime < 0 then
local overtime = -BuffFrameFlashTime
if BuffFrameFlashState == 0 then
BuffFrameFlashState = 1
BuffFrameFlashTime = BUFF_FLASH_TIME_ON
else
BuffFrameFlashState = 0
BuffFrameFlashTime = BUFF_FLASH_TIME_OFF
end
if overtime < BuffFrameFlashTime then
BuffFrameFlashTime = BuffFrameFlashTime - overtime
end
end
-- The pulse's level for this frame: a 0..1 ramp up across the ON half and back down
-- across the OFF half (both scaled by BUFF_FLASH_TIME_ON — the reference's own choice,
-- and invisible while the two halves are the same length), squashed into
-- [BUFF_MIN_ALPHA, 1] so a warning icon dims rather than disappears.
--
-- Computed into a LOCAL and published once. BUFF_ALPHA_VALUE is read by every visible
-- button, so it is not scratch space: the reference assigns the raw ramp to it and then
-- squashes it in a second statement, which leaves the global holding an un-squashed
-- value for the length of one statement.
buffFlashAdvance(elapsed)
local level
if BuffFrameFlashState == 1 then
BUFF_ALPHA_VALUE = (BUFF_FLASH_TIME_ON - BuffFrameFlashTime) / BUFF_FLASH_TIME_ON
level = (BUFF_FLASH_TIME_ON - BuffFrameFlashTime) / BUFF_FLASH_TIME_ON
else
BUFF_ALPHA_VALUE = BuffFrameFlashTime / BUFF_FLASH_TIME_ON
level = BuffFrameFlashTime / BUFF_FLASH_TIME_ON
end
BUFF_ALPHA_VALUE = (BUFF_ALPHA_VALUE * (1 - BUFF_MIN_ALPHA)) + BUFF_MIN_ALPHA
BUFF_ALPHA_VALUE = (level * (1 - BUFF_MIN_ALPHA)) + BUFF_MIN_ALPHA
end
-- BuffButtons_UpdatePositions (BuffFrame.lua l.152-160): the ROW PITCH depends on whether a
@@ -306,29 +327,32 @@
-- frames (`UiScript::tick`), exactly as the client's does, so a hidden button's stale
-- buffIndex is never read.
function BuffButton_OnUpdate()
local buffDuration = getglobal(this:GetName() .. "Duration")
if this.untilCancelled == 1 then
buffDuration:Hide()
local button = this
-- A permanent aura has nothing to count down and nothing to flash toward, so it is
-- done in one line: UpdateDuration with no time left is exactly "hide the timer",
-- which is the whole of the reference's early-out here. (The reference reaches for the
-- $parentDuration region itself to do it, on every frame of every visible button, and
-- then throws that region away and lets UpdateDuration look it up again.)
if button.untilCancelled == 1 then
BuffFrame_UpdateDuration(button, nil)
return
end
local buffIndex = this.buffIndex
local buffIndex = button.buffIndex
local timeLeft = GetPlayerBuffTimeLeft(buffIndex)
-- The flash is NOT gated on the timer setting: the reference pulses a button in its last
-- 31s whether or not a number is drawn under it, and with the timers off it is the only
-- thing left saying "about to drop".
if timeLeft < BUFF_WARNING_TIME then
this:SetAlpha(BUFF_ALPHA_VALUE)
button:SetAlpha(BUFF_ALPHA_VALUE)
else
this:SetAlpha(1.0)
button:SetAlpha(1.0)
end
BuffFrame_UpdateDuration(this, timeLeft)
BuffFrame_UpdateDuration(button, timeLeft)
if BuffFrameUpdateTime > 0 then
return
end
if GameTooltip and GameTooltip:IsOwned(this) then
-- A tooltip we own is re-read on the throttle's beat, not every frame.
if BuffFrameUpdateTime <= 0 and GameTooltip and GameTooltip:IsOwned(button) then
GameTooltip:SetPlayerBuff(buffIndex)
end
end
@@ -339,72 +363,65 @@
CancelPlayerBuff(this.buffIndex)
end
-- The temporary-weapon-enchant row (ref l.162-233). GetWeaponEnchantInfo is the whole data
-- path: two flags, two remaining-MILLISECOND counts, two charge counts. Off hand takes
-- TempEnchant1 and main hand the next free slot, so with both enchanted the main hand sits
-- to the LEFT of the off hand — the reference's own ordering.
function BuffFrame_Enchant_OnUpdate(elapsed)
local hasMainHandEnchant, mainHandExpiration, mainHandCharges, hasOffHandEnchant, offHandExpiration, offHandCharges = GetWeaponEnchantInfo()
-- Clear enchant slots `first`..2 — the tail of the row nothing is using this frame.
local function buffEnchantClear(first)
for slot = first, 2 do
getglobal("TempEnchant" .. slot):Hide()
getglobal("TempEnchant" .. slot .. "Duration"):Hide()
end
end
-- No enchants, kick out early — and park BuffFrame back on its resting point.
-- The temporary-weapon-enchant row (ref l.162-233). GetWeaponEnchantInfo is the whole data
-- path: two flags, two remaining-MILLISECOND counts, two charge counts (the charges are
-- read by nobody here, and are named only to spell the API's shape out).
--
-- The two hands are the same six steps on different numbers, so they are one loop over a
-- two-row table rather than the reference's two copies of the block. OFF HAND FIRST is the
-- part that matters: the row packs from slot 1 rightwards, so with both weapons enchanted
-- the main hand lands in slot 2 and therefore sits to the LEFT of the off hand.
function BuffFrame_Enchant_OnUpdate(elapsed)
local hasMainHandEnchant, mainHandExpiration, mainHandCharges,
hasOffHandEnchant, offHandExpiration, offHandCharges = GetWeaponEnchantInfo()
-- Nothing enchanted: clear the whole row and park BuffFrame back on its resting point.
if not hasMainHandEnchant and not hasOffHandEnchant then
TempEnchant1:Hide()
TempEnchant1Duration:Hide()
TempEnchant2:Hide()
TempEnchant2Duration:Hide()
buffEnchantClear(1)
BuffFrame:SetPoint("TOPRIGHT", "TemporaryEnchantFrame", "TOPRIGHT", 0, 0)
return
end
local enchantButton
local textureName
local enchantIndex = 0
if hasOffHandEnchant then
enchantIndex = enchantIndex + 1
textureName = GetInventoryItemTexture("player", 17)
TempEnchant1:SetID(17)
TempEnchant1Icon:SetTexture(textureName or BENILLA_FALLBACK_ICON)
TempEnchant1:Show()
local filled = 0
for _, hand in ipairs({
-- `slot` is the live API's inventory id, which is also what the button's hover
-- reads back off itself to ask for the ITEM's tooltip.
{ enchanted = hasOffHandEnchant, expiration = offHandExpiration, slot = 17 },
{ enchanted = hasMainHandEnchant, expiration = mainHandExpiration, slot = 16 },
}) do
if hand.enchanted then
filled = filled + 1
local button = getglobal("TempEnchant" .. filled)
button:SetID(hand.slot)
getglobal(button:GetName() .. "Icon"):SetTexture(
GetInventoryItemTexture("player", hand.slot) or BENILLA_FALLBACK_ICON)
button:Show()
if offHandExpiration then
offHandExpiration = offHandExpiration / 1000
end
BuffFrame_UpdateDuration(TempEnchant1, offHandExpiration)
if offHandExpiration and offHandExpiration < BUFF_WARNING_TIME then
TempEnchant1:SetAlpha(BUFF_ALPHA_VALUE)
else
TempEnchant1:SetAlpha(1.0)
-- The wire counts an enchant down in MILLISECONDS; everything the row draws is
-- in seconds. A nil expiration is "no countdown known", which the timer and the
-- warning flash both read as "nothing to warn about".
local timeLeft = hand.expiration and (hand.expiration / 1000)
BuffFrame_UpdateDuration(button, timeLeft)
if timeLeft and timeLeft < BUFF_WARNING_TIME then
button:SetAlpha(BUFF_ALPHA_VALUE)
else
button:SetAlpha(1.0)
end
end
end
if hasMainHandEnchant then
enchantIndex = enchantIndex + 1
enchantButton = getglobal("TempEnchant" .. enchantIndex)
textureName = GetInventoryItemTexture("player", 16)
enchantButton:SetID(16)
getglobal(enchantButton:GetName() .. "Icon"):SetTexture(textureName or BENILLA_FALLBACK_ICON)
enchantButton:Show()
buffEnchantClear(filled + 1)
if mainHandExpiration then
mainHandExpiration = mainHandExpiration / 1000
end
BuffFrame_UpdateDuration(enchantButton, mainHandExpiration)
if mainHandExpiration and mainHandExpiration < BUFF_WARNING_TIME then
enchantButton:SetAlpha(BUFF_ALPHA_VALUE)
else
enchantButton:SetAlpha(1.0)
end
end
-- Hide unused enchants
for i = enchantIndex + 1, 2 do
getglobal("TempEnchant" .. i):Hide()
getglobal("TempEnchant" .. i .. "Duration"):Hide()
end
-- Position buff frame: the row is `enchantIndex` icons wide, and the bar starts one
-- 5px gutter to its left.
TemporaryEnchantFrame:SetWidth(enchantIndex * 32)
-- Position buff frame: the row is `filled` icons wide, and the bar starts one 5px
-- gutter to its left.
TemporaryEnchantFrame:SetWidth(filled * 32)
BuffFrame:SetPoint("TOPRIGHT", "TemporaryEnchantFrame", "TOPLEFT", -5, 0)
end
@@ -427,17 +444,21 @@
-- known" case. Hidden, not blanked — see the header's deviation note.
function BuffFrame_UpdateDuration(buffButton, timeLeft)
local duration = getglobal(buffButton:GetName() .. "Duration")
if SHOW_BUFF_DURATIONS == "1" and timeLeft then
duration:SetText(SecondsToTimeAbbrev(timeLeft))
if timeLeft < BUFF_DURATION_WARNING_TIME then
duration:SetVertexColor(HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b)
else
duration:SetVertexColor(NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b)
end
duration:Show()
else
-- Two ways to have no number to draw: the setting is off, or nothing has told us how
-- long is left (the enchant row's "no countdown known").
if SHOW_BUFF_DURATIONS ~= "1" or not timeLeft then
duration:Hide()
return
end
duration:SetText(SecondsToTimeAbbrev(timeLeft))
-- Gold most of the way, white inside the last minute — the number's own warning, a
-- half-minute ahead of the icon starting to pulse.
local color = NORMAL_FONT_COLOR
if timeLeft < BUFF_DURATION_WARNING_TIME then
color = HIGHLIGHT_FONT_COLOR
end
duration:SetVertexColor(color.r, color.g, color.b)
duration:Show()
end
]]></Script>
+178 -135
View File
@@ -1,18 +1,20 @@
<Ui>
<!--
The cast bar - the 1.12 CastingBarFrame, transcribed from the extracted reference
(Interface\FrameXML\CastingBarFrame.xml/.lua, mpqx; decision 0137 phase 1). Geometry,
textures, colors, and every timing constant are the reference's own. Driven by the
SPELLCAST_* events crate::ui_cast fires off the wire (START name,ms / STOP / FAILED /
INTERRUPTED / DELAYED / CHANNEL_START ms,name / CHANNEL_UPDATE ms / CHANNEL_STOP).
The cast bar — benilla's own implementation of the 1.12 CastingBarFrame contract. The
extracted reference (Interface\FrameXML\CastingBarFrame.xml/.lua, mpqx; decision 0137
phase 1) is the SPECIFICATION: geometry, textures, colors, and every timing constant are
read off it, and the observable lifecycle below is the one it defines. The code is ours.
Driven by the SPELLCAST_* events crate::ui_cast fires off the wire (START name,ms / STOP /
FAILED / INTERRUPTED / DELAYED / CHANNEL_START ms,name / CHANNEL_UPDATE ms / CHANNEL_STOP).
The reference's globals (CastingBarText/Spark/Flash) are named regions, so they publish as
globals here exactly as they do there; `this` is the firing frame (RF-0025). Departures
from the reference .lua: its dead code (`CastingBarFrameStatusBar = CastingBarFrame`, a
self-alias — we just use the frame; the `if not this:IsVisible() then this:Hide() end`
no-op guard at the top of the STOP branch; the two write-only `this.mode` stores nothing
reads), and the flash/fade steps normalized to the reference tick rate (the
CASTING_BAR_REF_TICK comment below — decision 0454).
globals here exactly as they do there; `this` is the firing frame (RF-0025), and every
handler below reads it once into a named local rather than threading the global through a
dozen method calls. Deliberate departures from the specification's own .lua: its dead code
(the `if not this:IsVisible() then this:Hide() end` no-op guard at the head of the STOP
branch; the two write-only `this.mode` stores nothing reads), and the flash/fade steps
normalized to the reference tick rate (the CASTING_BAR_REF_TICK comment below —
decision 0454).
SPELLCAST_DELAYED is live: SMSG_SPELL_DELAYED (pushback on taking a hit) is decoded and fed,
so a hit slides the bar's window out instead of letting it finish early (decision 0256).
@@ -41,152 +43,193 @@
FAILED = FAILED or "Failed"
INTERRUPTED = INTERRUPTED or "Interrupted"
-- THE BAR'S WHOLE VOCABULARY, one entry per wire event, each taking the frame. OnLoad
-- registers this table's keys and OnEvent looks the incoming event up in it, so the set
-- the bar LISTENS for and the set it can ANSWER are the same set by construction — there
-- is no second list to fall out of step with. (The reference spells both halves out: eight
-- RegisterEvent calls, then a sixty-line if-chain testing the same eight strings back.)
local castingBarEvent = {}
-- Seat the fill at `value` and ride the spark on its leading edge. The bar is 195px wide,
-- so a fraction of `span` scales straight to a pixel offset from the frame's LEFT. A cast
-- and a channel differ only in which value and which span they hand in.
local function castingBarTrack(bar, value, span)
bar:SetValue(value)
CastingBarFlash:Hide()
local sparkPosition = ((value - bar.startTime) / span) * 195
-- Pushback slides startTime PAST the wall clock, so a cast really can sit before its
-- own window's start for a frame or two. (A channel cannot — its value counts down
-- from the far end and stops at startTime — so here the clamp is dead weight rather
-- than a change of behaviour.)
if sparkPosition < 0 then
sparkPosition = 0
end
CastingBarSpark:SetPoint("CENTER", CastingBarFrame, "LEFT", sparkPosition, 2)
end
-- A cast opens: orange, a full window anchored on the clock, the spark at the left edge.
castingBarEvent.SPELLCAST_START = function(bar)
bar:SetStatusBarColor(1.0, 0.7, 0.0)
CastingBarSpark:Show()
bar.startTime = GetTime()
bar.maxValue = bar.startTime + (arg2 / 1000)
bar:SetMinMaxValues(bar.startTime, bar.maxValue)
bar:SetValue(bar.startTime)
CastingBarText:SetText(arg1)
bar:SetAlpha(1.0)
bar.holdTime = 0
bar.casting = 1
bar.fadeOut = nil
bar:Show()
end
-- A channel opens. Its args are START's the other way round — milliseconds first, then the
-- name — and its fill DRAINS, so the bar is seated at the far end and walks back.
castingBarEvent.SPELLCAST_CHANNEL_START = function(bar)
bar:SetStatusBarColor(1.0, 0.7, 0.0)
CastingBarSpark:Show()
bar.maxValue = 1
bar.startTime = GetTime()
bar.endTime = bar.startTime + (arg1 / 1000)
bar.duration = arg1 / 1000
bar:SetMinMaxValues(bar.startTime, bar.endTime)
bar:SetValue(bar.endTime)
CastingBarText:SetText(arg2)
bar:SetAlpha(1.0)
bar.holdTime = 0
bar.casting = nil
bar.channeling = 1
bar.fadeOut = nil
bar:Show()
end
-- The server's mid-channel correction: the window keeps its LENGTH and is re-anchored to
-- end arg1 ms from now, so a pushback eats into what is left rather than extending it.
castingBarEvent.SPELLCAST_CHANNEL_UPDATE = function(bar)
if not bar:IsShown() then
return
end
local span = bar.endTime - bar.startTime
bar.endTime = GetTime() + (arg1 / 1000)
bar.startTime = bar.endTime - span
bar:SetMinMaxValues(bar.startTime, bar.endTime)
end
-- Pushback on taking a hit (SMSG_SPELL_DELAYED, decision 0256): BOTH ends of the window
-- slide out by arg1 ms, so the cast has that much longer to run and the spark jumps back.
-- A hit never cancels the bar.
castingBarEvent.SPELLCAST_DELAYED = function(bar)
if not bar:IsShown() then
return
end
local pushback = arg1 / 1000
bar.startTime = bar.startTime + pushback
bar.maxValue = bar.maxValue + pushback
bar:SetMinMaxValues(bar.startTime, bar.maxValue)
end
-- The cast landed, or a channel ran its course: green, full, and the completion bloom
-- armed at alpha 0 for OnUpdate to ramp. `flag` names the one field the event clears,
-- which is the only thing that separates the two of them.
local function castingBarFinished(bar, flag)
if not bar:IsShown() then
return
end
bar:SetValue(bar.maxValue)
bar:SetStatusBarColor(0.0, 1.0, 0.0)
CastingBarSpark:Hide()
CastingBarFlash:SetAlpha(0.0)
CastingBarFlash:Show()
bar[flag] = nil
bar.flash = 1
bar.fadeOut = 1
end
castingBarEvent.SPELLCAST_STOP = function(bar) castingBarFinished(bar, "casting") end
castingBarEvent.SPELLCAST_CHANNEL_STOP = function(bar) castingBarFinished(bar, "channeling") end
-- The cast did NOT land: red, the reason where the spell's name was, and held opaque for
-- CASTING_BAR_HOLD_TIME before the fade starts — no completion bloom. A channel is never
-- ended this way (the server closes one with CHANNEL_STOP), so a live channel ignores both.
local function castingBarAborted(bar, reason)
if not bar:IsShown() or bar.channeling then
return
end
bar:SetValue(bar.maxValue)
bar:SetStatusBarColor(1.0, 0.0, 0.0)
CastingBarSpark:Hide()
CastingBarText:SetText(reason)
bar.casting = nil
bar.fadeOut = 1
bar.holdTime = GetTime() + CASTING_BAR_HOLD_TIME
end
castingBarEvent.SPELLCAST_FAILED = function(bar) castingBarAborted(bar, FAILED) end
castingBarEvent.SPELLCAST_INTERRUPTED = function(bar) castingBarAborted(bar, INTERRUPTED) end
function CastingBarFrame_OnLoad()
this:RegisterEvent("SPELLCAST_START")
this:RegisterEvent("SPELLCAST_STOP")
this:RegisterEvent("SPELLCAST_FAILED")
this:RegisterEvent("SPELLCAST_INTERRUPTED")
this:RegisterEvent("SPELLCAST_DELAYED")
this:RegisterEvent("SPELLCAST_CHANNEL_START")
this:RegisterEvent("SPELLCAST_CHANNEL_UPDATE")
this:RegisterEvent("SPELLCAST_CHANNEL_STOP")
this.casting = nil
this.holdTime = 0
-- The ref's own last line of this function (`CastingBarFrame.lua:16`), and the one
-- line of it we dropped: a plain alias global. `CastingBarFrame` IS the StatusBar in
-- 1.12 — there is no separate child frame — so the reference publishes the name an
-- addon would look for, and then uses it itself for the rest of the file.
local bar = this
for spellcast in pairs(castingBarEvent) do
bar:RegisterEvent(spellcast)
end
bar.casting = nil
bar.holdTime = 0
-- The alias global the reference publishes at the end of its own OnLoad
-- (`CastingBarFrame.lua:16`). `CastingBarFrame` IS the StatusBar in 1.12 — there is no
-- separate child frame — so the name an addon reaches for has to resolve to the frame
-- itself.
--
-- 11 corpus addons index `CastingBarFrameStatusBar`, and it was the largest single
-- name in the whole session-start `attempt to index global` row. Nothing warned:
-- a line dropped INSIDE a function we otherwise transcribed verbatim is invisible to
-- every instrument here. `framexml-transcription-diff.py` compares elements and
-- handlers, not function bodies, and would not even pair these two files (ours is
-- CastingBar.xml, the reference's is CastingBarFrame.xml).
CastingBarFrameStatusBar = CastingBarFrame
this:Hide()
-- name in the whole session-start `attempt to index global` row. It went missing here
-- once and nothing warned: a line dropped from INSIDE a function is invisible to every
-- instrument we have. `framexml-transcription-diff.py` compares elements and handlers,
-- not function bodies, and would not even pair these two files (ours is CastingBar.xml,
-- the reference's is CastingBarFrame.xml).
CastingBarFrameStatusBar = bar
bar:Hide()
end
function CastingBarFrame_OnEvent()
if event == "SPELLCAST_START" then
this:SetStatusBarColor(1.0, 0.7, 0.0)
CastingBarSpark:Show()
this.startTime = GetTime()
this.maxValue = this.startTime + (arg2 / 1000)
this:SetMinMaxValues(this.startTime, this.maxValue)
this:SetValue(this.startTime)
CastingBarText:SetText(arg1)
this:SetAlpha(1.0)
this.holdTime = 0
this.casting = 1
this.fadeOut = nil
this:Show()
elseif event == "SPELLCAST_STOP" or event == "SPELLCAST_CHANNEL_STOP" then
if this:IsShown() then
this:SetValue(this.maxValue)
this:SetStatusBarColor(0.0, 1.0, 0.0)
CastingBarSpark:Hide()
CastingBarFlash:SetAlpha(0.0)
CastingBarFlash:Show()
if event == "SPELLCAST_STOP" then
this.casting = nil
else
this.channeling = nil
end
this.flash = 1
this.fadeOut = 1
end
elseif event == "SPELLCAST_FAILED" or event == "SPELLCAST_INTERRUPTED" then
if this:IsShown() and not this.channeling then
this:SetValue(this.maxValue)
this:SetStatusBarColor(1.0, 0.0, 0.0)
CastingBarSpark:Hide()
if event == "SPELLCAST_FAILED" then
CastingBarText:SetText(FAILED)
else
CastingBarText:SetText(INTERRUPTED)
end
this.casting = nil
this.fadeOut = 1
this.holdTime = GetTime() + CASTING_BAR_HOLD_TIME
end
elseif event == "SPELLCAST_DELAYED" then
if this:IsShown() then
this.startTime = this.startTime + (arg1 / 1000)
this.maxValue = this.maxValue + (arg1 / 1000)
this:SetMinMaxValues(this.startTime, this.maxValue)
end
elseif event == "SPELLCAST_CHANNEL_START" then
this:SetStatusBarColor(1.0, 0.7, 0.0)
CastingBarSpark:Show()
this.maxValue = 1
this.startTime = GetTime()
this.endTime = this.startTime + (arg1 / 1000)
this.duration = arg1 / 1000
this:SetMinMaxValues(this.startTime, this.endTime)
this:SetValue(this.endTime)
CastingBarText:SetText(arg2)
this:SetAlpha(1.0)
this.holdTime = 0
this.casting = nil
this.channeling = 1
this.fadeOut = nil
this:Show()
elseif event == "SPELLCAST_CHANNEL_UPDATE" then
if this:IsShown() then
local origDuration = this.endTime - this.startTime
this.endTime = GetTime() + (arg1 / 1000)
this.startTime = this.endTime - origDuration
this:SetMinMaxValues(this.startTime, this.endTime)
end
local handle = castingBarEvent[event]
if handle then
handle(this)
end
end
function CastingBarFrame_OnUpdate()
if this.casting then
local status = GetTime()
if status > this.maxValue then
status = this.maxValue
local bar = this
if bar.casting then
-- The fill IS the wall clock, pinned at the window's end once the cast is spent.
local now = GetTime()
if now > bar.maxValue then
now = bar.maxValue
end
this:SetValue(status)
CastingBarFlash:Hide()
local sparkPosition = ((status - this.startTime) / (this.maxValue - this.startTime)) * 195
if sparkPosition < 0 then
sparkPosition = 0
end
CastingBarSpark:SetPoint("CENTER", CastingBarFrame, "LEFT", sparkPosition, 2)
elseif this.channeling then
local time = GetTime()
if time > this.endTime then
time = this.endTime
end
if time == this.endTime then
this.channeling = nil
this.fadeOut = 1
castingBarTrack(bar, now, bar.maxValue - bar.startTime)
elseif bar.channeling then
local now = GetTime()
if now >= bar.endTime then
bar.channeling = nil
bar.fadeOut = 1
return
end
local barValue = this.startTime + (this.endTime - time)
this:SetValue(barValue)
CastingBarFlash:Hide()
local sparkPosition = ((barValue - this.startTime) / (this.endTime - this.startTime)) * 195
CastingBarSpark:SetPoint("CENTER", CastingBarFrame, "LEFT", sparkPosition, 2)
elseif GetTime() < this.holdTime then
-- A channel drains: the value is the window's start plus what is LEFT of it, so
-- the fill empties from full instead of filling from empty.
castingBarTrack(bar, bar.startTime + (bar.endTime - now), bar.endTime - bar.startTime)
elseif GetTime() < bar.holdTime then
return
elseif this.flash then
elseif bar.flash then
local alpha = CastingBarFlash:GetAlpha() + CASTING_BAR_FLASH_STEP * (arg1 * CASTING_BAR_REF_TICK)
if alpha < 1 then
CastingBarFlash:SetAlpha(alpha)
else
CastingBarFlash:SetAlpha(1.0)
this.flash = nil
bar.flash = nil
end
elseif this.fadeOut then
local alpha = this:GetAlpha() - CASTING_BAR_ALPHA_STEP * (arg1 * CASTING_BAR_REF_TICK)
elseif bar.fadeOut then
local alpha = bar:GetAlpha() - CASTING_BAR_ALPHA_STEP * (arg1 * CASTING_BAR_REF_TICK)
if alpha > 0 then
this:SetAlpha(alpha)
bar:SetAlpha(alpha)
else
this.fadeOut = nil
this:Hide()
bar.fadeOut = nil
bar:Hide()
end
end
end
+225 -191
View File
@@ -9,7 +9,7 @@
artifacts, never committed — the 0068/0138 pattern; line cites below are into that
extraction's own `ref-CharacterFrame.xml`/`.lua`, `ref-PaperDollFrame.xml`/`.lua`,
`ref-UIParent.lua`, `ref-GlobalStrings.lua`). `CharacterFrameTabButtonTemplate` is ALREADY
transcribed in `UiPanels.xml:507-563` (battle-tested by Merchant/QuestLog, 0138) — reused
defined in `UiPanels.xml:507-563` (battle-tested by Merchant/QuestLog, 0138) — reused
here, not redefined.
Docks the left UIPanel slot exactly like Gossip/Merchant/Quest/QuestLog (`UiPanels.xml`
@@ -101,7 +101,7 @@
shared template's own Scripts block at l.8-37), just keyed off `GetInventorySlotInfo(
"AmmoSlot")` instead of e.g. `"HeadSlot"`. Whatever `PLAYER_AMMO_ID`/bag-summing happens
for that particular slot id is the parallel binding's own implementation detail, invisible
to this Lua — transcribed here exactly as the ref wires it.
to this Lua — re-expressed here exactly as the ref wires it.
- `$parentStock` (the ammo slot's yellow "in bags" count, ref `ref-PaperDollFrame.xml:
970-978`): no Lua in this session's extraction ever populates it (likely an ammo-picker-
addon-only overlay outside this pull) — dropped rather than guessed at.
@@ -310,7 +310,7 @@
-- keybind path cannot disagree. l.47's trailing PlaySound is the ref's OWN redundant second
-- igCharacterInfoTab — ToggleCharacter already plays it on the "switch to this tab" path, so
-- this second call only matters when the window is ALREADY open on this same tab, i.e. the
-- HideUIPanel branch — transcribed verbatim rather than "fixed" since it's the ref's own
-- HideUIPanel branch — kept rather than "fixed" since it's the ref's own
-- behavior, not a bug of ours.
function BenillaCharacterFrameTab_OnClick(id)
ToggleCharacter(BENILLA_CHARACTERFRAME_SUBFRAMES[id])
@@ -402,7 +402,7 @@
end
-- ── The paper-doll page (ref PaperDollFrame.lua) ────────────────────────────────────────
-- ref PaperDollFrame_OnLoad (l.6-29), the label lines (l.7-13) transcribed verbatim: the
-- ref PaperDollFrame_OnLoad (l.6-29), the label lines (l.7-13) kept as the ref has them: the
-- labels live ONLY in the row template's $parentLabel (gold GameFontNormalSmall, LEFT) and
-- Lua fills them — never a re-declared region in an instance (the ref's instances carry no
-- <Layers> at all, and this engine's template expansion appends children, so a re-declared
@@ -432,37 +432,79 @@
frame:RegisterEvent("UNIT_ATTACK")
end
-- ref PaperDollFrame_OnEvent(event, unit) (l.37-76), the unit-token branch only (unit ==
-- "player" always true here — no pet/other-unit reuse in phase 1a) and this file's
-- registered subset only. `unit` is the DECLARED second parameter, not the `arg1` global:
-- the XML passes `arg1` into it exactly as the reference's own does (PaperDollFrame.xml),
-- so an addon calling the 1.12 arity is read correctly rather than off a stale global.
-- Every row of the sheet, in the order a full repaint fills them. The order is load-bearing
-- at the ranged end: SetRangedAttack is what decides `PaperDollFrame.noRanged`, and the two
-- ranged rows after it read that flag rather than re-deciding it.
--
-- Rows are NAMED, not referenced. Resolving each through the global table at call time is
-- what the reference's own call-by-name got for free, and the paper-doll setters are prime
-- addon-hook territory: a table of captured function values would keep calling the setter an
-- addon replaced.
local paperDollRows = {
"PaperDollFrame_SetLevel",
"PaperDollFrame_SetStats",
"PaperDollFrame_SetResistances",
"PaperDollFrame_SetArmor",
"PaperDollFrame_SetDamage",
"PaperDollFrame_SetAttackPower",
"PaperDollFrame_SetAttackBothHands",
"PaperDollFrame_SetRangedAttack",
"PaperDollFrame_SetRangedDamage",
"PaperDollFrame_SetRangedAttackPower",
}
-- A weapon that changed how hard it hits and one that changed how often move the same four
-- rows, so the two events share a list rather than a copy of one.
local weaponRows = {
"PaperDollFrame_SetDamage",
"PaperDollFrame_SetRangedAttack",
"PaperDollFrame_SetRangedDamage",
"PaperDollFrame_SetAttackBothHands",
}
-- Which rows a single change moves, keyed by the event reporting it. The reference spells
-- this out as an if/elseif ladder and then keeps a second, hand-parallel copy of the row
-- order in OnShow; one table read two ways cannot drift the way two call lists do.
--
-- Only this file's registered subset appears (see PaperDollFrame_OnLoad's note on the
-- omitted events). PLAYER_ENTERING_WORLD is registered and deliberately absent: the ref's
-- one use for it is `CharacterModelFrame:SetUnit("player")`, and our CharacterModelFrame is
-- a booth-texture stand-in rather than a live model widget (this file's header) — there is
-- no SetUnit to call. It keeps its early return below rather than a row here, because the
-- reference returns on it before even the visibility check.
local rowsMovedBy = {
UNIT_LEVEL = { "PaperDollFrame_SetLevel" },
UNIT_STATS = { "PaperDollFrame_SetStats" },
UNIT_RESISTANCES = { "PaperDollFrame_SetResistances", "PaperDollFrame_SetArmor" },
UNIT_ATTACK = { "PaperDollFrame_SetAttackBothHands" },
UNIT_ATTACK_POWER = { "PaperDollFrame_SetAttackPower" },
UNIT_DAMAGE = weaponRows,
UNIT_ATTACK_SPEED = weaponRows,
UNIT_RANGED_ATTACK_POWER = {
"PaperDollFrame_SetRangedAttack", "PaperDollFrame_SetRangedAttackPower",
},
}
local function paintRows(rows)
for i = 1, table.getn(rows) do
getglobal(rows[i])()
end
end
-- `unit` is the DECLARED second parameter, not the `arg1` global: the XML passes `arg1` into
-- it exactly as the reference's own does (PaperDollFrame.xml), so an addon calling the 1.12
-- arity is read correctly rather than off a stale global. Only the player's own sheet is
-- driven from here (no pet/other-unit reuse in phase 1a), and only while it is on screen —
-- a hidden page is repainted wholesale by OnShow when it comes back.
function PaperDollFrame_OnEvent(event, unit)
local frame = this
if event == "PLAYER_ENTERING_WORLD" then
return
end
if not frame:IsVisible() then return end
if not this:IsVisible() then return end
if unit ~= "player" then return end
if event == "UNIT_LEVEL" then
PaperDollFrame_SetLevel()
elseif event == "UNIT_DAMAGE" or event == "UNIT_ATTACK_SPEED" then
PaperDollFrame_SetDamage()
PaperDollFrame_SetRangedAttack()
PaperDollFrame_SetRangedDamage()
PaperDollFrame_SetAttackBothHands()
elseif event == "UNIT_ATTACK" then
PaperDollFrame_SetAttackBothHands()
elseif event == "UNIT_RESISTANCES" then
PaperDollFrame_SetResistances()
PaperDollFrame_SetArmor()
elseif event == "UNIT_STATS" then
PaperDollFrame_SetStats()
elseif event == "UNIT_ATTACK_POWER" then
PaperDollFrame_SetAttackPower()
elseif event == "UNIT_RANGED_ATTACK_POWER" then
PaperDollFrame_SetRangedAttack()
PaperDollFrame_SetRangedAttackPower()
local rows = rowsMovedBy[event]
if rows then
paintRows(rows)
end
end
@@ -471,16 +513,7 @@
-- vanilla-always-false, so CharacterAmmoSlot simply never has a reason to hide; it ships
-- with no hidden="true" at all (see its own Button below).
function PaperDollFrame_OnShow()
PaperDollFrame_SetLevel()
PaperDollFrame_SetStats()
PaperDollFrame_SetResistances()
PaperDollFrame_SetArmor()
PaperDollFrame_SetDamage()
PaperDollFrame_SetAttackPower()
PaperDollFrame_SetAttackBothHands()
PaperDollFrame_SetRangedAttack()
PaperDollFrame_SetRangedDamage()
PaperDollFrame_SetRangedAttackPower()
paintRows(paperDollRows)
end
-- ref PaperDollFrame_OnHide (l.587-592) is entirely commented-out in the ref itself (the
-- ShoppingTooltip/compare-tooltip lines, OMIT) — nothing to transcribe, no OnHide wired.
@@ -533,9 +566,20 @@
end
end
-- ref PaperDollFrame_SetResistances (l.176-228). `resistanceLevel` is a proper `local` here
-- (the ref leaks it as an implicit global, harmless there — Lua 5.0's default — but our
-- sandboxed globals table is worth not littering).
-- One word for how well a school is covered, from resistance-per-level. The bands descend,
-- so the first threshold cleared names it and a ratio of zero or less clears none of them.
-- (The reference leaves the answer in an implicit global; a pure function of the ratio is
-- both the honest shape and one less name in our sandboxed globals table.)
local function resistanceBand(ratio)
if ratio > 5 then return RESISTANCE_EXCELLENT end
if ratio > 3.75 then return RESISTANCE_VERYGOOD end
if ratio > 2.5 then return RESISTANCE_GOOD end
if ratio > 1.25 then return RESISTANCE_FAIR end
if ratio > 0 then return RESISTANCE_POOR end
return RESISTANCE_NONE
end
-- ref PaperDollFrame_SetResistances (l.176-228).
function PaperDollFrame_SetResistances()
for i = 1, BENILLA_NUM_RESISTANCE_TYPES do
local resistId = BENILLA_MAGICRES_IDS[i]
@@ -561,30 +605,20 @@
end
frame.tooltip = frame.tooltip .. FONT_COLOR_CODE_CLOSE .. " )"
end
-- The divisor floors at 20 because the reference does (l.210-211: it reads the level,
-- then clamps on the NEXT line — an earlier note here cited l.210 alone and called
-- the floor ours). The `or 1` is the only difference: the ref would error on a nil
-- level, and nothing is gained by reproducing that.
local unitLevel = math.max(UnitLevel("player") or 1, 20)
local magicResistanceNumber = resistance / unitLevel
local resistanceLevel
if magicResistanceNumber > 5 then
resistanceLevel = RESISTANCE_EXCELLENT
elseif magicResistanceNumber > 3.75 then
resistanceLevel = RESISTANCE_VERYGOOD
elseif magicResistanceNumber > 2.5 then
resistanceLevel = RESISTANCE_GOOD
elseif magicResistanceNumber > 1.25 then
resistanceLevel = RESISTANCE_FAIR
elseif magicResistanceNumber > 0 then
resistanceLevel = RESISTANCE_POOR
else
resistanceLevel = RESISTANCE_NONE
end
frame.tooltipSubtext = format(RESISTANCE_TOOLTIP_SUBTEXT,
getglobal("RESISTANCE_TYPE" .. resistId), unitLevel, resistanceLevel)
getglobal("RESISTANCE_TYPE" .. resistId), unitLevel,
resistanceBand(resistance / unitLevel))
end
end
-- ref PaperDollFrame_SetArmor (l.230-250). NOTE the ref's own third UnitArmor return
-- (`armor`) is unused here too — PaperDollFormatStat gets `base`, not `armor`; transcribed
-- verbatim, not "fixed".
-- (`armor`) is unused here too — PaperDollFormatStat gets `base`, not `armor`; the ref's
-- behavior kept, not "fixed".
function PaperDollFrame_SetArmor(unit, prefix)
unit = unit or "player"
prefix = prefix or "Character"
@@ -609,17 +643,85 @@
local base, modifier = UnitDefense(unit)
local frame = getglobal(prefix .. "DefenseFrame")
local text = getglobal(prefix .. "DefenseFrameStatText")
local posBuff = 0
local negBuff = 0
if modifier > 0 then
posBuff = modifier
elseif modifier < 0 then
negBuff = modifier
end
PaperDollFormatStat(DEFENSE_COLON, base, posBuff, negBuff, frame, text)
-- The sign is all that decides which side of the pair the one modifier lands on, and a
-- modifier of zero lands on neither.
PaperDollFormatStat(DEFENSE_COLON, base, math.max(modifier, 0), math.min(modifier, 0),
frame, text)
end
-- ref PaperDollFrame_SetDamage (l.274-372).
-- ── Weapon swings: the arithmetic and the wording, once ────────────────────────────────────
-- Three rows read a swing — main hand, off hand, ranged — and it is the same calculation
-- with the same vocabulary each time, so it lives here instead of two and three times over.
-- The sheet's green and red for damage figures. Byte-identical to Fonts.xml's GREEN/RED
-- font-colour codes today, and kept separate on purpose: these are the damage row's own
-- colours, and reading them off the font palette would tie one to the other.
local DAMAGE_UP_COLOR = "|cff20ff20"
local DAMAGE_DOWN_COLOR = "|cffff2020"
-- What the weapon itself swings for. The numbers we are handed already have the flat
-- physical bonuses added and the percent multiplier applied; the row shows those, but the
-- tooltip breaks out where they came from, so the un-modified swing has to be recovered.
-- Returns that raw range, the modified average, and the gap the modifiers make between them.
local function unscaleSwing(minDamage, maxDamage, bonusPos, bonusNeg, percent)
local rawMin = (minDamage / percent) - bonusPos - bonusNeg
local rawMax = (maxDamage / percent) - bonusPos - bonusNeg
local baseDamage = (rawMin + rawMax) * 0.5
local fullDamage = (baseDamage + bonusPos + bonusNeg) * percent
return rawMin, rawMax, fullDamage, fullDamage - baseDamage
end
-- Both ends of a swing, rounded outwards and floored at 1 — a weapon swinging for a
-- fraction of a point still reads as a weapon.
local function swingBounds(minDamage, maxDamage)
return math.max(math.floor(minDamage), 1), math.max(math.ceil(maxDamage), 1)
end
-- The range as a tooltip writes it, always spaced.
local function damageRange(minDamage, maxDamage)
local low, high = swingBounds(minDamage, maxDamage)
return low .. " - " .. high
end
-- The range as the ROW writes it: spaced while both ends are two digits, tightened up once
-- either reaches three and the row starts running out of width.
local function damageFigure(minDamage, maxDamage)
local low, high = swingBounds(minDamage, maxDamage)
if low < 100 and high < 100 then
return low .. " - " .. high
end
return low .. "-" .. high
end
-- The tooltip's account of what modified the raw range: the flat bonus, then the multiplier,
-- each coloured by which way it went and left out entirely when it is not in play.
local function damageModifiers(tooltip, bonusPos, bonusNeg, percent)
if bonusPos > 0 then
tooltip = tooltip .. DAMAGE_UP_COLOR .. " +" .. bonusPos .. "|r"
end
if bonusNeg < 0 then
tooltip = tooltip .. DAMAGE_DOWN_COLOR .. " " .. bonusNeg .. "|r"
end
if percent > 1 then
tooltip = tooltip .. DAMAGE_UP_COLOR .. " x" .. math.floor(percent * 100 + 0.5) .. "%|r"
elseif percent < 1 then
tooltip = tooltip .. DAMAGE_DOWN_COLOR .. " x" .. math.floor(percent * 100 + 0.5) .. "%|r"
end
return tooltip
end
-- Which way a modified figure is tinted. Only asked once a caller knows something modified
-- it, so a zero bonus never reaches here and has no colour of its own.
local function damageColor(totalBonus)
if totalBonus > 0 then
return DAMAGE_UP_COLOR
end
return DAMAGE_DOWN_COLOR
end
-- ref PaperDollFrame_SetDamage (l.274-372). The off hand is the same swing read twice: the
-- flat bonuses and the multiplier are the character's, not the weapon's, so both hands take
-- them, and only the speed and the raw range differ.
function PaperDollFrame_SetDamage(unit, prefix)
unit = unit or "player"
prefix = prefix or "Character"
@@ -628,68 +730,29 @@
local speed, offhandSpeed = UnitAttackSpeed(unit)
local minDamage, maxDamage, minOffHandDamage, maxOffHandDamage, physicalBonusPos, physicalBonusNeg, percent =
UnitDamage(unit)
local displayMin = math.max(math.floor(minDamage), 1)
local displayMax = math.max(math.ceil(maxDamage), 1)
minDamage = (minDamage / percent) - physicalBonusPos - physicalBonusNeg
maxDamage = (maxDamage / percent) - physicalBonusPos - physicalBonusNeg
local baseDamage = (minDamage + maxDamage) * 0.5
local fullDamage = (baseDamage + physicalBonusPos + physicalBonusNeg) * percent
local totalBonus = fullDamage - baseDamage
local damagePerSecond = math.max(fullDamage, 1) / speed
local damageTooltip = math.max(math.floor(minDamage), 1) .. " - " .. math.max(math.ceil(maxDamage), 1)
local colorPos = "|cff20ff20"
local colorNeg = "|cffff2020"
local figure = damageFigure(minDamage, maxDamage)
local rawMin, rawMax, fullDamage, totalBonus =
unscaleSwing(minDamage, maxDamage, physicalBonusPos, physicalBonusNeg, percent)
local damageTooltip = damageRange(rawMin, rawMax)
if totalBonus == 0 then
if displayMin < 100 and displayMax < 100 then
damageText:SetText(displayMin .. " - " .. displayMax)
else
damageText:SetText(displayMin .. "-" .. displayMax)
end
damageText:SetText(figure)
else
local color
if totalBonus > 0 then color = colorPos else color = colorNeg end
if displayMin < 100 and displayMax < 100 then
damageText:SetText(color .. displayMin .. " - " .. displayMax .. "|r")
else
damageText:SetText(color .. displayMin .. "-" .. displayMax .. "|r")
end
if physicalBonusPos > 0 then
damageTooltip = damageTooltip .. colorPos .. " +" .. physicalBonusPos .. "|r"
end
if physicalBonusNeg < 0 then
damageTooltip = damageTooltip .. colorNeg .. " " .. physicalBonusNeg .. "|r"
end
if percent > 1 then
damageTooltip = damageTooltip .. colorPos .. " x" .. math.floor(percent * 100 + 0.5) .. "%|r"
elseif percent < 1 then
damageTooltip = damageTooltip .. colorNeg .. " x" .. math.floor(percent * 100 + 0.5) .. "%|r"
end
damageText:SetText(damageColor(totalBonus) .. figure .. "|r")
damageTooltip =
damageModifiers(damageTooltip, physicalBonusPos, physicalBonusNeg, percent)
end
damageFrame.damage = damageTooltip
damageFrame.attackSpeed = speed
damageFrame.dps = damagePerSecond
damageFrame.dps = math.max(fullDamage, 1) / speed
if offhandSpeed then
minOffHandDamage = (minOffHandDamage / percent) - physicalBonusPos - physicalBonusNeg
maxOffHandDamage = (maxOffHandDamage / percent) - physicalBonusPos - physicalBonusNeg
local offhandBaseDamage = (minOffHandDamage + maxOffHandDamage) * 0.5
local offhandFullDamage = (offhandBaseDamage + physicalBonusPos + physicalBonusNeg) * percent
local offhandDamagePerSecond = math.max(offhandFullDamage, 1) / offhandSpeed
local offhandDamageTooltip =
math.max(math.floor(minOffHandDamage), 1) .. " - " .. math.max(math.ceil(maxOffHandDamage), 1)
if physicalBonusPos > 0 then
offhandDamageTooltip = offhandDamageTooltip .. colorPos .. " +" .. physicalBonusPos .. "|r"
end
if physicalBonusNeg < 0 then
offhandDamageTooltip = offhandDamageTooltip .. colorNeg .. " " .. physicalBonusNeg .. "|r"
end
if percent > 1 then
offhandDamageTooltip = offhandDamageTooltip .. colorPos .. " x" .. math.floor(percent * 100 + 0.5) .. "%|r"
elseif percent < 1 then
offhandDamageTooltip = offhandDamageTooltip .. colorNeg .. " x" .. math.floor(percent * 100 + 0.5) .. "%|r"
end
damageFrame.offhandDamage = offhandDamageTooltip
local offMin, offMax, offhandFullDamage = unscaleSwing(
minOffHandDamage, maxOffHandDamage, physicalBonusPos, physicalBonusNeg, percent)
-- The off hand gets its breakdown unconditionally: it has no row of its own to keep
-- plain, only the tooltip's second half.
damageFrame.offhandDamage = damageModifiers(damageRange(offMin, offMax),
physicalBonusPos, physicalBonusNeg, percent)
damageFrame.offhandAttackSpeed = offhandSpeed
damageFrame.offhandDps = offhandDamagePerSecond
damageFrame.offhandDps = math.max(offhandFullDamage, 1) / offhandSpeed
else
damageFrame.offhandAttackSpeed = nil
end
@@ -707,6 +770,26 @@
math.max(base + posBuff + negBuff, 0) / ATTACK_POWER_MAGIC_NUMBER)
end
-- An attack rating and whatever is moving it, on one row. Nothing moving it means the bare
-- number and no hover plate to explain; otherwise the row carries the modified TOTAL, tinted
-- by which way it went, and the plate carries the split that produced it. Both the melee row
-- and the ranged row read exactly this way.
local function setAttackRating(frame, text, base, modifier)
if modifier == 0 then
text:SetText(base)
frame.tooltip = nil
return
end
local color = RED_FONT_COLOR_CODE
local sign = " "
if modifier > 0 then
color = GREEN_FONT_COLOR_CODE
sign = " +"
end
frame.tooltip = base .. color .. sign .. modifier .. FONT_COLOR_CODE_CLOSE
text:SetText(color .. (base + modifier) .. FONT_COLOR_CODE_CLOSE)
end
-- ref PaperDollFrame_SetAttackBothHands (l.391-417) — the weapon-skill line (decision 0208's
-- own "to confirm": the equipped-weapon subclass -> skill-id table is UnitAttackBothHands's
-- own concern, not this file's).
@@ -714,21 +797,8 @@
unit = unit or "player"
prefix = prefix or "Character"
local mainHandAttackBase, mainHandAttackMod = UnitAttackBothHands(unit)
local frame = getglobal(prefix .. "AttackFrame")
local text = getglobal(prefix .. "AttackFrameStatText")
if mainHandAttackMod == 0 then
text:SetText(mainHandAttackBase)
frame.tooltip = nil
else
local color = RED_FONT_COLOR_CODE
if mainHandAttackMod > 0 then
color = GREEN_FONT_COLOR_CODE
frame.tooltip = mainHandAttackBase .. color .. " +" .. mainHandAttackMod .. FONT_COLOR_CODE_CLOSE
else
frame.tooltip = mainHandAttackBase .. color .. " " .. mainHandAttackMod .. FONT_COLOR_CODE_CLOSE
end
text:SetText(color .. (mainHandAttackBase + mainHandAttackMod) .. FONT_COLOR_CODE_CLOSE)
end
setAttackRating(getglobal(prefix .. "AttackFrame"),
getglobal(prefix .. "AttackFrameStatText"), mainHandAttackBase, mainHandAttackMod)
end
-- ref PaperDollFrame_SetRangedAttack (l.419-466). `hasRelic`/UnitHasRelicSlot DROPPED
@@ -758,19 +828,7 @@
PaperDollFrame_SetRangedDamage()
end
if not rangedTexture then return end
if rangedAttackMod == 0 then
text:SetText(rangedAttackBase)
frame.tooltip = nil
else
local color = RED_FONT_COLOR_CODE
if rangedAttackMod > 0 then
color = GREEN_FONT_COLOR_CODE
frame.tooltip = rangedAttackBase .. color .. " +" .. rangedAttackMod .. FONT_COLOR_CODE_CLOSE
else
frame.tooltip = rangedAttackBase .. color .. " " .. rangedAttackMod .. FONT_COLOR_CODE_CLOSE
end
text:SetText(color .. (rangedAttackBase + rangedAttackMod) .. FONT_COLOR_CODE_CLOSE)
end
setAttackRating(frame, text, rangedAttackBase, rangedAttackMod)
end
-- ref PaperDollFrame_SetRangedDamage (l.468-537).
@@ -788,42 +846,18 @@
end
local rangedAttackSpeed, minDamage, maxDamage, physicalBonusPos, physicalBonusNeg, percent =
UnitRangedDamage(unit)
local displayMin = math.max(math.floor(minDamage), 1)
local displayMax = math.max(math.ceil(maxDamage), 1)
minDamage = (minDamage / percent) - physicalBonusPos - physicalBonusNeg
maxDamage = (maxDamage / percent) - physicalBonusPos - physicalBonusNeg
local baseDamage = (minDamage + maxDamage) * 0.5
local fullDamage = (baseDamage + physicalBonusPos + physicalBonusNeg) * percent
local totalBonus = fullDamage - baseDamage
local figure = damageFigure(minDamage, maxDamage)
local rawMin, rawMax, fullDamage, totalBonus =
unscaleSwing(minDamage, maxDamage, physicalBonusPos, physicalBonusNeg, percent)
local damagePerSecond = math.max(fullDamage, 1) / rangedAttackSpeed
local tooltip = math.max(math.floor(minDamage), 1) .. " - " .. math.max(math.ceil(maxDamage), 1)
local tooltip = damageRange(rawMin, rawMax)
if totalBonus == 0 then
if displayMin < 100 and displayMax < 100 then
damageText:SetText(displayMin .. " - " .. displayMax)
else
damageText:SetText(displayMin .. "-" .. displayMax)
end
damageText:SetText(figure)
else
local colorPos = "|cff20ff20"
local colorNeg = "|cffff2020"
local color
if totalBonus > 0 then color = colorPos else color = colorNeg end
if displayMin < 100 and displayMax < 100 then
damageText:SetText(color .. displayMin .. " - " .. displayMax .. "|r")
else
damageText:SetText(color .. displayMin .. "-" .. displayMax .. "|r")
end
if physicalBonusPos > 0 then
tooltip = tooltip .. colorPos .. " +" .. physicalBonusPos .. "|r"
end
if physicalBonusNeg < 0 then
tooltip = tooltip .. colorNeg .. " " .. physicalBonusNeg .. "|r"
end
if percent > 1 then
tooltip = tooltip .. colorPos .. " x" .. math.floor(percent * 100 + 0.5) .. "%|r"
elseif percent < 1 then
tooltip = tooltip .. colorNeg .. " x" .. math.floor(percent * 100 + 0.5) .. "%|r"
end
damageText:SetText(damageColor(totalBonus) .. figure .. "|r")
tooltip = damageModifiers(tooltip, physicalBonusPos, physicalBonusNeg, percent)
-- Only the modified case gets a hover plate here: an unmodified bow has nothing to
-- explain beyond the figure already on the row.
damageFrame.tooltip = tooltip .. " " .. format(DPS_TEMPLATE, damagePerSecond)
end
damageFrame.attackSpeed = rangedAttackSpeed
@@ -988,7 +1022,7 @@
BenillaPaperDollSlot_Update(button)
end
-- Left: the reference's modifier fork verbatim (ref l.647-660) — CTRL previews the worn
-- Left: the reference's own modifier fork (ref l.647-660) — CTRL previews the worn
-- item in the dressing room (decision 1060), SHIFT posts its link into an open chat edit
-- box (decision 1059), and neither falls through to a pickup a real modified click would
-- never trigger. Plain click: PickupInventoryItem (pick up / place / swap / cancel,
@@ -1191,9 +1225,9 @@
-- UIParent.lua:2), against which the 0.03 click nudge above is a ~1.7° tap. Every 1.12 model
-- pane wires it (PaperDollFrame l.222-224, DressUpFrame.xml:202-204, TabardFrame.xml:406-408).
-- It used to be omitted here for want of `GetButtonState` — that method now answers "PUSHED"
-- for a LIVE mouse-held button (decision 0288 P3), so the ref's own loop ships verbatim.
-- Verbatim INCLUDES the ref's sign quirk: the held branches run OPPOSITE to the click helpers
-- (held-LEFT adds, held-RIGHT subtracts). Not a transcription slip — quoted, not re-derived.
-- for a LIVE mouse-held button (decision 0288 P3), so the ref's own loop ships here.
-- That INCLUDES the ref's sign quirk: the held branches run OPPOSITE to the click helpers
-- (held-LEFT adds, held-RIGHT subtracts). Not a slip of ours — the ref's own, not re-derived.
function BenillaPaperDollModel_OnUpdate(frame, elapsed)
local step = elapsed * 2 * PI * ROTATIONS_PER_SECOND
if getglobal(frame:GetName() .. "RotateLeftButton"):GetButtonState() == "PUSHED" then
@@ -1373,7 +1407,7 @@
l.65-78), on the minimize-button art this codebase's close buttons all use (no
UIPanelCloseButton template ships — Gossip/Bag/Merchant's own header comments).
OnLoad raises the frame level +1 (the same idiom CharacterNameFrame above uses,
transcribed from the ref): the window's own art is PaperDollFrame's BACKGROUND
the ref's own): the window's own art is PaperDollFrame's BACKGROUND
layer — a full-window sibling created AFTER this button — so at the shared default
level 0 the page's art paints over the button by creation order (order.rs). The ref's
close button rides the client's show-time bucket-tail re-insertion instead; benilla
+3 -3
View File
@@ -230,7 +230,7 @@
chatType("BATTLEGROUND", 1, 93, 255, 127, 0)
chatType("BATTLEGROUND_LEADER", 0, 94, 255, 219, 183)
-- ── BenillaFCF: the FloatingChatFrame behaviors, transcribed (ref FloatingChatFrame.lua)
-- ── BenillaFCF: the FloatingChatFrame behaviors, re-expressed (ref FloatingChatFrame.lua)
-- Quoted constants: CHAT_TAB_SHOW_DELAY=0.2, CHAT_FRAME_FADE_TIME=0.15,
-- DEFAULT_CHATFRAME_ALPHA=0.25, CHAT_BUTTON_FLASH_TIME=0.5,
-- MESSAGE_SCROLLBUTTON_INITIAL_DELAY=0, MESSAGE_SCROLLBUTTON_SCROLL_DELAY=0.05.
@@ -328,7 +328,7 @@
BenillaFCF.flashLeft = 60
end
-- PanelTemplates_TabResize(5, tab), transcribed: the ref sizes every docked tab to its
-- PanelTemplates_TabResize(5, tab), re-expressed: the ref sizes every docked tab to its
-- label (FCF_DockUpdate) — middle slice = text width + 5, tab = middle + the two 16px
-- caps; the flash/highlight bands follow the Left/Right pieces by anchor. The label
-- width is host-measured (GetStringWidth), so early frames may see 0 — the OnUpdate
@@ -470,7 +470,7 @@
end
end
-- ── BenillaUIMenu: the 1.12 UIMenu kit, transcribed (ref UIMenu.lua — the chat menu's
-- ── BenillaUIMenu: the 1.12 UIMenu kit, re-expressed (ref UIMenu.lua — the chat menu's
-- row engine: fixed 16px rows in a tooltip-backdrop box, a 2 s no-hover timeout that
-- walks up parentMenu chains, nested submenus opened on row hover). Constants quoted.
-- Scoped here because the chat menu is its only consumer; a second consumer graduates
@@ -1,9 +1,12 @@
<Ui>
<!--
Interface\FrameXML\ColorPickerFrame.xml, transcribed — the shared colour dialog. Line
numbers below are that file's (336 lines, XML only; it ships no `.lua`, its whole script is
inline and the one function that drives it, `UIDropDownMenuButton_OpenColorPicker`, lives in
UIDropDownMenu.lua).
The shared colour dialog — our own implementation of the window that
Interface\FrameXML\ColorPickerFrame.xml specifies. Line numbers below are that file's (336
lines, XML only; it ships no `.lua`, its whole script is inline and the one function that
drives it, `UIDropDownMenuButton_OpenColorPicker`, lives in UIDropDownMenu.lua). It is
cited throughout as the SPECIFICATION — for the names, the geometry, the sounds and the
call order, all of which are the API addons are written against and none of which we are
free to invent. The Lua in this file is ours.
WHY IT IS HERE, in the arc's own terms: **86 of the 218 corpus addons reach
`ColorPickerFrame`, 80 reach `OpacitySliderFrame`** — and 1207's lesson applies before those
@@ -139,17 +142,26 @@
</Layer>
</Layers>
<Frames>
<!-- ref l.60-77. Cancel is declared FIRST because Okay anchors to it. Its OnClick is the
reference's, verbatim: hide, then hand the addon back what it had. -->
<!-- ref l.60-77 for the name, the geometry and the sound. Cancel is declared FIRST
because Okay anchors to it. -->
<Button name="ColorPickerCancelButton" inherits="GameMenuButtonTemplate" text="CANCEL">
<Anchors>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-10" y="10"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnClick>
-- Dismiss first, undo second — the undo is the CALLER's code, and a
-- cancelFunc that opens a window of its own should not have to fight this
-- one for the panel slot.
HideUIPanel(this:GetParent());
if ( ColorPickerFrame.cancelFunc ) then
ColorPickerFrame.cancelFunc(ColorPickerFrame.previousValues);
-- This window remembers nothing itself: `previousValues` is the snapshot
-- whoever opened it left behind (`UIDropDownMenuButton_OpenColorPicker`
-- fills it in; Dewdrop closes over its own copy and ignores it) and
-- `cancelFunc` is the caller's restore. Both are optional fields on the
-- frame, so a caller that set neither simply gets the window closed.
local cancelFunc = ColorPickerFrame.cancelFunc;
if ( cancelFunc ) then
cancelFunc(ColorPickerFrame.previousValues);
end
PlaySound("igMainMenuOptionCheckBoxOn");
</OnClick>
@@ -166,9 +178,16 @@
<Scripts>
<OnClick>
HideUIPanel(this:GetParent());
-- Commit. Neither callback is handed the values: both read the widget and
-- the slider for themselves, which is the contract every corpus caller is
-- written against. `func` is deliberately NOT guarded (header) — a caller
-- that opened the window without one should hear about it. The alpha half
-- genuinely is optional: a caller that never asked for opacity has no
-- reason to supply one.
ColorPickerFrame.func();
if ( ColorPickerFrame.opacityFunc ) then
ColorPickerFrame.opacityFunc();
local opacityFunc = ColorPickerFrame.opacityFunc;
if ( opacityFunc ) then
opacityFunc();
end
PlaySound("igMainMenuOptionCheckBoxOn");
</OnClick>
@@ -209,8 +228,11 @@
</Layers>
<Scripts>
<OnValueChanged>
if ( ColorPickerFrame.opacityFunc ) then
ColorPickerFrame.opacityFunc();
-- Live preview: every step of the drag reaches the caller, which is what
-- makes the alpha follow the thumb instead of jumping at Okay.
local opacityFunc = ColorPickerFrame.opacityFunc;
if ( opacityFunc ) then
opacityFunc();
end
</OnValueChanged>
</Scripts>
@@ -220,17 +242,23 @@
</Slider>
</Frames>
<Scripts>
<!-- ref l.161-170. The opacity slider is opt-in per caller, and the window narrows by
the slider's 60 units when nobody asked for it. -->
<!-- ref l.161-170 for the two widths. The opacity slider is opt-in per caller, and the
window narrows by the slider's 60 units when nobody asked for it. -->
<OnShow>
-- Seeding the slider AFTER showing it is deliberate: SetValue fires
-- OnValueChanged, so the caller's opacityFunc runs against a slider that is
-- already on screen and against a window still at its old width — the reference's
-- own order, and observable to any addon whose preview writes through. The width
-- is settled once, at the end, instead of in both arms.
local width = 305;
if ( this.hasOpacity ) then
OpacitySliderFrame:Show();
OpacitySliderFrame:SetValue(this.opacity);
this:SetWidth(365);
width = 365;
else
OpacitySliderFrame:Hide();
this:SetWidth(305);
end
this:SetWidth(width);
</OnShow>
<!-- ref l.171-176. The widget's own slot, fired by SetColorRGB with the colour the
widget now holds (colorselect.rs). This is the ONLY thing that paints the swatch —
+296 -209
View File
@@ -1,11 +1,14 @@
<Ui>
<!--
The center-screen scrolling combat text (decision 0578) — the 1.11+ "Floating Combat Text"
option. TRANSCRIBED from the shipped load-on-demand addon `Blizzard_CombatText`
(patch.MPQ `Interface\AddOns\Blizzard_CombatText\`, extracted to
WoW/_extracted_addons/Blizzard_CombatText/): screen-space FontStrings scrolling up from
UIParent-bottom +384 (≈ over the character), driven by the engine events the addon
registers. Deviations from the ref, each named:
option, written here in our own terms. The shipped load-on-demand addon
`Blizzard_CombatText` (patch.MPQ `Interface\AddOns\Blizzard_CombatText\`, extracted to
WoW/_extracted_addons/Blizzard_CombatText/) is the SPECIFICATION, and what it fixes is
contract: the message-type keys, the option family, the scroll/fade/crit envelope and
every number in them are all addressed by name from addons, so they are reproduced exactly
while the code that carries them is ours. The picture: screen-space FontStrings scrolling
up from UIParent-bottom +384 (≈ over the character), driven by the engine events this
frame registers. Where our behaviour departs from the spec, it is named:
- The ref is load-on-demand (`UIParentLoadAddOn` when SHOW_COMBAT_TEXT == "1"); we load
always and let CombatText_UpdateDisplayedMessages gate registration, ref-identically.
- SHOW_COMBAT_TEXT defaults "1" here (ref "0") — the director's ask IS the enabled
@@ -20,63 +23,91 @@
gates, not this file (0875).
- Lua 5.0 `for k,v in t` iteration becomes explicit index loops (5.1 VM), backwards
where the body removes.
- COMBAT_TEXT_TYPE_INFO is grown from a compact spec (colour · gate · flags · the types
that share them) walked by the loop under it, instead of one literal per row: the same
facts with the repetition taken out. The table it produces IS the contract — the same
keys with the same values, and one fresh entry table per key, because `show` is
rewritten per entry and an addon may edit any of them.
- The two low-resource warnings, the two flow directions in CombatText_AddMessage, and the
"park a string back at the seat" step all appear once here; the spec writes each of them
out twice.
The wire → messageType mapping feeding COMBAT_TEXT_UPDATE lives app-side
(net/apply/combat_log.rs + ui_unit.rs) and is PROVISIONAL pending the dispatched wow-re
emission pin (decision 0578).
-->
<Script><![CDATA[
-- ── The pool ────────────────────────────────────────────────────────────────────────────
-- One screen line per pool string, and the list of the ones currently in flight. The pool
-- is fixed: a message arriving with all twenty busy evicts the oldest.
NUM_COMBAT_TEXT_LINES = 20
COMBAT_TEXT_SCROLLSPEED = 1.9
COMBAT_TEXT_FADEOUT_TIME = 1.3
COMBAT_TEXT_HEIGHT = 25
COMBAT_TEXT_CRIT_MAXHEIGHT = 60
COMBAT_TEXT_CRIT_MINHEIGHT = 30
COMBAT_TEXT_CRIT_SCALE_TIME = 0.05
COMBAT_TEXT_CRIT_SHRINKTIME = 0.2
COMBAT_TEXT_TO_ANIMATE = {}
COMBAT_TEXT_STAGGER_RANGE = 20
-- ── Where a line sits ───────────────────────────────────────────────────────────────────
-- COMBAT_TEXT_LOCATIONS is the seat and the target the current float mode implies; it is
-- filled in by CombatText_UpdateDisplayedMessages, not here. A new line is pushed clear of
-- everything already in flight by one line plus COMBAT_TEXT_SPACING, but the stack never
-- runs further than COMBAT_TEXT_MAX_OFFSET from the seat — past that it starts over. The
-- staggered types jitter sideways by up to half COMBAT_TEXT_STAGGER_RANGE either way, so a
-- flurry of hits does not print as one column.
COMBAT_TEXT_LOCATIONS = {}
COMBAT_TEXT_SPACING = 10
COMBAT_TEXT_MAX_OFFSET = 130
COMBAT_TEXT_STAGGER_RANGE = 20
-- ── How long a line lives ───────────────────────────────────────────────────────────────
-- It travels for COMBAT_TEXT_SCROLLSPEED seconds; COMBAT_TEXT_FADEOUT_TIME is when the
-- fade STARTS, not how long it takes, so the fade is the tail of the same window.
COMBAT_TEXT_SCROLLSPEED = 1.9
COMBAT_TEXT_FADEOUT_TIME = 1.3
-- ── How big it is drawn ─────────────────────────────────────────────────────────────────
-- An ordinary line is COMBAT_TEXT_HEIGHT and stays there. A crit "pops": seeded at the
-- minimum, grown to the maximum across CRIT_SCALE_TIME, and back down to ordinary size by
-- CRIT_SHRINKTIME (CombatText_OnUpdate walks that envelope).
COMBAT_TEXT_HEIGHT = 25
COMBAT_TEXT_CRIT_MINHEIGHT = 30
COMBAT_TEXT_CRIT_MAXHEIGHT = 60
COMBAT_TEXT_CRIT_SCALE_TIME = 0.05
COMBAT_TEXT_CRIT_SHRINKTIME = 0.2
-- ── When the low-resource warnings latch ────────────────────────────────────────────────
-- Fractions of the bar, tested on the way down; see CombatText_LowResourceWarning.
COMBAT_TEXT_LOW_HEALTH_THRESHOLD = 0.2
COMBAT_TEXT_LOW_MANA_THRESHOLD = 0.2
COMBAT_TEXT_LOCATIONS = {}
-- The option family's saved-variables (ref UIOptionsFrame.lua l.125-152), at the ref
-- defaults except the master (see the header). Each is assigned AT FILE SCOPE and
-- registered on the line under it — the reference's own load order (decision 1128):
-- defaults first, the saved chunk over the top, consumers third; reverse any two and a
-- saved value can never win. The Options window's Combat page drives all fourteen
-- (decision 1134) and takes its Defaults values from these very assignments, read at the
-- row's OnLoad — which runs before the saved chunk — so each default lives in one place,
-- here, and the panel cannot drift from it.
SHOW_COMBAT_TEXT = "1"
RegisterForSave("SHOW_COMBAT_TEXT")
COMBAT_TEXT_SHOW_LOW_HEALTH_MANA = "1"
RegisterForSave("COMBAT_TEXT_SHOW_LOW_HEALTH_MANA")
COMBAT_TEXT_SHOW_AURAS = "1"
RegisterForSave("COMBAT_TEXT_SHOW_AURAS")
COMBAT_TEXT_SHOW_AURA_FADE = "0"
RegisterForSave("COMBAT_TEXT_SHOW_AURA_FADE")
COMBAT_TEXT_SHOW_COMBAT_STATE = "1"
RegisterForSave("COMBAT_TEXT_SHOW_COMBAT_STATE")
COMBAT_TEXT_SHOW_DODGE_PARRY_MISS = "0"
RegisterForSave("COMBAT_TEXT_SHOW_DODGE_PARRY_MISS")
COMBAT_TEXT_SHOW_RESISTANCES = "0"
RegisterForSave("COMBAT_TEXT_SHOW_RESISTANCES")
COMBAT_TEXT_SHOW_REPUTATION = "0"
RegisterForSave("COMBAT_TEXT_SHOW_REPUTATION")
COMBAT_TEXT_SHOW_REACTIVES = "0"
RegisterForSave("COMBAT_TEXT_SHOW_REACTIVES")
COMBAT_TEXT_SHOW_FRIENDLY_NAMES = "0"
RegisterForSave("COMBAT_TEXT_SHOW_FRIENDLY_NAMES")
COMBAT_TEXT_SHOW_COMBO_POINTS = "0"
RegisterForSave("COMBAT_TEXT_SHOW_COMBO_POINTS")
COMBAT_TEXT_SHOW_MANA = "0"
RegisterForSave("COMBAT_TEXT_SHOW_MANA")
COMBAT_TEXT_SHOW_HONOR_GAINED = "1"
RegisterForSave("COMBAT_TEXT_SHOW_HONOR_GAINED")
COMBAT_TEXT_FLOAT_MODE = "1"
RegisterForSave("COMBAT_TEXT_FLOAT_MODE")
-- The option family: every saved global this feature owns, next to the value it ships
-- with. All fourteen sit at the spec's defaults (UIOptionsFrame.lua l.69-84) except the
-- master SHOW_COMBAT_TEXT, which ships "1" against the spec's "0" — the director's ask IS
-- the enabled experience (see the header).
--
-- Each name is assigned AT FILE SCOPE and registered for saving in the same pass, because
-- the load order is what makes a saved value stick (decision 1128): defaults first, the
-- saved chunk over the top, consumers third; reverse any two and a saved value can never
-- win. The Options window's Combat page drives all fourteen (decision 1134) and takes its
-- "Defaults" from these very globals, read at each row's OnLoad — which runs before the
-- saved chunk — so a default lives in exactly one place, this table, and the panel cannot
-- drift from it.
local combatTextOptions = {
{"SHOW_COMBAT_TEXT", "1"},
{"COMBAT_TEXT_SHOW_LOW_HEALTH_MANA", "1"},
{"COMBAT_TEXT_SHOW_AURAS", "1"},
{"COMBAT_TEXT_SHOW_AURA_FADE", "0"},
{"COMBAT_TEXT_SHOW_COMBAT_STATE", "1"},
{"COMBAT_TEXT_SHOW_DODGE_PARRY_MISS", "0"},
{"COMBAT_TEXT_SHOW_RESISTANCES", "0"},
{"COMBAT_TEXT_SHOW_REPUTATION", "0"},
{"COMBAT_TEXT_SHOW_REACTIVES", "0"},
{"COMBAT_TEXT_SHOW_FRIENDLY_NAMES", "0"},
{"COMBAT_TEXT_SHOW_COMBO_POINTS", "0"},
{"COMBAT_TEXT_SHOW_MANA", "0"},
{"COMBAT_TEXT_SHOW_HONOR_GAINED", "1"},
{"COMBAT_TEXT_FLOAT_MODE", "1"},
}
for i = 1, getn(combatTextOptions) do
local name, default = combatTextOptions[i][1], combatTextOptions[i][2]
setglobal(name, default)
RegisterForSave(name)
end
-- The message words (shipped enUS GlobalStrings values, hardcoded like the rest of our
-- enUS-only data; or-guarded so an earlier definition wins).
@@ -108,66 +139,126 @@
COMBAT_TEXT_HONOR_GAINED = COMBAT_TEXT_HONOR_GAINED or "Honor %s"
--[[
List of COMBAT_TEXT_TYPE_INFO attributes (ref comment, verbatim)
======================================================
r, g, b = [floats] -- The floating text color
show = [nil, 1] -- Display this message type in the UI
isStaggered = [nil, 1] -- Randomly stagger these messages from left to right
var = [nil, 1] -- This messageType is shown if this variable resolves to "1"
COMBAT_TEXT_TYPE_INFO — one entry per message type, and the four things an entry says:
r, g, b the colour the line prints in, as 0..1 floats
show 1 while this type is currently being printed, nil while it is not.
CombatText_UpdateDisplayedMessages rewrites it for every gated type from
the option `var` names, so an entry carrying `var` and no `show` has
merely not been refreshed yet, while an entry carrying `show` and no
`var` is on for as long as the feature itself is
isStaggered 1 to jitter the line sideways instead of printing it on the centre line
var the name of the saved global that gates this type; nil = ungated
This table is API: addons index it by message-type key and read and write these fields, so
the keys and the values are exactly the specification's. The rows below are OURS — almost
every type shares its colour and its gate with several others, so the facts are written
once per group and the loop underneath expands them into one entry per type. Fresh table
per entry, never a shared one: `show` is rewritten per entry, and an addon may edit any
single entry without that leaking into its neighbours.
]]
-- The six colours the whole table draws from.
local CT_WHITE = {1, 1, 1}
local CT_RED = {1, 0.1, 0.1}
local CT_GREEN = {0.1, 1, 0.1}
local CT_BLUE = {0.1, 0.1, 1}
local CT_VIOLET = {0.79, 0.3, 0.85}
local CT_GOLD = {1, 0.82, 0}
-- Colour · gate · flags · the message types that share them. `always` is the `show` an
-- ungated type is born with; `jitter` is `isStaggered`; `gate` is `var`.
local combatTextSpec = {
-- Always on. Only the master toggle takes these away.
{colour = CT_RED, always = 1, jitter = 1, types = {"DAMAGE"}},
{colour = CT_RED, always = 1, types = {"DAMAGE_CRIT"}},
{colour = CT_VIOLET, always = 1, types = {"SPELL_DAMAGE"}},
{colour = CT_WHITE, always = 1, types = {"SPLIT_DAMAGE"}},
{colour = CT_GREEN, always = 1, types = {"HEAL", "HEAL_CRIT", "PERIODIC_HEAL", "SPELL_CAST"}},
-- Never on by themselves: no `always`, no gate, so `show` is nil and stays nil. They
-- carry a colour so that anything which does switch one on has one ready.
{colour = CT_WHITE, types = {"INTERRUPT", "ENCHANTMENT_ADDED", "ENCHANTMENT_REMOVED",
"DISPEL_FAILED", "SPELL_CAST_START", "SPELL_DISPELLED", "DAMAGE_SHIELD",
"EXTRA_ATTACKS"}},
-- "Show dodge/parry/miss": swings that went nowhere, melee in red and spells in white.
-- The melee whiffs jitter because several can land in the same instant.
{colour = CT_RED, gate = "COMBAT_TEXT_SHOW_DODGE_PARRY_MISS", jitter = 1,
types = {"MISS", "DODGE", "PARRY", "EVADE"}},
{colour = CT_RED, gate = "COMBAT_TEXT_SHOW_DODGE_PARRY_MISS",
types = {"IMMUNE", "DEFLECT"}},
{colour = CT_WHITE, gate = "COMBAT_TEXT_SHOW_DODGE_PARRY_MISS",
types = {"SPELL_MISSED", "SPELL_DODGED", "SPELL_PARRIED", "SPELL_EVADED",
"SPELL_IMMUNE", "SPELL_DEFLECTED", "SPELL_REFLECTED"}},
-- "Show resistances": a hit that landed but was partly eaten.
{colour = CT_RED, gate = "COMBAT_TEXT_SHOW_RESISTANCES",
types = {"BLOCK", "ABSORB", "RESIST", "PROC_RESISTED"}},
{colour = CT_WHITE, gate = "COMBAT_TEXT_SHOW_RESISTANCES", types = {"SPELL_BLOCKED"}},
{colour = CT_VIOLET, gate = "COMBAT_TEXT_SHOW_RESISTANCES",
types = {"SPELL_ABSORBED", "SPELL_RESISTED"}},
-- The aura pair — what landed, and what wore off — each split by whose side it helps.
{colour = CT_GREEN, gate = "COMBAT_TEXT_SHOW_AURAS", types = {"AURA_START"}},
{colour = CT_RED, gate = "COMBAT_TEXT_SHOW_AURAS", types = {"AURA_START_HARMFUL"}},
{colour = CT_GREEN, gate = "COMBAT_TEXT_SHOW_AURA_FADE", types = {"AURA_END"}},
{colour = CT_RED, gate = "COMBAT_TEXT_SHOW_AURA_FADE", types = {"AURA_END_HARMFUL"}},
-- Resources and standing: everything you gained rather than took.
{colour = CT_BLUE, gate = "COMBAT_TEXT_SHOW_MANA",
types = {"MANA", "RAGE", "FOCUS", "ENERGY"}},
{colour = CT_BLUE, gate = "COMBAT_TEXT_SHOW_COMBO_POINTS", types = {"COMBO_POINTS"}},
{colour = CT_BLUE, gate = "COMBAT_TEXT_SHOW_REPUTATION", types = {"FACTION"}},
{colour = CT_BLUE, gate = "COMBAT_TEXT_SHOW_HONOR_GAINED", types = {"HONOR_GAINED"}},
-- The state warnings, each on its own switch.
{colour = CT_RED, gate = "COMBAT_TEXT_SHOW_LOW_HEALTH_MANA",
types = {"HEALTH_LOW", "MANA_LOW"}},
{colour = CT_RED, gate = "COMBAT_TEXT_SHOW_COMBAT_STATE",
types = {"ENTERING_COMBAT", "LEAVING_COMBAT"}},
{colour = CT_GOLD, gate = "COMBAT_TEXT_SHOW_REACTIVES", types = {"SPELL_ACTIVE"}},
}
COMBAT_TEXT_TYPE_INFO = {}
COMBAT_TEXT_TYPE_INFO["INTERRUPT"] = {r = 1, g = 1, b = 1}
COMBAT_TEXT_TYPE_INFO["DAMAGE_CRIT"] = {r = 1, g = 0.1, b = 0.1, show = 1}
COMBAT_TEXT_TYPE_INFO["DAMAGE"] = {r = 1, g = 0.1, b = 0.1, isStaggered = 1, show = 1}
COMBAT_TEXT_TYPE_INFO["MISS"] = {r = 1, g = 0.1, b = 0.1, isStaggered = 1, var = "COMBAT_TEXT_SHOW_DODGE_PARRY_MISS"}
COMBAT_TEXT_TYPE_INFO["BLOCK"] = {r = 1, g = 0.1, b = 0.1, var = "COMBAT_TEXT_SHOW_RESISTANCES"}
COMBAT_TEXT_TYPE_INFO["ABSORB"] = {r = 1, g = 0.1, b = 0.1, var = "COMBAT_TEXT_SHOW_RESISTANCES"}
COMBAT_TEXT_TYPE_INFO["RESIST"] = {r = 1, g = 0.1, b = 0.1, var = "COMBAT_TEXT_SHOW_RESISTANCES"}
COMBAT_TEXT_TYPE_INFO["EVADE"] = {r = 1, g = 0.1, b = 0.1, isStaggered = 1, var = "COMBAT_TEXT_SHOW_DODGE_PARRY_MISS"}
COMBAT_TEXT_TYPE_INFO["DODGE"] = {r = 1, g = 0.1, b = 0.1, isStaggered = 1, var = "COMBAT_TEXT_SHOW_DODGE_PARRY_MISS"}
COMBAT_TEXT_TYPE_INFO["PARRY"] = {r = 1, g = 0.1, b = 0.1, isStaggered = 1, var = "COMBAT_TEXT_SHOW_DODGE_PARRY_MISS"}
COMBAT_TEXT_TYPE_INFO["IMMUNE"] = {r = 1, g = 0.1, b = 0.1, var = "COMBAT_TEXT_SHOW_DODGE_PARRY_MISS"}
COMBAT_TEXT_TYPE_INFO["DEFLECT"] = {r = 1, g = 0.1, b = 0.1, var = "COMBAT_TEXT_SHOW_DODGE_PARRY_MISS"}
COMBAT_TEXT_TYPE_INFO["ENCHANTMENT_REMOVED"] = {r = 1, g = 1, b = 1}
COMBAT_TEXT_TYPE_INFO["ENCHANTMENT_ADDED"] = {r = 1, g = 1, b = 1}
COMBAT_TEXT_TYPE_INFO["PERIODIC_HEAL"] = {r = 0.1, g = 1, b = 0.1, show = 1}
COMBAT_TEXT_TYPE_INFO["MANA"] = {r = 0.1, g = 0.1, b = 1, var = "COMBAT_TEXT_SHOW_MANA"}
COMBAT_TEXT_TYPE_INFO["RAGE"] = {r = 0.1, g = 0.1, b = 1, var = "COMBAT_TEXT_SHOW_MANA"}
COMBAT_TEXT_TYPE_INFO["FOCUS"] = {r = 0.1, g = 0.1, b = 1, var = "COMBAT_TEXT_SHOW_MANA"}
COMBAT_TEXT_TYPE_INFO["ENERGY"] = {r = 0.1, g = 0.1, b = 1, var = "COMBAT_TEXT_SHOW_MANA"}
COMBAT_TEXT_TYPE_INFO["SPELL_ABSORBED"] = {r = 0.79, g = 0.3, b = 0.85, var = "COMBAT_TEXT_SHOW_RESISTANCES"}
COMBAT_TEXT_TYPE_INFO["SPELL_RESISTED"] = {r = 0.79, g = 0.3, b = 0.85, var = "COMBAT_TEXT_SHOW_RESISTANCES"}
COMBAT_TEXT_TYPE_INFO["PROC_RESISTED"] = {r = 1, g = 0.1, b = 0.1, var = "COMBAT_TEXT_SHOW_RESISTANCES"}
COMBAT_TEXT_TYPE_INFO["DISPEL_FAILED"] = {r = 1, g = 1, b = 1}
COMBAT_TEXT_TYPE_INFO["SPELL_CAST"] = {r = 0.1, g = 1, b = 0.1, show = 1}
COMBAT_TEXT_TYPE_INFO["SPELL_CAST_START"] = {r = 1, g = 1, b = 1}
COMBAT_TEXT_TYPE_INFO["AURA_END"] = {r = 0.1, g = 1, b = 0.1, var = "COMBAT_TEXT_SHOW_AURA_FADE"}
COMBAT_TEXT_TYPE_INFO["AURA_END_HARMFUL"] = {r = 1, g = 0.1, b = 0.1, var = "COMBAT_TEXT_SHOW_AURA_FADE"}
COMBAT_TEXT_TYPE_INFO["AURA_START"] = {r = 0.1, g = 1, b = 0.1, var = "COMBAT_TEXT_SHOW_AURAS"}
COMBAT_TEXT_TYPE_INFO["AURA_START_HARMFUL"] = {r = 1, g = 0.1, b = 0.1, var = "COMBAT_TEXT_SHOW_AURAS"}
COMBAT_TEXT_TYPE_INFO["SPELL_DAMAGE"] = {r = 0.79, g = 0.3, b = 0.85, show = 1}
COMBAT_TEXT_TYPE_INFO["SPELL_DODGED"] = {r = 1, g = 1, b = 1, var = "COMBAT_TEXT_SHOW_DODGE_PARRY_MISS"}
COMBAT_TEXT_TYPE_INFO["SPELL_PARRIED"] = {r = 1, g = 1, b = 1, var = "COMBAT_TEXT_SHOW_DODGE_PARRY_MISS"}
COMBAT_TEXT_TYPE_INFO["SPELL_BLOCKED"] = {r = 1, g = 1, b = 1, var = "COMBAT_TEXT_SHOW_RESISTANCES"}
COMBAT_TEXT_TYPE_INFO["SPELL_EVADED"] = {r = 1, g = 1, b = 1, var = "COMBAT_TEXT_SHOW_DODGE_PARRY_MISS"}
COMBAT_TEXT_TYPE_INFO["SPELL_IMMUNE"] = {r = 1, g = 1, b = 1, var = "COMBAT_TEXT_SHOW_DODGE_PARRY_MISS"}
COMBAT_TEXT_TYPE_INFO["SPELL_DEFLECTED"] = {r = 1, g = 1, b = 1, var = "COMBAT_TEXT_SHOW_DODGE_PARRY_MISS"}
COMBAT_TEXT_TYPE_INFO["SPELL_REFLECTED"] = {r = 1, g = 1, b = 1, var = "COMBAT_TEXT_SHOW_DODGE_PARRY_MISS"}
COMBAT_TEXT_TYPE_INFO["SPELL_MISSED"] = {r = 1, g = 1, b = 1, var = "COMBAT_TEXT_SHOW_DODGE_PARRY_MISS"}
COMBAT_TEXT_TYPE_INFO["SPELL_ACTIVE"] = {r = 1, g = 0.82, b = 0, var = "COMBAT_TEXT_SHOW_REACTIVES"}
COMBAT_TEXT_TYPE_INFO["FACTION"] = {r = 0.1, g = 0.1, b = 1, var = "COMBAT_TEXT_SHOW_REPUTATION"}
COMBAT_TEXT_TYPE_INFO["HEAL_CRIT"] = {r = 0.1, g = 1, b = 0.1, show = 1}
COMBAT_TEXT_TYPE_INFO["HEAL"] = {r = 0.1, g = 1, b = 0.1, show = 1}
COMBAT_TEXT_TYPE_INFO["DAMAGE_SHIELD"] = {r = 1, g = 1, b = 1}
COMBAT_TEXT_TYPE_INFO["SPELL_DISPELLED"] = {r = 1, g = 1, b = 1}
COMBAT_TEXT_TYPE_INFO["EXTRA_ATTACKS"] = {r = 1, g = 1, b = 1}
COMBAT_TEXT_TYPE_INFO["SPLIT_DAMAGE"] = {r = 1, g = 1, b = 1, show = 1}
COMBAT_TEXT_TYPE_INFO["HONOR_GAINED"] = {r = 0.1, g = 0.1, b = 1, var = "COMBAT_TEXT_SHOW_HONOR_GAINED"}
COMBAT_TEXT_TYPE_INFO["HEALTH_LOW"] = {r = 1, g = 0.1, b = 0.1, var = "COMBAT_TEXT_SHOW_LOW_HEALTH_MANA"}
COMBAT_TEXT_TYPE_INFO["MANA_LOW"] = {r = 1, g = 0.1, b = 0.1, var = "COMBAT_TEXT_SHOW_LOW_HEALTH_MANA"}
COMBAT_TEXT_TYPE_INFO["ENTERING_COMBAT"] = {r = 1, g = 0.1, b = 0.1, var = "COMBAT_TEXT_SHOW_COMBAT_STATE"}
COMBAT_TEXT_TYPE_INFO["LEAVING_COMBAT"] = {r = 1, g = 0.1, b = 0.1, var = "COMBAT_TEXT_SHOW_COMBAT_STATE"}
COMBAT_TEXT_TYPE_INFO["COMBO_POINTS"] = {r = 0.1, g = 0.1, b = 1, var = "COMBAT_TEXT_SHOW_COMBO_POINTS"}
for i = 1, getn(combatTextSpec) do
local row = combatTextSpec[i]
for j = 1, getn(row.types) do
COMBAT_TEXT_TYPE_INFO[row.types[j]] = {
r = row.colour[1],
g = row.colour[2],
b = row.colour[3],
show = row.always,
isStaggered = row.jitter,
var = row.gate,
}
end
end
-- A latched low-resource warning. The line prints on the crossing INTO the danger band and
-- not again until the bar has climbed back out of it — otherwise every tick of a fight
-- spent under 20% would print another one. `flag` names the field on the CombatText frame
-- that remembers we already warned; `low` is the caller's verdict, because "low" is not
-- the same question for the two bars (mana additionally requires the bar to BE mana: a
-- rage or energy user is never low on mana). Returns the message type to print, or nil.
local function CombatText_LowResourceWarning(flag, low, messageType)
if not low then
CombatText[flag] = nil
return nil
end
if CombatText[flag] then
return nil
end
CombatText[flag] = 1
return messageType
end
-- The engine events whose entire meaning is "print this message type". Everything else
-- either carries its own type in arg1 (COMBAT_TEXT_UPDATE) or IS its own type.
local combatTextStateMessage = {
PLAYER_REGEN_DISABLED = "ENTERING_COMBAT",
PLAYER_REGEN_ENABLED = "LEAVING_COMBAT",
}
function CombatText_OnLoad()
CombatText_UpdateDisplayedMessages()
@@ -175,70 +266,57 @@
end
function CombatText_OnEvent(event)
-- Set up the messageType
-- What we are about to print: the type (which picks the colour and the gating), the
-- payload it formats from, the finished line, and the optional "crit" presentation.
local messageType, message
-- Set the message data
local data = arg2
local displayType
if event == "UNIT_HEALTH" then
if arg1 == "player" then
if UnitHealth("player") / UnitHealthMax("player") <= COMBAT_TEXT_LOW_HEALTH_THRESHOLD then
if not CombatText.lowHealth then
messageType = "HEALTH_LOW"
CombatText.lowHealth = 1
end
else
CombatText.lowHealth = nil
end
local low = UnitHealth("player") / UnitHealthMax("player") <= COMBAT_TEXT_LOW_HEALTH_THRESHOLD
messageType = CombatText_LowResourceWarning("lowHealth", low, "HEALTH_LOW")
end
if not messageType then
return
end
elseif event == "UNIT_POWER_UPDATE" then
-- Ref: UNIT_MANA + UnitMana/UnitManaMax (header deviation — the Era surface).
-- Spec: UNIT_MANA + UnitMana/UnitManaMax (header deviation — the Era surface).
-- Power type 0 is mana; the same event also carries rage, focus and energy, none
-- of which has a low warning.
local mana = UnitMana("player")
local powerType = UnitPowerType("player")
if arg1 == "player" then
if mana / UnitManaMax("player") <= COMBAT_TEXT_LOW_MANA_THRESHOLD and powerType == 0 then
if not CombatText.lowMana then
messageType = "MANA_LOW"
CombatText.lowMana = 1
end
else
CombatText.lowMana = nil
end
local low = mana / UnitManaMax("player") <= COMBAT_TEXT_LOW_MANA_THRESHOLD and powerType == 0
messageType = CombatText_LowResourceWarning("lowMana", low, "MANA_LOW")
end
if not messageType then
return
end
elseif event == "PLAYER_REGEN_DISABLED" then
messageType = "ENTERING_COMBAT"
elseif event == "PLAYER_REGEN_ENABLED" then
messageType = "LEAVING_COMBAT"
elseif event == "PLAYER_COMBO_POINTS" then
local comboPoints = GetComboPoints()
if comboPoints > 0 then
messageType = "COMBO_POINTS"
data = comboPoints
if MAX_COMBO_POINTS and comboPoints == MAX_COMBO_POINTS then
displayType = "crit"
end
else
-- The count itself is the payload, and a full bar is worth a crit-sized line.
-- Spending them fires this too, with nothing left to announce.
data = GetComboPoints()
if data <= 0 then
return
end
messageType = "COMBO_POINTS"
if data == MAX_COMBO_POINTS then
displayType = "crit"
end
elseif event == "COMBAT_TEXT_UPDATE" then
messageType = arg1
else
messageType = event
messageType = combatTextStateMessage[event] or event
end
-- Process the messageType and format the message
-- The type decides the colour and whether this is printed at all. An unknown type
-- is white and, having no `show`, prints nothing — which is how a message type we do
-- not know about stays silent instead of erroring.
local info = COMBAT_TEXT_TYPE_INFO[messageType]
if not info then
info = {r = 1, g = 1, b = 1}
end
-- See if we should display the message or not
if not info.show then
return
end
@@ -319,7 +397,8 @@
end
end
-- Add the message
-- A branch that produced no line (the empty type, or a global-string lookup that
-- came back nil) simply prints nothing.
if message then
CombatText_AddMessage(message, COMBAT_TEXT_SCROLL_FUNCTION, info.r, info.g, info.b, displayType, isStaggered)
end
@@ -341,7 +420,8 @@
local alpha = 1 - ((value.scrollTime - COMBAT_TEXT_FADEOUT_TIME) / (COMBAT_TEXT_SCROLLSPEED - COMBAT_TEXT_FADEOUT_TIME))
value:SetAlpha(max(alpha, 0))
end
-- Handle crit
-- The crit pop: up to the maximum inside the scale window, back down by the
-- shrink time, then it stops being a crit and holds its size.
if value.isCrit then
if value.scrollTime <= COMBAT_TEXT_CRIT_SCALE_TIME then
value:SetTextHeight(floor(COMBAT_TEXT_CRIT_MINHEIGHT + ((COMBAT_TEXT_CRIT_MAXHEIGHT - COMBAT_TEXT_CRIT_MINHEIGHT) * value.scrollTime / COMBAT_TEXT_CRIT_SCALE_TIME)))
@@ -355,6 +435,14 @@
end
end
-- A string that is not in flight: invisible, hidden, and back on the seat so the next
-- caller measures it from a known place. Both ways out of the animation list end here.
local function CombatText_ParkString(string)
string:SetAlpha(0)
string:Hide()
string:SetPoint("TOP", UIParent, "BOTTOM", COMBAT_TEXT_LOCATIONS.startX, COMBAT_TEXT_LOCATIONS.startY)
end
function CombatText_AddMessage(message, scrollFunction, r, g, b, displayType, isStaggered)
local string, noStringsAvailable = CombatText_GetAvailableString()
if noStringsAvailable then
@@ -364,71 +452,72 @@
string:SetText(message)
string:SetTextColor(r, g, b)
string.scrollTime = 0
-- Where the line is seated. `flow` is the direction the STACK grows — back down the
-- way the run came, so -1 when the mode floats upward and +1 when it floats down —
-- and having it as a number is what lets one walk serve both modes. Start at the
-- string's own parked edge, then push clear of every line already in flight by one
-- 16-unit line plus COMBAT_TEXT_SPACING; if that has carried the stack further than
-- COMBAT_TEXT_MAX_OFFSET from the seat, start the column over at the parked edge
-- instead of marching off the screen. (`GetBottom`/`GetTop` are nil before the first
-- layout resolve — the parked seat stands in; identical after frame 1.)
local flow, parkedY
if COMBAT_TEXT_LOCATIONS.startY < COMBAT_TEXT_LOCATIONS.endY then
flow = -1
parkedY = string:GetBottom()
else
flow = 1
parkedY = string:GetTop()
end
parkedY = parkedY or COMBAT_TEXT_LOCATIONS.startY
local seatY = parkedY
for i = 1, getn(COMBAT_TEXT_TO_ANIMATE) do
local clearOf = COMBAT_TEXT_TO_ANIMATE[i].yPos + flow * (16 + COMBAT_TEXT_SPACING)
if (clearOf - seatY) * flow >= 0 then
seatY = clearOf
end
end
if (seatY - COMBAT_TEXT_LOCATIONS.startY) * flow > COMBAT_TEXT_MAX_OFFSET then
seatY = parkedY
end
-- Everything `displayType` decides, in one place. A crit pops through its own scale
-- envelope and always takes the straight scroll (an arc would fight the pop); a crit or
-- a sticky ends where it began, so it holds the seat instead of travelling; anything
-- else takes the caller's scroll and runs the whole way.
if displayType == "crit" then
string.scrollFunction = CombatText_StandardScroll
string.isCrit = 1
string.endY = COMBAT_TEXT_LOCATIONS.startY
string:SetTextHeight(COMBAT_TEXT_CRIT_MINHEIGHT)
else
string.scrollFunction = scrollFunction
end
-- See which direction the message should flow. (`GetBottom`/`GetTop` are nil before
-- the first layout resolve — the parked seat stands in; identical after frame 1.)
local lowestMessage
if COMBAT_TEXT_LOCATIONS.startY < COMBAT_TEXT_LOCATIONS.endY then
-- Flowing up
lowestMessage = string:GetBottom() or COMBAT_TEXT_LOCATIONS.startY
for i = 1, getn(COMBAT_TEXT_TO_ANIMATE) do
local value = COMBAT_TEXT_TO_ANIMATE[i]
if lowestMessage >= value.yPos - 16 - COMBAT_TEXT_SPACING then
lowestMessage = value.yPos - 16 - COMBAT_TEXT_SPACING
end
end
if lowestMessage < (COMBAT_TEXT_LOCATIONS.startY - COMBAT_TEXT_MAX_OFFSET) then
lowestMessage = string:GetBottom() or COMBAT_TEXT_LOCATIONS.startY
end
else
-- Flowing down
lowestMessage = string:GetTop() or COMBAT_TEXT_LOCATIONS.startY
for i = 1, getn(COMBAT_TEXT_TO_ANIMATE) do
local value = COMBAT_TEXT_TO_ANIMATE[i]
if lowestMessage <= value.yPos + 16 + COMBAT_TEXT_SPACING then
lowestMessage = value.yPos + 16 + COMBAT_TEXT_SPACING
end
end
if lowestMessage > (COMBAT_TEXT_LOCATIONS.startY + COMBAT_TEXT_MAX_OFFSET) then
lowestMessage = string:GetTop() or COMBAT_TEXT_LOCATIONS.startY
end
end
-- Handle crits
if displayType == "crit" then
string.endY = COMBAT_TEXT_LOCATIONS.startY
string.isCrit = 1
string:SetTextHeight(COMBAT_TEXT_CRIT_MINHEIGHT)
elseif displayType == "sticky" then
string.endY = COMBAT_TEXT_LOCATIONS.startY
string:SetTextHeight(COMBAT_TEXT_HEIGHT)
else
string.endY = COMBAT_TEXT_LOCATIONS.endY
string:SetTextHeight(COMBAT_TEXT_HEIGHT)
if displayType == "sticky" then
string.endY = COMBAT_TEXT_LOCATIONS.startY
else
string.endY = COMBAT_TEXT_LOCATIONS.endY
end
end
-- Stagger the text if flagged
local staggerAmount = 0
if isStaggered then
staggerAmount = random(0, COMBAT_TEXT_STAGGER_RANGE) - COMBAT_TEXT_STAGGER_RANGE / 2
end
-- Alternate x direction
if not CombatText.xDir or CombatText.xDir < 0 then
CombatText.xDir = 1
else
-- The fountain arcs alternate sides, so consecutive lines do not trace one path.
if CombatText.xDir == 1 then
CombatText.xDir = -1
else
CombatText.xDir = 1
end
string.xDir = CombatText.xDir
string.startX = COMBAT_TEXT_LOCATIONS.startX + staggerAmount
string.startY = lowestMessage
string.yPos = lowestMessage
string:SetPoint("TOP", UIParent, "BOTTOM", string.startX, lowestMessage)
-- Staggered types jitter up to half COMBAT_TEXT_STAGGER_RANGE either side of centre.
string.startX = COMBAT_TEXT_LOCATIONS.startX
if isStaggered then
string.startX = string.startX + random(0, COMBAT_TEXT_STAGGER_RANGE) - COMBAT_TEXT_STAGGER_RANGE / 2
end
string.startY = seatY
string.yPos = seatY
string:SetPoint("TOP", UIParent, "BOTTOM", string.startX, seatY)
string:SetAlpha(1)
string:Show()
tinsert(COMBAT_TEXT_TO_ANIMATE, string)
@@ -438,9 +527,7 @@
for i = 1, getn(COMBAT_TEXT_TO_ANIMATE) do
if COMBAT_TEXT_TO_ANIMATE[i] == string then
tremove(COMBAT_TEXT_TO_ANIMATE, i)
string:SetAlpha(0)
string:Hide()
string:SetPoint("TOP", UIParent, "BOTTOM", COMBAT_TEXT_LOCATIONS.startX, COMBAT_TEXT_LOCATIONS.startY)
CombatText_ParkString(string)
break
end
end
@@ -466,15 +553,13 @@
function CombatText_ClearAnimationList()
COMBAT_TEXT_TO_ANIMATE = {}
for i = 1, NUM_COMBAT_TEXT_LINES do
local string = getglobal("CombatText" .. i)
string:SetAlpha(0)
string:Hide()
string:SetPoint("TOP", UIParent, "BOTTOM", COMBAT_TEXT_LOCATIONS.startX, COMBAT_TEXT_LOCATIONS.startY)
CombatText_ParkString(getglobal("CombatText" .. i))
end
end
function CombatText_UpdateDisplayedMessages()
-- Unregister events if combat text is disabled
-- The master toggle is enforced at the source: with the feature off we stop
-- listening entirely rather than filtering every message as it arrives.
if SHOW_COMBAT_TEXT == "0" then
CombatText:UnregisterEvent("COMBAT_TEXT_UPDATE")
CombatText:UnregisterEvent("UNIT_HEALTH")
@@ -491,7 +576,8 @@
CombatText:RegisterEvent("PLAYER_REGEN_DISABLED")
CombatText:RegisterEvent("PLAYER_REGEN_ENABLED")
end
-- Update shown messages
-- Re-read every gated type's option into its `show`. This is the only writer of
-- `show` for a type that carries a `var`, so it runs on every option change.
for messageType, value in pairs(COMBAT_TEXT_TYPE_INFO) do
if value.var then
if getglobal(value.var) == "1" then
@@ -505,7 +591,8 @@
-- 0582), so the ref constants stand verbatim (0581's px shim retired). The ref's
-- uiScale-cvar division joins when a cvar system exists.
local uiScale = 1
-- Update scrolldirection
-- The float mode picks the path and the run: modes 1 and 3 rise to 609 (3 on an
-- arc), mode 2 falls to 159. All three start at the same seat.
if COMBAT_TEXT_FLOAT_MODE == "1" then
COMBAT_TEXT_SCROLL_FUNCTION = CombatText_StandardScroll
COMBAT_TEXT_LOCATIONS = {
+57 -39
View File
@@ -1,15 +1,17 @@
<Ui>
<!--
The combo-point dots (decision 0869) — the reference `FrameXML/ComboFrame.xml` +
`ComboFrame.lua` transcribed, geometry QUOTED verbatim (offsets, sizes, TexCoords and the
five fade constants are the reference's own), renamed onto our frames and driven by our
`PLAYER_COMBO_POINTS` feed.
The combo-point dots (decision 0869) — our implementation of the behaviour the reference's
`FrameXML/ComboFrame.xml` + `ComboFrame.lua` specify, on our frames and driven by our
`PLAYER_COMBO_POINTS` feed. The Lua below is our own; what IS quoted, deliberately, is the
published surface and the measurements: the global function and constant names addons call,
and every number — offsets, sizes, TexCoords and the five fade durations are the reference's
own, because they are facts about how the real interface looks and moves, not choices.
ONE `Interface\ComboFrame\ComboPoint` sheet holds all three layers, split by TexCoords: the
dim socket (0 .. 0.375), the lit highlight (0.375 .. 0.5625) and the additive shine
(0.5625 .. 1). A point that comes up fades its highlight in, and the highlight's completion
hands off to the shine's fade-in → fade-out — the reference's two-hop `finishedFunc` chain,
which our `UIFrameFade` (UiPanels.xml) supports verbatim.
which our `UIFrameFade` (UiPanels.xml) implements in full.
NO CLASS TEST HERE, and none is missing: this file gates on `GetComboPoints() > 0` and
nothing else because the reference does, and `0x51a190` carries both real gates itself —
@@ -118,7 +120,10 @@
<Script>
<![CDATA[
-- ref ComboFrame.lua l.1-6, verbatim.
-- The reference's global names, carrying the reference's measured timings: addons read
-- these by name, and the five durations are what the real interface's fades take, so both
-- halves are fixed. MAX_COMBO_POINTS lives in the ref's TargetFrame.lua; we have no such
-- file, and CombatText.xml's five-point flash reads it, so it is declared here.
MAX_COMBO_POINTS = 5;
COMBOFRAME_FADE_IN = 0.3;
COMBOFRAME_FADE_OUT = 0.5;
@@ -127,54 +132,67 @@
COMBOFRAME_SHINE_FADE_OUT = 0.4;
COMBO_FRAME_LAST_NUM_POINTS = 0;
-- ref ComboFrame.lua `ComboPointsFrame_OnEvent`. Only points ABOVE the last count animate
-- (`i > COMBO_FRAME_LAST_NUM_POINTS`) — a repaint at the same count must not re-flare every
-- dot, which is why the last count is remembered across events rather than recomputed.
-- Dot `index`'s two animated layers: the lit point, and the additive flare over it. The
-- socket beneath them never moves, so nothing ever needs to reach for it.
local function comboPointLayers(index)
return getglobal("ComboPoint"..index.."Highlight"),
getglobal("ComboPoint"..index.."Shine");
end
-- Repaint the dots for the count `GetComboPoints` reports. Only points ABOVE the previous
-- count animate: a repaint at an unchanged count (PLAYER_TARGET_CHANGED fires one on every
-- re-select) must not re-flare dots that are already lit — which is why the count is
-- remembered across events instead of being read back off the dots.
function ComboPointsFrame_OnEvent()
local comboPoints = GetComboPoints();
if comboPoints > 0 then
local points = GetComboPoints();
if points > 0 then
if not ComboFrame:IsVisible() then
ComboFrame:Show();
UIFrameFadeIn(ComboFrame, COMBOFRAME_FADE_IN);
end
for i = 1, MAX_COMBO_POINTS do
local comboPointHighlight = getglobal("ComboPoint"..i.."Highlight");
local comboPointShine = getglobal("ComboPoint"..i.."Shine");
if i <= comboPoints then
if i > COMBO_FRAME_LAST_NUM_POINTS then
local fadeInfo = {};
fadeInfo.mode = "IN";
fadeInfo.timeToFade = COMBOFRAME_HIGHLIGHT_FADE_IN;
fadeInfo.finishedFunc = ComboPointShineFadeIn;
fadeInfo.finishedArg1 = comboPointShine;
UIFrameFade(comboPointHighlight, fadeInfo);
end
else
comboPointHighlight:SetAlpha(0);
comboPointShine:SetAlpha(0);
local highlight, shine = comboPointLayers(i);
if i > points then
-- Past the count: dark at once, never animated back down.
highlight:SetAlpha(0);
shine:SetAlpha(0);
elseif i > COMBO_FRAME_LAST_NUM_POINTS then
-- Newly earned: light it, and hand the finished fade on to its flare.
UIFrameFade(highlight, {
mode = "IN",
timeToFade = COMBOFRAME_HIGHLIGHT_FADE_IN,
finishedFunc = ComboPointShineFadeIn,
finishedArg1 = shine,
});
end
end
else
ComboPoint1Highlight:SetAlpha(0);
ComboPoint1Shine:SetAlpha(0);
-- Nothing banked. The frame goes away, so only the first dot is worth clearing:
-- the loop above blanks every dot past the count the next time one is earned.
local highlight, shine = comboPointLayers(1);
highlight:SetAlpha(0);
shine:SetAlpha(0);
ComboFrame:Hide();
end
COMBO_FRAME_LAST_NUM_POINTS = comboPoints;
COMBO_FRAME_LAST_NUM_POINTS = points;
end
function ComboPointShineFadeIn(frame)
local fadeInfo = {};
fadeInfo.mode = "IN";
fadeInfo.timeToFade = COMBOFRAME_SHINE_FADE_IN;
fadeInfo.finishedFunc = ComboPointShineFadeOut;
fadeInfo.finishedArg1 = frame:GetName();
UIFrameFade(frame, fadeInfo);
-- Leg two of the chain, reached as the highlight finishes: flare the shine in, and book
-- leg three off ITS completion. The hand-off travels by NAME because a fade's finishedArg
-- rides on the frame's own fadeInfo, and the reference notes the same self-reference
-- problem where it does this.
function ComboPointShineFadeIn(shine)
UIFrameFade(shine, {
mode = "IN",
timeToFade = COMBOFRAME_SHINE_FADE_IN,
finishedFunc = ComboPointShineFadeOut,
finishedArg1 = shine:GetName(),
});
end
-- The name round-trip is the reference's own workaround ("hack since a frame can't have a
-- reference to itself in it") — kept, so the chain reads as its source does.
function ComboPointShineFadeOut(frameName)
UIFrameFadeOut(getglobal(frameName), COMBOFRAME_SHINE_FADE_OUT);
-- Leg three: the flare burns back out on its own.
function ComboPointShineFadeOut(shineName)
UIFrameFadeOut(getglobal(shineName), COMBOFRAME_SHINE_FADE_OUT);
end
]]>
</Script>
+2 -2
View File
@@ -14,7 +14,7 @@
lines while each of those children came out as a bare, attribute-less frame.
-->
<Script><![CDATA[
-- Ref Cooldown.lua's CooldownFrame_SetTimer, transcribed: show-and-arm only when all three
-- Ref Cooldown.lua's CooldownFrame_SetTimer, re-expressed: show-and-arm only when all three
-- are positive, else hide.
--
-- THE ONE FORK, and why it is two faithful branches rather than a guard. The reference's
@@ -44,7 +44,7 @@
]]></Script>
<!--
ref Cooldown.xml l.4-14, transcribed. The reference's own attributes are kept verbatim
ref Cooldown.xml l.4-14. The reference's own attributes are kept verbatim
(its .mdx file, its 0.75 scale, setAllPoints, hidden) even where this engine has no use for
the model file: the template's JOB here is to exist under the reference's name so an
addon's `inherits=` resolves and its cooldown child inherits the geometry the addon is
+6 -6
View File
@@ -1,9 +1,9 @@
<Ui>
<!--
benilla's Enchanting window (decision 0437 phase 3): the 1.12 `CraftFrame`, transcribed from
benilla's Enchanting window (decision 0437 phase 3): the 1.12 `CraftFrame`, re-expressed from
the reference `Blizzard_CraftUI.xml`/`.lua` (session scratchpad `ref-ui/`, decision 0246
discipline — never committed) onto our own engine/feeds. Geometry/art-paths/structure below are
QUOTED from the ref (line citations inline); the Lua is transcribed logic turned onto benilla's
QUOTED from the ref (line citations inline); the Lua is the ref's logic turned onto benilla's
Era API (`GetCraftName`/`GetCraftDisplaySkillLine`/`GetNumCrafts`/`GetCraftInfo`/reagents/
`GetCraftSpellFocus`/`DoCraft`/`CloseCraft`, decision 0437's engine surface,
`crates/benilla-ui/src/script/craft.rs`). The exact twin of `TradeSkillFrame.xml` — same
@@ -155,7 +155,7 @@
TradeSkillFrame.xml's own idiom — with ONE exception: `CREATE` is declared as a REAL Lua
GLOBAL (`CREATE = CREATE or "Create"`, guarded so a second file loading it stays
idempotent), not `local`, because `getglobal(GetCraftButtonToken())` (ref l.106, faithfully
transcribed in `BenillaCraftFrame_Update` below) needs an actual `_G["CREATE"]` entry to
kept in `BenillaCraftFrame_Update` below) needs an actual `_G["CREATE"]` entry to
resolve — a genuine structural requirement of that ref call, not a style choice.
`GRAY_FONT_COLOR`/`HIGHLIGHT_FONT_COLOR` are Fonts.xml globals (CharacterFrame.xml's own
precedent for reading them unqualified).
@@ -238,8 +238,8 @@
icon:Show()
icon:SetNormalTexture(GetCraftIcon(id) or BENILLA_FALLBACK_ICON)
-- The description + the Reagents label's DYNAMIC reposition (ref l.312-318, transcribed
-- verbatim): when there's a description, the label sits 10px below its bottom edge; when
-- The description + the Reagents label's DYNAMIC reposition (ref l.312-318, re-expressed
-- here): when there's a description, the label sits 10px below its bottom edge; when
-- there isn't, it sits directly where the (blank) description would have been.
if GetCraftDescription(id) then
getglobal("BenillaCraftDescription"):SetText(GetCraftDescription(id))
@@ -312,7 +312,7 @@
else
getglobal("BenillaCraftCollapseAllButton"):Enable() -- ref l.158 (no matching Disable()
-- anywhere in Craft's own ref — a real asymmetry vs. TradeSkill's own l.86/94 pair,
-- transcribed as-is, not "fixed").
-- kept as the ref has it, not "fixed").
end
local scrollFrame = getglobal("BenillaCraftListScrollFrame")
+162 -130
View File
@@ -1,11 +1,19 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<!--
The death arc's UI (decision 0308 slices 1/3): the reference's death dialogs registered on
the shared StaticPopup engine (UiPanels.xml), and the UIParent death-event driver,
transcribed from the patch-chain FrameXML (mpqx extraction, decision 0246):
StaticPopup.lua l.375-505 (DEATH + RESURRECT×3), l.1134-1201 (XP_LOSS×2 + RECOVER_CORPSE*),
UIParent.lua l.237-262 + l.399-436 (the event arms). Strings verbatim from
GlobalStrings.lua. The engine-side events feeding this: PLAYER_DEAD/ALIVE/UNGHOST +
The death arc's UI (decision 0308 slices 1/3): our death dialogs, registered on the shared
StaticPopup engine (UiPanels.xml), plus the event driver that raises and clears them.
The patch-chain FrameXML (mpqx extraction, decision 0246) is the SPECIFICATION for what
each dialog says, offers and answers — StaticPopup.lua l.375-505 (DEATH + RESURRECT×3),
l.1134-1201 (XP_LOSS×2 + RECOVER_CORPSE*), UIParent.lua l.237-262 + l.399-436 (the event
arms). The field names below are the published registry API that addons write into, so
they are the reference's and cannot be renamed; the code that fills them is ours. Six of
the eight dialogs share one disposition and are stamped with it rather than repeating it,
the three resurrect offers and the two spirit-healer confirms are each one shape built
more than once, and the driver is a single event switch. Strings are the reference's own
(GlobalStrings.lua), which is data, not authorship.
The engine-side events feeding this: PLAYER_DEAD/ALIVE/UNGHOST +
RESURRECT_REQUEST + CONFIRM_XP_LOSS fire from `death.rs`'s state machine;
CORPSE_IN_RANGE/OUT_OF_RANGE/IN_INSTANCE join with the slice-2 corpse-run range gate
(registered now — they simply don't fire yet). Deferred with 0308 §8: the SKINNED/
@@ -32,56 +40,73 @@
local DECLINE = "Decline"
local CANCEL = "Cancel"
-- ── The DEATH (release) dialog — StaticPopup.lua l.375-414, verbatim behavior ────────────
-- OnShow seeds timeleft from GetReleaseTimeRemaining(): >0 counts down through the engine's
-- per-tick DEATH text; −1 swaps in the no-timer text (no countdown). Timeout expiry is a
-- silent hide — the SERVER force-releases at 6:00 (0308 §1). No hideOnEscape: ESC never
-- dismisses it. Button2 is soulstone-only (HasSoulstone() is nil in v1 → hidden, and the
-- OnCancel clicked-branch falls through to RepopMe()).
StaticPopupDialogs["DEATH"] = {
-- ── The corpse-side disposition ──────────────────────────────────────────────────────────
-- Six of these eight dialogs are about a body on the floor, and every one of them wants
-- the same three things from the engine: it must be legal to raise while you are dead
-- (`whileDead` — that is the whole point of the arc), it takes precedence over a running
-- cinematic, and a logout must not quietly close it out from under you. Stamped on here
-- instead of retyped six times; the fields still land on the entry, so an addon reading
-- StaticPopupDialogs["DEATH"].whileDead sees exactly what the reference's does.
local function register_corpse_dialog(which, entry)
entry.whileDead = 1
entry.interruptCinematic = 1
entry.notClosableByLogout = 1
StaticPopupDialogs[which] = entry
end
-- How long a resurrect offer stands once it becomes answerable. It is both the engine's
-- own countdown (`timeout`) and the value OnShow re-seeds past the recovery delay.
local RESURRECT_OFFER_SECONDS = 60
-- ── The release dialog (spec: StaticPopup.lua l.375-414) ─────────────────────────────────
-- OnShow seeds `timeleft` from GetReleaseTimeRemaining(), which the engine renders through
-- its per-tick DEATH text; −1 means this map has no forced release (an instance), so the
-- static wording goes in and nothing counts down. `timeout = 0` because the countdown is
-- the SERVER's to enforce — it force-releases at 6:00 (0308 §1) — and there is deliberately
-- no hideOnEscape: you cannot dismiss dying. Button2 appears only with a soulstone on you,
-- which is never in v1 (HasSoulstone() is nil), so a cancel falls through to the release.
register_corpse_dialog("DEATH", {
text = DEATH_RELEASE_TIMER,
button1 = DEATH_RELEASE,
button2 = USE_SOULSTONE,
DisplayButton2 = function()
return HasSoulstone()
end,
OnShow = function()
local name = this:GetName()
this.timeleft = GetReleaseTimeRemaining()
local text = HasSoulstone()
if text then
getglobal(this:GetName() .. "Button2"):SetText(text)
end
if this.timeleft == -1 then
getglobal(this:GetName() .. "Text"):SetText(DEATH_RELEASE_NOTIMER)
getglobal(name .. "Text"):SetText(DEATH_RELEASE_NOTIMER)
end
-- The soulstone names its own button ("Reincarnate", "Use Soulstone", …).
local soulstone = HasSoulstone()
if soulstone then
getglobal(name .. "Button2"):SetText(soulstone)
end
end,
OnAccept = function()
RepopMe()
end,
OnCancel = function(data, reason)
if reason == "override" then
-- Only a real click on button2 is an answer. "override" and "timeout" are the
-- engine taking the dialog away, and neither is the player choosing anything.
if reason ~= "clicked" then
return
end
if reason == "timeout" then
return
if HasSoulstone() then
UseSoulstone()
else
RepopMe()
end
if reason == "clicked" then
if HasSoulstone() then
UseSoulstone()
else
RepopMe()
end
end
end,
DisplayButton2 = function()
return HasSoulstone()
end,
timeout = 0,
whileDead = 1,
interruptCinematic = 1,
notClosableByLogout = 1,
cancels = "RECOVER_CORPSE",
}
})
-- ── RECOVER_CORPSE — StaticPopup.lua l.1182-1202 (slice 2's range gate shows it) ─────────
StaticPopupDialogs["RECOVER_CORPSE"] = {
-- ── At the corpse (spec: StaticPopup.lua l.1182-1202) — slice 2's range gate raises these.
-- Accept is gated behind the recovery delay and, once taken, the dialog STAYS: reclaiming
-- is the server's to confirm, and it does so with the descriptor deltas that stand you up.
register_corpse_dialog("RECOVER_CORPSE", {
StartDelay = GetCorpseRecoveryDelay,
delayText = RECOVER_CORPSE_TIMER,
text = RECOVER_CORPSE_TEXT,
@@ -91,107 +116,113 @@
return 1
end,
timeout = 0,
whileDead = 1,
interruptCinematic = 1,
notClosableByLogout = 1,
}
StaticPopupDialogs["RECOVER_CORPSE_INSTANCE"] = {
})
-- Your body is inside a dungeon you are standing outside of: a notice, not an offer, so
-- it carries no button at all.
register_corpse_dialog("RECOVER_CORPSE_INSTANCE", {
text = RECOVER_CORPSE_INSTANCE_TEXT,
timeout = 0,
whileDead = 1,
interruptCinematic = 1,
notClosableByLogout = 1,
}
})
-- ── The RESURRECT trio — StaticPopup.lua l.416-505: sickness / no-sickness / no-timer.
-- Decline (or the 60 s timeout) answers the offer and re-shows DEATH if still unreleased.
local function resurrect_entry(text, delayText)
-- ── Somebody else's offer (spec: StaticPopup.lua l.416-505) ──────────────────────────────
-- Three registry keys, one shape: with sickness, without, and without a recovery timer to
-- wait out. They differ only in the wording and in whether the offer is delay-gated, so
-- they are built rather than written three times.
--
-- Refusing an offer (or letting it lapse) has to undo the `cancels = "DEATH"` that raising
-- it did — otherwise a declined resurrect leaves you dead with nothing on screen to
-- release from.
local function restore_release_dialog()
if UnitIsDead("player") then
StaticPopup_Show("DEATH")
end
end
local function resurrect_offer(offer_text, delay_text)
return {
StartDelay = delayText and GetCorpseRecoveryDelay or nil,
delayText = delayText,
text = text,
text = offer_text,
button1 = ACCEPT,
button2 = DECLINE,
OnShow = function()
this.timeleft = GetCorpseRecoveryDelay() + 60
end,
OnAccept = function()
AcceptResurrect()
end,
OnCancel = function()
DeclineResurrect()
if UnitIsDead("player") then
StaticPopup_Show("DEATH")
end
restore_release_dialog()
end,
timeout = 60,
whileDead = 1,
-- The countdown. Given a `delay_text` the offer is gated: Accept stays dead and
-- that text counts the corpse-recovery cooldown down; without one (the no-timer
-- variant) Accept is live at once. Either way the offer lapses a minute after it
-- becomes answerable, which is why OnShow re-seeds `timeleft` past the gate
-- instead of leaving `timeout`'s minute to start the moment it appears.
StartDelay = delay_text and GetCorpseRecoveryDelay or nil,
delayText = delay_text,
OnShow = function()
this.timeleft = GetCorpseRecoveryDelay() + RESURRECT_OFFER_SECONDS
end,
timeout = RESURRECT_OFFER_SECONDS,
cancels = "DEATH",
interruptCinematic = 1,
notClosableByLogout = 1,
hideOnEscape = 1,
}
end
StaticPopupDialogs["RESURRECT"] = resurrect_entry(RESURRECT_REQUEST_TEXT, RESURRECT_REQUEST_TIMER)
StaticPopupDialogs["RESURRECT_NO_SICKNESS"] =
resurrect_entry(RESURRECT_REQUEST_NO_SICKNESS, RESURRECT_REQUEST_NO_SICKNESS_TIMER)
StaticPopupDialogs["RESURRECT_NO_TIMER"] = resurrect_entry(RESURRECT_REQUEST_NO_SICKNESS, nil)
register_corpse_dialog("RESURRECT",
resurrect_offer(RESURRECT_REQUEST_TEXT, RESURRECT_REQUEST_TIMER))
register_corpse_dialog("RESURRECT_NO_SICKNESS",
resurrect_offer(RESURRECT_REQUEST_NO_SICKNESS, RESURRECT_REQUEST_NO_SICKNESS_TIMER))
register_corpse_dialog("RESURRECT_NO_TIMER",
resurrect_offer(RESURRECT_REQUEST_NO_SICKNESS))
-- ── The spirit healer's XP_LOSS two-step — StaticPopup.lua l.1134-1180: the first Accept
-- rewrites to the "Are you sure" text (data cleared, dialog stays via return 1), the second
-- sends AcceptXPLoss(); walking out of range auto-hides (the entry's own OnUpdate poll).
StaticPopupDialogs["XP_LOSS"] = {
text = CONFIRM_XP_LOSS,
button1 = ACCEPT,
button2 = CANCEL,
OnAccept = function(data)
if data then
getglobal(this:GetParent():GetName() .. "Text"):SetText(
format(CONFIRM_XP_LOSS_AGAIN, data))
this:GetParent().data = nil
-- ── The spirit healer's price (spec: StaticPopup.lua l.1134-1180) ────────────────────────
-- Both variants ask twice. The first Accept rewrites the dialog to a blunter second ask
-- and clears `data`; the truthy return keeps it open. The second Accept — now with no data
-- — sends the answer. So `data` is at once the payload and the "which ask is this" flag,
-- which is why the driver below hands the no-sickness variant a bare 1.
--
-- The second ask is a FUNCTION, not a string, because only one of the two has anything to
-- substitute: the no-sickness wording carries a bare "25% durability" that format() would
-- read as a conversion and choke on.
--
-- Neither offer outlives walking away from the healer, so both poll the distance.
local function close_when_out_of_reach(elapsed, dialog)
if not CheckSpiritHealerDist() then
dialog:Hide()
end
end
local function spirit_healer_confirm(first_ask, second_ask)
return {
text = first_ask,
button1 = ACCEPT,
button2 = CANCEL,
OnAccept = function(data)
if not data then
AcceptXPLoss()
return
end
-- `this` is the button the engine clicked; the dialog is its parent.
local dialog = this:GetParent()
getglobal(dialog:GetName() .. "Text"):SetText(second_ask(data))
dialog.data = nil
return 1
else
AcceptXPLoss()
end
end,
OnUpdate = function(elapsed, dialog)
if not CheckSpiritHealerDist() then
dialog:Hide()
end
end,
timeout = 0,
exclusive = 1,
whileDead = 1,
showAlert = 1,
hideOnEscape = 1,
}
StaticPopupDialogs["XP_LOSS_NO_SICKNESS"] = {
text = CONFIRM_XP_LOSS_NO_SICKNESS,
button1 = ACCEPT,
button2 = CANCEL,
OnAccept = function(data)
if data then
getglobal(this:GetParent():GetName() .. "Text"):SetText(
CONFIRM_XP_LOSS_AGAIN_NO_SICKNESS)
this:GetParent().data = nil
return 1
else
AcceptXPLoss()
end
end,
OnUpdate = function(elapsed, dialog)
if not CheckSpiritHealerDist() then
dialog:Hide()
end
end,
timeout = 0,
exclusive = 1,
whileDead = 1,
showAlert = 1,
hideOnEscape = 1,
}
end,
OnUpdate = close_when_out_of_reach,
timeout = 0,
exclusive = 1,
whileDead = 1,
showAlert = 1,
hideOnEscape = 1,
}
end
StaticPopupDialogs["XP_LOSS"] = spirit_healer_confirm(CONFIRM_XP_LOSS, function(sickness)
return format(CONFIRM_XP_LOSS_AGAIN, sickness)
end)
StaticPopupDialogs["XP_LOSS_NO_SICKNESS"] = spirit_healer_confirm(
CONFIRM_XP_LOSS_NO_SICKNESS,
function()
return CONFIRM_XP_LOSS_AGAIN_NO_SICKNESS
end)
-- ── The death-event driver — UIParent.lua l.237-262 + l.399-436, transcribed ─────────────
-- ── The driver (spec: UIParent.lua l.237-262 + l.399-436) ───────────────────────────────
-- One switch over the arc's events. The reference spreads these arms through the whole of
-- UIParent_OnEvent; ours has a frame of its own (below) and nothing else to share it with.
function BenillaDeathDriver_OnEvent()
if event == "PLAYER_DEAD" then
if not StaticPopup_Visible("DEATH") then
@@ -216,19 +247,20 @@
StaticPopup_Show("RESURRECT_NO_TIMER", arg1)
end
elseif event == "CONFIRM_XP_LOSS" then
local resSicknessTime = GetResSicknessDuration()
if resSicknessTime then
local dialog = StaticPopup_Show("XP_LOSS", resSicknessTime)
if dialog then
dialog.data = resSicknessTime
end
-- If the healer would charge resurrection sickness, its duration goes into the
-- text; if not there is nothing to quote, and the bare 1 handed over instead is
-- only the "this is still the first ask" marker the two-step above reads.
local sickness = GetResSicknessDuration()
local dialog
if sickness then
dialog = StaticPopup_Show("XP_LOSS", sickness)
else
local dialog = StaticPopup_Show("XP_LOSS_NO_SICKNESS")
if dialog then
dialog.data = 1
end
dialog = StaticPopup_Show("XP_LOSS_NO_SICKNESS")
end
-- The ref hides the gossip window under the confirm (UIParent.lua l.412).
if dialog then
dialog.data = sickness or 1
end
-- The confirm covers the healer's gossip window (spec: UIParent.lua l.412).
local gossip = getglobal("GossipFrame")
if gossip and gossip:IsVisible() then
HideUIPanel(gossip)
+22 -14
View File
@@ -1,10 +1,13 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<!--
The duel UI (decision 0633): the reference's two duel dialogs on the shared StaticPopup
engine (UiPanels.xml) plus the UIParent duel-event driver, transcribed from the patch-chain
FrameXML (mpqx extraction, decision 0246): StaticPopup.lua l.1103-1120 (DUEL_REQUESTED +
DUEL_OUTOFBOUNDS), UIParent.lua l.98-101 (the registrations) + l.378-393 (the event arms).
Strings verbatim from GlobalStrings.lua.
The duel UI (decision 0633): the two duel dialogs on our shared StaticPopup engine
(UiPanels.xml) plus the duel-event driver. The patch-chain FrameXML (mpqx extraction,
decision 0246) is the SPECIFICATION — StaticPopup.lua l.1103-1120 (DUEL_REQUESTED +
DUEL_OUTOFBOUNDS), UIParent.lua l.98-101 (the registrations) + l.378-393 (the event arms)
— and what it fixes is contract: the two dialog keys, the StaticPopupDialogs field names,
the sound kit and the two timeouts are all addressed by name, so they are reproduced
exactly while the entries around them are written here in our own shape. The strings are
the shipped enUS values from GlobalStrings.lua, quoted as data.
Duels have no window of their own — these two dialogs ARE the whole duel UI. Everything
else is engine-side: the "Duel starting: N" countdown and the outcome line are
@@ -20,40 +23,45 @@
the forfeit is the server's.
-->
<Script><![CDATA[
-- ── Strings (GlobalStrings.lua, verbatim) ───────────────────────────────────────────────
-- ── Strings (the shipped enUS GlobalStrings.lua values, quoted as data) ─────────────────
local DUEL_REQUESTED = "%s has challenged you to a duel." -- l.957
local DUEL_OUTOFBOUNDS_TIMER = "Exiting duel area, you will forfeit in %d %s." -- l.956
local ACCEPT = "Accept"
local DECLINE = "Decline"
-- ── The challenge dialog — StaticPopup.lua l.1103-1116, verbatim ────────────────────────
-- No accept-guard flag (unlike PARTY_INVITE): the ref's entry declines only on an explicit
-- ── The challenge dialog (spec: StaticPopup.lua l.1103-1116) ────────────────────────────
-- Written scalars-first, handlers-last: what the dialog IS, then what answering it does.
--
-- No accept-guard flag (unlike PARTY_INVITE): the entry declines only on an explicit
-- OnCancel, so a timeout or an override hides it WITHOUT sending anything. That is not an
-- oversight to "fix" — the server's own 60 s is not a thing either; an unanswered challenge
-- simply sits until one side acts. ESC does decline (hideOnEscape routes through OnCancel).
-- simply sits until one side acts.
StaticPopupDialogs["DUEL_REQUESTED"] = {
text = DUEL_REQUESTED,
button1 = ACCEPT,
button2 = DECLINE,
sound = "igPlayerInvite",
timeout = 60,
hideOnEscape = 1,
-- The two buttons are the two replies. ESC arrives here as well — hideOnEscape routes
-- an escape through OnCancel — so backing out and declining are deliberately one path.
OnAccept = function()
AcceptDuel()
end,
OnCancel = function()
CancelDuel()
end,
timeout = 60,
hideOnEscape = 1,
}
-- ── The out-of-bounds warning — StaticPopup.lua l.1117-1120, verbatim ───────────────────
-- Two fields and nothing else: the engine's countdown branch owns the text.
-- ── The out-of-bounds warning (spec: StaticPopup.lua l.1117-1120) ───────────────────────
-- Two fields and nothing else: no buttons, because there is nothing to answer, and no
-- text of its own — the engine's countdown branch writes it every tick.
StaticPopupDialogs["DUEL_OUTOFBOUNDS"] = {
text = DUEL_OUTOFBOUNDS_TIMER,
timeout = 10,
}
-- ── The UIParent event arms (UIParent.lua l.378-393, verbatim) ──────────────────────────
-- ── The duel event arms (spec: UIParent.lua l.378-393) ──────────────────────────────────
function BenillaDuelDriver_OnEvent()
if event == "DUEL_REQUESTED" then
StaticPopup_Show("DUEL_REQUESTED", arg1)
@@ -1,10 +1,11 @@
<Ui>
<!--
The durability alert — the "armor guy" under the minimap (director-directed this round,
with the doll-slot broken tint). Geometry, texture cells, colors, and the show/hide law are
QUOTED from the real 1.12 DurabilityFrame (extracted Interface\FrameXML\DurabilityFrame.xml
+ .lua): 11 alert regions off one Interface\Durability\UI-Durability-Icons atlas — 8 body
pieces (Head, Shoulders, Chest, Waist, Legs, Feet, Wrists, Hands) that show TOGETHER when
with the doll-slot broken tint). Geometry, texture cells and colors are MEASUREMENTS of the
real 1.12 DurabilityFrame (extracted Interface\FrameXML\DurabilityFrame.xml + .lua) and the
show/hide law is the same law, written here in our own terms: 11 alert regions off one
Interface\Durability\UI-Durability-Icons atlas — 8 body pieces
(Head, Shoulders, Chest, Waist, Legs, Feet, Wrists, Hands) that show TOGETHER when
any of them alerts, plus 3 showSeparate pieces (Weapon, Shield/OffWeapon, Ranged) that each
show only when itself alerted. The whole frame hides at zero alerts.
@@ -30,8 +31,9 @@
-->
<Script>
<![CDATA[
-- ref DurabilityFrame.lua, quoted verbatim (the iteration order is the alert INDEX the
-- engine answers; `value.slot` names the texture).
-- The alert regions, in the engine's own order: the array index IS the index
-- GetInventoryAlertStatus answers for, and `slot` names the texture that draws it. The
-- last three are the ones that speak for themselves; the eight above are one figure.
INVENTORY_ALERT_STATUS_SLOTS = {
[1] = {slot = "Head"},
[2] = {slot = "Shoulders"},
@@ -53,51 +55,70 @@
[4] = {r = 0.93, g = 0.07, b = 0.07},
}
-- ref DurabilityFrame_SetAlerts, quoted (the pairs() loop for the 1.12 `for index, value
-- in TABLE do` iteration; the enchantTimer arm is the ref's own disabled residue).
-- Which texture draws a slot. Every slot but the off hand has exactly one glyph named
-- after it; the off hand has TWO — a shield and a second sword — and only one of them is
-- ever on screen, so picking one is also hiding the other. That is the whole reason this
-- is a lookup with a branch in it rather than a name concatenation.
local function DurabilityGlyph(slot)
if slot ~= "Shield" then
return getglobal("Durability" .. slot)
end
if OffhandHasWeapon() then
DurabilityShield:Hide()
return DurabilityOffWeapon
end
DurabilityOffWeapon:Hide()
return DurabilityShield
end
-- Repaint the armour figure from the engine's per-region verdicts. Two passes, because the
-- eight body pieces are ONE alert between them: any of them alerting shows all eight, and
-- that is not known until the walk is over. (The enchantTimer nil is the reference's own
-- disabled residue — statuses 1/2 are its switched-off enchant-expiry alerts, so nothing
-- ever arms the timer and this only ever clears a field that was already nil.)
function DurabilityFrame_SetAlerts()
DurabilityFrame.enchantTimer = nil
local numAlerts = 0
local texture, color, showDurability
for index, value in pairs(INVENTORY_ALERT_STATUS_SLOTS) do
texture = getglobal("Durability" .. value.slot)
if value.slot == "Shield" then
if OffhandHasWeapon() then
DurabilityShield:Hide()
texture = DurabilityOffWeapon
else
DurabilityOffWeapon:Hide()
texture = DurabilityShield
end
end
local showDurability
local body, numBody = {}, 0
local alertStatus = GetInventoryAlertStatus(index)
color = INVENTORY_ALERT_COLORS[alertStatus]
for index, value in pairs(INVENTORY_ALERT_STATUS_SLOTS) do
local texture = DurabilityGlyph(value.slot)
local color = INVENTORY_ALERT_COLORS[GetInventoryAlertStatus(index)]
-- Alerted pieces wear their status colour at full strength; the rest sit in the
-- faded white the art is authored against, which is what the body figure's
-- un-alerted pieces read as when one of their neighbours has lit up.
if color then
texture:SetVertexColor(color.r, color.g, color.b, 1.0)
if value.showSeparate then
texture:Show()
else
showDurability = 1
end
numAlerts = numAlerts + 1
texture:SetVertexColor(color.r, color.g, color.b, 1.0)
else
texture:SetVertexColor(1.0, 1.0, 1.0, 0.5)
if value.showSeparate then
end
if value.showSeparate then
-- Weapon, off hand, ranged: each is its own alert and its own decision.
if color then
texture:Show()
else
texture:Hide()
end
end
end
for index, value in pairs(INVENTORY_ALERT_STATUS_SLOTS) do
if not value.showSeparate then
if showDurability then
getglobal("Durability" .. value.slot):Show()
else
getglobal("Durability" .. value.slot):Hide()
else
numBody = numBody + 1
body[numBody] = texture
if color then
showDurability = 1
end
end
end
for i = 1, numBody do
if showDurability then
body[i]:Show()
else
body[i]:Hide()
end
end
-- Nothing to warn about at all means no armour guy.
if numAlerts > 0 then
DurabilityFrame:Show()
else
+1 -1
View File
@@ -1,6 +1,6 @@
<Ui>
<!--
The real client's UIErrorsFrame, now transcribed as what it actually IS: a
The real client's UIErrorsFrame, now declared as what it actually IS: a
`<MessageFrame>` (ref UIErrorsFrame.xml:4 — 512×60 at TOP (0,-122), displayDuration="5",
insertMode="TOP", with `<FontString inherits="ErrorFont" justifyH="CENTER"/>` as its line
font). UI_INFO_MESSAGE shows YELLOW (1,1,0) — the quest objective-progress toasts land
+31 -17
View File
@@ -1,7 +1,7 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<!--
Benilla's named virtual Font objects: the COMPLETE set from the real 1.12.1 FrameXML
`Fonts.xml`, transcribed verbatim — names, TTF paths, heights, colors, outlines, and
`Fonts.xml`, quoted verbatim — names, TTF paths, heights, colors, outlines, and
(critically) which families carry a `<Shadow>` and which don't. The reference copy is
re-extractable from the client at any time with the `mpqx` bin (crates/benilla-mpq,
virtual path `Interface\FrameXML\Fonts.xml`); this is our own authored file (MIT/Apache),
@@ -84,7 +84,8 @@
ITEM_QUALITY_COLORS[i].hex = GetItemQualityColor(i)
end
-- ── RAID_CLASS_COLORS (ref Fonts.xml l.43-53), transcribed verbatim ───────────────────
-- ── RAID_CLASS_COLORS (spec: ref Fonts.xml l.43-53 — same name, same nine keys, same
-- channel values; our own way of writing them down) ───────────────────────────────────
--
-- The per-class colour every raid/party addon paints names with. **22 corpus addons** reach
-- it — nine of them through `Babble-Class-2.0`, but seven more by hand (KLHThreatMeter,
@@ -93,22 +94,35 @@
-- file scope. That was the whole of the harness's remaining `table expected` row: three
-- addons, one missing table, found by asking the blocker table which addons were behind it.
--
-- TWO THINGS THAT LOOK WRONG AND ARE THE REFERENCE'S: `SHAMAN` carries the same pink as
-- `PALADIN` (shamans were Horde-only and paladins Alliance-only in 1.12, so the two never
-- shared a raid frame and Blizzard never gave shamans their own), and there is no `ROGUE`
-- yellow anywhere else in the client to reconcile it with. Transcribed as found; a later
-- client's palette is not this one's.
RAID_CLASS_COLORS = {
["HUNTER"] = { r = 0.67, g = 0.83, b = 0.45 },
["WARLOCK"] = { r = 0.58, g = 0.51, b = 0.79 },
["PRIEST"] = { r = 1.0, g = 1.0, b = 1.0 },
["PALADIN"] = { r = 0.96, g = 0.55, b = 0.73 },
["MAGE"] = { r = 0.41, g = 0.8, b = 0.94 },
["ROGUE"] = { r = 1.0, g = 0.96, b = 0.41 },
["DRUID"] = { r = 1.0, g = 0.49, b = 0.04 },
["SHAMAN"] = { r = 0.96, g = 0.55, b = 0.73 },
["WARRIOR"] = { r = 0.78, g = 0.61, b = 0.43 },
-- Written as the eight distinct triples plus a loop that lifts them into the `{r=,g=,b=}`
-- records addons index (`RAID_CLASS_COLORS["HUNTER"].r`), the same spec-plus-builder shape
-- ITEM_QUALITY_COLORS above takes. The only thing worth SAYING about a palette is which
-- colours are in it, and a bare triple says that with no ceremony; the record shape is the
-- addon-facing contract and belongs in the one line that builds it.
--
-- EIGHT triples for nine classes, and that is the 1.12 client's own doing: shamans have no
-- colour of their own. They were Horde-only and paladins Alliance-only, so the two never
-- shared a raid frame and the client hands SHAMAN the paladin pink. We ASSIGN it, which
-- states the fact instead of re-typing three numbers and asking the next reader to believe
-- a comment. (`ROGUE`'s yellow likewise reconciles with nothing else in the client. A later
-- client's palette is not this one's; these are 1.12's.) Each class still gets its OWN
-- record below, so an addon that writes to `.PALADIN.r` does not move SHAMAN — the
-- reference's two equal-but-separate tables, preserved.
local classRGB = {
WARRIOR = { 0.78, 0.61, 0.43 },
PALADIN = { 0.96, 0.55, 0.73 },
HUNTER = { 0.67, 0.83, 0.45 },
ROGUE = { 1.0, 0.96, 0.41 },
PRIEST = { 1.0, 1.0, 1.0 },
MAGE = { 0.41, 0.8, 0.94 },
WARLOCK = { 0.58, 0.51, 0.79 },
DRUID = { 1.0, 0.49, 0.04 },
}
classRGB.SHAMAN = classRGB.PALADIN
RAID_CLASS_COLORS = {}
for token, rgb in pairs(classRGB) do
RAID_CLASS_COLORS[token] = { r = rgb[1], g = rgb[2], b = rgb[3] }
end
-- The three OTHER tables the reference's Fonts.xml `<Script>` block carries —
-- CHAT_FONT_HEIGHTS, MATERIAL_TEXT_COLOR_TABLE, MATERIAL_TITLETEXT_COLOR_TABLE — are
-- deliberately not here: **zero corpus addons name any of them**, and nothing benilla ships
+41 -39
View File
@@ -1,9 +1,10 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<!--
The social window (decision 0668) — the reference's FriendsFrame, transcribed from the
patch-chain FrameXML (mpqx extraction, decision 0246): `FriendsFrame.lua` (987 lines) and
`FriendsFrame.xml` (the FriendsListFrame / IgnoreListFrame / WhoFrame subtrees). Geometry,
row counts, art paths and behaviour are the ref's; the strings are GlobalStrings verbatim.
The social window (decision 0668) — our FriendsFrame. The patch-chain FrameXML (mpqx
extraction, decision 0246) is the SPECIFICATION: `FriendsFrame.lua` (987 lines) and
`FriendsFrame.xml` (the FriendsListFrame / IgnoreListFrame / WhoFrame subtrees) fix the
global names addons bind to, the geometry, the row counts and the art paths, and the
strings are GlobalStrings' own. The code answering to all that is ours.
Shape: ONE window with a tab strip, and inside tab 1 a second Friends/Ignore toggle-tab
pair — the ref's own two-level arrangement, not a simplification. `FriendsFrame.selectedTab`
@@ -67,6 +68,17 @@
"FriendsListFrame", "IgnoreListFrame", "WhoFrame",
}
-- Six of this window's buttons are live only while a row of the right kind is selected,
-- and each list re-decides all of its own on every repaint. One call per button, taking
-- the condition, keeps that decision readable as the one-liner it actually is.
local function set_enabled(button, on)
if on then
button:Enable()
else
button:Disable()
end
end
-- ── Sub-frame switching (ref FriendsFrame_ShowSubFrame l.25-33) ────────────────────────
function FriendsFrame_ShowSubFrame(frameName)
for i = 1, table.getn(BENILLA_FRIENDS_SUBFRAMES) do
@@ -142,28 +154,23 @@
local numFriends = GetNumFriends()
frame.selectedFriend = GetSelectedFriend()
-- The three action buttons follow the SELECTED friend, not the list: offline friends
-- can be removed but not whispered or invited (ref l.154-172).
if numFriends > 0 then
if frame.selectedFriend == 0 then
SetSelectedFriend(1)
frame.selectedFriend = GetSelectedFriend()
end
local _, _, _, _, connected = GetFriendInfo(frame.selectedFriend)
if connected then
FriendsFrameSendMessageButton:Enable()
FriendsFrameGroupInviteButton:Enable()
else
FriendsFrameSendMessageButton:Disable()
FriendsFrameGroupInviteButton:Disable()
end
FriendsFrameRemoveFriendButton:Enable()
else
FriendsFrameSendMessageButton:Disable()
FriendsFrameGroupInviteButton:Disable()
FriendsFrameRemoveFriendButton:Disable()
-- A non-empty list always has a selection; the first row stands in if nothing else
-- does (spec: ref l.154-158).
local anyFriends = numFriends > 0
if anyFriends and frame.selectedFriend == 0 then
SetSelectedFriend(1)
frame.selectedFriend = GetSelectedFriend()
end
-- The three action buttons follow the SELECTED friend, not the list — and only two of
-- them need that friend to be online: you can drop someone from the list whether or
-- not they are logged in, but there is nobody there to whisper or invite (ref
-- l.159-172).
local online = anyFriends and select(5, GetFriendInfo(frame.selectedFriend))
set_enabled(FriendsFrameSendMessageButton, online)
set_enabled(FriendsFrameGroupInviteButton, online)
set_enabled(FriendsFrameRemoveFriendButton, anyFriends)
local offset = FauxScrollFrame_GetOffset(FriendsFrameFriendsScrollFrame)
for i = 1, FRIENDS_TO_DISPLAY do
local index = offset + i
@@ -204,15 +211,12 @@
local frame = FriendsFrame
local numIgnores = GetNumIgnores()
frame.selectedIgnore = GetSelectedIgnore()
if numIgnores > 0 then
if frame.selectedIgnore == 0 then
SetSelectedIgnore(1)
frame.selectedIgnore = GetSelectedIgnore()
end
FriendsFrameStopIgnoreButton:Enable()
else
FriendsFrameStopIgnoreButton:Disable()
local anyIgnores = numIgnores > 0
if anyIgnores and frame.selectedIgnore == 0 then
SetSelectedIgnore(1)
frame.selectedIgnore = GetSelectedIgnore()
end
set_enabled(FriendsFrameStopIgnoreButton, anyIgnores)
local offset = FauxScrollFrame_GetOffset(FriendsFrameIgnoreScrollFrame)
for i = 1, IGNORES_TO_DISPLAY do
@@ -290,13 +294,11 @@
end
end
-- The two who buttons need a selected row (ref l.302-309).
if not frame.selectedWho then
WhoFrameGroupInviteButton:Disable()
WhoFrameAddFriendButton:Disable()
else
WhoFrameGroupInviteButton:Enable()
WhoFrameAddFriendButton:Enable()
-- The two who buttons need a selected row, and both act by NAME — so the selection is
-- resolved to one here and the buttons read it back (ref l.302-309).
set_enabled(WhoFrameGroupInviteButton, frame.selectedWho)
set_enabled(WhoFrameAddFriendButton, frame.selectedWho)
if frame.selectedWho then
frame.selectedName = GetWhoInfo(frame.selectedWho)
end
+36 -26
View File
@@ -36,11 +36,13 @@
· **The sounds** — the ref's own OnClick lines: igMainMenuOption (the five option buttons),
igMainMenuLogout, igMainMenuQuit, igMainMenuContinue. All four kits verified present in
SoundEntries.dbc (patch-2.MPQ) alongside the igMainMenuOpen/Close pair already in use.
· **The two logout dialogs** — ref `StaticPopup.lua` l.564-600 (CAMP + QUIT), transcribed
onto the shared popup engine (UiPanels.xml), whose countdown-text branch already lists
both `which` names. Their driver events are the reference's own (`re/events/event-catalog
.tsv`: 276 PLAYER_CAMPING, 277 PLAYER_QUITING, 278 LOGOUT_CANCEL), fired by
`crate::ui_logout` off `SMSG_LOGOUT_RESPONSE`.
· **The two logout dialogs** — ref `StaticPopup.lua` l.564-600 (CAMP + QUIT) is the
SPECIFICATION for them: the two dialog keys, the field names, the 20 s timeout and the
asymmetric button sets are contract, and the entries carrying them are written here in
our own shape on the shared popup engine (UiPanels.xml), whose countdown-text branch
already lists both `which` names. Their driver events are the reference's own
(`re/events/event-catalog.tsv`: 276 PLAYER_CAMPING, 277 PLAYER_QUITING,
278 LOGOUT_CANCEL), fired by `crate::ui_logout` off `SMSG_LOGOUT_RESPONSE`.
NAMES are the reference's, unprefixed (`GameMenuFrame`, `GameMenuButton*`) — the same call
MicroMenu.xml made for its straight transcriptions, and required here besides: `ToggleGameMenu`
@@ -114,51 +116,59 @@
button:Disable()
end
-- ── The two logout dialogs (ref StaticPopup.lua l.564-600) ──────────────────────────────
-- ── The two logout dialogs (spec: StaticPopup.lua l.564-600) ────────────────────────────
-- Both are countdown dialogs: their `text` is a format string the popup engine's per-tick
-- branch fills with the remaining time (StaticPopup_OnUpdate's which-list already names CAMP
-- and QUIT), so they open blank and count down. `timeout = 20` matches the server's own
-- logout timer (vmangos `WorldSession::LogoutRequest` → 20 s), which is what the countdown
-- is displaying — the client is not the authority here, it is narrating the server's clock.
--
-- The asymmetry between them is the reference's: CAMP offers only Cancel (the commented-out
-- The asymmetry between them is the spec's: CAMP offers only Cancel (the commented-out
-- CAMP_NOW/ForceLogout button is dead in 1.12 — "uncomment once forced logouts are completely
-- implemented"), while QUIT offers Exit now + Cancel, because a force-quit needs no server
-- round trip. Closing either one EARLY (button, ESC, or an override) cancels the logout;
-- letting it run out does not — the engine zeroes `timeleft` before hiding, which is exactly
-- what the ref's `if this.timeleft > 0` guard reads.
-- round trip.
--
-- Both entries below are written scalars-first, handlers-last, so the dialog's SHAPE reads
-- before its behaviour does.
-- Dismissing a running countdown means "I didn't mean it": call the logout off. Only while
-- it is still RUNNING, though — the popup engine zeroes `timeleft` before hiding a dialog
-- that expired, so reaching zero leaves the logout alone. (Backwards, and every logout in
-- the field is cancelled by its own dialog at t=0 and the character never leaves.) One
-- function because both dialogs mean exactly this; `this` is the popup frame, which the
-- engine has bound around the handler.
local function CancelLogoutIfStillCounting()
if this.timeleft > 0 then
CancelLogout()
end
end
StaticPopupDialogs["CAMP"] = {
text = CAMP_TIMER,
button1 = CANCEL,
OnAccept = function()
CancelLogout()
end,
OnHide = function()
if this.timeleft > 0 then
CancelLogout()
end
end,
timeout = 20,
whileDead = 1,
hideOnEscape = 1,
OnAccept = function()
CancelLogout()
end,
OnHide = CancelLogoutIfStillCounting,
}
StaticPopupDialogs["QUIT"] = {
text = QUIT_TIMER,
button1 = QUIT_NOW,
button2 = CANCEL,
timeout = 20,
whileDead = 1,
hideOnEscape = 1,
-- A force-quit needs no server round trip, so the countdown it was narrating is over
-- the moment the button is pressed: zero `timeleft` so the OnHide below stands down.
OnAccept = function()
ForceQuit()
this.timeleft = 0
end,
OnHide = function()
if this.timeleft > 0 then
CancelLogout()
end
end,
timeout = 20,
whileDead = 1,
hideOnEscape = 1,
OnHide = CancelLogoutIfStillCounting,
}
-- The camp/quit event driver. The ref handles these three in UIParent's own OnEvent
+60 -66
View File
@@ -1,8 +1,11 @@
<!--
The minimap's time-of-day indicator (GameTimeFrame) — transcribed from the 1.12 FrameXML
GameTime.xml + GameTime.lua (the reference's own file split; its TOC loads GameTime.xml right
after Minimap.xml, and the manifest mirrors that beside MinimapCluster.xml). This closes the
"day/night indicator" line in MinimapCluster.xml's OUT list.
The minimap's time-of-day indicator (GameTimeFrame) — our implementation of the behaviour the
1.12 FrameXML GameTime.xml + GameTime.lua specify, in their own file split (the reference TOC
loads GameTime.xml right after Minimap.xml, and the manifest mirrors that beside
MinimapCluster.xml). The Lua below is ours; the published names (GameTimeFrame_Update,
GameTime_GetTime, GAMETIME_DAWN/DUSK, GameTimeTexture) and every number are the reference's,
because addons call the first by name and the second are measurements of the real interface.
This closes the "day/night indicator" line in MinimapCluster.xml's OUT list.
The 50×50 frame overhanging the cluster's top-right corner shows
Interface\Minimap\UI-TOD-Indicator (128×64: the sun disc on the left half, the moon on the
@@ -17,90 +20,81 @@
`ui/scratch/scripts-auto-enable.md`, resolved 2026-08-07) — benilla's loader models the same
law (`loader/scripts.rs`), so this file stays attribute-free like the reference.
Deltas from the reference, each deliberate:
- TEXT() is dropped (this file set's convention: the ref's build-time no-op localization
macro; CharacterFrame.xml's note).
Deltas from the reference's behaviour, each deliberate:
- No TEXT() wrapper around the format strings (this file set's convention: the ref's
build-time no-op localization macro; CharacterFrame.xml's note).
- TwentyFourHourTime = 1 is SET here: the reference sets it in Localization.lua's
LocalizeFrames() — this install is enGB, and enGB is a 24-hour locale — called at
VARIABLES_LOADED. benilla fires that event since decision 1128 and could carry this as a saved
variable (it is on 1128's follow-on list), but the value here is a LOCALE fact, not a player
toggle: this install is enGB and enGB is a 24-hour locale. So the tooltip reads "21:07" exactly
as the reference client on this install does; the 12-hour AM/PM branches stay verbatim below
for the day a locale/CVar drives it.
as the reference client on this install does; the 12-hour AM/PM path below is implemented and
kept live for the day a locale/CVar drives it.
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Script><![CDATA[
-- ref-GameTime.lua, verbatim (minus TEXT(); TwentyFourHourTime per the header).
-- Set here rather than by Localization.lua's LocalizeFrames — see the header.
TwentyFourHourTime = 1;
-- The reference's own thresholds, in minutes since midnight (game time), and its own
-- global names: an addon that wants to know when night falls reads exactly these.
GAMETIME_DAWN = ( 5 * 60) + 30; -- 5:30 AM
GAMETIME_DUSK = (21 * 60) + 0; -- 9:00 PM
-- UI-TOD-Indicator is one 128x64 sheet carrying two 50-px icons side by side: the sun at
-- u=0, the moon exactly half a sheet to its right. So the whole day/night decision is
-- which of the two u origins the 50-px window sits on.
local TOD_ICON_WIDTH = 50/128;
local TOD_ICON_HEIGHT = 50/64;
local TOD_NIGHT_ORIGIN = 0.5;
function GameTimeFrame_Update()
local hour, minute = GetGameTime();
local time = (hour * 60) + minute;
if(time ~= this.timeOfDay) then
this.timeOfDay = time;
local minx = 0;
local maxx = 50/128;
local miny = 0;
local maxy = 50/64;
if(time < GAMETIME_DAWN or time >= GAMETIME_DUSK) then
minx = minx + 0.5;
maxx = maxx + 0.5;
end
GameTimeTexture:SetTexCoord(minx, maxx, miny, maxy);
if(GameTooltip:IsOwned(this)) then
GameTimeFrame_UpdateTooltip(hour, minute);
end
local minuteOfDay = (hour * 60) + minute;
-- OnUpdate calls this every frame; only a changed game MINUTE is worth any work.
if minuteOfDay == this.timeOfDay then
return;
end
this.timeOfDay = minuteOfDay;
local origin = 0;
if minuteOfDay < GAMETIME_DAWN or minuteOfDay >= GAMETIME_DUSK then
origin = TOD_NIGHT_ORIGIN;
end
GameTimeTexture:SetTexCoord(origin, origin + TOD_ICON_WIDTH, 0, TOD_ICON_HEIGHT);
-- A tooltip we still own is showing last minute's clock; re-word it in place.
if GameTooltip:IsOwned(this) then
GameTimeFrame_UpdateTooltip(hour, minute);
end
end
-- The one clock formatter, since the tooltip and the getter below want the same string in
-- two different wrappers. TwentyFourHourTime picks the plain HH:MM form; otherwise the
-- hour folds into 1..12 (midnight and noon both being 12) and the AM/PM half is carried by
-- which format string is used, not by any text this file spells out.
local function clockText(hour, minute)
if TwentyFourHourTime then
return format(TIME_TWENTYFOURHOURS, hour, minute);
end
local afternoon = (hour >= 12);
if hour > 12 then
hour = hour - 12;
elseif hour == 0 then
hour = 12;
end
if afternoon then
return format(TIME_TWELVEHOURPM, hour, minute);
end
return format(TIME_TWELVEHOURAM, hour, minute);
end
function GameTimeFrame_UpdateTooltip(hours, minutes)
if(TwentyFourHourTime) then
GameTooltip:SetText(format(TIME_TWENTYFOURHOURS, hours, minutes));
else
local pm = 0;
if(hours >= 12) then
pm = 1;
end
if(hours > 12) then
hours = hours - 12;
end
if(hours == 0) then
hours = 12;
end
if(pm == 0) then
GameTooltip:SetText(format(TIME_TWELVEHOURAM, hours, minutes));
else
GameTooltip:SetText(format(TIME_TWELVEHOURPM, hours, minutes));
end
end
GameTooltip:SetText(clockText(hours, minutes));
end
function GameTime_GetTime()
local hour, minute = GetGameTime();
if(TwentyFourHourTime) then
return format(TIME_TWENTYFOURHOURS, hour, minute);
else
local pm = 0;
if(hour >= 12) then
pm = 1;
end
if(hour > 12) then
hour = hour - 12;
end
if(hour == 0) then
hour = 12;
end
if(pm == 0) then
return format(TIME_TWELVEHOURAM, hour, minute);
else
return format(TIME_TWELVEHOURPM, hour, minute);
end
end
return clockText(GetGameTime());
end
]]></Script>
<Frame name="GameTimeFrame" parent="MinimapCluster">
+48 -33
View File
@@ -91,7 +91,7 @@
tooltip.default = 1
end
-- The two-line UI-element tooltip (ref-GameTooltip.lua l.107-123, quoted): a white label,
-- The two-line UI-element tooltip (spec: ref-GameTooltip.lua l.107-123): a white label,
-- then the long explanation wrapped underneath in NORMAL_FONT_COLOR — the whole reason a
-- 1.12 hover over the ping meter or a micro button is a paragraph and not four words.
-- With detailed tips on it seats at the DEFAULT corner, not beside the owner; with them off
@@ -115,25 +115,34 @@
BenillaTooltip_AddNewbieTipFor(this, normalText, r, g, b, newbieText, noNormalText)
end
-- The explicit-owner form our FrameXML uses. Same body the reference's has, with the owner
-- threaded rather than taken from `this` — kept under a Benilla name so it can never be
-- mistaken for a reference global an addon might call or replace.
-- The explicit-owner form our FrameXML uses, and where the behaviour above actually lives:
-- the owner is threaded in rather than read off `this`. Kept under a Benilla name so it can
-- never be mistaken for a reference global an addon might call or replace.
function BenillaTooltip_AddNewbieTipFor(owner, normalText, r, g, b, newbieText, noNormalText)
if SHOW_NEWBIE_TIPS == "1" then
GameTooltip_SetDefaultAnchor(GameTooltip, owner)
if normalText then
GameTooltip:SetText(normalText, r, g, b)
GameTooltip:AddLine(newbieText, NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b, 1)
else
GameTooltip:SetText(newbieText, r, g, b, 1, 1)
end
GameTooltip:Show()
else
-- Detailed tips OFF is the short shape: the label alone, beside whatever it labels.
-- A caller that says its label is worthless on its own then has nothing left to show.
if SHOW_NEWBIE_TIPS ~= "1" then
if not noNormalText then
GameTooltip:SetOwner(owner, "ANCHOR_RIGHT")
GameTooltip:SetText(normalText, r, g, b)
end
return
end
-- Detailed tips ON: a paragraph, and paragraphs go to the screen corner instead of
-- crowding the thing they explain.
GameTooltip_SetDefaultAnchor(GameTooltip, owner)
if normalText then
-- Label in the caller's colour; the explanation under it, wrapped, always in
-- NORMAL_FONT_COLOR — the two lines are deliberately not the same colour.
GameTooltip:SetText(normalText, r, g, b)
GameTooltip:AddLine(newbieText, NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b, 1)
else
-- No label, so the explanation IS the tooltip: it takes the caller's colour, plus
-- SetText's alpha and wrap flags (args 5 and 6) — the wrap the second line above
-- would otherwise have carried.
GameTooltip:SetText(newbieText, r, g, b, 1, 1)
end
GameTooltip:Show()
end
-- ref GameTooltip_OnHide (ref-GameTooltip.lua l.100-104, quoted): the plate colours
@@ -219,32 +228,38 @@
return 1.0, 1.0, 1.0
end
-- HealthBar_OnValueChanged (ref-HealthBar.lua, quoted; the tooltip bar passes no
-- `smooth`, so the fill reads the ref's plain green).
-- The health bar's own fill colour (spec: ref-HealthBar.lua, the whole file).
--
-- Two colourings in one handler. `smooth` asks for the health RAMP — green at the top of
-- the range, red at the bottom, and between them a two-segment fade that pins one channel
-- at full and slides the other, so the halfway mark reads solid yellow. Without it the bar
-- is flat green whatever it reads. Blue is 0 either way, which is what keeps the fade
-- inside the red-yellow-green wedge and never lets it wash out.
--
-- **Nothing passes `smooth` — not us and not the reference.** Verified: our only caller is
-- the status bar's `<OnValueChanged>` below (one argument), and 1.12's own two callers are
-- ref-GameTooltipTemplate.xml l.593 and ref-UnitFrame.lua l.181, both of which forward
-- `value` alone. So the ramp has never coloured a bar in this game; it is kept because it
-- is the contract an addon replacing a unit frame may still call with two arguments.
--
-- A value outside the bar's own range leaves the colour untouched rather than being clamped
-- to an end of the ramp — the reference's own guard, kept as the reference has it.
function HealthBar_OnValueChanged(value, smooth)
if not value then return end
local r, g, b
local min, max = this:GetMinMaxValues()
local bar = this
local min, max = bar:GetMinMaxValues()
if value < min or value > max then return end
if (max - min) > 0 then
value = (value - min) / (max - min)
else
value = 0
end
local r, g = 0.0, 1.0
if smooth then
if value > 0.5 then
r = (1.0 - value) * 2
g = 1.0
local filled = 0
if max > min then filled = (value - min) / (max - min) end
if filled > 0.5 then
r, g = (1.0 - filled) * 2, 1.0
else
r = 1.0
g = value * 2
r, g = 1.0, filled * 2
end
else
r = 0.0
g = 1.0
end
b = 0.0
this:SetStatusBarColor(r, g, b)
bar:SetStatusBarColor(r, g, 0.0)
end
-- `self or this` for the same reason as GameTooltip_OnHide: an addon inheriting
@@ -1,10 +1,10 @@
<Ui>
<!--
benilla's group-loot roll window (decision 0591) — the Need/Greed/Pass dialog: four stacked
`GroupLootFrame`s, transcribed from the real 1.12 client (`LootFrame.xml` l.243-533,
`GroupLootFrame`s, re-expressed from the real 1.12 client (`LootFrame.xml` l.243-533,
`LootFrame.lua` l.246-296, `UIParent.lua` l.513-515/1577 — all extracted, scratchpad
`WoW/_extracted_framexml/`). Geometry, art paths, and behavior below are QUOTED from those
(line citations inline); the Lua is transcribed logic turned onto our own `loot_roll` engine
(line citations inline); the Lua is re-expressed logic turned onto our own `loot_roll` engine
seam (`benilla-ui/src/script/loot_roll.rs`, decision 0591) and its app feed
(`crates/benilla/src/ui_loot_roll.rs`).
@@ -46,7 +46,7 @@
THE DRESSING-ROOM / CHAT-LINK CLICK LANDED (decisions 1059/1060 — this note used to read "no
dressing room exists", which was true until the room did). The ref icon button's Ctrl-click
`DressUpItemLink`/Shift-click `ChatFrameEditBox:Insert` (ref l.353-361) are transcribed in
`DressUpItemLink`/Shift-click `ChatFrameEditBox:Insert` (ref l.353-361) are re-expressed in
`BenillaGroupLootFrame_Icon_OnClick` below, over the `GetLootRollItemLink(rollID)` binding the
same arc added to `loot_roll.rs`. Still dropped: the ref's `this.hasItem = 1` OnLoad flag
(ref l.338-340) and its `CursorUpdate()`/`ResetCursor()`/`CursorOnUpdate()` hover-cursor calls
@@ -274,7 +274,7 @@
GameTooltip:Hide()
end
-- The item icon's click (ref $parentIconFrame OnClick, LootFrame.xml l.353-361, transcribed
-- The item icon's click (ref $parentIconFrame OnClick, LootFrame.xml l.353-361, re-expressed
-- whole): CTRL previews the rolled item in the dressing room (decision 1060), SHIFT posts
-- its link into an open chat edit box (decision 1059) — "link me that" being most of what a
-- roll popup gets asked. Both read `GetLootRollItemLink(rollID)`, the binding this arc added
@@ -146,7 +146,7 @@
-- ref ToggleInspect (l.67-83) + InspectFrame_ShowSubFrame (l.85-93). With one page shipped
-- the interesting branch is the ref's own "clicking the tab of the page already showing
-- CLOSES the window" — transcribed rather than smoothed away, because that is what the real
-- CLOSES the window" — kept rather than smoothed away, because that is what the real
-- tab does. ToggleInspect is BARE (not Benilla-prefixed), the addon-facing name, matching
-- this codebase's ToggleCharacter/ToggleQuestLog precedent.
function ToggleInspect(tab)
@@ -178,7 +178,7 @@
end
-- ref InspectFrame_OnUpdate (l.61-65): the window closes the moment there is no target.
-- Transcribed verbatim including its bluntness — the ref checks "target" specifically, even
-- The ref's behaviour kept, bluntness included — the ref checks "target" specifically, even
-- when inspecting a party token, so inspecting a party member and then clearing your target
-- closes the window. That is the shipped 1.12 behavior.
function BenillaInspectFrame_OnUpdate(frame)
@@ -1,7 +1,10 @@
<!--
The reference's `ItemButtonTemplate.lua` helper family (transcribed whole, like
TextStatusBar.xml) — the eight `SetItemButton*` globals every 1.12 window uses to paint an item
slot: its icon, its stack count, its merchant stock line, and the four vertex-colour verbs.
Our implementation of the eight `SetItemButton*` globals every 1.12 window uses to paint an item
slot: its icon, its stack count, its merchant stock line, and the four vertex-colour verbs. The
specification is the reference's `ItemButtonTemplate.lua` — the names, the argument order and the
behaviour of every branch are its contract; the code below is ours, and it is factored the way
eight near-identical verbs want to be factored (one region lookup, one tint verb) rather than the
way eight separately-written ones came out.
**Why this file exists, and what it cost to be without it.** The director installed Bagnon, hit
B, and got a frame with a title and a gold line and NO BAG SLOTS. `Bagnon_Core/core/Item.lua`
@@ -17,8 +20,8 @@
reproduction.
These are FrameXML, not engine (decision 1190's split): the real client ships them as Lua in
`Interface\FrameXML\ItemButtonTemplate.lua`, so they belong here beside our other
transcriptions rather than in a Rust binding. Read off the install's own copy with the
`Interface\FrameXML\ItemButtonTemplate.lua`, so ours belong here in our own interface rather
than in a Rust binding. The specification was read off the install's own copy with the
`mpqcat` example over `WoW/Data/interface.MPQ` — not from memory.
(An XML comment may not contain a double hyphen, which is why no command line is spelled out
here; `benilla-mpq/examples/mpqcat.rs`'s own header carries the invocation.)
@@ -37,8 +40,8 @@
Six addons in all — Bagnon_Core, CT_MailMod, CT_MasterMod, AdvancedTradeSkillWindow,
QuestHistory, SpecialTalentUI. The last two rows have **zero** corpus callers and are here
anyway because this is one reference file transcribed whole and they are two lines each; that
is stated rather than dressed up as demand (1210).
anyway because the family is one contract and, once the tint verb below is shared, they are one
line each; that is stated rather than dressed up as demand (1210).
**The virtual `ItemButtonTemplate` Button is deliberately NOT here.** The harness's template
demand ranks it at zero on both axes — no corpus addon names it in a `CreateFrame` fourth
@@ -55,29 +58,47 @@
-- `$parent…` template shape, and it is why these are free functions taking a button rather
-- than widget methods: any button that follows the naming works, whoever built it.
--
-- Nil guards are the REFERENCE's, not ours — one on `button` per verb, and one on the icon
-- region in SetItemButtonDesaturated. A missing region anywhere else raises, and that is
-- the right loudness: "this is not an item button" is a real mistake, and 1203's lesson is
-- that the silent version of a failure is the expensive one.
-- TWO DIFFERENT NILS, and the difference is the whole error policy of this file.
-- A nil BUTTON is a no-op everywhere: callers hand us whatever their loop is holding, and
-- the reference tolerates that in all eight verbs, so we do too. A button that is missing
-- the REGION is not tolerated — the lookup yields nil and the very next `:` raises. That
-- loudness is deliberate: "this is not an item button" is a real mistake by the caller, and
-- 1203's lesson is that the silent version of a failure is the expensive one.
-- One named region off a button, which is the only address these verbs ever compute. Nil in,
-- nil out, so the button guard reads as one expression instead of eight copied early
-- returns.
local function region(button, suffix)
return button and getglobal(button:GetName() .. suffix)
end
-- Four of the eight differ in nothing but which region they tint, so they share the verb
-- and name the region. The guard is on the BUTTON and deliberately not on the region it
-- resolved: a nil button is the tolerated case, a button carrying no such region is the
-- loud one. `texture` is a local rather than an inline call only so that the raise names
-- which lookup came back empty.
local function tint(button, suffix, r, g, b)
local texture = region(button, suffix)
if button then
texture:SetVertexColor(r, g, b)
end
end
function SetItemButtonCount(button, count)
if not button then
return
end
if not count then
count = 0
end
count = count or 0
button.count = count
local label = region(button, "Count")
-- `isBag` is the reference's own flag for a bag button, where even a single item
-- counts as a number worth showing.
-- counts as a number worth showing. Above 999 the slot shows a bare `*` — the stack
-- is "more than the label can say" — while `button.count` keeps the real number.
if count > 1 or (button.isBag and count > 0) then
if count > 999 then
count = "*"
end
getglobal(button:GetName() .. "Count"):SetText(count)
getglobal(button:GetName() .. "Count"):Show()
label:SetText(count > 999 and "*" or count)
label:Show()
else
getglobal(button:GetName() .. "Count"):Hide()
label:Hide()
end
end
@@ -89,15 +110,14 @@
-- string/number comparison Lua refuses — a raise on the only path the default can
-- reach. Ours defaults to 0, which is what the branch below actually means. Not aping
-- a quirk (method step 3); the behaviour on every reachable path is identical.
if not numInStock then
numInStock = 0
end
numInStock = numInStock or 0
button.numInStock = numInStock
local label = region(button, "Stock")
if numInStock > 0 then
getglobal(button:GetName() .. "Stock"):SetText(format(MERCHANT_STOCK, numInStock))
getglobal(button:GetName() .. "Stock"):Show()
label:SetText(format(MERCHANT_STOCK, numInStock))
label:Show()
else
getglobal(button:GetName() .. "Stock"):Hide()
label:Hide()
end
end
@@ -105,28 +125,39 @@
if not button then
return
end
-- Show/Hide FIRST, then SetTexture — the reference's order, and it matters: a nil
-- texture hides the region rather than leaving an empty quad behind.
local icon = region(button, "IconTexture")
-- Visibility tracks the ARGUMENT, not the region's contents, and it is settled before
-- the texture is assigned: clearing a slot with a nil texture must leave the region
-- hidden, never an empty quad standing where the icon was.
if texture then
getglobal(button:GetName() .. "IconTexture"):Show()
icon:Show()
else
getglobal(button:GetName() .. "IconTexture"):Hide()
icon:Hide()
end
getglobal(button:GetName() .. "IconTexture"):SetTexture(texture)
icon:SetTexture(texture)
end
function SetItemButtonTextureVertexColor(button, r, g, b)
if not button then
return
end
getglobal(button:GetName() .. "IconTexture"):SetVertexColor(r, g, b)
tint(button, "IconTexture", r, g, b)
end
function SetItemButtonNormalTextureVertexColor(button, r, g, b)
tint(button, "NormalTexture", r, g, b)
end
function SetItemButtonNameFrameVertexColor(button, r, g, b)
tint(button, "NameFrame", r, g, b)
end
function SetItemButtonSlotVertexColor(button, r, g, b)
tint(button, "SlotTexture", r, g, b)
end
function SetItemButtonDesaturated(button, desaturated, r, g, b)
if not button then
return
end
local icon = getglobal(button:GetName() .. "IconTexture")
-- The one verb whose own nil guard is on the ICON rather than the button: the
-- reference checks both, and `region()` collapses them into this single test without
-- changing which calls no-op and which raise.
local icon = region(button, "IconTexture")
if not icon then
return
end
@@ -138,37 +169,14 @@
-- is on the METHOD's existence and the no-shader branch is the one we take. When the
-- real verb lands this line starts using it with nothing else to change.
local shaderSupported = icon.SetDesaturated and icon:SetDesaturated(desaturated)
-- Undesaturated is full colour whatever the caller passed; desaturated is the caller's
-- own tint when they named one AND the shader took it, and the flat grey otherwise.
if not desaturated then
r = 1.0
g = 1.0
b = 1.0
r, g, b = 1.0, 1.0, 1.0
elseif not r or not shaderSupported then
r = 0.5
g = 0.5
b = 0.5
r, g, b = 0.5, 0.5, 0.5
end
icon:SetVertexColor(r, g, b)
end
function SetItemButtonNormalTextureVertexColor(button, r, g, b)
if not button then
return
end
getglobal(button:GetName() .. "NormalTexture"):SetVertexColor(r, g, b)
end
function SetItemButtonNameFrameVertexColor(button, r, g, b)
if not button then
return
end
getglobal(button:GetName() .. "NameFrame"):SetVertexColor(r, g, b)
end
function SetItemButtonSlotVertexColor(button, r, g, b)
if not button then
return
end
getglobal(button:GetName() .. "SlotTexture"):SetVertexColor(r, g, b)
end
]]></Script>
</Ui>
+163 -105
View File
@@ -1,10 +1,12 @@
<Ui>
<!--
The item-text reader — letters, books, plaques (the mail arc's "permanent copy" lands
here). Transcribed from the reference ItemTextFrame.xml (354 l) + ItemTextFrame.lua
(104 l); geometry/art quoted verbatim, names the reference's own. Driven by the engine's
item-text seam (benilla-ui/src/script/item_text.rs) + crate::ui_item_text's feed/drain:
the app fires the reference event flow ITEM_TEXT_BEGIN → ITEM_TEXT_READY → ITEM_TEXT_CLOSED.
here). The Lua below is OURS; the reference ItemTextFrame.xml (354 l) + ItemTextFrame.lua
(104 l) are the SPECIFICATION it satisfies — frame/region names, the event flow, the
material art paths and the geometry/art are the reference's and are quoted as such.
Driven by the engine's item-text seam (benilla-ui/src/script/item_text.rs) +
crate::ui_item_text's feed/drain: the app fires the reference event flow
ITEM_TEXT_BEGIN → ITEM_TEXT_READY → ITEM_TEXT_CLOSED.
ADAPTATIONS (the MailFrame.xml precedents):
· The body scroll (ItemTextScrollFrame, UIPanelScrollFrameTemplate 280×355 at -66,-76) is
@@ -12,22 +14,26 @@
a very long page clips. The ref's own flanking scrollbar art (UI-Character-ScrollBar
top/bottom + the black middle strip) is quoted verbatim as inert layers, so the right
edge reads exactly like the reference at rest.
· ItemTextPageText is SimpleHTML in the ref — transcribed as a FontString at the same
· ItemTextPageText is SimpleHTML in the ref — drawn here as a FontString at the same
rect (our letters/pages are plain text; an HTML page renders as its markup, accepted).
· The Lua's ScrollFrame-only verbs (UpdateScrollChildRect, ScrollBar SetValue/Hide,
scrollBarHideable) are dropped with the live scrollbar.
· GetMaterialTextColors + the two material color tables live in the reference Fonts.xml /
UIParent.lua — inlined here Benilla-scoped (the MoneyInput-shim precedent), quoted
verbatim.
· The ScrollFrame-only verbs the reference's OnLoad/READY use (UpdateScrollChildRect,
ScrollBar SetValue/Hide, scrollBarHideable) are dropped with the live scrollbar.
· The two material colour TABLES are the reference's data (its Fonts.xml), quoted
verbatim — the numbers ARE the look. They live here Benilla-scoped (the MoneyInput-shim
precedent) because our own Fonts.xml deliberately doesn't ship the reference's two
globals; the lookup around them is ours, under the reference's published name.
· GlobalStrings inlined: PREV "Prev", NEXT "Next", ITEM_TEXT_FROM "From," (the reference
string really is comma'd).
· ITEM_TEXT_TRANSLATION (the foreign-language progress bar) is transcribed but never
· ITEM_TEXT_TRANSLATION (the foreign-language progress bar) is implemented but never
fires — no translation mechanic on our letters; the StatusBar stays hidden.
· <HitRectInsets> quoted verbatim for fidelity but currently inert (the loader ignores
the element).
-->
<Script><![CDATA[
-- ref Fonts.xml l.27-42 (quoted verbatim, Benilla-scoped).
-- ── The material palette ─────────────────────────────────────────────────────────────
-- The six materials a page can be printed on, and the ink each one takes. The COLOURS are
-- the reference's own data (its Fonts.xml l.27-42) and are quoted verbatim: a different
-- number is a different look. Benilla-scoped names — see the header.
BENILLA_MATERIAL_TEXT_COLOR_TABLE = {
["Default"] = {0.18, 0.12, 0.06},
["Stone"] = {1.0, 1.0, 1.0},
@@ -44,7 +50,9 @@
["Silver"] = {0.93, 0.82, 0},
["Bronze"] = {0.93, 0.82, 0}
}
-- ref UIParent.lua l.1880-1888.
-- The page ink and the title ink for a material, under the reference's published name
-- (its UIParent.lua l.1880-1888). Falls back to "Default" unless BOTH halves are known, so
-- an unrecognised material never gets one table's colour and the other's default.
function GetMaterialTextColors(material)
local textColor = BENILLA_MATERIAL_TEXT_COLOR_TABLE[material]
local titleColor = BENILLA_MATERIAL_TITLETEXT_COLOR_TABLE[material]
@@ -55,99 +63,155 @@
return textColor, titleColor
end
-- ref ItemTextFrame.lua l.1-8.
-- ── The reader's regions, by role ────────────────────────────────────────────────────
-- The four material corners are one SET: a page is either bare parchment (all four off)
-- or dressed in a material, in which case each corner wears that material's own art.
-- Naming the suffixes once is also what makes the art path a single expression.
local MATERIAL_CORNERS = { "TopLeft", "TopRight", "BotLeft", "BotRight" }
-- Everything a fresh ITEM_TEXT_BEGIN clears away. At that point only the title is known,
-- so only the title stays on screen — the page and all of its paging chrome go.
local PAGE_CHROME = {
"ItemTextScrollFrame", "ItemTextCurrentPage", "ItemTextStatusBar",
"ItemTextPrevPageButton", "ItemTextNextPageButton",
}
local function ShowOrHide(region, wanted)
if wanted then
region:Show()
else
region:Hide()
end
end
-- A page always HAS a material: the server may name none, and the frame's own art is
-- already parchment, so that is what "none" means.
local function PageMaterial()
return ItemTextGetMaterial() or "Parchment"
end
-- Dress the page's four corners in its material. Parchment IS the frame's own art, so
-- parchment leaves the corners off and lets the bare frame show through; anything else
-- lights all four and points each at its own slice of that material's art set.
local function DressPage(material)
if material == "Parchment" then
for _, corner in ipairs(MATERIAL_CORNERS) do
getglobal("ItemTextMaterial" .. corner):Hide()
end
return
end
local art = "Interface\\ItemTextFrame\\ItemText-" .. material .. "-"
for _, corner in ipairs(MATERIAL_CORNERS) do
local layer = getglobal("ItemTextMaterial" .. corner)
layer:Show()
layer:SetTexture(art .. corner)
end
end
-- Put the reader on screen. The panel manager is allowed to refuse (something
-- native-center is holding the screen) — and then we are not reading after all, so the
-- server has to be told, or the session stays open behind a window that never appeared.
-- The reference makes the same check, on both of the paths that open this window.
local function OpenReader(frame)
ShowUIPanel(frame)
if not frame:IsVisible() then
CloseItemText()
end
end
-- ── The event flow ───────────────────────────────────────────────────────────────────
-- One function per event of the reference's flow: ItemTextFrame_OnLoad registers FLOW in
-- order and ItemTextFrame_OnEvent dispatches into Handlers by name. (The reference states
-- the same pairing twice — four RegisterEvent calls, then a four-arm `if event == …`
-- chain — which is how a fifth event gets added to one of them and not the other.)
local FLOW = {
"ITEM_TEXT_BEGIN", "ITEM_TEXT_TRANSLATION", "ITEM_TEXT_READY", "ITEM_TEXT_CLOSED",
}
local Handlers = {}
-- A text has been opened. Only its title is known yet, so the page is cleared away and
-- the ink is set for the material the pages are going to arrive on.
function Handlers.ITEM_TEXT_BEGIN(frame)
ItemTextTitleText:SetText(ItemTextGetItem())
for _, name in ipairs(PAGE_CHROME) do
getglobal(name):Hide()
end
local ink = GetMaterialTextColors(PageMaterial())
ItemTextPageText:SetTextColor(ink[1], ink[2], ink[3])
end
-- A foreign-language page is being translated: the turn buttons stand down and the
-- progress bar takes over, ranged across the arg1 seconds the server quoted (OnUpdate
-- fills it). Never fires on our letters — see the header.
function Handlers.ITEM_TEXT_TRANSLATION(frame)
ItemTextPrevPageButton:Hide()
ItemTextNextPageButton:Hide()
frame.translationElapsed = 0
ItemTextStatusBar:SetMinMaxValues(0, arg1)
ItemTextStatusBar:Show()
OpenReader(frame)
end
-- A page has arrived. A letter signs off with its sender (ITEM_TEXT_FROM — the reference
-- string really is comma'd); anything else just gets its trailing blank line. The paging
-- row appears only when there is another page to be on, and then each button shows only
-- if there is something in its direction.
function Handlers.ITEM_TEXT_READY(frame)
local creator = ItemTextGetCreator()
local body = "\n" .. ItemTextGetText()
if creator then
body = body .. "\n\n" .. "From," .. "\n" .. creator .. "\n\n"
else
body = body .. "\n"
end
ItemTextPageText:SetText(body)
ItemTextScrollFrame:Show()
local pageNumber = ItemTextGetPage()
local hasNext = ItemTextHasNextPage()
DressPage(PageMaterial())
if pageNumber > 1 or hasNext then
ItemTextCurrentPage:SetText(pageNumber)
ItemTextCurrentPage:Show()
ShowOrHide(ItemTextPrevPageButton, pageNumber > 1)
ShowOrHide(ItemTextNextPageButton, hasNext)
end
ItemTextStatusBar:Hide()
OpenReader(frame)
end
function Handlers.ITEM_TEXT_CLOSED(frame)
HideUIPanel(frame)
end
function ItemTextFrame_OnLoad()
this:RegisterEvent("ITEM_TEXT_BEGIN")
this:RegisterEvent("ITEM_TEXT_TRANSLATION")
this:RegisterEvent("ITEM_TEXT_READY")
this:RegisterEvent("ITEM_TEXT_CLOSED")
for _, event in ipairs(FLOW) do
this:RegisterEvent(event)
end
end
-- ref l.10-96.
function ItemTextFrame_OnEvent(event)
if event == "ITEM_TEXT_BEGIN" then
ItemTextTitleText:SetText(ItemTextGetItem())
ItemTextScrollFrame:Hide()
ItemTextCurrentPage:Hide()
ItemTextStatusBar:Hide()
ItemTextPrevPageButton:Hide()
ItemTextNextPageButton:Hide()
local material = ItemTextGetMaterial()
if not material then material = "Parchment" end
local textColor = GetMaterialTextColors(material)
ItemTextPageText:SetTextColor(textColor[1], textColor[2], textColor[3])
return
end
if event == "ITEM_TEXT_TRANSLATION" then
ItemTextPrevPageButton:Hide()
ItemTextNextPageButton:Hide()
this.translationElapsed = 0
ItemTextStatusBar:SetMinMaxValues(0, arg1)
ItemTextStatusBar:Show()
ShowUIPanel(this)
if not this:IsVisible() then CloseItemText() end
return
end
if event == "ITEM_TEXT_READY" then
local creator = ItemTextGetCreator()
if creator then
creator = "\n\n".."From,".."\n"..creator.."\n\n" -- ITEM_TEXT_FROM
ItemTextPageText:SetText("\n"..ItemTextGetText()..creator)
else
ItemTextPageText:SetText("\n"..ItemTextGetText().."\n")
end
ItemTextScrollFrame:Show()
local page = ItemTextGetPage()
local next = ItemTextHasNextPage()
local material = ItemTextGetMaterial()
if not material then material = "Parchment" end
if material == "Parchment" then
ItemTextMaterialTopLeft:Hide()
ItemTextMaterialTopRight:Hide()
ItemTextMaterialBotLeft:Hide()
ItemTextMaterialBotRight:Hide()
else
ItemTextMaterialTopLeft:Show()
ItemTextMaterialTopRight:Show()
ItemTextMaterialBotLeft:Show()
ItemTextMaterialBotRight:Show()
ItemTextMaterialTopLeft:SetTexture("Interface\\ItemTextFrame\\ItemText-"..material.."-TopLeft")
ItemTextMaterialTopRight:SetTexture("Interface\\ItemTextFrame\\ItemText-"..material.."-TopRight")
ItemTextMaterialBotLeft:SetTexture("Interface\\ItemTextFrame\\ItemText-"..material.."-BotLeft")
ItemTextMaterialBotRight:SetTexture("Interface\\ItemTextFrame\\ItemText-"..material.."-BotRight")
end
if (page > 1) or next then
ItemTextCurrentPage:SetText(page)
ItemTextCurrentPage:Show()
if page > 1 then
ItemTextPrevPageButton:Show()
else
ItemTextPrevPageButton:Hide()
end
if next then
ItemTextNextPageButton:Show()
else
ItemTextNextPageButton:Hide()
end
end
ItemTextStatusBar:Hide()
ShowUIPanel(this)
if not this:IsVisible() then CloseItemText() end
return
end
if event == "ITEM_TEXT_CLOSED" then
HideUIPanel(this)
return
local handler = Handlers[event]
if handler then
handler(this)
end
end
-- ref l.98-104.
-- The translation bar's clock: while the bar is up it fills with the seconds spent so far.
function ItemTextFrame_OnUpdate(elapsed)
if ItemTextStatusBar:IsVisible() then
elapsed = this.translationElapsed + elapsed
ItemTextStatusBar:SetValue(elapsed)
this.translationElapsed = elapsed
if not ItemTextStatusBar:IsVisible() then
return
end
local spent = this.translationElapsed + elapsed
ItemTextStatusBar:SetValue(spent)
this.translationElapsed = spent
end
-- Both page buttons are one gesture in two directions: the paging click, then the
-- engine's own page request. `turnPage` is ItemTextPrevPage or ItemTextNextPage.
function ItemTextFrame_TurnPage(turnPage)
PlaySound("igMainMenuOptionCheckBoxOn")
turnPage()
end
]]></Script>
<Frame name="ItemTextFrame" toplevel="true" movable="true" enableMouse="true" hidden="true" parent="UIParent">
@@ -281,10 +345,7 @@
</Layer>
</Layers>
<Scripts>
<OnClick>
PlaySound("igMainMenuOptionCheckBoxOn")
ItemTextPrevPage()
</OnClick>
<OnClick>ItemTextFrame_TurnPage(ItemTextPrevPage)</OnClick>
</Scripts>
<NormalTexture file="Interface\Buttons\UI-SpellbookIcon-PrevPage-Up"/>
<PushedTexture file="Interface\Buttons\UI-SpellbookIcon-PrevPage-Down"/>
@@ -302,10 +363,7 @@
</Layer>
</Layers>
<Scripts>
<OnClick>
PlaySound("igMainMenuOptionCheckBoxOn")
ItemTextNextPage()
</OnClick>
<OnClick>ItemTextFrame_TurnPage(ItemTextNextPage)</OnClick>
</Scripts>
<NormalTexture file="Interface\Buttons\UI-SpellbookIcon-NextPage-Up"/>
<PushedTexture file="Interface\Buttons\UI-SpellbookIcon-NextPage-Down"/>
@@ -151,7 +151,7 @@
-- ── Key display ── the 1.12 GetBindingText law by hand: modifier prefixes stay the
-- canonical uppercase tokens, the base key reads its KEY_* global (Spacebar, Up Arrow,
-- Mouse Button 4...) else itself. "CTRL--" renders CTRL + the minus key. (The action
-- bar's ABBREVIATED labels ride the real GetBindingText transcription in UIParent.xml;
-- bar's ABBREVIATED labels ride our own GetBindingText in UIParent.xml;
-- this page and the tooltips show the full form, both references' own split.)
function KeyBindings_KeyText(key)
if not key then
+2 -2
View File
@@ -1,7 +1,7 @@
<Ui>
<!--
benilla's MACRO window (decision 0983) — the 384x512 macro editor + its name/icon popup,
transcribed from the real 1.12 `Interface\AddOns\Blizzard_MacroUI\Blizzard_MacroUI.xml`
re-expressed from the real 1.12 `Interface\AddOns\Blizzard_MacroUI\Blizzard_MacroUI.xml`
and `.lua` (extracted from the MPQ chain to session scratch; director-local reference
artifacts, never committed — the 0068/0138 pattern. Line cites below are into that
extraction). All the art it names is shipped: `Interface\MacroFrame\MacroFrame-Icon`,
@@ -168,7 +168,7 @@
end
end
-- ref l.51-120, transcribed.
-- ref l.51-120, re-expressed.
function BenillaMacroFrame_Update()
BenillaMacroFrame_EnsureSelection()
local frame = getglobal("BenillaMacroFrame")
+52 -24
View File
@@ -7,6 +7,11 @@
(`benilla-ui/src/script/mail.rs`) + `crate::ui_mail`'s feed/drain. The house dressing
convention (decisions 0084/0112/0120).
THE LUA IS OURS. The reference's `MailFrame.lua` is the SPECIFICATION — the function names an
addon can call, the event vocabulary, and every branch's observable effect are read off it,
and the line cites in the script block are that file's. How any of it is spelled is this
file's own business, and several pieces are deliberately not spelled its way.
Where the reference inherits a shared template benilla does NOT ship (UIPanelButtonTemplate,
UIPanelCloseButton, ItemButtonTemplate, SmallMoneyFrameTemplate, MoneyInputFrameTemplate,
UIRadioButtonTemplate, FriendsFrameTabTemplate, UIPanelScrollFrameTemplate — grep-verified
@@ -14,7 +19,8 @@
ANCHORS/sizes kept exactly; the tab pair inherits benilla's CharacterFrameTabButtonTemplate at
the reference tab anchors. `MailTextFontNormal`/`GameFontRedSmall` DO ship and are used verbatim.
RENDER APPROXIMATIONS (the only geometry-affecting deviations, all else is verbatim):
RENDER APPROXIMATIONS (the only geometry-affecting deviations; every other measurement in the
markup below is the reference's own):
· Unread/read row **desaturation**: the engine has no `SetDesaturation`, so the read icon +
slot ring grey via vertex color (0.5) — ref MailFrame.lua l.130-141's SetVertexColor legs
are exact, only the icon's desaturation is the vertex-grey stand-in.
@@ -93,26 +99,53 @@
end
end
-- The tab pair + the four corner-texture swaps (ref MailFrameTab_OnClick l.64-93).
-- THE TWO SKINS the window wears (ref MailFrameTab_OnClick l.64-93). The inbox is dressed in
-- the item-text parchment, the compose form in the class-trainer panel; four corner slabs
-- and the top-left slab's own offset are the whole of the difference between them, so they
-- are a table here rather than two copies of the same five calls. `$parentTopLeft` and its
-- siblings publish as MailFrameTopLeft…, which is what the corner keys compose.
local mailSkin = {
inbox = {
TopLeft = "Interface\\ItemTextFrame\\UI-ItemText-TopLeft",
TopRight = "Interface\\Spellbook\\UI-SpellbookPanel-TopRight",
BotLeft = "Interface\\ItemTextFrame\\UI-ItemText-BotLeft",
BotRight = "Interface\\Spellbook\\UI-SpellbookPanel-BotRight",
x = 0, y = 0,
},
compose = {
TopLeft = "Interface\\ClassTrainerFrame\\UI-ClassTrainer-TopLeft",
TopRight = "Interface\\ClassTrainerFrame\\UI-ClassTrainer-TopRight",
BotLeft = "Interface\\ClassTrainerFrame\\UI-ClassTrainer-BotLeft",
BotRight = "Interface\\ClassTrainerFrame\\UI-ClassTrainer-BotRight",
-- The trainer art does not sit flush the way the parchment does; the ref's own
-- one-pixel nudge is what lines the two skins' outer edges up.
x = 2, y = -1,
},
}
local mailCorners = { "TopLeft", "TopRight", "BotLeft", "BotRight" }
-- Tab 1 is the inbox; anything else is the compose form.
function MailFrameTab_OnClick(tab)
if not tab then tab = this:GetID() end
PanelTemplates_SetTab(MailFrame, tab)
if tab == 1 then
local reading = (tab == 1)
if reading then
InboxFrame:Show()
SendMailFrame:Hide()
MailFrameTopLeft:SetTexture("Interface\\ItemTextFrame\\UI-ItemText-TopLeft")
MailFrameTopRight:SetTexture("Interface\\Spellbook\\UI-SpellbookPanel-TopRight")
MailFrameBotLeft:SetTexture("Interface\\ItemTextFrame\\UI-ItemText-BotLeft")
MailFrameBotRight:SetTexture("Interface\\Spellbook\\UI-SpellbookPanel-BotRight")
MailFrameTopLeft:SetPoint("TOPLEFT", "MailFrame", "TOPLEFT", 0, 0)
else
InboxFrame:Hide()
SendMailFrame:Show()
MailFrameTopLeft:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-TopLeft")
MailFrameTopRight:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-TopRight")
MailFrameBotLeft:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-BotLeft")
MailFrameBotRight:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-BotRight")
MailFrameTopLeft:SetPoint("TOPLEFT", "MailFrame", "TOPLEFT", 2, -1)
end
local skin = reading and mailSkin.inbox or mailSkin.compose
for _, corner in ipairs(mailCorners) do
getglobal("MailFrame" .. corner):SetTexture(skin[corner])
end
MailFrameTopLeft:SetPoint("TOPLEFT", "MailFrame", "TOPLEFT", skin.x, skin.y)
if not reading then
-- Arriving at the form repaints it and clears the mode a Reply had set.
SendMailFrame_Update()
SendMailFrame.sendMode = "send"
end
@@ -191,17 +224,12 @@
end
index = index + 1
end
-- Page arrows (ref l.188-198).
if MailFrame.pageNum == 1 then
InboxPrevPageButton:Disable()
else
InboxPrevPageButton:Enable()
end
if (MailFrame.pageNum * BENILLA_INBOXITEMS_TO_DISPLAY) < numItems then
InboxNextPageButton:Enable()
else
InboxNextPageButton:Disable()
end
-- Page arrows (ref l.188-198): back is dead on the first page, forward is dead once
-- this page reaches the end of the list. `lastOnPage` is the list index this page's
-- last row would carry if it were full.
local lastOnPage = MailFrame.pageNum * BENILLA_INBOXITEMS_TO_DISPLAY
if MailFrame.pageNum == 1 then InboxPrevPageButton:Disable() else InboxPrevPageButton:Enable() end
if lastOnPage < numItems then InboxNextPageButton:Enable() else InboxNextPageButton:Disable() end
-- InboxCurrentPage is declared in the ref XML (l.329) but NO reference Lua ever writes
-- it — the real 1.12 window shows nothing there, so neither do we.
end
+158 -72
View File
@@ -1,6 +1,8 @@
<!--
The HUD minimap cluster — transcribed from the 1.12 FrameXML Minimap.xml (decision 0203
phase 1). The <Minimap> widget itself is the engine-rendered map hole: benilla-ui carries its
The HUD minimap cluster — our own implementation of what the 1.12 FrameXML Minimap.xml
specifies (decision 0203 phase 1), which is cited throughout for the names, the geometry, the
art and the event wiring: that half is the API addons reposition and hook by name, and none of
it is ours to invent. The <Minimap> widget itself is the engine-rendered map hole: benilla-ui carries its
rect + zoom, and the app (`minimap.rs`) fills it with the streamed tile window, the
MinimapMask circle, and the player arrow; everything in THIS file is the chrome the reference
authors around that hole, at the reference's own geometry and art.
@@ -34,69 +36,146 @@
end
end
-- ref-Minimap.lua Minimap_Update(), verbatim — the PVP tint rides GetZonePVPInfo
-- (decision 0287; closes the "zone PVP state we don't track yet" reduction that kept
-- the label gold), plus the ref's refresh-while-hovered tooltip block.
function Minimap_Update()
MinimapZoneText:SetText(GetMinimapZoneText());
local pvpType, factionName, isArena = GetZonePVPInfo();
if ( isArena ) then
MinimapZoneText:SetTextColor(1.0, 0.1, 0.1);
elseif ( pvpType == "friendly" ) then
MinimapZoneText:SetTextColor(0.1, 1.0, 0.1);
elseif ( pvpType == "hostile" ) then
MinimapZoneText:SetTextColor(1.0, 0.1, 0.1);
-- The zone label's tint, keyed by the zone's PvP disposition. Colours quoted from
-- ref-Minimap.lua (they are measurements of the real interface, not choices of ours); the
-- table is ours, because five branches that differ only in three floats want to be data.
local ZONE_PVP_TINT = {
friendly = { r = 0.1, g = 1.0, b = 0.1 },
hostile = { r = 1.0, g = 0.1, b = 0.1 },
contested = { r = 1.0, g = 0.7, b = 0 },
};
-- Every line of the zone tooltip is drawn the same way — plain white, nothing in the
-- right-hand column — so the stanza is written once here instead of once per line.
local function AddZoneTooltipLine(text)
GameTooltip:AddLine(text, "", 1.0, 1.0, 1.0);
end
-- The zone's "whose land is this" line, or nil for a zone that is nobody's (sanctuary, or
-- a disposition we don't recognise). Free-for-all is deliberately NOT decided here: the
-- two callers below reach it by genuinely different routes and have to keep doing so — see
-- the note in Minimap_Update.
local function ZoneTerritoryLine(pvpType, factionName)
if ( pvpType == "friendly" or pvpType == "hostile" ) then
return format(FACTION_CONTROLLED_TERRITORY, factionName);
elseif ( pvpType == "contested" ) then
MinimapZoneText:SetTextColor(1.0, 0.7, 0);
return CONTESTED_TERRITORY;
end
return nil;
end
-- Our implementation of what ref-Minimap.lua's Minimap_Update() specifies: relabel the
-- minimap's zone line, tint it for the zone's PVP state (decision 0287 — GetZonePVPInfo
-- closes the "zone PVP state we don't track yet" reduction that kept the label gold), and
-- refresh the hover tooltip if — and only if — the cursor is already parked on the label,
-- so a zone change under the mouse updates what is on screen.
function Minimap_Update()
local zoneName = GetMinimapZoneText();
MinimapZoneText:SetText(zoneName);
local pvpType, factionName, isArena = GetZonePVPInfo();
-- An arena zone is free-for-all, so it wears the hostile red whatever the zone's own
-- type says; resolving that override after the lookup keeps it in one place. A zone
-- with no tint of its own (sanctuary, or a type we don't know) stays normal gold.
local tint = ZONE_PVP_TINT[pvpType];
if ( isArena ) then
tint = ZONE_PVP_TINT.hostile;
end
if ( tint ) then
MinimapZoneText:SetTextColor(tint.r, tint.g, tint.b);
else
MinimapZoneText:SetTextColor(NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b);
end
if ( GameTooltip:IsOwned(MinimapZoneTextButton) ) then
GameTooltip:SetOwner(MinimapZoneTextButton, "ANCHOR_LEFT");
GameTooltip:AddLine(GetMinimapZoneText(), "", 1.0, 1.0, 1.0);
if ( (pvpType == "friendly") or (pvpType == "hostile") ) then
GameTooltip:AddLine(format(FACTION_CONTROLLED_TERRITORY, factionName), "", 1.0, 1.0, 1.0);
elseif ( pvpType == "contested" ) then
GameTooltip:AddLine(CONTESTED_TERRITORY, "", 1.0, 1.0, 1.0);
elseif ( pvpType == "arena" ) then
GameTooltip:AddLine(FREE_FOR_ALL_TERRITORY, "", 1.0, 1.0, 1.0);
end
GameTooltip:Show();
if ( not GameTooltip:IsOwned(MinimapZoneTextButton) ) then
return;
end
-- AT MOST ONE territory line on this path, and free-for-all is an ALTERNATIVE to the
-- faction line here, reached off `pvpType` rather than off `isArena` — so with the
-- live API (which never reports "arena" as a type) it never fires at all. That is the
-- reference's own asymmetry with MinimapZoneTextButton_OnEnter below, where the FFA
-- line is an EXTRA line gated on `isArena`. The two disagree, they are kept apart
-- deliberately, and converging them would be a behaviour change wearing a tidy-up's
-- clothes — which is why ZoneTerritoryLine stops short of this arm.
local territory = ZoneTerritoryLine(pvpType, factionName);
if ( pvpType == "arena" ) then
territory = FREE_FOR_ALL_TERRITORY;
end
GameTooltip:SetOwner(MinimapZoneTextButton, "ANCHOR_LEFT");
AddZoneTooltipLine(zoneName);
if ( territory ) then
AddZoneTooltipLine(territory);
end
GameTooltip:Show();
end
-- The zone label's own hover tooltip. Minimap_Update above refreshes this same tooltip in
-- place while the cursor is parked here, but the two are NOT one routine: this one adds
-- the free-for-all line as an EXTRA line gated on `isArena` (so an arena that is also
-- contested says both), while Minimap_Update reaches it off `pvpType == "arena"` INSTEAD
-- of the faction line. See the note there; the difference is the reference's, it is what
-- players actually see, and it is preserved on purpose.
function MinimapZoneTextButton_OnEnter()
GameTooltip:SetOwner(this, "ANCHOR_LEFT");
AddZoneTooltipLine(GetMinimapZoneText());
local pvpType, factionName, isArena = GetZonePVPInfo();
local territory = ZoneTerritoryLine(pvpType, factionName);
if ( territory ) then
AddZoneTooltipLine(territory);
end
if ( isArena ) then
AddZoneTooltipLine(FREE_FOR_ALL_TERRITORY);
end
GameTooltip:Show();
end
-- The end of the track a step of `step` is heading for. The two ends are not spelled the
-- same way — index 0 is a constant, the tight end rides however many levels the widget
-- carries — which is the only reason the +/- buttons need saying twice at all.
local function MinimapZoomLimit(step)
if ( step < 0 ) then
return 0;
end
return Minimap:GetZoomLevels() - 1;
end
-- One zoom step, either direction (ref-Minimap.lua's two click handlers say this twice in
-- mirror image; benilla says it once and hands it the direction). Three things happen: the
-- OPPOSITE button is re-armed, because a step this way always leaves room to come back;
-- the widget moves; and this button greys if the step landed on its own end of the track.
-- Note the limit is read AFTER the step, so a widget that clamps the move is still asked
-- where it actually ended up.
local function MinimapZoomStep(step, sound, button, opposite)
opposite:Enable();
PlaySound(sound);
Minimap:SetZoom(Minimap:GetZoom() + step);
if ( Minimap:GetZoom() == MinimapZoomLimit(step) ) then
button:Disable();
end
end
-- ref-Minimap.lua zoom clicks, verbatim: step the widget, re-arm both buttons, disable
-- the one at its bound.
function Minimap_ZoomInClick()
MinimapZoomOut:Enable();
PlaySound("igMiniMapZoomIn");
Minimap:SetZoom(Minimap:GetZoom() + 1);
if ( Minimap:GetZoom() == (Minimap:GetZoomLevels() - 1) ) then
MinimapZoomIn:Disable();
end
MinimapZoomStep(1, "igMiniMapZoomIn", MinimapZoomIn, MinimapZoomOut);
end
function Minimap_ZoomOutClick()
MinimapZoomIn:Enable();
PlaySound("igMiniMapZoomOut");
Minimap:SetZoom(Minimap:GetZoom() - 1);
if ( Minimap:GetZoom() == 0 ) then
MinimapZoomOut:Disable();
end
MinimapZoomStep(-1, "igMiniMapZoomOut", MinimapZoomOut, MinimapZoomIn);
end
-- ref-Minimap.lua Minimap_OnEvent's MINIMAP_UPDATE_ZOOM branch, verbatim: re-sync both
-- buttons to the CURRENT zoom. The app fires MINIMAP_UPDATE_ZOOM when the active zoom index
-- switches (stepping inside/outside flips to the other, independent level), so the +/- greying
-- follows the map you are actually on instead of the one you left.
-- Re-sync BOTH buttons to the zoom as it stands, which is what a click cannot do: a click
-- knows which way it moved, this does not. The app fires MINIMAP_UPDATE_ZOOM when the
-- active zoom index switches (stepping inside/outside flips to the other, independent
-- level), so the +/- greying follows the map you are actually on instead of the one you
-- left. Both ends are re-armed first and then at most one is greyed again — a widget with
-- a single zoom level sits on both ends at once, and the tight end wins that tie.
function Minimap_UpdateZoom()
MinimapZoomIn:Enable();
MinimapZoomOut:Enable();
local zoom = Minimap:GetZoom();
if ( zoom == (Minimap:GetZoomLevels() - 1) ) then
if ( zoom == MinimapZoomLimit(1) ) then
MinimapZoomIn:Disable();
elseif ( zoom == 0 ) then
elseif ( zoom == MinimapZoomLimit(-1) ) then
MinimapZoomOut:Disable();
end
end
@@ -127,6 +206,32 @@
GameTooltip:SetText(HAVE_MAIL);
end
-- The tracking icon, same shape as the mail frame above. `GetTrackingTexture()` is the
-- WHOLE of the state: the icon path of the one tracking effect the aura rebuild diverts
-- out of the buff bar (`ui_aura`), or nil when nothing is tracked — so there is nothing to
-- reconcile, the answer is either an icon to wear or a reason to get out of the way.
-- PLAYER_AURAS_CHANGED is the only signal that can change it (ref-Minimap.xml l.109-174
-- registers nothing else on this frame). Note the icon is NOT cleared on the way out: the
-- frame is hidden, and the next tracking effect repaints it before it is seen again.
function MiniMapTrackingFrame_OnEvent()
if ( event ~= "PLAYER_AURAS_CHANGED" ) then
return;
end
local tracking = GetTrackingTexture();
if ( not tracking ) then
MiniMapTrackingFrame:Hide();
return;
end
MiniMapTrackingIcon:SetTexture(tracking);
MiniMapTrackingFrame:Show();
end
-- Hovering the icon names the tracking spell itself, which the tooltip knows how to build.
function MiniMapTrackingFrame_OnEnter()
GameTooltip:SetOwner(this, "ANCHOR_BOTTOMLEFT");
GameTooltip:SetTrackingSpell();
end
]]></Script>
<Frame name="MinimapCluster" frameStrata="BACKGROUND" enableMouse="true" parent="UIParent">
<Size>
@@ -174,18 +279,7 @@
</Layers>
<Scripts>
<OnEnter>
GameTooltip:SetOwner(this, "ANCHOR_LEFT");
GameTooltip:AddLine(GetMinimapZoneText(), "", 1.0, 1.0, 1.0);
local pvpType, factionName, isArena = GetZonePVPInfo();
if ( (pvpType == "friendly") or (pvpType == "hostile") ) then
GameTooltip:AddLine(format(FACTION_CONTROLLED_TERRITORY, factionName), "", 1.0, 1.0, 1.0);
elseif ( pvpType == "contested" ) then
GameTooltip:AddLine(CONTESTED_TERRITORY, "", 1.0, 1.0, 1.0);
end
if ( isArena ) then
GameTooltip:AddLine(FREE_FOR_ALL_TERRITORY, "", 1.0, 1.0, 1.0);
end
GameTooltip:Show();
MinimapZoneTextButton_OnEnter();
</OnEnter>
<OnLeave>
GameTooltip:Hide();
@@ -224,9 +318,10 @@
</Anchor>
</Anchors>
<Frames>
<!-- ref-Minimap.xml l.109-174, verbatim — the tracking icon (the mail frame's
nesting precedent: a child of the round Minimap WIDGET, whose TOPLEFT the
(-15,0) anchor hangs off). Driven by PLAYER_AURAS_CHANGED →
<!-- ref-Minimap.xml l.109-174 for the names, geometry and art — the tracking
icon (the mail frame's nesting precedent: a child of the round Minimap
WIDGET, whose TOPLEFT the (-15,0) anchor hangs off). Driven by
PLAYER_AURAS_CHANGED →
GetTrackingTexture(): the reference's aura-cache rebuild diverts a
tracking-effect aura (Find Minerals, Track Beasts, …) out of the buff bar
into a tracking global this icon shows (byte-verified — the {0x2c,0x2d,0x97}
@@ -274,15 +369,7 @@
this:RegisterEvent("PLAYER_AURAS_CHANGED");
</OnLoad>
<OnEvent>
if ( event == "PLAYER_AURAS_CHANGED" ) then
local icon = GetTrackingTexture();
if ( icon ) then
MiniMapTrackingIcon:SetTexture(icon);
MiniMapTrackingFrame:Show();
else
MiniMapTrackingFrame:Hide();
end
end
MiniMapTrackingFrame_OnEvent();
</OnEvent>
<OnMouseUp>
if ( arg1 == "RightButton" ) then
@@ -290,8 +377,7 @@
end
</OnMouseUp>
<OnEnter>
GameTooltip:SetOwner(this, "ANCHOR_BOTTOMLEFT");
GameTooltip:SetTrackingSpell();
MiniMapTrackingFrame_OnEnter();
</OnEnter>
<OnLeave>
GameTooltip:Hide();
+78 -78
View File
@@ -1,8 +1,9 @@
<Ui>
<!--
The mirror timers - the 1.12 MirrorTimer1/2/3, transcribed from the extracted reference
(Interface\FrameXML\MirrorTimer.xml/.lua, mpqx; decision 0874). Geometry, textures, colors
and the countdown integration are the reference's own.
The mirror timers - our MirrorTimer1/2/3. The extracted reference
(Interface\FrameXML\MirrorTimer.xml/.lua, mpqx; decision 0874) is the SPECIFICATION here,
not the source: it fixes the frame and function names addons bind to, the geometry, the
textures and the four colours, and this file is our own implementation of that contract.
"Mirror" is the client's word for a timer whose authority is the SERVER: the client never
computes breath or fatigue. The server ships a value + a signed rate and the frame
@@ -16,18 +17,19 @@
look up the bar's caption ("Fatigue" / "Breath"; there is no FEIGNDEATH_LABEL in the 1.12
GlobalStrings, so that one shows an empty caption, exactly as the reference does).
Departures from the reference, all three deliberate:
Where our behaviour departs from the reference's, all three deliberate:
1. THE START DRIVER. The reference registers MIRROR_TIMER_START on UIParent itself and
calls MirrorTimer_Show from UIParent_OnEvent (ref UIParent.lua l.97 + l.374-375). Our
UIParent.xml is script-only - it has no frame of its own to register events on - so the
hidden MirrorTimerDriver below hosts that one registration and makes the identical call.
2. THE FREE-DIALOG LOOP BOUND. The reference's second loop in MirrorTimer_Show iterates to
STATICPOPUP_NUMDIALOGS, a copy-paste from StaticPopup.lua (its first loop, over the same
frames, correctly uses MIRRORTIMER_NUMTIMERS). With 3 frames and 3 client timer types
the bug is latent - but it would index a nil MirrorTimer4. Corrected to the constant
that names these frames.
2. ONE WALK OVER THE POOL, NOT TWO. The reference asks its two questions - "is this timer
already on a bar?" then "is any bar idle?" - in two separate loops, and bounds the
second by STATICPOPUP_NUMDIALOGS, a copy-paste from StaticPopup.lua that would index a
nil MirrorTimer4 the day the two constants diverge. A bar already carrying the timer
wins over an idle one wherever either sits, so one pass answers both questions and
cannot run off the end of the pool.
3. THE PAUSE ARG. The reference's MIRROR_TIMER_PAUSE branch reads the same arg1 as both a
timer name (`arg1 ~= this.timer`, above) and a number (`arg1 > 0`), so a real pause
@@ -35,110 +37,108 @@
substitutes a full START instead (Player::SendMirrorTimers says so in the source). We
read the flag from arg2, which is what both branches plainly intend.
MirrorTimerColors carries a "DEATH" entry the reference never uses (no timer type maps to
it); kept verbatim.
POSITION: TOP, y=-96 - the reference's own anchor, under the top-center of the screen, with
timers 2 and 3 stacking downward from it.
-->
<Script><![CDATA[
MIRRORTIMER_NUMTIMERS = 3
MirrorTimerColors = {}
MirrorTimerColors["EXHAUSTION"] = { r = 1.00, g = 0.90, b = 0.00 }
MirrorTimerColors["BREATH"] = { r = 0.00, g = 0.50, b = 1.00 }
MirrorTimerColors["DEATH"] = { r = 1.00, g = 0.70, b = 0.00 }
MirrorTimerColors["FEIGNDEATH"] = { r = 1.00, g = 0.70, b = 0.00 }
-- The bar colour per timer type, keyed by the name that arrives on the wire. DEATH is in
-- the published set even though nothing in 1.12 ever raises a timer of that type - the
-- table is the contract, so it carries the whole set and not just the three we see.
MirrorTimerColors = {
EXHAUSTION = { r = 1.00, g = 0.90, b = 0.00 },
BREATH = { r = 0.00, g = 0.50, b = 1.00 },
DEATH = { r = 1.00, g = 0.70, b = 0.00 },
FEIGNDEATH = { r = 1.00, g = 0.70, b = 0.00 },
}
-- Which bar `timer` belongs on. A bar already carrying it wins wherever it sits - the
-- server re-sends a whole START on every change, and a re-STATE must land back in place
-- instead of eating another bar; failing that the first idle bar takes it, and if all
-- three are busy the timer simply goes unshown (see departure 2).
local function claim_bar(timer)
local idle
for index = 1, MIRRORTIMER_NUMTIMERS do
local bar = getglobal("MirrorTimer" .. index)
if bar:IsVisible() then
if bar.timer == timer then
return bar
end
elseif not idle then
idle = bar
end
end
return idle
end
-- Hand a bar back to the pool: off screen, holding nothing.
local function release_bar(bar)
bar:Hide()
bar.timer = nil
end
-- Raise or re-seat one timer. Milliseconds on the wire, seconds on the bar; `scale` is
-- the server's signed rate, which OnUpdate integrates from here.
function MirrorTimer_Show(timer, value, maxvalue, scale, paused, label)
-- Pick a free dialog to use
local dialog = nil
if not dialog then
-- Find an open dialog of the requested type
for index = 1, MIRRORTIMER_NUMTIMERS, 1 do
local frame = getglobal("MirrorTimer"..index)
if frame:IsVisible() and (frame.timer == timer) then
dialog = frame
break
end
end
end
if not dialog then
-- Find a free dialog (ref: STATICPOPUP_NUMDIALOGS - see departure 2)
for index = 1, MIRRORTIMER_NUMTIMERS, 1 do
local frame = getglobal("MirrorTimer"..index)
if not frame:IsVisible() then
dialog = frame
break
end
end
end
if not dialog then
local bar = claim_bar(timer)
if not bar then
return nil
end
dialog.timer = timer
dialog.value = (value / 1000)
dialog.scale = scale
if paused > 0 then
dialog.paused = 1
else
dialog.paused = nil
end
bar.timer = timer
bar.value = value / 1000
bar.scale = scale
bar.paused = paused > 0 and 1 or nil
-- Set the text of the dialog
local text = getglobal(dialog:GetName().."Text")
text:SetText(label)
local name = bar:GetName()
getglobal(name .. "Text"):SetText(label)
-- Set the status bar of the dialog
local statusbar = getglobal(dialog:GetName().."StatusBar")
local fill = getglobal(name .. "StatusBar")
local color = MirrorTimerColors[timer]
statusbar:SetMinMaxValues(0, (maxvalue / 1000))
statusbar:SetValue(dialog.value)
statusbar:SetStatusBarColor(color.r, color.g, color.b)
fill:SetMinMaxValues(0, maxvalue / 1000)
fill:SetValue(bar.value)
fill:SetStatusBarColor(color.r, color.g, color.b)
dialog:Show()
return dialog
bar:Show()
return bar
end
function MirrorTimerFrame_OnLoad()
-- The two events that address a timer already running...
this:RegisterEvent("MIRROR_TIMER_PAUSE")
this:RegisterEvent("MIRROR_TIMER_STOP")
-- ...and the one that ends whatever this bar was holding.
this:RegisterEvent("PLAYER_ENTERING_WORLD")
this.timer = nil
end
function MirrorTimerFrame_OnEvent()
if event == "PLAYER_ENTERING_WORLD" then
this:Hide()
this.timer = nil
-- A worldport ends every timer the old world was running; a stale breath bar must
-- not survive the loading screen.
release_bar(this)
return
end
if not this:IsShown() or (arg1 ~= this.timer) then
-- Everything below names one running timer. Anything else is another bar's business.
if not this:IsShown() or arg1 ~= this.timer then
return
end
if event == "MIRROR_TIMER_PAUSE" then
-- ref: `if arg1 > 0` on the name it just string-compared (see departure 3)
if arg2 and arg2 > 0 then
this.paused = 1
else
this.paused = nil
end
return
end
if event == "MIRROR_TIMER_STOP" then
this:Hide()
this.timer = nil
-- The flag rides in arg2 (departure 3), and absent means running.
this.paused = (arg2 and arg2 > 0) and 1 or nil
elseif event == "MIRROR_TIMER_STOP" then
release_bar(this)
end
end
-- The whole of the client-side motion: between packets the bar walks itself along the
-- rate the server named. Nothing here decides anything - drowning is the server's call.
function MirrorTimerFrame_OnUpdate(frame, elapsed)
if frame.paused then
return
if not frame.paused then
frame.value = frame.value + frame.scale * elapsed
getglobal(frame:GetName() .. "StatusBar"):SetValue(frame.value)
end
local statusbar = getglobal(frame:GetName().."StatusBar")
frame.value = (frame.value + frame.scale * elapsed)
statusbar:SetValue(frame.value)
end
-- The ref's UIParent_OnEvent MIRROR_TIMER_START arm, rehosted (see departure 1).
+323 -323
View File
@@ -1,12 +1,17 @@
<Ui>
<!--
The reference's MONEY FRAME kit — `MoneyFrameTemplate` / `SmallMoneyFrameTemplate` plus the
`MoneyTypeInfo` table and the `MoneyFrame_*` API that drives them. Transcribed from the
extracted 1.12 `Interface\FrameXML\MoneyFrame.lua` (356 lines) and `MoneyFrame.xml` (297),
line numbers cited below as `l.N`.
THE MONEY FRAME KIT — `MoneyFrameTemplate` / `SmallMoneyFrameTemplate`, the `MoneyTypeInfo`
table and the `MoneyFrame_*` API that drives them.
WHY IT IS HERE: it is FrameXML's, not Rust's (decision 1190 — a name the shipped 1.12 UI
defines is transcribed, never hardcoded), and the corpus asks for it by name.
This is **our implementation** of that kit. The specification it implements is the extracted
1.12 `Interface\FrameXML\MoneyFrame.lua` (356 lines) and `MoneyFrame.xml` (297), cited below
as `ref l.N`: the names, the published field names, the constants and every branch of the
collapse rule are the specification's, because addons call this by name and a compatible kit
has no freedom in any of that. How it is written — the structure, the helpers, the local
names, the comments — is ours.
WHY IT IS HERE: it is FrameXML's surface, not Rust's (decision 1190 — a name the shipped 1.12
UI defines belongs in a UI file, never hardcoded), and the addon corpus asks for it by name.
`MoneyFrame_SetType` is wanted by 11 addons, `MoneyFrame_Update` by 9, `SmallMoneyFrame_OnLoad`
by 9, and 13 declare a frame `inherits="SmallMoneyFrameTemplate"`. `MoneyTypeInfo` was one of
the top runtime walls the frame rename left standing.
@@ -16,375 +21,418 @@
NOT use this file. They render coins through `BenillaMoney_Set`/`_Clear`/`_SetColor` in
MerchantFrame.xml: a three-SLOT chain (`<prefix>Coin1..3`) that fills only the non-zero
denominations in display order and re-flows, rather than three fixed GoldButton/SilverButton/
CopperButton frames that hide themselves. That kit was quoted FROM this one (its header cites
these same line numbers for the coin TexCoords, the collapse and the shrink) and its layout is
the one the director has signed off across those eight windows.
CopperButton frames that hide themselves. That kit takes its coin TexCoords, its collapse and
its shrink from the same specification as this one, and its layout is the one the director has
signed off across those eight windows.
So the two coexist deliberately, and the split is by AUDIENCE, not by accident:
· `BenillaMoney_*` + `<prefix>Coin1..3` — what our own transcribed windows are laid out on.
· `MoneyFrame_*` + `MoneyTypeInfo` here — the reference's names and structure, which is
what an addon writes against and what an addon's `inherits="SmallMoneyFrameTemplate"`
resolves to.
· `BenillaMoney_*` + `<prefix>Coin1..3` — what our own windows are laid out on.
· `MoneyFrame_*` + `MoneyTypeInfo` here — the names and structure an addon writes against,
and what an addon's `inherits="SmallMoneyFrameTemplate"` resolves to.
The reconciliation — migrating those eight windows onto `SmallMoneyFrameTemplate` +
`MoneyFrame_Update` and retiring `BenillaMoney_*` — is what the reference itself does and is
the right end state; it is a re-layout of hand-authored art in eight files (an approved look),
so it is named here rather than done in passing.
`MoneyFrame_Update` and retiring `BenillaMoney_*` — is what the specification itself does and
is the right end state; it is a re-layout of hand-authored art in eight files (an approved
look), so it is named here rather than done in passing.
── WHAT IS NOT CARRIED, and why (each is guarded at its own site below) ─────────────────────
· THE COIN PICKUP. `OpenCoinPickupFrame`/`UpdateCoinPickupFrame`/`CoinPickupFrame` live in the
reference's CoinPickupFrame.lua/.xml, which we do not ship — and the engine verbs underneath
it (`PickupPlayerMoney`, `DropCursorMoney`, `PickupTradeMoney`, `AddTradeMoney`) are not
built either, so a picked-up coin would have nowhere to go. The templates' `<OnClick>` is
the reference's, guarded on the function existing; a `canPickup` money frame therefore
enables its mouse (the reference's own state) and clicking it does nothing.
· `GetCursorMoney` (PLAYER's UpdateFunc, l.17) — the engine verb for coins held on the cursor.
· THE COIN PICKUP. `OpenCoinPickupFrame`/`UpdateCoinPickupFrame`/`CoinPickupFrame` belong to
CoinPickupFrame.lua/.xml, which we do not ship — and the engine verbs underneath them
(`PickupPlayerMoney`, `DropCursorMoney`, `PickupTradeMoney`, `AddTradeMoney`) are not built
either, so a picked-up coin would have nowhere to go. The coin buttons still take the mouse
for a `canPickup` type (that is the specified state) and the click is guarded, so it does
nothing.
· `GetCursorMoney` (PLAYER's amount, ref l.17) — the engine verb for coins held on the cursor.
With no coin pickup there is no cursor money, so its term is not approximated away: it is
structurally zero. Stated at the spot.
· `GetSendMailMoney` / `GetSendMailCOD` (l.73, l.90) — 1.12 engine verbs we have not built. We
ship their SETTERS (`SetSendMailMoney`/`SetSendMailCOD`, which MailFrame.xml drives); nothing
reads the amount back yet. Guarded to 0 rather than left as a nil call.
· `message()` (l.145, l.173, l.181) — the reference's one-line StaticPopup shim, which lives in
BasicControls.xml, a file family we have not transcribed. Guarded, so a misuse of the API
stays silent instead of erroring on the diagnostic.
· `Button:GetTextWidth()` (l.202 etc.) — our Button has no such method yet. The button's own
· `GetSendMailMoney` / `GetSendMailCOD` (ref l.73, l.90) — 1.12 engine verbs we have not
built. We ship their SETTERS (`SetSendMailMoney`/`SetSendMailCOD`, which MailFrame.xml
drives); nothing reads the amount back yet, so those two types read 0.
· `message()` (ref l.145, l.173, l.181) — the one-line StaticPopup shim, which belongs to
BasicControls.xml, a file family we have not built. Guarded, so a misuse of the API stays
silent instead of erroring on the diagnostic.
· `Button:GetTextWidth()` (ref l.202 etc.) — our Button has no such method. The button's own
`<ButtonText name="$parentText">` FontString carries `GetStringWidth`, which is the same
measurement, and that is what is called.
· The four `Pickup*`/`Add*` mail verbs the reference's own MoneyTypeInfo closures name are not
in 1.12's `_G` either (they are absent from reference/1.12-globals.tsv) — so those bodies are
transcribed verbatim and are as unreachable here as they are there.
measurement of the same string with the same font object, and that is what we call.
· The four `Pickup*`/`Add*` mail verbs the pickup closures name are not in 1.12's `_G` either
(they are absent from reference/1.12-globals.tsv) — so those closures are as unreachable
here as they are there. They exist because the table's shape is the contract.
-->
<Script><![CDATA[
-- l.2-12, verbatim.
MONEY_ICON_WIDTH = 19
MONEY_ICON_WIDTH_SMALL = 13
MONEY_BUTTON_SPACING = -4
MONEY_BUTTON_SPACING_SMALL = -4
-- ── What a coin is worth ─────────────────────────────────────────────────────────────────
-- Published globals: an addon that formats a price does the same split we do below, and
-- reads these rather than spelling 100 twice.
COPPER_PER_SILVER = 100
SILVER_PER_GOLD = 100
COPPER_PER_GOLD = COPPER_PER_SILVER * SILVER_PER_GOLD
-- ── How wide a coin draws ────────────────────────────────────────────────────────────────
-- One coin icon's width at each of the two sizes the templates below come in, and the gap
-- between two coin buttons — NEGATIVE, because the three deliberately bite into each other
-- so a group reads as one number rather than as three. `COIN_BUTTON_WIDTH` is the fixed slot
-- a `fixedWidth` type pads its smaller coins out to, so a column of prices lines up.
-- The values are the specification's (ref l.2-12): measurements of the shipped coin art, not
-- ours to re-derive.
MONEY_ICON_WIDTH = 19
MONEY_ICON_WIDTH_SMALL = 13
MONEY_BUTTON_SPACING = -4
MONEY_BUTTON_SPACING_SMALL = -4
COIN_BUTTON_WIDTH = 32
-- l.14-103: the money TYPES. A money frame carries no amount of its own — it carries a type,
-- and the type says where to read the number from (`UpdateFunc`, called with `this` set to the
-- frame), whether zero denominations collapse away, whether the smaller coins show anyway, and
-- whether the coins are click-to-pick-up. Addons read this table directly, so its shape is the
-- contract, not just its behaviour.
-- ── The money types (ref l.14-103) ───────────────────────────────────────────────────────
--
-- A money frame carries no amount of its own: it carries a TYPE, and the type says where the
-- number comes from and how the coins behave. An entry has three parts:
--
-- · `UpdateFunc()` returns the amount, in copper. It is called with the global `this` bound
-- to the frame being painted, so a type may read the frame as well as the engine.
-- · `PickupFunc(amount)` / `DropFunc()` are the cursor verbs a click-to-pick-up type drives.
-- · the display flags `MoneyFrame_Update` reads: `collapse` (drop leading zero
-- denominations), `showSmallerCoins` (keep the trailing ones on even at zero; the value
-- "Backpack" additionally keeps copper on by itself), `canPickup` (the coins take the
-- mouse), `fixedWidth` (pad the smaller coins to COIN_BUTTON_WIDTH), `truncateSmallCoins`
-- (drop copper once there is gold).
--
-- Nothing else is consulted. That is why those field names and the two function signatures
-- are the published contract and not an implementation detail: addons register types of their
-- own into this table and expect exactly these parts to be honoured.
MoneyTypeInfo = { }
-- Read an amount through an engine verb that may not be built here yet: absent, it reads 0
-- rather than calling into nil. Two of the seven types below need it — see the header.
local function AmountFrom(verbName)
local verb = getglobal(verbName)
if verb then
return verb()
end
return 0
end
-- The purse. The specification subtracts a third term here (ref l.17,
-- `GetMoney() - GetCursorMoney() - GetPlayerTradeMoney()`): coins lifted onto the cursor by
-- the coin-pickup frame. benilla ships neither that frame nor the `PickupPlayerMoney` verb
-- behind it, so the term is structurally 0 rather than approximated away — it comes back the
-- moment either lands.
MoneyTypeInfo["PLAYER"] = {
UpdateFunc = function()
-- Reference l.17: `GetMoney() - GetCursorMoney() - GetPlayerTradeMoney()`. The
-- cursor term is dropped, not approximated: `GetCursorMoney` is the amount lifted onto
-- the cursor by the coin-pickup frame, and benilla ships neither that frame nor the
-- `PickupPlayerMoney` verb behind it, so it is structurally 0 here. The term returns
-- the moment either lands.
return GetMoney() - GetPlayerTradeMoney()
end,
PickupFunc = function(amount)
PickupPlayerMoney(amount)
end,
DropFunc = function()
DropCursorMoney()
end,
UpdateFunc = function() return GetMoney() - GetPlayerTradeMoney() end,
PickupFunc = function(amount) PickupPlayerMoney(amount) end,
DropFunc = function() DropCursorMoney() end,
collapse = 1,
canPickup = 1,
showSmallerCoins = "Backpack"
showSmallerCoins = "Backpack",
}
MoneyTypeInfo["STATIC"] = {
UpdateFunc = function()
return this.staticMoney
end,
-- A number the owner parked on the frame itself — what `MoneyFrame_Update(name, amount)`
-- leaves behind, and how an addon shows a price it worked out for itself.
MoneyTypeInfo["STATIC"] = {
UpdateFunc = function() return this.staticMoney end,
collapse = 1,
}
-- The auction house's price column: the same source as STATIC, but the smaller coins pad out
-- to a fixed slot and stay on at zero, so a stack of prices reads as a column of digits.
MoneyTypeInfo["AUCTION"] = {
UpdateFunc = function()
return this.staticMoney
end,
UpdateFunc = function() return this.staticMoney end,
collapse = 1,
showSmallerCoins = "Backpack",
fixedWidth = 1,
collapse = 1,
truncateSmallCoins = nil,
}
-- Our side of an open trade, and the other party's. Only ours is click-to-pick-up.
MoneyTypeInfo["PLAYER_TRADE"] = {
UpdateFunc = function()
return GetPlayerTradeMoney()
end,
PickupFunc = function(amount)
PickupTradeMoney(amount)
end,
DropFunc = function()
AddTradeMoney()
end,
UpdateFunc = function() return GetPlayerTradeMoney() end,
PickupFunc = function(amount) PickupTradeMoney(amount) end,
DropFunc = function() AddTradeMoney() end,
collapse = 1,
canPickup = 1,
}
MoneyTypeInfo["TARGET_TRADE"] = {
UpdateFunc = function()
return GetTargetTradeMoney()
end,
MoneyTypeInfo["TARGET_TRADE"] = {
UpdateFunc = function() return GetTargetTradeMoney() end,
collapse = 1,
}
-- Mail postage and cash-on-delivery. Postage explicitly does NOT collapse (ref l.84): the
-- send-mail frame wants all three coins standing whatever the amount, because you are typing
-- into them. Both read 0 until their engine getters exist — see the header.
MoneyTypeInfo["SEND_MAIL"] = {
UpdateFunc = function()
-- `GetSendMailMoney` (ref l.73) is an engine verb we have not built — see the header.
if not GetSendMailMoney then return 0 end
return GetSendMailMoney()
end,
PickupFunc = function(amount)
PickupSendMailMoney(amount)
end,
DropFunc = function()
AddSendMailMoney()
end,
UpdateFunc = function() return AmountFrom("GetSendMailMoney") end,
PickupFunc = function(amount) PickupSendMailMoney(amount) end,
DropFunc = function() AddSendMailMoney() end,
collapse = nil,
canPickup = 1,
showSmallerCoins = "Backpack",
}
MoneyTypeInfo["SEND_MAIL_COD"] = {
UpdateFunc = function()
-- `GetSendMailCOD` (ref l.90) — same gap as SEND_MAIL above.
if not GetSendMailCOD then return 0 end
return GetSendMailCOD()
end,
PickupFunc = function(amount)
PickupSendMailCOD(amount)
end,
DropFunc = function()
AddSendMailCOD()
end,
UpdateFunc = function() return AmountFrom("GetSendMailCOD") end,
PickupFunc = function(amount) PickupSendMailCOD(amount) end,
DropFunc = function() AddSendMailCOD() end,
collapse = 1,
canPickup = 1,
}
-- l.105-122: the two OnLoads. Identical but for `this.small`, which is what picks the 13px
-- coin + tight spacing over the 19px one in MoneyFrame_Update below. Both then take type
-- PLAYER, so a frame that is never told otherwise shows the purse.
-- ── Hearing about a change (ref l.105-140) ───────────────────────────────────────────────
--
-- Every money frame registers all five money events; each then decides whether the one that
-- arrived is about the number IT is showing. That pairing is data, not control flow, so it is
-- written as data: one row per event, in the order the templates register them, naming the
-- money types that event can change.
local MONEY_EVENTS = {
{ "PLAYER_MONEY", { PLAYER = 1 } },
{ "PLAYER_TRADE_MONEY", { PLAYER = 1, PLAYER_TRADE = 1 } },
{ "TRADE_MONEY_CHANGED", { TARGET_TRADE = 1 } },
{ "SEND_MAIL_MONEY_CHANGED", { PLAYER = 1, SEND_MAIL = 1 } },
{ "SEND_MAIL_COD_CHANGED", { PLAYER = 1, SEND_MAIL_COD = 1 } },
}
-- The same rows keyed by event name, built once, so the handler below is a lookup.
local moneyEventTypes = { }
for _, row in ipairs(MONEY_EVENTS) do
moneyEventTypes[row[1]] = row[2]
end
local function MoneyFrameListen(frame)
for _, row in ipairs(MONEY_EVENTS) do
frame:RegisterEvent(row[1])
end
end
-- The two OnLoads differ in exactly one thing: `small`, which is what picks the 13px coin and
-- its tighter spacing over the 19px one in MoneyFrame_Update below. Both then take type
-- PLAYER, so a frame nobody tells otherwise shows the purse.
function MoneyFrame_OnLoad()
this:RegisterEvent("PLAYER_MONEY")
this:RegisterEvent("PLAYER_TRADE_MONEY")
this:RegisterEvent("TRADE_MONEY_CHANGED")
this:RegisterEvent("SEND_MAIL_MONEY_CHANGED")
this:RegisterEvent("SEND_MAIL_COD_CHANGED")
MoneyFrameListen(this)
MoneyFrame_SetType("PLAYER")
end
function SmallMoneyFrame_OnLoad()
this:RegisterEvent("PLAYER_MONEY")
this:RegisterEvent("PLAYER_TRADE_MONEY")
this:RegisterEvent("TRADE_MONEY_CHANGED")
this:RegisterEvent("SEND_MAIL_MONEY_CHANGED")
this:RegisterEvent("SEND_MAIL_COD_CHANGED")
MoneyFrameListen(this)
this.small = 1
MoneyFrame_SetType("PLAYER")
end
-- l.124-140, verbatim: every money frame hears every money event, and each decides for itself
-- whether the one that arrived is about the number IT is showing.
function MoneyFrame_OnEvent()
if ( not this.info or not this:IsVisible() ) then
if not this.info or not this:IsVisible() then
return
end
if ( event == "PLAYER_MONEY" and this.moneyType == "PLAYER" ) then
MoneyFrame_UpdateMoney()
elseif ( event == "PLAYER_TRADE_MONEY" and (this.moneyType == "PLAYER" or this.moneyType == "PLAYER_TRADE") ) then
MoneyFrame_UpdateMoney()
elseif ( event == "TRADE_MONEY_CHANGED" and this.moneyType == "TARGET_TRADE" ) then
MoneyFrame_UpdateMoney()
elseif ( event == "SEND_MAIL_MONEY_CHANGED" and (this.moneyType == "PLAYER" or this.moneyType == "SEND_MAIL") ) then
MoneyFrame_UpdateMoney()
elseif ( event == "SEND_MAIL_COD_CHANGED" and (this.moneyType == "PLAYER" or this.moneyType == "SEND_MAIL_COD") ) then
local affected = moneyEventTypes[event]
if affected and affected[this.moneyType] then
MoneyFrame_UpdateMoney()
end
end
-- l.142-162: point a frame at a money type. Operates on `this`, not on a passed frame — the
-- reference's convention here, and what the eleven corpus callers write.
-- ── Painting ─────────────────────────────────────────────────────────────────────────────
-- A money frame's three coin buttons, biggest first. Every verb below addresses them as a
-- trio, so this is the one place their `$parent<Denomination>Button` names are spelled.
local function MoneyFrameCoins(frameName)
return getglobal(frameName .. "GoldButton"),
getglobal(frameName .. "SilverButton"),
getglobal(frameName .. "CopperButton")
end
-- Point a frame at a money type (ref l.142-162): remember it, wire the mouse to match, and
-- repaint. Reads `this` rather than taking a frame — that is the convention the eleven corpus
-- callers write against.
function MoneyFrame_SetType(type)
local info = MoneyTypeInfo[type]
if ( not info ) then
-- ref l.145 `message("Invalid money type: "..type)` — `message` is BasicControls.xml's
-- StaticPopup shim, not transcribed (see the header).
if ( message ) then message("Invalid money type: "..type) end
if not info then
-- ref l.145 diagnoses this with `message()`, which we do not ship (see the header).
if message then
message("Invalid money type: " .. type)
end
return
end
this.info = info
this.moneyType = type
local frameName = this:GetName()
if ( info.canPickup ) then
getglobal(frameName.."GoldButton"):EnableMouse(true)
getglobal(frameName.."SilverButton"):EnableMouse(true)
getglobal(frameName.."CopperButton"):EnableMouse(true)
else
getglobal(frameName.."GoldButton"):EnableMouse(false)
getglobal(frameName.."SilverButton"):EnableMouse(false)
getglobal(frameName.."CopperButton"):EnableMouse(false)
end
-- Only a click-to-pick-up type wants the mouse; on the rest a click falls through to
-- whatever is behind the coins.
local gold, silver, copper = MoneyFrameCoins(this:GetName())
local pickable = info.canPickup and true or false
gold:EnableMouse(pickable)
silver:EnableMouse(pickable)
copper:EnableMouse(pickable)
MoneyFrame_UpdateMoney()
end
-- l.164-175: re-read the number from the type and repaint. `this` again.
-- Re-read the amount from the type and repaint (ref l.164-175). `this` again.
function MoneyFrame_UpdateMoney()
if ( this.info ) then
local money = this.info.UpdateFunc()
MoneyFrame_Update(this:GetName(), money)
-- ref l.169-171 also drives the coin-pickup frame while one is open. `hasPickup` is
-- only ever set by the templates' OnClick, which is guarded off (see the header), so
-- this branch is unreachable today rather than removed.
if ( this.hasPickup == 1 and UpdateCoinPickupFrame ) then
UpdateCoinPickupFrame(money)
if not this.info then
if message then
message("Error moneyType not set")
end
else
if ( message ) then message("Error moneyType not set") end
return
end
local money = this.info.UpdateFunc()
MoneyFrame_Update(this:GetName(), money)
-- ref l.169-171 also feeds the coin-pickup frame while one is open. `hasPickup` is set
-- only by the coin click, which is guarded off (see the header), so this is unreachable
-- today rather than removed.
if this.hasPickup == 1 and UpdateCoinPickupFrame then
UpdateCoinPickupFrame(money)
end
end
-- The button's laid-out text width. The reference calls `Button:GetTextWidth()` (l.202 and
-- kin); our Button has no such method, but the `<ButtonText name="$parentText">` FontString
-- the templates below name does carry `GetStringWidth`, which measures the same string with
-- the same font object. Falls back to 0 when the label has not resolved (headless, no atlas),
-- which leaves the coin at exactly its icon width — the same shape, without the digits.
local function moneyButtonTextWidth(buttonName)
local text = getglobal(buttonName.."Text")
if ( not text or not text.GetStringWidth ) then return 0 end
return text:GetStringWidth() or 0
-- Lay one denomination out: its digits, and the width those digits plus the coin icon need.
--
-- The specification measures with `Button:GetTextWidth()` (ref l.202 and kin); our Button
-- carries no such method, but the `<ButtonText name="$parentText">` FontString the templates
-- below name does carry `GetStringWidth`, which measures the same string with the same font
-- object. It reads 0 before the label has resolved (headless, no font atlas), which leaves the
-- coin at exactly its icon width — the same shape, without the digits.
local function ShowCoin(coin, coinName, amount, iconWidth)
coin:SetText(amount)
local label = getglobal(coinName .. "Text")
local digits = 0
if label and label.GetStringWidth then
digits = label:GetStringWidth() or 0
end
coin:SetWidth(digits + iconWidth)
coin:Show()
end
-- l.177-270: THE PAINTER. Splits a copper amount into the three denominations, sizes each coin
-- to its own digits + icon, and then — if the type collapses — hides the leading zeros and
-- re-chains what is left so the group packs tight and the frame reports its true width.
-- Chain the next-bigger coin onto `coin`, and report what `coin` contributes to the group's
-- width.
--
-- Note that this one takes a frame NAME, not `this`: it is the only entry point in the family
-- an owner can call about a frame that is not the one currently dispatching (`MoneyFrame_Update
-- (frameName, money)` is how an addon paints a STATIC frame it just filled).
-- Showing: the bigger coin hangs off this one's LEFT edge by `spacing` (negative — they
-- overlap), and the group grows by this coin's width less that overlap, but only when the
-- bigger coin is actually on screen to overlap with. Not showing: this coin hides and the
-- bigger one collapses onto its RIGHT edge, contributing nothing. `padToSlot` is the
-- `fixedWidth` exception, which sizes the coin to the fixed column slot first.
local function ChainCoin(bigger, coin, coinName, showing, padToSlot, spacing)
if not showing then
coin:Hide()
bigger:SetPoint("RIGHT", coinName, "RIGHT", 0, 0)
return 0
end
if padToSlot then
coin:SetWidth(COIN_BUTTON_WIDTH)
end
local grown = coin:GetWidth()
bigger:SetPoint("RIGHT", coinName, "LEFT", spacing, 0)
if bigger:IsVisible() then
grown = grown - spacing
end
return grown
end
-- THE PAINTER (ref l.177-270). Split a copper amount into the three denominations, size each
-- coin to its own digits plus its icon, and then — if the type collapses — drop the leading
-- zeros, re-chain what is left so the group packs tight, and report the frame's true width.
--
-- This one takes a frame NAME, not `this`: it is the only entry point in the family an owner
-- can call about a frame other than the one currently dispatching, which is how an addon
-- paints a STATIC frame it has just filled.
function MoneyFrame_Update(frameName, money)
local frame = getglobal(frameName)
local info = frame.info
if ( not info ) then
if ( message ) then message("Error moneyType not set") end
if not info then
-- ref l.181 diagnoses and, notably, does NOT return: the coins below are still laid
-- out, and the collapse read further down is what actually stops it. Kept as-is.
if message then
message("Error moneyType not set")
end
end
-- Breakdown the money into denominations
local gold = floor(money / (COPPER_PER_SILVER * SILVER_PER_GOLD))
local silver = floor((money - (gold * COPPER_PER_SILVER * SILVER_PER_GOLD)) / COPPER_PER_SILVER)
-- Copper is the low two digits, silver the next two, gold everything above. Silver comes
-- out by subtracting the gold back off rather than by a second `mod`: `mod` here is 1.12's
-- `math.mod`, which truncates towards zero, so `mod(-1, 100)` is -1 and not 99 — the two
-- forms part company the moment `money` goes negative (which `GetMoney()` minus a trade
-- offer can), and this one is the specified answer.
local gold = floor(money / COPPER_PER_GOLD)
local silver = floor((money - gold * COPPER_PER_GOLD) / COPPER_PER_SILVER)
local copper = mod(money, COPPER_PER_SILVER)
local goldButton = getglobal(frameName.."GoldButton")
local silverButton = getglobal(frameName.."SilverButton")
local copperButton = getglobal(frameName.."CopperButton")
local iconWidth = MONEY_ICON_WIDTH
local spacing = MONEY_BUTTON_SPACING
if ( frame.small ) then
iconWidth = MONEY_ICON_WIDTH_SMALL
spacing = MONEY_BUTTON_SPACING_SMALL
local goldCoin, silverCoin, copperCoin = MoneyFrameCoins(frameName)
local iconWidth, spacing = MONEY_ICON_WIDTH, MONEY_BUTTON_SPACING
if frame.small then
iconWidth, spacing = MONEY_ICON_WIDTH_SMALL, MONEY_BUTTON_SPACING_SMALL
end
-- Set values for each denomination
goldButton:SetText(gold)
goldButton:SetWidth(moneyButtonTextWidth(frameName.."GoldButton") + iconWidth)
goldButton:Show()
silverButton:SetText(silver)
silverButton:SetWidth(moneyButtonTextWidth(frameName.."SilverButton") + iconWidth)
silverButton:Show()
copperButton:SetText(copper)
copperButton:SetWidth(moneyButtonTextWidth(frameName.."CopperButton") + iconWidth)
copperButton:Show()
ShowCoin(goldCoin, frameName .. "GoldButton", gold, iconWidth)
ShowCoin(silverCoin, frameName .. "SilverButton", silver, iconWidth)
ShowCoin(copperCoin, frameName .. "CopperButton", copper, iconWidth)
-- Store how much money the frame is displaying
-- Remember what the frame is displaying — STATIC's own reader reads it back.
frame.staticMoney = money
-- If not collapsable don't need to continue
if ( not info.collapse ) then
-- A type that does not collapse is finished: all three coins stand, at the width their
-- digits asked for, and the frame keeps whatever width it was given.
if not info.collapse then
return
end
-- Gold leads. Present, it stays and sets the terms for the two below it; absent, it goes.
-- The running width starts at one icon's worth of frame padding.
local width = iconWidth
local showLowerDenominations, truncateCopper
if ( gold > 0 ) then
width = width + goldButton:GetWidth()
if ( info.showSmallerCoins ) then
showLowerDenominations = 1
end
if ( info.truncateSmallCoins ) then
truncateCopper = 1
end
local keepSmaller, dropCopper
if gold > 0 then
width = width + goldCoin:GetWidth()
keepSmaller = info.showSmallerCoins and 1 or nil
dropCopper = info.truncateSmallCoins and 1 or nil
else
goldButton:Hide()
goldCoin:Hide()
end
if ( silver > 0 or showLowerDenominations ) then
-- Exception if showLowerDenominations and fixedWidth
if ( showLowerDenominations and info.fixedWidth ) then
silverButton:SetWidth(COIN_BUTTON_WIDTH)
end
width = width + silverButton:GetWidth()
goldButton:SetPoint("RIGHT", frameName.."SilverButton", "LEFT", spacing, 0)
if ( goldButton:IsVisible() ) then
width = width - spacing
end
if ( info.showSmallerCoins ) then
showLowerDenominations = 1
end
else
silverButton:Hide()
goldButton:SetPoint("RIGHT", frameName.."SilverButton", "RIGHT", 0, 0)
-- Silver, then copper: each either shows — padded, chained and counted — or hides and lets
-- the coin above it collapse onto its edge. Silver showing under a `showSmallerCoins` type
-- is what carries the exemption down to copper; copper also survives at zero for a
-- "Backpack" type, and dies whatever it is once gold truncated it.
local showSilver = silver > 0 or keepSmaller
width = width + ChainCoin(goldCoin, silverCoin, frameName .. "SilverButton",
showSilver, keepSmaller and info.fixedWidth, spacing)
if showSilver and info.showSmallerCoins then
keepSmaller = 1
end
-- Used if we're not showing lower denominations
if ( (copper > 0 or showLowerDenominations or info.showSmallerCoins == "Backpack") and not truncateCopper) then
-- Exception if showLowerDenominations and fixedWidth
if ( showLowerDenominations and info.fixedWidth ) then
copperButton:SetWidth(COIN_BUTTON_WIDTH)
end
width = width + copperButton:GetWidth()
silverButton:SetPoint("RIGHT", frameName.."CopperButton", "LEFT", spacing, 0)
if ( silverButton:IsVisible() ) then
width = width - spacing
end
else
copperButton:Hide()
silverButton:SetPoint("RIGHT", frameName.."CopperButton", "RIGHT", 0, 0)
end
local showCopper = (copper > 0 or keepSmaller or info.showSmallerCoins == "Backpack")
and not dropCopper
width = width + ChainCoin(silverCoin, copperCoin, frameName .. "CopperButton",
showCopper, keepSmaller and info.fixedWidth, spacing)
frame:SetWidth(width)
end
-- l.272-346: a NO-OP in 1.12 — the reference's whole body is inside a `--[[ ]]` block, an
-- earlier painter that MoneyFrame_Update replaced. Kept because the name is in the shipped
-- client's `_G` and an addon that calls it gets exactly this there: nothing.
-- A NO-OP, deliberately. The specification's whole body for this one sits inside a `--[[ ]]`
-- block (ref l.272-346) — an earlier painter that MoneyFrame_Update replaced. The name is in
-- the shipped client's `_G`, and an addon that calls it there gets exactly this: nothing.
function RefreshMoneyFrame(frameName, money, small, collapse, showSmallerCoins)
end
-- l.348-356, verbatim: recolour the DIGITS (the coin icons keep their own texture) — how the
-- reference reddens a price you cannot afford.
-- Recolour the DIGITS; the coin icons keep their own texture (ref l.348-356). This is how a
-- price you cannot afford turns red.
function SetMoneyFrameColor(frameName, r, g, b)
local goldButton = getglobal(frameName.."GoldButton")
local silverButton = getglobal(frameName.."SilverButton")
local copperButton = getglobal(frameName.."CopperButton")
local gold, silver, copper = MoneyFrameCoins(frameName)
gold:SetTextColor(r, g, b)
silver:SetTextColor(r, g, b)
copper:SetTextColor(r, g, b)
end
goldButton:SetTextColor(r, g, b)
silverButton:SetTextColor(r, g, b)
copperButton:SetTextColor(r, g, b)
-- A click on a coin opens the pickup frame on that denomination, seeded with what the frame is
-- currently showing; `denomination` is one coin's worth in copper, which is the unit the
-- pickup frame counts in.
--
-- Guarded, because we ship no coin pickup (see the header) — so today the click does nothing,
-- and the mouse-enabled state a `canPickup` type gives the coins is all that is observable.
-- Note the amount is read by calling the type's own `UpdateFunc` while `this` is still the
-- BUTTON rather than the frame, which is what the specification's own coin `<OnClick>` does
-- (ref MoneyFrame.xml l.20-23) and what a frame-reading type would see through.
function BenillaMoneyFrameCoin_OnClick(coin, denomination)
if not OpenCoinPickupFrame then
return
end
local frame = coin:GetParent()
OpenCoinPickupFrame(denomination, MoneyTypeInfo[frame.moneyType].UpdateFunc(), frame)
frame.hasPickup = 1
end
-- Closing a money frame takes any pickup it opened down with it.
function BenillaMoneyFrame_OnHide()
if this.hasPickup == 1 and CoinPickupFrame then
CoinPickupFrame:Hide()
this.hasPickup = 0
end
end
]]></Script>
@@ -393,9 +441,7 @@
NumberFontNormalLarge digits. The three buttons chain RIGHT-to-LEFT from the copper end, which
is what lets MoneyFrame_Update above re-point them past a hidden denomination.
The `<OnClick>` bodies are the reference's, guarded on `OpenCoinPickupFrame` — the coin-pickup
frame is not transcribed (see the header). `<PushedTextOffset>` is carried for provenance; our
loader does not model it and ignores it.
`<PushedTextOffset>` is carried for provenance; our loader does not model it and ignores it.
-->
<Frame name="MoneyFrameTemplate" virtual="true">
<Size><AbsDimension x="192" y="28"/></Size>
@@ -406,13 +452,7 @@
<Anchor point="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnClick>
local parent = this:GetParent()
if ( OpenCoinPickupFrame ) then
OpenCoinPickupFrame(1, MoneyTypeInfo[parent.moneyType].UpdateFunc(), parent)
parent.hasPickup = 1
end
</OnClick>
<OnClick>BenillaMoneyFrameCoin_OnClick(this, 1)</OnClick>
</Scripts>
<NormalTexture file="Interface\MoneyFrame\UI-MoneyIcons">
<Size><AbsDimension x="19" y="19"/></Size>
@@ -433,13 +473,7 @@
<Anchor point="RIGHT" relativeTo="$parentCopperButton" relativePoint="LEFT"><Offset><AbsDimension x="-4" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnClick>
local parent = this:GetParent()
if ( OpenCoinPickupFrame ) then
OpenCoinPickupFrame(COPPER_PER_SILVER, MoneyTypeInfo[parent.moneyType].UpdateFunc(), parent)
parent.hasPickup = 1
end
</OnClick>
<OnClick>BenillaMoneyFrameCoin_OnClick(this, COPPER_PER_SILVER)</OnClick>
</Scripts>
<NormalTexture file="Interface\MoneyFrame\UI-MoneyIcons">
<Size><AbsDimension x="19" y="19"/></Size>
@@ -460,13 +494,7 @@
<Anchor point="RIGHT" relativeTo="$parentSilverButton" relativePoint="LEFT"><Offset><AbsDimension x="-4" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnClick>
local parent = this:GetParent()
if ( OpenCoinPickupFrame ) then
OpenCoinPickupFrame(COPPER_PER_GOLD, MoneyTypeInfo[parent.moneyType].UpdateFunc(), parent)
parent.hasPickup = 1
end
</OnClick>
<OnClick>BenillaMoneyFrameCoin_OnClick(this, COPPER_PER_GOLD)</OnClick>
</Scripts>
<NormalTexture file="Interface\MoneyFrame\UI-MoneyIcons">
<Size><AbsDimension x="19" y="19"/></Size>
@@ -486,12 +514,7 @@
<OnLoad>MoneyFrame_OnLoad()</OnLoad>
<OnEvent>MoneyFrame_OnEvent()</OnEvent>
<OnShow>MoneyFrame_UpdateMoney()</OnShow>
<OnHide>
if ( this.hasPickup == 1 and CoinPickupFrame ) then
CoinPickupFrame:Hide()
this.hasPickup = 0
end
</OnHide>
<OnHide>BenillaMoneyFrame_OnHide()</OnHide>
</Scripts>
</Frame>
@@ -510,13 +533,7 @@
<Anchor point="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnClick>
local parent = this:GetParent()
if ( OpenCoinPickupFrame ) then
OpenCoinPickupFrame(1, MoneyTypeInfo[parent.moneyType].UpdateFunc(), parent)
parent.hasPickup = 1
end
</OnClick>
<OnClick>BenillaMoneyFrameCoin_OnClick(this, 1)</OnClick>
</Scripts>
<NormalTexture file="Interface\MoneyFrame\UI-MoneyIcons">
<Size><AbsDimension x="13" y="13"/></Size>
@@ -537,13 +554,7 @@
<Anchor point="RIGHT" relativeTo="$parentCopperButton" relativePoint="LEFT"><Offset><AbsDimension x="-4" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnClick>
local parent = this:GetParent()
if ( OpenCoinPickupFrame ) then
OpenCoinPickupFrame(COPPER_PER_SILVER, MoneyTypeInfo[parent.moneyType].UpdateFunc(), parent)
parent.hasPickup = 1
end
</OnClick>
<OnClick>BenillaMoneyFrameCoin_OnClick(this, COPPER_PER_SILVER)</OnClick>
</Scripts>
<NormalTexture file="Interface\MoneyFrame\UI-MoneyIcons">
<Size><AbsDimension x="13" y="13"/></Size>
@@ -564,13 +575,7 @@
<Anchor point="RIGHT" relativeTo="$parentSilverButton" relativePoint="LEFT"><Offset><AbsDimension x="-4" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnClick>
local parent = this:GetParent()
if ( OpenCoinPickupFrame ) then
OpenCoinPickupFrame(COPPER_PER_GOLD, MoneyTypeInfo[parent.moneyType].UpdateFunc(), parent)
parent.hasPickup = 1
end
</OnClick>
<OnClick>BenillaMoneyFrameCoin_OnClick(this, COPPER_PER_GOLD)</OnClick>
</Scripts>
<NormalTexture file="Interface\MoneyFrame\UI-MoneyIcons">
<Size><AbsDimension x="13" y="13"/></Size>
@@ -590,12 +595,7 @@
<OnLoad>SmallMoneyFrame_OnLoad()</OnLoad>
<OnEvent>MoneyFrame_OnEvent()</OnEvent>
<OnShow>MoneyFrame_UpdateMoney()</OnShow>
<OnHide>
if ( this.hasPickup == 1 and CoinPickupFrame ) then
CoinPickupFrame:Hide()
this.hasPickup = 0
end
</OnHide>
<OnHide>BenillaMoneyFrame_OnHide()</OnHide>
</Scripts>
</Frame>
</Ui>
@@ -107,7 +107,7 @@
the two states are authored rather than driven. The mixin port comes with the addon
list. Width is era's text+40 law via an OnUpdate measure poll; the era's
measure-at-the-medium-font quirk is deliberately dropped (~6 units of padding).
- **Search WORKS (0984)** — the era SettingsPanel mechanism transcribed (the Script
- **Search WORKS (0984)** — the era SettingsPanel mechanism re-expressed (the Script
block's search section): label-tag substring match scored by matched length,
results grouped under clickable category heads ordered by best score, a matched
child pulls its parent row in, "Search Results" title with Defaults hidden while
@@ -158,7 +158,7 @@
page went LIVE in 0992 (the three UnitName* checkbox rows).
- **The window rides a scale knob** (slice B): fonts ride frame scale engine-wide now
(the 0219 §2 divergence closed), so ERA_WINDOW_SCALE × the era checkFit shrink
(UIPanelUpdateScaleForFit, transcribed in this file) sizes the whole window; 0.78 is
(UIPanelUpdateScaleForFit, re-expressed in this file) sizes the whole window; 0.78 is
the director's-eye starting point, live-tunable. The knob is SHARED: it stands in for
the era client's UIParent scale (smaller than our 1.12 768-canvas on modern screens),
so every era-shaped window wears it — GameMenuFrame.xml reads it in its OnShow.
@@ -207,7 +207,7 @@
function OptionsFrame_UpdateScale()
-- The era panel is registered `checkFit = 1`: the panel manager also shrinks it to
-- fit a small screen (UIPanelUpdateScaleForFit: min over width/height ratios
-- against the panel + 20 units of air each way, capped at 1) — transcribed here.
-- against the panel + 20 units of air each way, capped at 1) — re-expressed here.
local k = ERA_WINDOW_SCALE
local wFit = GetScreenWidth() / 940
local hFit = GetScreenHeight() / 744
@@ -248,7 +248,7 @@
bg:SetVertexColor(0.196, 0.388, 0.8)
end
-- The selection — 1.14's OptionsList_SelectButton/_ClearSelection pair, verbatim
-- The selection — 1.14's OptionsList_SelectButton/_ClearSelection pair, re-expressed
-- (OptionsFrameTemplates.lua l.107-121): the selected row's wash goes GOLD (1,1,0)
-- and locks on; every other row drops back to the blue hover tint, wash off. The
-- white selected label is LockHighlight's own text state, carried by the font swap.
@@ -308,7 +308,7 @@
end
end
-- ── Search (0984): the era SettingsPanel mechanism, transcribed ─────────────────────
-- ── Search (0984): the era SettingsPanel mechanism, re-expressed ────────────────────
-- Blizzard_SettingsPanel.lua l.566-692 + Blizzard_Settings.lua l.65-77, cut to our
-- static rows: a setting's search tag is its LABEL uppercased (the era's default tag
-- is exactly the setting name — SettingControls.lua l.773); a query matches by plain
@@ -953,7 +953,7 @@
-- would read as moved-off-"0.4").
local str = string.gsub(string.format("%.2f", value), "%.?0+$", "")
if row.deferred then
-- era CommitFlag.Apply, transcribed (Blizzard_Setting.lua's staged
-- era CommitFlag.Apply, re-expressed (Blizzard_Setting.lua's staged
-- SetValue half): the control shows the new value, the ENGINE keeps the
-- old one, and the CVar moves only when Apply commits — dragging a
-- slider that rescales itself mid-drag is exactly what this flag
@@ -1,7 +1,7 @@
<Ui>
<!--
The reference's shared OPTIONS widget kit — Interface\FrameXML\OptionsFrameTemplates.xml,
transcribed from the extracted 1.12.1 file (line numbers below are that file's), plus the
The shared OPTIONS widget kit — our build of the five templates addons name, specified by
Interface\FrameXML\OptionsFrameTemplates.xml (line numbers below are that file's) plus the
one template from UIOptionsFrame.xml that addons actually name.
These are UIPanelTemplates.xml's descendants: `OptionsCheckButtonTemplate` inherits
@@ -11,17 +11,56 @@
WHY THE UIOptionsFrame ONE LIVES HERE. `UIOptionsCheckButtonTemplate` is 8 `CreateFrame`
call sites in the corpus — the fourth most-wanted template we lacked — and in the reference
it is declared in UIOptionsFrame.xml (l.6-10) as a two-line size override on
`OptionsCheckButtonTemplate`. We do not transcribe UIOptionsFrame (benilla's own options
`OptionsCheckButtonTemplate`. We do not build UIOptionsFrame at all (benilla's own options
window is OptionsFrame.xml, a different design), so the one template that file contributes
to the shared namespace sits with its parent rather than in a file of its own. Its
provenance is on the declaration.
Every name here is in `reference/1.12-globals.tsv`; none is invented. What this supersedes
but does not delete: nothing — OptionsFrame.xml's own `Options*Template` family
(`OptionsRedButtonTemplate`, `OptionsCheckboxRowTemplate`, `OptionsSliderRowTemplate`, …)
are benilla's row widgets for benilla's window, distinct names and a distinct shape.
Every TEMPLATE name here is in `reference/1.12-globals.tsv`; none is invented. The one name
that is ours is the hover helper below, and it wears the `Benilla` prefix that says so —
the reference open-codes that hover twice, once per widget, and two copies of one behaviour
is how they end up disagreeing. What this supersedes but does not delete: nothing —
OptionsFrame.xml's own `Options*Template` family (`OptionsRedButtonTemplate`,
`OptionsCheckboxRowTemplate`, `OptionsSliderRowTemplate`, …) are benilla's row widgets for
benilla's window, distinct names and a distinct shape.
-->
<Script><![CDATA[
-- The hover both widgets below share: an instance carries its own `tooltipText` (the
-- caption, wrapped) and optionally a `tooltipRequirement` (the "needs a restart / a relog"
-- line under it) as plain fields, set by whoever built the panel. Neither field is
-- required, and a widget with neither raises nothing.
--
-- BOTH LINES RENDER GOLD, and the second one's colour arguments are why that has to be
-- said out loud: `AddLine(text, "", 1, 1, 1)` is 1.12's archaic spelling, and the empty
-- string sitting in the r-slot is what makes the binding drop the whole colour tail and
-- fall back to the tooltip default (`script/tooltip/verbs.rs`'s AddLine carries the byte
-- note, and names this exact `(text, "", r, g, b)` shape as the corpus' own). The
-- arguments are kept because they are the reference's; the white they appear to ask for is
-- not what any client, ours or theirs, actually draws.
--
-- One home for it rather than one per template: the checkbox and the slider are different
-- widget types with no common ancestor to inherit from, so sharing the behaviour means
-- sharing a function. It takes the widget as an argument rather than reading `this`,
-- because a shared function has no handler of its own to read it from — each of the two
-- OnEnters below passes its own.
function BenillaOptionsWidget_ShowTooltip(widget)
local caption, requirement = widget.tooltipText, widget.tooltipRequirement
if caption then
GameTooltip:SetOwner(widget, "ANCHOR_RIGHT")
GameTooltip:SetText(caption, nil, nil, nil, nil, 1)
end
-- Only the requirement line asks for the tooltip to be shown, and that is the
-- reference's behaviour, not an oversight of ours: `SetText` already puts the plate on
-- screen, so the caption case needs no second verb, while `AddLine` onto a plate that
-- is already up has to ask for the relayout.
if requirement then
GameTooltip:AddLine(requirement, "", 1.0, 1.0, 1.0)
GameTooltip:Show()
end
end
]]></Script>
<!-- ref l.3-34. The titled group box: a tooltip-border backdrop with its caption hung above the
top-left corner, greyed by its own OnLoad. -->
<Frame name="OptionFrameBoxTemplate" virtual="true">
@@ -56,23 +95,11 @@
<CheckButton name="OptionsCheckButtonTemplate" inherits="UICheckButtonTemplate" virtual="true">
<HitRectInsets><AbsInset left="0" right="-100" top="0" bottom="0"/></HitRectInsets>
<Scripts>
<!-- The click kit is the state the box has just reached, not the one it left. -->
<OnClick>
if (this:GetChecked()) then
PlaySound("igMainMenuOptionCheckBoxOn")
else
PlaySound("igMainMenuOptionCheckBoxOff")
end
PlaySound(this:GetChecked() and "igMainMenuOptionCheckBoxOn" or "igMainMenuOptionCheckBoxOff")
</OnClick>
<OnEnter>
if (this.tooltipText) then
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetText(this.tooltipText, nil, nil, nil, nil, 1)
end
if (this.tooltipRequirement) then
GameTooltip:AddLine(this.tooltipRequirement, "", 1.0, 1.0, 1.0)
GameTooltip:Show()
end
</OnEnter>
<OnEnter>BenillaOptionsWidget_ShowTooltip(this)</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</CheckButton>
@@ -108,16 +135,7 @@
</Layer>
</Layers>
<Scripts>
<OnEnter>
if (this.tooltipText) then
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetText(this.tooltipText, nil, nil, nil, nil, 1)
end
if (this.tooltipRequirement) then
GameTooltip:AddLine(this.tooltipRequirement, "", 1.0, 1.0, 1.0)
GameTooltip:Show()
end
</OnEnter>
<OnEnter>BenillaOptionsWidget_ShowTooltip(this)</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
<ThumbTexture name="$parentThumb" file="Interface\Buttons\UI-SliderBar-Button-Horizontal">
+50 -22
View File
@@ -1,10 +1,11 @@
<Ui>
<!--
The party member frames + the group-invite popup (decision 0434 phase 3). Geometry, art
paths, behavior laws, and the popup entry are QUOTED from the reference client's own
FrameXML, extracted through the full MPQ patch chain (decision 0246; survey 2026-07-17):
paths and behavior laws are MEASURED against the reference client's own FrameXML, extracted
through the full MPQ patch chain (decision 0246; survey 2026-07-17):
PartyFrame.xml, PartyFrameTemplates.xml, PartyMemberFrame.lua, StaticPopup.lua ("PARTY_INVITE"),
UIParent.lua (the PARTY_INVITE_REQUEST wiring). Data flow is ours: the app feeds
UIParent.lua (the PARTY_INVITE_REQUEST wiring) — the code below is our own. Data flow is
ours too: the app feeds
party1..party4 unit snapshots + the PartyState roster (crates/benilla/src/ui_party.rs) and
fires the PARTY_* events; the popup's Accept/Decline queue PartyRequests the app drains.
@@ -26,7 +27,7 @@
DOUBLE-DECLINE, KEPT: the ref's popup calls DeclineGroup() in OnCancel AND again in OnHide
(only OnAccept sets the inviteAccepted guard), so a clicked Decline sends two
CMSG_GROUP_DECLINE. vmangos silently ignores the second (no pending invite -> early
return). Transcribed verbatim rather than "fixed" - the wire tolerates it and the guard's
return). Matched rather than "fixed" - the wire tolerates it and the guard's
real job is the timeout/override/escape paths.
-->
<Script><![CDATA[
@@ -52,32 +53,59 @@
NUM_RAID_GROUPS = 8
MEMBERS_PER_RAID_GROUP = 5
-- StaticPopup entry, quoted from the ref's StaticPopup.lua (INVITATION =
-- "%s invites you to a group.", GlobalStrings:2586-area; sound + 60 s silent timeout +
-- whileDead + hideOnEscape are the ref's own fields).
-- ── The group-invite popup ────────────────────────────────────────────────────────────
-- The strings and the three flags are the reference's own (INVITATION = "%s invites you to
-- a group.", GlobalStrings:2586-area; a 60 s silent timeout, answerable while dead,
-- closable with ESC).
--
-- The whole of its behaviour turns on ONE latch, `inviteAccepted`, and the four hooks below
-- are just the four moments that read or write it. It lives on the registry entry rather
-- than in an upvalue here because that is where the reference keeps it and an addon
-- inspecting the dialog expects to find it there — which is why each hook goes back through
-- `StaticPopupDialogs["PARTY_INVITE"]` instead of closing over a local.
--
-- They are named rather than inlined so the entry below reads as the manifest it is: four
-- strings, three flags, four hooks.
-- Every showing starts unanswered — including a re-show of the same entry, which is what a
-- second invite arriving while the first is up does.
local function PartyInvite_OnShow()
StaticPopupDialogs["PARTY_INVITE"].inviteAccepted = nil
end
-- The only path that answers the server affirmatively, and the only one that latches.
local function PartyInvite_OnAccept()
AcceptGroup()
StaticPopupDialogs["PARTY_INVITE"].inviteAccepted = 1
end
local function PartyInvite_OnCancel()
DeclineGroup()
end
-- The catch-all. EVERY way this dialog leaves the screen lands here — Decline, the 60 s
-- timeout, ESC, and another dialog overriding it — and the latch is what tells the one
-- that already said yes from the four that never answered at all. A clicked Decline
-- therefore declines twice (OnCancel, then here); see DOUBLE-DECLINE in the header, which
-- is the reference's behaviour and the wire tolerates it.
local function PartyInvite_OnHide()
if not StaticPopupDialogs["PARTY_INVITE"].inviteAccepted then
DeclineGroup()
end
end
StaticPopupDialogs["PARTY_INVITE"] = {
text = INVITATION,
button1 = ACCEPT,
button2 = DECLINE,
sound = "igPlayerInvite",
OnShow = function()
StaticPopupDialogs["PARTY_INVITE"].inviteAccepted = nil
end,
OnAccept = function()
AcceptGroup()
StaticPopupDialogs["PARTY_INVITE"].inviteAccepted = 1
end,
OnCancel = function()
DeclineGroup()
end,
OnHide = function()
if not StaticPopupDialogs["PARTY_INVITE"].inviteAccepted then
DeclineGroup()
end
end,
timeout = 60,
whileDead = 1,
hideOnEscape = 1,
OnShow = PartyInvite_OnShow,
OnAccept = PartyInvite_OnAccept,
OnCancel = PartyInvite_OnCancel,
OnHide = PartyInvite_OnHide,
}
-- ref PartyMemberFrame.lua's OnLoad: cache children, register, first paint.
+27 -9
View File
@@ -19,7 +19,10 @@
Token slots (the commands and reactions) return the NAMES OF GLOBALS rather than values,
which is the reference's own convention (`PetActionBarFrame.lua:98-104`): the name keys are
GlobalStrings' (PET_ACTION_ATTACK, PET_MODE_DEFENSIVE, …), and the texture globals are
declared below, quoted verbatim from `PetActionBarFrame.lua:6-12`.
declared below. Both halves of that mapping are a contract we do not get to choose — the
names are what `ui_pet/bar.rs` hands back and the icons are real files in the install — so
the specification for them is `PetActionBarFrame.lua:6-12`; see the declaration's own note
for what IS ours about the layout.
Geometry QUOTED from the ref (PetActionBarFrame.xml): frame 509x43; the two UI-PetBar shelf
strips (256x44 at TOPLEFT with TexCoords 0..1 / 0.015625..0.359375, then 184x44 to its
@@ -61,16 +64,31 @@
<Script><![CDATA[
NUM_PET_ACTION_SLOTS = 10
-- The token textures, quoted verbatim from ref PetActionBarFrame.lua:6-12. They are
-- GLOBALS on purpose: `GetPetActionInfo` returns the NAME of one for a token slot, and the
-- paint below resolves it with getglobal, exactly as the reference does.
-- ── The token icons ───────────────────────────────────────────────────────────────────
--
-- These are GLOBALS on purpose, and the names are a contract, not a choice:
-- `GetPetActionInfo` hands back the NAME of one of them for a token slot and the paint
-- below resolves it with getglobal — the reference's own convention, and the one
-- `ui_pet/bar.rs` writes against. The icon paths are the reference's too; they name real
-- .blp files in the install, so there is nothing to choose there either.
--
-- What IS ours is the grouping. The bar's tokens come in two families and `ui_pet/bar.rs`
-- already separates them at the seam that names these — `command_token` (the orders you
-- give, keyed `PET_ACTION_*`) and `reaction_token` (the stance the pet holds, keyed
-- `PET_MODE_*`) — so they are laid out in those two families, in that file's own order,
-- and the two halves of the mapping read side by side. The reference keeps them in one
-- undifferentiated block, which hides the split.
-- Commands — the four orders, in `ui_pet/bar.rs`'s `command_token` order.
PET_WAIT_TEXTURE = "Interface\\Icons\\Spell_Nature_TimeStop"
PET_FOLLOW_TEXTURE = "Interface\\Icons\\Ability_Tracking"
PET_ATTACK_TEXTURE = "Interface\\Icons\\Ability_GhoulFrenzy"
PET_DISMISS_TEXTURE = "Interface\\Icons\\Spell_Shadow_Teleport"
-- Reactions — the three stances, in `ui_pet/bar.rs`'s `reaction_token` order.
PET_PASSIVE_TEXTURE = "Interface\\Icons\\Ability_Seal"
PET_DEFENSIVE_TEXTURE = "Interface\\Icons\\Ability_Defend"
PET_AGGRESSIVE_TEXTURE = "Interface\\Icons\\Ability_Racial_BloodRage"
PET_PASSIVE_TEXTURE = "Interface\\Icons\\Ability_Seal"
PET_ATTACK_TEXTURE = "Interface\\Icons\\Ability_GhoulFrenzy"
PET_FOLLOW_TEXTURE = "Interface\\Icons\\Ability_Tracking"
PET_WAIT_TEXTURE = "Interface\\Icons\\Spell_Nature_TimeStop"
PET_DISMISS_TEXTURE = "Interface\\Icons\\Spell_Shadow_Teleport"
-- ── The autocast sparkle ──────────────────────────────────────────────────────────────
--
@@ -44,14 +44,14 @@
machinery does.
- `PET_UI_UPDATE` / `PET_UI_CLOSE` are **registered but nothing fires them yet** (nothing
in the app sources either event). Registered anyway rather than dropped: the ref's own
handler arms are transcribed whole, and `UNIT_PET` + `PET_BAR_UPDATE` — both of which we
handler arms are re-expressed whole, and `UNIT_PET` + `PET_BAR_UPDATE` — both of which we
DO fire — already cover the same "the pet went away while its page was open" case
through the identical branch.
- **CLOSED (decision 1062):** the level line's family word and the diet tooltip both
landed. `UnitCreatureFamily("pet")` and `GetPetFoodTypes()` are real bindings now, fed
from `CreatureFamily.dbc` (the word + its pet-food mask) and `ItemPetFood.dbc` (the
eight diet names) through the pet's cached creature template; `BuildListString` is the
ref's own `UIParent.lua:1051` joiner, transcribed in `UIParent.xml`. Both call sites
ref's own `UIParent.lua:1051` joiner, re-expressed in `UIParent.xml`. Both call sites
below dropped their existence guards. The ref's OWN `if ( UnitCreatureFamily("pet") )`
guard stays — nil is still reachable (a template with no family; the creature query not
yet answered) and a blank level line is the ref's answer to it.
@@ -181,15 +181,32 @@
end
getglobal("PetLoyaltyText"):SetText(GetPetLoyalty())
PetExpBar_Update()
-- The two panes this page has to draw itself, for two DIFFERENT reasons (both below):
-- the resistance column genuinely differs from the character page's, while the stat
-- block is the same logic that only duplicates because PetStatFrame/CharacterStatFrame
-- share no prefix for a `prefix..` renderer to take.
PetPaperDollFrame_SetResistances()
PetPaperDollFrame_SetStats()
PaperDollFrame_SetDamage("pet", "Pet")
PaperDollFrame_SetRangedDamage("pet", "Pet")
PaperDollFrame_SetAttackPower("pet", "Pet")
PaperDollFrame_SetRangedAttackPower("pet", "Pet")
PaperDollFrame_SetArmor("pet", "Pet")
-- ...and then the shared rows, which are not this page's code at all: every one of
-- these is CharacterFrame.xml's own renderer, handed ("pet", "Pet") where the character
-- page hands it ("player", "Character"), so `prefix.."ArmorFrame"` resolves PetArmorFrame
-- here and CharacterArmorFrame there. That substitution IS the page (header comment).
-- Called top-to-bottom in the order the rows are anchored on screen — each renderer only
-- writes its own row's FontStrings from live API state, so nothing here reads anything
-- another one wrote and the order is free.
PaperDollFrame_SetAttackBothHands("pet", "Pet")
PaperDollFrame_SetAttackPower("pet", "Pet")
PaperDollFrame_SetDamage("pet", "Pet")
PaperDollFrame_SetDefense("pet", "Pet")
PaperDollFrame_SetArmor("pet", "Pet")
-- The pet sheet has no ranged rows at all, and these two say so themselves — both open
-- with the reference's own `if unit == "pet" then return end` (CharacterFrame.xml). They
-- are called anyway so that both pages drive the identical set of renderers and neither
-- can quietly fall behind the other.
PaperDollFrame_SetRangedDamage("pet", "Pet")
PaperDollFrame_SetRangedAttackPower("pet", "Pet")
-- The hunter-only block (ref l.83-93): the diet icon and the training-point line are
-- the two things a warlock's minion never has. `canGainXP` is HasPetUI's second return.
@@ -210,7 +227,7 @@
-- ref PetPaperDollFrame_SetResistances (l.96-137). Deliberately NOT the character page's
-- version: the pet's has no resistance-quality subtext (no `RESISTANCE_TOOLTIP_SUBTEXT`
-- line at all) and colours the NUMBER off its own sign rather than off pos-vs-neg
-- magnitude — two real differences in the reference, transcribed rather than unified.
-- magnitude — two real differences in the reference, kept rather than unified.
function PetPaperDollFrame_SetResistances()
for i = 1, BENILLA_NUM_PET_RESISTANCE_TYPES do
local resistId = BENILLA_MAGICRES_IDS[i]
@@ -490,7 +507,7 @@
<OnEnter>
-- ref l.267-270, unguarded now that both halves exist (decision 1062):
-- `GetPetFoodTypes` is a real binding and `BuildListString` is the ref's
-- own UIParent.lua joiner, transcribed in UIParent.xml.
-- own UIParent.lua joiner, re-expressed in UIParent.xml.
--
-- `BuildListString()` returns NIL on an empty list and `format`'s "%s"
-- would error on it, so the pairing matters. Two things keep it safe, and
@@ -600,7 +617,7 @@
<OnLoad>self:EnableMouse(true)</OnLoad>
<!-- ref l.429-433: the pet's attack row shows the BARE tooltip title, with
no ATTACK_TOOLTIP_SUBTEXT line — the character page's own row adds it.
Transcribed as the ref has it. -->
Kept as the ref has it. -->
<OnEnter>
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
GameTooltip:SetText(ATTACK_TOOLTIP, 1.0, 1.0, 1.0)
+53 -47
View File
@@ -40,10 +40,10 @@
— a quest with many objectives/a long description/many rewards scrolls and clips at the
pane's own edge instead of running past the window's bottom (pin §3's doNotScroll
asymmetry is implemented — see `BenillaQuestLogDetail_Update`'s own comment).
- Difficulty coloring is the ref's own `QuestDifficultyColor` table + `GetDifficultyColor`
(QUOTED, l.14-20 + l.584-599) over the real `GetQuestGreenRange()` binding — see the
function's own comment. (The old `BenillaQuestDifficultyColor` INTERIM and its
approximated thresholds are retired.)
- Difficulty coloring wears the reference's names and bands (`QuestDifficultyColor` +
`GetDifficultyColor`, specified at l.14-20 + l.584-599) over the real
`GetQuestGreenRange()` binding — see the function's own comment. (The old
`BenillaQuestDifficultyColor` INTERIM and its approximated thresholds are retired.)
- No headers ship (`benilla-ui`'s `quest_log.rs`: flat entries only, headers need the
QuestSort/AreaTable DBC join) — the header branch below is dead code today, kept for when
that join lands rather than re-derived then.
@@ -124,37 +124,41 @@
local detailLastAppliedHeight = nil
-- ── Difficulty coloring ──────────────────────────────────────────────────────────────────
-- QUOTED from ref-QuestLogFrame.lua: the table (l.14-20) and GetDifficultyColor (l.584-599,
-- "Used for quests and enemy coloration" — the ref's own comment; the TARGET FRAME's level
-- number colors through this exact function too, TargetFrame_CheckLevel l.130). The
-- green→grey boundary is the real GetQuestGreenRange() binding (benilla-ui unit.rs — the
-- byte-verified grey band the plate/tooltip share; the binding's own byte read is under
-- the 2026-07-17 wow-re §5). This retires the BenillaQuestDifficultyColor INTERIM and its
-- approximated -2/-10 thresholds. One deviation: the ref assigns its scratch `color` as a
-- GLOBAL (vanilla Lua sloppiness); ours is local.
QuestDifficultyColor = { }
QuestDifficultyColor["impossible"] = { r = 1.00, g = 0.10, b = 0.10 }
QuestDifficultyColor["verydifficult"] = { r = 1.00, g = 0.50, b = 0.25 }
QuestDifficultyColor["difficult"] = { r = 1.00, g = 1.00, b = 0.00 }
QuestDifficultyColor["standard"] = { r = 0.25, g = 0.75, b = 0.25 }
QuestDifficultyColor["trivial"] = { r = 0.50, g = 0.50, b = 0.50 }
QuestDifficultyColor["header"] = { r = 0.7, g = 0.7, b = 0.7 }
-- The six bands and their exact colors are the reference's contract, not a palette of ours:
-- ref-QuestLogFrame.lua's table (l.14-20) and GetDifficultyColor (l.584-599, "Used for
-- quests and enemy coloration" — the ref's own comment; the TARGET FRAME's level number
-- colors through this same function, TargetFrame_CheckLevel l.130), and `QuestDifficultyColor`
-- is a name addons read, so both the global and its six keys are fixed. The green→grey
-- boundary is the real GetQuestGreenRange() binding (benilla-ui unit.rs — the byte-verified
-- grey band the plate/tooltip share; the binding's own byte read is under the 2026-07-17
-- wow-re §5). This retires the BenillaQuestDifficultyColor INTERIM and its approximated
-- -2/-10 thresholds.
QuestDifficultyColor = {
impossible = { r = 1.00, g = 0.10, b = 0.10 },
verydifficult = { r = 1.00, g = 0.50, b = 0.25 },
difficult = { r = 1.00, g = 1.00, b = 0.00 },
standard = { r = 0.25, g = 0.75, b = 0.25 },
trivial = { r = 0.50, g = 0.50, b = 0.50 },
header = { r = 0.70, g = 0.70, b = 0.70 },
}
-- How far ABOVE the player the quest sits picks the band. Note the shape of the last two
-- tests: the three hard bands are fixed offsets, but the green/grey boundary is not a
-- constant at all — GetQuestGreenRange() widens as the player levels — which is why it is
-- asked rather than compared against a number, and why "trivial" is what is left over
-- rather than a band with an edge of its own.
function GetDifficultyColor(level)
local color
local levelDiff = level - UnitLevel("player")
if ( levelDiff >= 5 ) then
color = QuestDifficultyColor["impossible"]
elseif ( levelDiff >= 3 ) then
color = QuestDifficultyColor["verydifficult"]
elseif ( levelDiff >= -2 ) then
color = QuestDifficultyColor["difficult"]
elseif ( -levelDiff <= GetQuestGreenRange() ) then
color = QuestDifficultyColor["standard"]
else
color = QuestDifficultyColor["trivial"]
if levelDiff >= 5 then
return QuestDifficultyColor.impossible
elseif levelDiff >= 3 then
return QuestDifficultyColor.verydifficult
elseif levelDiff >= -2 then
return QuestDifficultyColor.difficult
elseif -levelDiff <= GetQuestGreenRange() then
return QuestDifficultyColor.standard
end
return color
return QuestDifficultyColor.trivial
end
-- ── The list page ────────────────────────────────────────────────────────────────────────
@@ -495,7 +499,7 @@
BenillaQuestLogDetail_ResizeChild(detailObjAnchorIndex, detailRewardHeight)
end
-- ref QuestLog_OnUpdate (QuestLogFrame.lua:96-105), transcribed 1:1 including its
-- ref QuestLog_OnUpdate (QuestLogFrame.lua:96-105), re-expressed here including its
-- UPDATE_DELAY throttle: the countdown is repainted ten times a second, not every frame —
-- SecondsToTime's output only changes once a second, so anything faster is a re-measure of
-- an identical string. The flag/accumulator pair is the ref's own (its two fields on
@@ -616,7 +620,7 @@
--
-- Guard-for-guard with the ref: ctrl is gated on `this.rewardType ~= "spell"` (l.544), and
-- so is shift — but on the ELSEIF line itself (l.547), where the giver window's twin puts it
-- inside (QuestFrame.lua:121). Same outcome, and transcribed where the ref puts it. Ours
-- inside (QuestFrame.lua:121). Same outcome, and kept where the ref puts it. Ours
-- adds `ChatFrameEditBox and` (ChatFrame.xml is a separate file a window test need not
-- load — BenillaPaperDollSlot_OnClick's precedent) and a nil-link guard: `link` is nil until
-- the ask-once item template lands, a state the reference's synchronous item cache never
@@ -634,7 +638,7 @@
end
end
-- The reward-panel layout, ported verbatim from the ref's shared `QuestFrameItems_Update`
-- The reward-panel layout, ported from the ref's shared `QuestFrameItems_Update`
-- (QuestFrame.lua:311-522, the same routine the live QuestFrame reward panel uses) — this
-- window's v1 subset of it: no spell-reward row (GetQuestLogRewardSpell has no v1 caller,
-- QuestLogFrame.xml's header comment), capped at BENILLA_QUESTLOG_MAX_ITEMS (6, half the ref's
@@ -792,7 +796,7 @@
local description, objectivesText = GetQuestLogQuestText()
getglobal("QuestLogObjectivesText"):SetText(objectivesText or "")
-- pin §3, QuestLog_UpdateQuestDetails (QuestLogFrame.lua:364-374) verbatim, minus the
-- pin §3, QuestLog_UpdateQuestDetails (QuestLogFrame.lua:364-374) followed, minus the
-- Show/Hide our region model has no verb for: a timed quest gets the "Time Remaining:"
-- row and pushes Objective1 below it; an untimed one blanks the row (our stand-in for
-- the ref's :Hide()) and anchors Objective1 straight onto ObjectivesText, so the row
@@ -845,7 +849,7 @@
-- down by however much dead space the unused tail slots add (this window's own comment
-- on the FontString). No RequiredMoney branch here (this file's documented v1 gap: no
-- widget backs GetQuestLogRequiredMoney yet) — that ref branch collapses into the rest.
-- The objective-less TIMED branch (ref :432-437) is transcribed: a timed quest with no
-- The objective-less TIMED branch (ref :432-437) is kept: a timed quest with no
-- leaderboard lines hangs the header off the countdown row, not off ObjectivesText.
local objAnchorIndex = math.min(numObjectives, MAX_OBJECTIVES)
local descTitle = getglobal("QuestLogDescriptionTitle")
@@ -865,7 +869,7 @@
end
-- ── The always-visible quest-watch tracker (QuestWatchFrame) ─────────────────────────
-- Ref QuestWatch_Update (QuestLogFrame.lua:602-663), transcribed onto the flat 30-line pool
-- Ref QuestWatch_Update (QuestLogFrame.lua:602-663), re-expressed onto the flat 30-line pool
-- (the frame's own XML comment): every paint re-anchors each used line — a quest TITLE drops
-- -5 below the previous line (ref -4 under its 13px boxes; ours are measured 12s, same
-- pitch), an OBJECTIVE drops -1 (ref 0) — writes " - "-prefixed objective rows colored by
@@ -1114,11 +1118,13 @@
HideUIPanel(getglobal("QuestLogFrame"))
end
-- ── The abandon two-step (mark on click → confirm on Yes) — the ref's ABANDON_QUEST
-- entry on the shared StaticPopup engine (UiPanels.xml, decision 0308 §3), verbatim from
-- StaticPopup.lua:749-761: ABANDON_QUEST_CONFIRM 'Abandon "%s"?' (GlobalStrings.lua:5),
-- OnAccept = AbandonQuest() + the igQuestLogAbandonQuest kit. exclusive is v1-inert
-- (engine header note). ──────────────────────────────────────────────────────────────────
-- ── The abandon two-step (mark on click → confirm on Yes) — our `ABANDON_QUEST` entry on
-- the shared StaticPopup engine (UiPanels.xml, decision 0308 §3). Every field below is
-- StaticPopup's own vocabulary and every value is specified by StaticPopup.lua:749-761:
-- the ABANDON_QUEST_CONFIRM wording 'Abandon "%s"?' (GlobalStrings.lua:5), abandoning on
-- accept, and the igQuestLogAbandonQuest kit. There is no expression left to choose here,
-- which is why this reads the way the specification does. exclusive is v1-inert (engine
-- header note). ─────────────────────────────────────────────────────────────────────────
StaticPopupDialogs["ABANDON_QUEST"] = {
text = "Abandon \"%s\"?", -- ABANDON_QUEST_CONFIRM
button1 = "Yes", -- YES
@@ -1317,7 +1323,7 @@
<FontString name="QuestLogTitleText" inherits="GameFontHighlight" justifyH="CENTER" text="Quest Log">
<Anchors><Anchor point="TOP" relativePoint="TOP"><Offset><AbsDimension x="0" y="-15"/></Offset></Anchor></Anchors>
</FontString>
<!-- pin §1, transcribed byte-for-byte (ref QuestLogFrame.xml:204-243): the quest-count
<!-- pin §1, geometry/art byte-for-byte (ref QuestLogFrame.xml:204-243): the quest-count
pill is the Common-Input-Border 3-texture trio + a FontString RIGHT of the right
piece — REPLACING this window's old bare text at (-30,-16), which sat inside the
close X's hit area and visually collided with it. -->
@@ -1485,7 +1491,7 @@
</Layer></Layers>
</Frame>
<!-- pin §2, transcribed byte-for-byte (ref QuestLogFrame.xml:535-588): 6 title rows,
<!-- pin §2, geometry byte-for-byte (ref QuestLogFrame.xml:535-588): 6 title rows,
300×16, row1 TOPLEFT of the frame at (19,-75); rows 2-6 chain TOPLEFT→prior BOTTOMLEFT
at (0,+1) — a TIGHT 1px OVERLAP, not a gap (the ref's own offset is positive). -->
<Button name="QuestLogTitle1" inherits="BenillaQuestLogTitleTemplate">
@@ -1539,7 +1545,7 @@
QuestLogFrame.xml:648-659, at its own anchor and offset. Untimed quests are the
overwhelming majority, so the row must cost them NOTHING: the ref hides it and
re-SetPoints Objective1 straight onto ObjectivesText (QuestLogFrame.lua:370/374),
and `BenillaQuestLogDetail_Update` transcribes exactly that pair of branches. Our
and `BenillaQuestLogDetail_Update` keeps exactly that pair of branches. Our
region model has no Show/Hide for a bare FontString (the recurring comment
below), so the untimed case leaves this string EMPTY — it draws nothing, and
nothing anchors through it, so its 12px sits harmlessly behind Objective1's row
@@ -1762,7 +1768,7 @@
</Scripts>
</ScrollFrame>
<!-- pin §4, transcribed byte-for-byte (ref QuestLogFrame.xml:451-498): Abandon 125×21 at
<!-- pin §4, geometry byte-for-byte (ref QuestLogFrame.xml:451-498): Abandon 125×21 at
(17,54), Exit 77×21 at (-43,54). numEntries==0 disables Abandon. -->
<Button name="QuestLogAbandonButton" inherits="BenillaQuestLogPanelButtonTemplate" text="Abandon Quest">
<Size><AbsDimension x="125" y="21"/></Size>
@@ -1843,7 +1849,7 @@
up front and `QuestWatch_Update` only ever SetTexts/colors them, never creates one.
-->
<!-- The always-on quest-watch tracker HUD (ref QuestWatchFrame, QuestLogFrame.xml:963-1146
+ QuestWatch_Update, QuestLogFrame.lua:602-663), transcribed: TOPRIGHT hangs off the
+ QuestWatch_Update, QuestLogFrame.lua:602-663), re-expressed: TOPRIGHT hangs off the
MinimapCluster's BOTTOMRIGHT (+10 up — the cluster art overhangs), an empty 13px
GameFontNormal spacer (the ref's never-written QuestWatchQuestName) seats the first line,
and ONE FLAT POOL of 30 lines (MAX_QUESTWATCH_LINES) carries titles and objectives alike —
+173 -135
View File
@@ -1,12 +1,16 @@
<Ui>
<!--
benilla's Reputation tab: the 1.12 `ReputationFrame`, transcribed from the reference
FrameXML `ReputationFrame.{xml,lua}` onto our own engine and feeds. Every geometry number,
art path and TexCoord below is QUOTED from the reference with its line cite inline (cites
read `ref l.N` into `ReputationFrame.xml`, `ref-lua l.N` into `ReputationFrame.lua`); the
Lua is transcribed logic turned onto benilla's Era API — the twelve reputation globals of
benilla's Reputation tab: our own build of the 1.12 `ReputationFrame`. The reference
FrameXML `ReputationFrame.{xml,lua}` is the SPECIFICATION throughout — what the page must
look like and do — and it is cited line by line at every site (cites read `ref l.N` into
`ReputationFrame.xml`, `ref-lua l.N` into `ReputationFrame.lua`). Every geometry number, art
path and TexCoord below is QUOTED from it, because those are the client's facts and there is
no other value they could take; the Lua is ours, written against that specification and onto
benilla's Era API — the twelve reputation globals of
`crates/benilla-ui/src/script/reputation.rs`, whose module doc is the display law this file
paints, over `crates/benilla-app/src/ui_reputation.rs`'s feed.
paints, over `crates/benilla-app/src/ui_reputation.rs`'s feed. Where a line still reads
exactly like the reference's it is because the frame names and the API are the
addon-visible contract and neither is ours to vary.
A CHILD PAGE of `CharacterFrame` (ref l.211: `parent="CharacterFrame"`, `id="3"`), shown and
hidden by `CharacterFrame_ShowSubFrame` — but a SEPARATE top-level file, the SkillFrame.xml /
@@ -160,39 +164,155 @@
return getglobal("FACTION_STANDING_LABEL" .. standingID) or ""
end
-- Fill the detail popup for the row at `factionIndex` — ref-lua l.106-133, split out of
-- the update loop only so that function stays readable; the body is the reference's,
-- branch for branch. Called only when the popup is up AND this row is the selected one.
function BenillaReputationDetail_Fill(factionIndex, name, description, atWarWith, canToggleAtWar, isWatched)
ReputationDetailFactionName:SetText(name)
ReputationDetailFactionDescription:SetText(description)
if atWarWith then
ReputationDetailAtWarCheckBox:SetChecked(1)
-- A checkbox takes the client's 1/nil rather than a boolean, and every box on this page is
-- driven straight off a flag out of the faction tuple — so one verb, not an if/else pair
-- per box.
function BenillaReputation_SetBoxChecked(box, on)
box:SetChecked(on and 1 or nil)
end
-- The selection glow is a PAIR of textures, because the row's frame art is two pieces
-- (`$parentHighlight1` reaches back over the name, `2` caps the bar's right end). They are
-- always raised and dropped together, from three places: the update below, and the row's
-- own OnEnter/OnLeave.
function BenillaReputationBar_SetGlow(barName, lit)
local left, right = getglobal(barName .. "Highlight1"), getglobal(barName .. "Highlight2")
if lit then
left:Show()
right:Show()
else
ReputationDetailAtWarCheckBox:SetChecked(nil)
end
if canToggleAtWar then
ReputationDetailAtWarCheckBox:Enable()
ReputationDetailAtWarCheckBoxText:SetTextColor(RED_FONT_COLOR.r, RED_FONT_COLOR.g, RED_FONT_COLOR.b)
else
ReputationDetailAtWarCheckBox:Disable()
ReputationDetailAtWarCheckBoxText:SetTextColor(GRAY_FONT_COLOR.r, GRAY_FONT_COLOR.g, GRAY_FONT_COLOR.b)
end
if IsFactionInactive(factionIndex) then
ReputationDetailInactiveCheckBox:SetChecked(1)
else
ReputationDetailInactiveCheckBox:SetChecked(nil)
end
if isWatched then
ReputationDetailMainScreenCheckBox:SetChecked(1)
else
ReputationDetailMainScreenCheckBox:SetChecked(nil)
left:Hide()
right:Hide()
end
end
-- The list page (ref-lua ReputationFrame_Update, l.32-149) — transcribed whole, onto the
-- house faux-scroll kit and with the reference's own unread `rightBarTexture` local dropped
-- (this file's header comment, deviation 6).
-- Fill the detail popup for the row at `factionIndex` (ref-lua l.106-133 is the
-- specification). Called only when the popup is up AND this row is the selected one.
-- Everything it touches is a `ReputationDetail*` widget, which is the addon-visible name
-- and so not ours to vary; what IS ours is that the three tick states are one verb and the
-- at-war label's colour is written once instead of once per arm.
function BenillaReputationDetail_Fill(factionIndex, name, description, atWarWith, canToggleAtWar, isWatched)
ReputationDetailFactionName:SetText(name)
ReputationDetailFactionDescription:SetText(description)
BenillaReputation_SetBoxChecked(ReputationDetailAtWarCheckBox, atWarWith)
-- A peace-forced faction cannot be fought, so its box is dead and the label greys off
-- the angry red to say so.
local label = GRAY_FONT_COLOR
if canToggleAtWar then
label = RED_FONT_COLOR
ReputationDetailAtWarCheckBox:Enable()
else
ReputationDetailAtWarCheckBox:Disable()
end
ReputationDetailAtWarCheckBoxText:SetTextColor(label.r, label.g, label.b)
BenillaReputation_SetBoxChecked(ReputationDetailInactiveCheckBox, IsFactionInactive(factionIndex))
BenillaReputation_SetBoxChecked(ReputationDetailMainScreenCheckBox, isWatched)
end
-- A row SLOT is a pair of twins on one line — the header BUTTON `ReputationHeader<i>` and
-- the faction STATUSBAR `ReputationBar<i>` with all its furniture — of which exactly one is
-- ever up. The two verbs below cover every state a slot can be in (a header, a faction, or
-- nothing at all), and each raises the twin it wants and drops the other, so no caller has
-- to remember the pairing.
-- Slot `i` has nothing to hold: both twins go dark (ref-lua l.141-143).
function BenillaReputationSlot_Clear(i)
getglobal("ReputationHeader" .. i):Hide()
getglobal("ReputationBar" .. i):Hide()
end
-- Slot `i` holds the visible row at `factionIndex`. Split out of the update loop so that
-- loop is about the SCROLL WINDOW and this is about ONE ROW; the eleven-wide faction tuple
-- then has exactly one reader in the file. ref-lua l.51-139 is the specification, and the
-- reference's own unread `rightBarTexture` local is not carried (this file's header
-- comment, deviation 6).
function BenillaReputationSlot_Paint(i, factionIndex, gender)
local bar = getglobal("ReputationBar" .. i)
local header = getglobal("ReputationHeader" .. i)
local check = getglobal("ReputationBar" .. i .. "Check")
local name, description, standingID, barMin, barMax, barValue, atWarWith,
canToggleAtWar, isHeader, isCollapsed, isWatched = GetFactionInfo(factionIndex)
if isHeader then
-- A GROUP HEADER: the group's name, the fold icon its state calls for, and the
-- visible index a click on it folds by. The icon says what the click WILL do — an
-- open group offers the minus that shuts it, a shut one the plus that opens it.
local foldIcon = "Interface\\Buttons\\UI-MinusButton-Up"
if isCollapsed then
foldIcon = "Interface\\Buttons\\UI-PlusButton-Up"
end
header.index = factionIndex
header.isCollapsed = isCollapsed
header:SetText(name)
header:SetNormalTexture(foldIcon)
header:Show()
bar:Hide()
check:Hide()
return
end
-- A FACTION: the name and the standing word it currently wears.
local standingText = BenillaReputation_StandingLabel(standingID, gender)
local factionName = getglobal("ReputationBar" .. i .. "FactionName")
factionName:SetText(name)
getglobal("ReputationBar" .. i .. "FactionStanding"):SetText(standingText)
-- The crossed-swords pennant off the row's right end.
local atWarIndicator = getglobal("ReputationBar" .. i .. "AtWarCheck")
if atWarWith then
atWarIndicator:Show()
else
atWarIndicator:Hide()
end
-- The engine hands the rank window over ABSOLUTE for exactly this reason:
-- reputation.rs's module doc names the reference's own normalization (ref-lua l.79-82)
-- as the contract the tuple is written against. The bar's range is the window's WIDTH,
-- its value the distance into it, and the hover tooltip is the same pair spelled out.
local rankWidth = barMax - barMin
local progress = barValue - barMin
bar.id = factionIndex
bar.standingText = standingText
bar.tooltip = HIGHLIGHT_FONT_COLOR_CODE .. " " .. progress .. " / " .. rankWidth .. FONT_COLOR_CODE_CLOSE
bar:SetMinMaxValues(0, rankWidth)
bar:SetValue(progress)
-- UNGUARDED, deliberately, exactly as the reference has it (ref-lua l.89).
-- `GetFactionInfo` is eleven wide on EVERY path including a miss, and its miss puts the
-- client's own `1` in the standingID slot for precisely this index (reputation.rs
-- `unknown_faction`) — so the caller's `factionIndex <= numFactions` gate is belt, and
-- that `1` is braces. Adding a nil-check here would be inventing a branch the client
-- does not have and cannot reach.
local color = FACTION_BAR_COLORS[standingID]
bar:SetStatusBarColor(color.r, color.g, color.b)
bar:SetID(factionIndex)
bar:Show()
header:Hide()
-- The watched checkmark rides the END of the name string, so the name box gives up 10
-- of its 110px to make room for it (ref-lua l.95-103).
if isWatched then
check:Show()
factionName:SetWidth(100)
check:SetPoint("LEFT", factionName, "LEFT", factionName:GetStringWidth(), 0)
else
check:Hide()
factionName:SetWidth(110)
end
-- The selected row keeps its glow with the mouse away, and is the one that fills the
-- popup while the popup is open (ref-lua l.105-139).
local selected = factionIndex == GetSelectedFaction()
if selected and ReputationDetailFrame:IsShown() then
BenillaReputationDetail_Fill(factionIndex, name, description, atWarWith,
canToggleAtWar, isWatched)
end
BenillaReputationBar_SetGlow("ReputationBar" .. i, selected)
end
-- The list page (ref-lua ReputationFrame_Update, l.32-149 is the specification): fit the
-- scroll window to the list, then bind each of the fixed slots to the row the offset puts
-- under it, on the house faux-scroll kit rather than the reference's real ScrollFrame
-- (this file's header comment, deviation 4).
function ReputationFrame_Update()
local numFactions = GetNumFactions()
local scrollFrame = getglobal("ReputationListScrollFrame")
@@ -210,92 +330,10 @@
local gender = UnitSex("player")
for i = 1, NUM_FACTIONS_DISPLAYED do
local factionIndex = factionOffset + i
local factionBar = getglobal("ReputationBar" .. i)
local factionHeader = getglobal("ReputationHeader" .. i)
local factionCheck = getglobal("ReputationBar" .. i .. "Check")
if factionIndex <= numFactions then
local name, description, standingID, barMin, barMax, barValue, atWarWith,
canToggleAtWar, isHeader, isCollapsed, isWatched = GetFactionInfo(factionIndex)
if isHeader then
-- ref-lua l.52-63.
factionHeader:SetText(name)
if isCollapsed then
factionHeader:SetNormalTexture("Interface\\Buttons\\UI-PlusButton-Up")
else
factionHeader:SetNormalTexture("Interface\\Buttons\\UI-MinusButton-Up")
end
factionHeader.index = factionIndex
factionHeader.isCollapsed = isCollapsed
factionBar:Hide()
factionHeader:Show()
factionCheck:Hide()
else
-- ref-lua l.65-139.
local factionStanding = BenillaReputation_StandingLabel(standingID, gender)
local factionName = getglobal("ReputationBar" .. i .. "FactionName")
factionName:SetText(name)
getglobal("ReputationBar" .. i .. "FactionStanding"):SetText(factionStanding)
local atWarIndicator = getglobal("ReputationBar" .. i .. "AtWarCheck")
if atWarWith then
atWarIndicator:Show()
else
atWarIndicator:Hide()
end
-- Normalize values (ref-lua l.79-82). The engine hands these over ABSOLUTE
-- for exactly this reason: reputation.rs's module doc names the reference's
-- own three lines as the contract the tuple is written against.
barMax = barMax - barMin
barValue = barValue - barMin
barMin = 0
factionBar.id = factionIndex
factionBar.standingText = factionStanding
factionBar.tooltip = HIGHLIGHT_FONT_COLOR_CODE .. " " .. barValue .. " / " .. barMax .. FONT_COLOR_CODE_CLOSE
factionBar:SetMinMaxValues(0, barMax)
factionBar:SetValue(barValue)
-- UNGUARDED, deliberately, exactly as the reference has it (ref-lua l.89).
-- `GetFactionInfo` is eleven wide on EVERY path including a miss, and its
-- miss puts the client's own `1` in the standingID slot for precisely this
-- index (reputation.rs `unknown_faction`) — so the reference's own
-- `factionIndex <= numFactions` gate above is belt, and that `1` is braces.
-- Adding a nil-check here would be inventing a branch the client does not
-- have and cannot reach.
local color = FACTION_BAR_COLORS[standingID]
factionBar:SetStatusBarColor(color.r, color.g, color.b)
factionBar:SetID(factionIndex)
factionBar:Show()
factionHeader:Hide()
-- The watched checkmark rides the END of the name string, so the name box
-- narrows by 10 to make room (ref-lua l.95-103, verbatim).
if isWatched then
factionCheck:Show()
factionName:SetWidth(100)
factionCheck:SetPoint("LEFT", factionName, "LEFT", factionName:GetStringWidth(), 0)
else
factionCheck:Hide()
factionName:SetWidth(110)
end
-- ref-lua l.105-139: the selected row wears the highlight pair, and fills
-- the popup if it is open.
if factionIndex == GetSelectedFaction() then
if ReputationDetailFrame:IsShown() then
BenillaReputationDetail_Fill(factionIndex, name, description,
atWarWith, canToggleAtWar, isWatched)
end
getglobal("ReputationBar" .. i .. "Highlight1"):Show()
getglobal("ReputationBar" .. i .. "Highlight2"):Show()
else
getglobal("ReputationBar" .. i .. "Highlight1"):Hide()
getglobal("ReputationBar" .. i .. "Highlight2"):Hide()
end
end
BenillaReputationSlot_Paint(i, factionIndex, gender)
else
factionHeader:Hide()
factionBar:Hide()
BenillaReputationSlot_Clear(i)
end
end
-- ref-lua l.146-148: nothing selected means nothing to detail. `GetSelectedFaction`
@@ -307,18 +345,19 @@
end
-- A bar click toggles the popup for that faction (ref-lua ReputationBar_OnClick,
-- l.151-159). Parameterless and reading `this`, exactly as the reference writes it: our
-- l.151-159): the row that is already showing shuts it, any other row moves the selection
-- and opens it. Parameterless and reading `this`, exactly as the reference writes it: our
-- dispatcher sets `this` before invoking a handler (script/event.rs `invoke_with_globals`)
-- and it stays set through the call, the same contract ScrollTemplates.xml's
-- `FauxScrollFrame_OnVerticalScroll` already relies on.
function ReputationBar_OnClick()
if ReputationDetailFrame:IsShown() and (GetSelectedFaction() == this.id) then
if ReputationDetailFrame:IsShown() and GetSelectedFaction() == this.id then
ReputationDetailFrame:Hide()
else
SetSelectedFaction(this.id)
ReputationDetailFrame:Show()
ReputationFrame_Update()
return
end
SetSelectedFaction(this.id)
ReputationDetailFrame:Show()
ReputationFrame_Update()
end
-- A header click folds or unfolds its group (ref l.9-15, the template's own OnClick).
@@ -517,23 +556,22 @@
</Scripts>
</Frame>
</Frames>
<!-- ref l.189-207, transcribed verbatim: hovering swaps the standing word for the raw
"value / max" and lights the glow; leaving puts the word back and drops the glow unless
this row is the selected one. -->
<!-- ref l.189-207 is the specification: hovering swaps the standing word for the raw
"value / max" the update stamped on the bar and lights the glow; leaving puts the word
back and drops the glow again — unless this row is the SELECTED one, which keeps it.
Both strings and both glow states were put on the bar by the update above, so the
hover only chooses between them; the glow pair goes through that function's own verb
so a hover and a repaint can never disagree about which textures are up. -->
<Scripts>
<OnEnter>
if (this.tooltip) then
if this.tooltip then
getglobal(this:GetName() .. "FactionStanding"):SetText(this.tooltip)
end
getglobal(this:GetName() .. "Highlight1"):Show()
getglobal(this:GetName() .. "Highlight2"):Show()
BenillaReputationBar_SetGlow(this:GetName(), 1)
</OnEnter>
<OnLeave>
getglobal(this:GetName() .. "FactionStanding"):SetText(this.standingText)
if (GetSelectedFaction() ~= this.id) then
getglobal(this:GetName() .. "Highlight1"):Hide()
getglobal(this:GetName() .. "Highlight2"):Hide()
end
BenillaReputationBar_SetGlow(this:GetName(), GetSelectedFaction() == this.id)
</OnLeave>
<OnMouseUp>ReputationBar_OnClick()</OnMouseUp>
</Scripts>
+4 -4
View File
@@ -1,10 +1,10 @@
<Ui>
<!--
benilla's Skills tab (decision 0437 phase 4): the 1.12 `SkillFrame`, transcribed from the
benilla's Skills tab (decision 0437 phase 4): the 1.12 `SkillFrame`, re-expressed from the
reference FrameXML `SkillFrame.{xml,lua}` (session scratchpad `ref-ui/`, decision 0246
discipline — never committed) onto our own engine/feeds. Geometry/art-paths below are QUOTED
from the ref (line citations inline, into that same `SkillFrame.xml`/`.lua`); the Lua is
transcribed logic turned onto benilla's Era API (`GetNumSkillLines`/`GetSkillLineInfo`/
the ref's logic turned onto benilla's Era API (`GetNumSkillLines`/`GetSkillLineInfo`/
`Expand`/`CollapseSkillHeader`/`SetSelectedSkill`/`GetSelectedSkill`, decision 0437's skills
seam, `crates/benilla-ui/src/script/skills.rs`, built alongside this file).
@@ -160,8 +160,8 @@
bgTexture:SetVertexColor(0.0, 0.0, 0.75, 0.5)
bar:SetMinMaxValues(0, skillMaxRank)
bar:SetValue(skillRank)
-- ref SkillFrame.lua:178-186, transcribed byte-exact (this file's header comment,
-- deviation 4).
-- the ref's rank-text law (SkillFrame.lua:178-186), re-expressed here (this file's
-- header comment, deviation 4).
if skillModifier == 0 then
rankText:SetText(skillRank .. "/" .. skillMaxRank)
else
+46 -42
View File
@@ -11,9 +11,9 @@
(`UiPanels.xml`'s `UIPanelWindows["SpellBookFrame"] = { area = "left", pushable =
0 }`, mirroring the ref's own `SpellBookFrame` row) — so it competes for/replaces those
windows, never stacks with them. `ToggleSpellBook(bookType)` (ref `SpellBookFrame.lua:
9-50`) is the addon-facing entry point; like every frame name here it is the reference's own
`ToggleQuestLog` precedent) is the show/hide entry point; the app's 'P' binding calls it
with `BOOKTYPE_SPELL` (`ui_script/input.rs`, ref `Bindings.xml:582-583`).
9-50`) is the show/hide entry point, and it is addon-facing: it wears the reference's bare
name for the same reason `ToggleCharacter`/`ToggleQuestLog` do. The app's 'P' binding calls
it with `BOOKTYPE_SPELL` (`ui_script/input.rs`, ref `Bindings.xml:582-583`).
Every frame this file defines wears the REFERENCE's name (`SpellBookFrame`, `SpellButton1`,
…), because addons address them by it — the `Benilla` prefix these carried until the
@@ -408,35 +408,40 @@
local subSpellString = getglobal(name .. "SubSpellName")
local cooldown = getglobal(name .. "Cooldown")
local autocastable = getglobal(name .. "AutoCastable")
-- ref l.328: the out-of-tab DISABLE is `(bookType ~= BOOKTYPE_PET) and ...` — the pet
local highlightTexture = getglobal(name .. "Highlight")
local normalTexture = getglobal(name .. "NormalTexture")
-- TWO WAYS A SLOT COMES UP EMPTY, one dress for both. Past the end of the tab the
-- button also goes DEAD; a slot the book counts but has no icon for stays live and
-- only puts its highlight kit back to the square. Everything else the two do — the
-- five regions down, the autocast bit cleared, the ring unchecked, the button's own
-- ring back to plain white — is the same blanking, and it is written once here rather
-- than once per arm.
--
-- ref l.328: the out-of-tab test is `(bookType ~= BOOKTYPE_PET) and ...` — the pet
-- page has no tab to be out of, so a pet button past the end of the book falls to the
-- no-texture branch below and stays ENABLED. Faithful, and harmless: an enabled button
-- no-icon arm instead and stays ENABLED. Faithful, and harmless: an enabled button
-- with no spell behind it clicks into GetSpellName's nil and does nothing.
if (not isPet) and id > (offset + numSpells) then
local outOfTab = (not isPet) and id > (offset + numSpells)
if outOfTab then
button:Disable()
iconTexture:Hide()
spellString:Hide()
subSpellString:Hide()
cooldown:Hide()
autocastable:Hide()
button.autocasting = nil
button:SetChecked(0)
getglobal(name .. "NormalTexture"):SetVertexColor(1.0, 1.0, 1.0)
return
else
button:Enable()
end
local texture = GetSpellTexture(id, SpellBookFrame.bookType)
local highlightTexture = getglobal(name .. "Highlight")
local normalTexture = getglobal(name .. "NormalTexture")
if (not texture) or (texture == "") then
local texture
if not outOfTab then
texture = GetSpellTexture(id, SpellBookFrame.bookType)
end
if outOfTab or (not texture) or (texture == "") then
iconTexture:Hide()
spellString:Hide()
subSpellString:Hide()
cooldown:Hide()
autocastable:Hide()
button.autocasting = nil
highlightTexture:SetTexture("Interface\\Buttons\\ButtonHilight-Square")
if not outOfTab then
highlightTexture:SetTexture("Interface\\Buttons\\ButtonHilight-Square")
end
button:SetChecked(0)
normalTexture:SetVertexColor(1.0, 1.0, 1.0)
return
@@ -446,11 +451,8 @@
-- widget stays hidden (CooldownFrame_SetTimer's own gate) and the icon reads 40%.
local start, duration, enable = GetSpellCooldown(id, SpellBookFrame.bookType)
CooldownFrame_SetTimer(cooldown, start, duration, enable)
if enable == 1 then
iconTexture:SetVertexColor(1.0, 1.0, 1.0)
else
iconTexture:SetVertexColor(0.4, 0.4, 0.4)
end
local brightness = (enable == 1) and 1.0 or 0.4
iconTexture:SetVertexColor(brightness, brightness, brightness)
-- ref l.367-377: the autocast pair. `GetSpellAutocast` answers (nil, nil) for the
-- player book by the engine's own short-circuit, so this needs no isPet guard — the
-- overlay and the sparkle simply never come on outside the pet page.
@@ -459,15 +461,21 @@
button.autocasting = autoCastEnabled
local spellName, subSpellName = GetSpellName(id, SpellBookFrame.bookType)
local isPassive = IsSpellPassive(id, SpellBookFrame.bookType)
-- A passive slot wears a whole different dress — the button ring blacked right out,
-- the round passive highlight in place of the square one, and the name in the passive
-- grey. Three values that always travel together, so they are chosen once and applied
-- once instead of each verb appearing in both arms.
local ringLevel, highlightFile, nameColor
if isPassive then
normalTexture:SetVertexColor(0, 0, 0)
highlightTexture:SetTexture("Interface\\Buttons\\UI-PassiveHighlight")
spellString:SetTextColor(PASSIVE_SPELL_FONT_COLOR.r, PASSIVE_SPELL_FONT_COLOR.g, PASSIVE_SPELL_FONT_COLOR.b)
ringLevel, highlightFile, nameColor =
0, "Interface\\Buttons\\UI-PassiveHighlight", PASSIVE_SPELL_FONT_COLOR
else
normalTexture:SetVertexColor(1.0, 1.0, 1.0)
highlightTexture:SetTexture("Interface\\Buttons\\ButtonHilight-Square")
spellString:SetTextColor(NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b)
ringLevel, highlightFile, nameColor =
1.0, "Interface\\Buttons\\ButtonHilight-Square", NORMAL_FONT_COLOR
end
normalTexture:SetVertexColor(ringLevel, ringLevel, ringLevel)
highlightTexture:SetTexture(highlightFile)
spellString:SetTextColor(nameColor.r, nameColor.g, nameColor.b)
iconTexture:SetTexture(texture)
spellString:SetText(spellName)
subSpellString:SetText(subSpellName)
@@ -551,19 +559,15 @@
+ (SPELLS_PER_PAGE * ((SPELLBOOK_PAGENUMBERS[SpellBookFrame.selectedSkillLine] or 1) - 1))
end
-- ref SpellBook_UpdatePageArrows (l.467-479).
-- ref SpellBook_UpdatePageArrows (l.467-479). An arrow is live exactly while the page it
-- points at is a DIFFERENT page. Stated as an inequality rather than a range test on
-- purpose: the pet book's arrows write a counter its own page ignores (the do-not-fix
-- notes above), so a page number can sit outside 1..maxPages, and when it does BOTH arrows
-- stay live — which is the reference's answer, not an accident of this spelling.
function SpellBook_UpdatePageArrows()
local currentPage, maxPages = SpellBook_GetCurrentPage()
if currentPage == 1 then
SpellBookPrevPageButton:Disable()
else
SpellBookPrevPageButton:Enable()
end
if currentPage == maxPages then
SpellBookNextPageButton:Disable()
else
SpellBookNextPageButton:Enable()
end
if currentPage ~= 1 then SpellBookPrevPageButton:Enable() else SpellBookPrevPageButton:Disable() end
if currentPage ~= maxPages then SpellBookNextPageButton:Enable() else SpellBookNextPageButton:Disable() end
end
-- ref SpellBook_GetCurrentPage (l.481-493), both branches. `maxPages` is clamped to at
+1 -1
View File
@@ -1,7 +1,7 @@
<Ui>
<!--
The stack-split spinner (decision 0216 §6/§D, scratchpad `ref-framexml/StackSplitFrame.xml`/
`.lua`, transcribed near-verbatim onto our own cursor seam — `SplitContainerItem`/
`.lua`, re-expressed onto our own cursor seam — `SplitContainerItem`/
`OpenStackSplitFrame` already exist in benilla-ui's `cursor.rs`; this file is purely the
window + the Left/Right/Okay/Cancel plumbing around it). One shared instance, like the real
client's `StackSplitFrame` — any owner (a bag slot today; the paper doll / bank / mail slices
+258 -208
View File
@@ -41,7 +41,7 @@
TAB STRIP — reuses the house's EXISTING tab kit, not a hand-rolled `BenillaTalentFrame.
selectedTab` variable. The task brief's own ENGINE API note flagged `PanelTemplates_*` as
unavailable ("implement your own small select/deselect Lua") — verified false this session:
`UiPanels.xml:502-578` already transcribes the FULL kit (`PanelTemplates_SetNumTabs/_SetTab/
`UiPanels.xml:502-578` already provides the FULL kit (`PanelTemplates_SetNumTabs/_SetTab/
_GetSelectedTab/_UpdateTabs/_SelectTab/_DeselectTab/_TabResize`), and `CharacterFrameTab-
ButtonTemplate` (`UiPanels.xml:603-654`) is ALREADY battle-tested by CharacterFrame.xml's own
tab. Better still: the REFERENCE's own `TalentTabTemplate` (Blizzard_TalentUITemplates.xml:3)
@@ -54,7 +54,7 @@
own `TalentButtonTemplate` inline OnEnter, Blizzard_TalentUITemplates.xml:92) and
`PanelTemplates_GetSelectedTab(TalentFrame)` (read by TalentFrame.lua's own functions) are
THE SAME FIELD — `PanelTemplates_SetTab` stores straight onto `frame.selectedTab` — so this
file transcribes each call site exactly as the reference itself spells it, not a benilla-
file spells each call site exactly as the reference itself does, not a benilla-
introduced inconsistency.
SCROLL DESIGN — a real ScrollFrame with a FIXED-HEIGHT child, not the reference's own dynamic
@@ -144,7 +144,7 @@
fires it (grepped `crates/benilla-ui/src`, `crates/benilla/src`) — forward-compatible,
harmless, the same "transcribe the hook even if nothing drives it yet" posture as
HitRectInsets above.
- Bare `min`/`max`/`select` (used verbatim in `_DrawLines`/`_SetPrereqs` below): CONFIRMED
- Bare `min`/`max`/`select` (used unqualified in `_DrawLines`/`_SetPrereqs` below): CONFIRMED
available — `min`/`max` are house-stdlib aliases for `math.min`/`math.max`
(`benilla-ui/src/script/stdlib.rs`), `select` is stock Lua 5.1. The ref's own `arg`/`arg.n`
Lua-5.0 vararg table (`_SetPrereqs`) is written the 5.1-native way
@@ -171,17 +171,38 @@
CHARACTER_POINTS1_COLON = CHARACTER_POINTS1_COLON or "Talent Points:"
MASTERY_POINTS_SPENT = MASTERY_POINTS_SPENT or "Points spent in %s Talents:" -- :2674
-- ── This window's own constants (ref Blizzard_TalentUI.lua:2-11, bare-named exactly as the
-- ref names them — none collide with anything else in this engine, grepped) ─────────────────
MAX_NUM_TALENTS = 20
-- ── This window's own constants ─────────────────────────────────────────────────────────
--
-- Spec: ref Blizzard_TalentUI.lua:2-11. The NAMES and VALUES are the contract — a tree's
-- data is laid out against them and addons read them — so those are the reference's; the
-- grouping is ours, by what each one is for rather than by the order they happen to appear.
-- The tree's shape: eight rows of four cells, never more than twenty talents in one tree.
MAX_NUM_TALENT_TIERS = 8
NUM_TALENT_COLUMNS = 4
TALENT_BRANCH_ARRAY = {}
TALENT_BUTTON_SIZE = 32
MAX_NUM_BRANCH_TEXTURES = 30
MAX_NUM_ARROW_TEXTURES = 30
MAX_NUM_TALENTS = 20
-- Where that shape lands inside the scroll child: the first cell sits INITIAL_TALENT_
-- OFFSET_X/Y in from its top-left corner and every next one is a PITCH further along.
-- TALENT_BUTTON_SIZE is how far the connector art reaches out of a cell, NOT the talent
-- button's own 37px size — the two are independent (this file's header comment, "The
-- talent button"). Naming the pitch is what let the two places that used to spell out
-- `((n - 1) * 63) + INITIAL_TALENT_OFFSET_…` — the button placement and the connector draw
-- — collapse into the one `talentCellOrigin` below, so they cannot drift apart.
INITIAL_TALENT_OFFSET_X = 35
INITIAL_TALENT_OFFSET_Y = 20
TALENT_BUTTON_SIZE = 32
BENILLA_TALENT_GRID_PITCH = 63
-- The connector art comes out of two fixed pools, claimed in order by each repaint and
-- hidden again past the last one claimed (BenillaTalentFrame_Get*Texture below).
MAX_NUM_BRANCH_TEXTURES = 30
MAX_NUM_ARROW_TEXTURES = 30
-- One drawing-state cell per grid position — built once in OnLoad, cleared (never rebuilt)
-- by every repaint. What a cell records is written down at `clearBranchCell` below.
TALENT_BRANCH_ARRAY = {}
-- Benilla-prefixed + renumbered (5 -> 3): this file's header comment, "SCOPE CALL".
BENILLA_MAX_TALENT_TABS = 3
-- The scroll child's fixed height: this file's header comment, "SCROLL DESIGN".
@@ -192,64 +213,95 @@
-- own BENILLA_QUESTLOG_SCROLL_STEP precedent for a real
-- ScrollFrame's per-notch pixel step)
-- ref Blizzard_TalentUI.lua:13-69, verbatim.
TALENT_BRANCH_TEXTURECOORDS = {
up = {
[1] = {0.12890625, 0.25390625, 0, 0.484375},
[-1] = {0.12890625, 0.25390625, 0.515625, 1.0}
},
down = {
[1] = {0, 0.125, 0, 0.484375},
[-1] = {0, 0.125, 0.515625, 1.0}
},
left = {
[1] = {0.2578125, 0.3828125, 0, 0.5},
[-1] = {0.2578125, 0.3828125, 0.5, 1.0}
},
right = {
[1] = {0.2578125, 0.3828125, 0, 0.5},
[-1] = {0.2578125, 0.3828125, 0.5, 1.0}
},
topright = {
[1] = {0.515625, 0.640625, 0, 0.5},
[-1] = {0.515625, 0.640625, 0.5, 1.0}
},
topleft = {
[1] = {0.640625, 0.515625, 0, 0.5},
[-1] = {0.640625, 0.515625, 0.5, 1.0}
},
bottomright = {
[1] = {0.38671875, 0.51171875, 0, 0.5},
[-1] = {0.38671875, 0.51171875, 0.5, 1.0}
},
bottomleft = {
[1] = {0.51171875, 0.38671875, 0, 0.5},
[-1] = {0.51171875, 0.38671875, 0.5, 1.0}
},
tdown = {
[1] = {0.64453125, 0.76953125, 0, 0.5},
[-1] = {0.64453125, 0.76953125, 0.5, 1.0}
},
tup = {
[1] = {0.7734375, 0.8984375, 0, 0.5},
[-1] = {0.7734375, 0.8984375, 0.5, 1.0}
},
}
-- ── The branch/arrow atlases: CUT from the sheet, not typed out ─────────────────────────
--
-- Spec: the same two tables the reference builds (ref Blizzard_TalentUI.lua:13-69) — same
-- global names, same [direction][+1/-1] shape, same quads to the bit. Written our own way,
-- because what these are is not 104 fractions: both sheets are ONE STRIP of 32px cells
-- with the lit variant on the top row and the greyed variant directly beneath it, so a
-- direction is fully described by three facts — which cell carries its art, whether it is
-- drawn u-reversed, and how tall the drawn part of a row is. Those facts are the spec
-- below and the loop under them cuts the quads, which is both shorter and legible: the
-- strip's layout is visible in the source instead of implied by the numbers.
--
-- The grid is MEASURED, not inferred from the fractions — both BLPs pulled out of the
-- live patch chain this session (`mpqx <Data> 'Interface\TalentFrame\UI-Talent*.blp'`,
-- dimensions off the BLP2 header):
-- UI-TalentBranches 256 x 64 — seven 32px cells on a 33px pitch (x = 0, 33, 66, …)
-- UI-TalentArrows 64 x 64 — two 32px cells, butted, no gutter
-- Every edge is therefore n/256 or n/64 — exact binary fractions, so the divides below are
-- exact in doubles and these quads are bit-identical to the ones the reference lists.
--
-- Three things in the data that look like mistakes and are the sheet's own:
-- · `left` and `right` are the SAME quad. There is one piece of horizontal branch art;
-- which side of the button it lands on is the draw loop's doing (xOffset -/+
-- TALENT_BUTTON_SIZE in _Update below), not the sheet's.
-- · the corner pieces are drawn in ONE handedness — `topleft`/`bottomleft` are
-- `topright`/`bottomright` with u reversed (SetTexCoord reads left > right as a
-- mirror), which is how one drawing serves both.
-- · the two VERTICAL pieces take a 31px row, leaving a 2px gutter across the sheet's
-- middle, where every other cell takes the flat 32. The greyed row stays the lit
-- row's reflection through the midline either way — one `rowH` drives both.
local function talentSheetQuads(sheetW, sheetH, cellW, pitch, spec)
local quads = {}
for name, cell in pairs(spec) do
local u0, u1 = cell[1] * pitch / sheetW, (cell[1] * pitch + cellW) / sheetW
if cell.flip then u0, u1 = u1, u0 end
local v = (cell.rowH or cellW) / sheetH
quads[name] = { [1] = { u0, u1, 0, v }, [-1] = { u0, u1, 1 - v, 1.0 } }
end
return quads
end
TALENT_ARROW_TEXTURECOORDS = {
top = {
[1] = {0, 0.5, 0, 0.5},
[-1] = {0, 0.5, 0.5, 1.0}
},
right = {
[1] = {1.0, 0.5, 0, 0.5},
[-1] = {1.0, 0.5, 0.5, 1.0}
},
left = {
[1] = {0.5, 1.0, 0, 0.5},
[-1] = {0.5, 1.0, 0.5, 1.0}
},
}
-- Which cell serves which direction (the reference's own key names, which is where the
-- reading of each cell's art comes from): 0 `down`, 1 `up`, 2 the horizontal run, 3 the
-- bottom corners, 4 the top corners, 5 the T pointing down, 6 the T pointing up.
TALENT_BRANCH_TEXTURECOORDS = talentSheetQuads(256, 64, 32, 33, {
down = { 0, rowH = 31 },
up = { 1, rowH = 31 },
left = { 2 },
right = { 2 },
bottomright = { 3 },
bottomleft = { 3, flip = true },
topright = { 4 },
topleft = { 4, flip = true },
tdown = { 5 },
tup = { 6 },
})
-- cell 0 is the arrow that points up the column; cell 1 is the sideways one, mirrored for
-- the other hand.
TALENT_ARROW_TEXTURECOORDS = talentSheetQuads(64, 64, 32, 32, {
top = { 0 },
left = { 1 },
right = { 1, flip = true },
})
-- ── The branch grid ─────────────────────────────────────────────────────────────────────
--
-- A cell of TALENT_BRANCH_ARRAY holds the talent that occupies its grid position (`id`),
-- and the drawing state of the connector art passing THROUGH that position: four half-
-- branches (`up`/`down`/`left`/`right`, each the piece of a segment on this cell's side of
-- the join) and three arrowheads. Every one of those is either 0 — nothing drawn here — or
-- the ±1 that the draw pass indexes TALENT_BRANCH_TEXTURECOORDS/TALENT_ARROW_TEXTURECOORDS
-- by: +1 the lit art, -1 the greyed. That is the whole reason a met and an unmet prereq are
-- one code path in two colours rather than two code paths.
--
-- Clearing rather than rebuilding keeps the 32 cells allocated exactly once (OnLoad), and
-- puts their field set in ONE place instead of two that can drift apart.
local function clearBranchCell(cell)
cell.id = nil
cell.up, cell.down, cell.left, cell.right = 0, 0, 0, 0
cell.topArrow, cell.leftArrow, cell.rightArrow = 0, 0, 0
return cell
end
-- A grid cell's top-left corner inside the scroll child. The talent button seats exactly on
-- it; the connector art seats at the reference's own 2px inset from it (+2 x, -2 y).
local function talentCellOrigin(tier, column)
return (column - 1) * BENILLA_TALENT_GRID_PITCH + INITIAL_TALENT_OFFSET_X,
-((tier - 1) * BENILLA_TALENT_GRID_PITCH) - INITIAL_TALENT_OFFSET_Y
end
-- ── The window: toggle (ref TalentFrame_Toggle, l.73-79) ────────────────────────────────
-- Renamed ToggleTalentFrame — this file's header comment, "ONE deliberate naming deviation".
@@ -272,13 +324,11 @@
-- UNIT_PORTRAIT_UPDATE: this file's header comment (engine-gap notes) — never fired
-- today, registered anyway for ref fidelity/forward-compat.
frame:RegisterEvent("UNIT_PORTRAIT_UPDATE")
-- The grid's one allocation: every repaint clears these same cells in place.
for i = 1, MAX_NUM_TALENT_TIERS do
TALENT_BRANCH_ARRAY[i] = {}
for j = 1, NUM_TALENT_COLUMNS do
TALENT_BRANCH_ARRAY[i][j] = {
id = nil, up = 0, left = 0, right = 0, down = 0,
leftArrow = 0, rightArrow = 0, topArrow = 0,
}
TALENT_BRANCH_ARRAY[i][j] = clearBranchCell({})
end
end
end
@@ -298,7 +348,7 @@
PlaySound("TalentScreenClose")
end
-- ref TalentFrame_OnEvent (l.110-118) — no IsVisible() guard: transcribed exactly as THIS
-- ref TalentFrame_OnEvent (l.110-118) — no IsVisible() guard: followed exactly as THIS
-- reference does it (it refreshes even while hidden; a different window guarding its own
-- OnEvent, e.g. SpellBookFrame, is that window's own choice, not borrowed here).
function BenillaTalentFrame_OnEvent(frame)
@@ -313,7 +363,7 @@
-- ref TalentFrameTalent_OnEvent (Blizzard_TalentUITemplates.xml:120-124) — the hovered
-- button's own CHARACTER_POINTS_CHANGED re-poll. GameTooltip:IsOwned VERIFIED present
-- (`benilla-ui/src/script/tooltip/verbs.rs:107`), transcribed as-is (not an emulation).
-- (`benilla-ui/src/script/tooltip/verbs.rs:107`), used as-is (not an emulation).
function BenillaTalentButton_OnEvent(button)
if GameTooltip:IsOwned(button) then
GameTooltip:SetTalent(PanelTemplates_GetSelectedTab(BenillaTalentFrame), button:GetID())
@@ -448,9 +498,15 @@
end
end
-- Draw the prereq branches (ref l.232-320, verbatim). UpdateScrollChildRect() DROPPED
-- Now paint the routes the pass above marked onto the grid (spec: ref l.232-320): walk
-- every cell in order, claim a pooled texture for each piece of art it asks for, and
-- hide whatever is left of the pools at the end. UpdateScrollChildRect() is DROPPED
-- from the tier loop's tail — this file's header comment ("SCROLL DESIGN": a fixed-
-- height child never needs its range re-derived).
--
-- `ignoreUp` deliberately survives from one cell to the NEXT: an empty cell carrying a
-- straight vertical through it draws its own `down` piece one row lower, so the cell
-- below must not draw the `up` piece again on top of it.
local node
local xOffset, yOffset
local ignoreUp
@@ -460,10 +516,9 @@
for i = 1, MAX_NUM_TALENT_TIERS do
for j = 1, NUM_TALENT_COLUMNS do
node = TALENT_BRANCH_ARRAY[i][j]
-- Setup offsets
xOffset = ((j - 1) * 63) + INITIAL_TALENT_OFFSET_X + 2
yOffset = -((i - 1) * 63) - INITIAL_TALENT_OFFSET_Y - 2
-- The cell's own corner, then the connector art's inset inside it.
xOffset, yOffset = talentCellOrigin(i, j)
xOffset, yOffset = xOffset + 2, yOffset - 2
if node.id then
-- Has talent
@@ -618,136 +673,137 @@
return requirementsMet
end
-- ref TalentFrame_DrawLines (l.398-527) — verbatim (bare min/max confirmed available,
-- this file's header comment, `benilla-ui/src/script/stdlib.rs`). message() calls DROPPED
-- at their 3 sites (this file's header comment); the `return`s that followed them are kept.
function BenillaTalentFrame_DrawLines(buttonTier, buttonColumn, tier, column, requirementsMet)
if requirementsMet then
requirementsMet = 1
else
requirementsMet = -1
end
-- ── Routing one prereq onto the grid ────────────────────────────────────────────────────
--
-- Spec: ref TalentFrame_DrawLines (l.398-527). A route is never stored as a path; each cell
-- it crosses simply records which of its own half-branches and arrowheads the route lit,
-- and the value recorded is the route's STATE — +1 met, -1 unmet — which is the very key
-- the draw pass looks the art up by (`clearBranchCell` above).
--
-- The one rule every leg obeys: a route may not run through a cell that holds a talent of
-- its own, because the connector art would land under that button. Each leg scans its span
-- first and abandons the whole route rather than draw over one. (The reference raises a
-- `message()` at three of those points; the diagnostic is dropped and the abandonment kept
-- — this file's header comment, "message()". No 1.12 tree trips any of them.)
--
-- These five are the grid's vocabulary; the routing below is written entirely in them.
-- Check to see if are in the same column
if buttonColumn == column then
-- Check for blocking talents
if (buttonTier - tier) > 1 then
-- If more than one tier difference
for i = tier + 1, buttonTier - 1 do
if TALENT_BRANCH_ARRAY[i][buttonColumn].id then
-- If there's an id, there's a blocker
return
end
end
end
-- One link of a vertical run: the lower half of the upper cell plus the upper half of the
-- cell beneath it. Two half-branches are what make one drawn segment.
local function linkDown(tier, column, state)
TALENT_BRANCH_ARRAY[tier][column].down = state
TALENT_BRANCH_ARRAY[tier + 1][column].up = state
end
-- Draw the lines
for i = tier, buttonTier - 1 do
TALENT_BRANCH_ARRAY[i][buttonColumn].down = requirementsMet
if (i + 1) <= (buttonTier - 1) then
TALENT_BRANCH_ARRAY[i + 1][buttonColumn].up = requirementsMet
end
end
-- The same across, left cell to right cell.
local function linkRight(tier, column, state)
TALENT_BRANCH_ARRAY[tier][column].right = state
TALENT_BRANCH_ARRAY[tier][column + 1].left = state
end
-- Set the arrow
TALENT_BRANCH_ARRAY[buttonTier][buttonColumn].topArrow = requirementsMet
return
-- Does a talent sit anywhere in this span of one tier / of one column? (An empty span —
-- adjacent cells with nothing between them — is trivially clear.)
local function occupiedAcross(tier, fromColumn, toColumn)
for i = fromColumn, toColumn do
if TALENT_BRANCH_ARRAY[tier][i].id then return true end
end
-- Check to see if they're in the same tier
if buttonTier == tier then
local left = min(buttonColumn, column)
local right = max(buttonColumn, column)
return false
end
-- See if the distance is greater than one space
if (right - left) > 1 then
-- Check for blocking talents
for i = left + 1, right - 1 do
if TALENT_BRANCH_ARRAY[tier][i].id then
-- If there's an id, there's a blocker
return
end
end
end
-- If we get here then we're in the clear
for i = left, right - 1 do
TALENT_BRANCH_ARRAY[tier][i].right = requirementsMet
TALENT_BRANCH_ARRAY[tier][i + 1].left = requirementsMet
end
-- Determine where the arrow goes
if buttonColumn < column then
TALENT_BRANCH_ARRAY[buttonTier][buttonColumn].rightArrow = requirementsMet
else
TALENT_BRANCH_ARRAY[buttonTier][buttonColumn].leftArrow = requirementsMet
end
return
local function occupiedDown(column, fromTier, toTier)
for i = fromTier, toTier do
if TALENT_BRANCH_ARRAY[i][column].id then return true end
end
-- Now we know the prereq is diagonal from us
local left = min(buttonColumn, column)
local right = max(buttonColumn, column)
-- Don't check the location of the current button
if left == column then
left = left + 1
else
right = right - 1
end
-- Check for blocking talents
local blocked = nil
for i = left, right do
if TALENT_BRANCH_ARRAY[tier][i].id then
-- If there's an id, there's a blocker
blocked = 1
end
end
left = min(buttonColumn, column)
right = max(buttonColumn, column)
if not blocked then
TALENT_BRANCH_ARRAY[tier][buttonColumn].down = requirementsMet
TALENT_BRANCH_ARRAY[buttonTier][buttonColumn].up = requirementsMet
return false
end
for i = tier, buttonTier - 1 do
TALENT_BRANCH_ARRAY[i][buttonColumn].down = requirementsMet
TALENT_BRANCH_ARRAY[i + 1][buttonColumn].up = requirementsMet
end
for i = left, right - 1 do
TALENT_BRANCH_ARRAY[tier][i].right = requirementsMet
TALENT_BRANCH_ARRAY[tier][i + 1].left = requirementsMet
end
-- Place the arrow
TALENT_BRANCH_ARRAY[buttonTier][buttonColumn].topArrow = requirementsMet
return
end
-- If we're here then we were blocked trying to go vertically first so we have to go
-- over first, then up
if left == buttonColumn then
left = left + 1
else
right = right - 1
end
-- Check for blocking talents
for i = left, right do
if TALENT_BRANCH_ARRAY[buttonTier][i].id then
-- If there's an id, then this layout is undrawable
return
end
end
-- If we're here we can draw the line
left = min(buttonColumn, column)
right = max(buttonColumn, column)
for i = tier, buttonTier - 1 do
TALENT_BRANCH_ARRAY[i][column].up = requirementsMet
TALENT_BRANCH_ARRAY[i + 1][column].down = requirementsMet
end
-- Determine where the arrow goes
-- The arrowhead on the dependent talent's own cell, pointing back along the way the route
-- came in — right if the prereq lies to the right of us, left if it lies to the left.
local function pointArrowSideways(cell, buttonColumn, column, state)
if buttonColumn < column then
TALENT_BRANCH_ARRAY[buttonTier][buttonColumn].rightArrow = requirementsMet
cell.rightArrow = state
else
TALENT_BRANCH_ARRAY[buttonTier][buttonColumn].leftArrow = requirementsMet
cell.leftArrow = state
end
end
-- Mark the route from a prerequisite at (tier, column) to the talent that depends on it at
-- (buttonTier, buttonColumn). Called once per prereq by BenillaTalentFrame_SetPrereqs.
function BenillaTalentFrame_DrawLines(buttonTier, buttonColumn, tier, column, requirementsMet)
local state = -1
if requirementsMet then state = 1 end
-- The dependent talent's own cell: SetPrereqs' caller has already stamped its `id`, so
-- it always exists. Every branch below ends by marking an arrowhead on it.
local cell = TALENT_BRANCH_ARRAY[buttonTier][buttonColumn]
-- STRAIGHT UP THE COLUMN. One vertical run, and the last link stops half short: the
-- talent's own cell wears the arrowhead where an `up` stub would otherwise go.
if buttonColumn == column then
if occupiedDown(column, tier + 1, buttonTier - 1) then return end
for i = tier, buttonTier - 1 do
if i + 1 < buttonTier then
linkDown(i, column, state)
else
TALENT_BRANCH_ARRAY[i][column].down = state
end
end
cell.topArrow = state
return
end
-- ALONG THE TIER. One horizontal run between the two columns, arrowhead on the side
-- the prereq lies. Both halves of every join are marked, including the talent's own.
if buttonTier == tier then
local left, right = min(buttonColumn, column), max(buttonColumn, column)
if occupiedAcross(tier, left + 1, right - 1) then return end
for i = left, right - 1 do
linkRight(tier, i, state)
end
pointArrowSideways(cell, buttonColumn, column, state)
return
end
-- DIAGONAL, so the route must turn one corner, and there are two corners to choose
-- from. Preferred: run ACROSS the prereq's own tier to our column, then DOWN it. What
-- has to be clear for that is the prereq's tier from the cell NEXT to the prereq
-- through to the corner at our own column — the prereq's own cell is where the route
-- starts, so it is never in its own way.
local left, right = min(buttonColumn, column), max(buttonColumn, column)
local from, to = left, right
if left == column then from = left + 1 else to = right - 1 end
if not occupiedAcross(tier, from, to) then
-- The corner and our own cell's top half, marked outright: the loop below covers
-- both, EXCEPT when the prereq sits below us and the run is empty.
TALENT_BRANCH_ARRAY[tier][buttonColumn].down = state
cell.up = state
for i = tier, buttonTier - 1 do
linkDown(i, buttonColumn, state)
end
for i = left, right - 1 do
linkRight(tier, i, state)
end
cell.topArrow = state
return
end
-- That corner is blocked, so take the other one: DOWN the prereq's column first, then
-- across our own tier — which now has to be the clear span, from our cell to the corner.
from, to = left, right
if left == buttonColumn then from = left + 1 else to = right - 1 end
if occupiedAcross(buttonTier, from, to) then return end
-- Two reference quirks live in these three lines and BOTH are deliberately kept, because
-- they are what the real client draws: the across leg is never marked at all (the
-- reference's own two lines for it are commented out at Blizzard_TalentUI.lua:508-509),
-- and each vertical segment is attributed to the OUTER halves of its two cells rather
-- than the facing ones — the mirror image of `linkDown`, which is why that helper is not
-- used here.
for i = tier, buttonTier - 1 do
TALENT_BRANCH_ARRAY[i][column].up = state
TALENT_BRANCH_ARRAY[i + 1][column].down = state
end
pointArrowSideways(cell, buttonColumn, column, state)
end
-- ref TalentFrameTalent_OnClick (Blizzard_TalentUITemplates.xml:97-99).
function BenillaTalentButton_OnClick(button)
LearnTalent(PanelTemplates_GetSelectedTab(BenillaTalentFrame), button:GetID())
@@ -768,25 +824,19 @@
BenillaTalentFrame.talentPoints = cp1
end
-- ref SetTalentButtonLocation (l.549-553).
-- Seat a talent button on its grid cell (spec: ref SetTalentButtonLocation, l.549-553) —
-- the same corner the connector art insets from, so the two can never drift apart.
function BenillaSetTalentButtonLocation(button, tier, column)
local x = ((column - 1) * 63) + INITIAL_TALENT_OFFSET_X
local y = -((tier - 1) * 63) - INITIAL_TALENT_OFFSET_Y
local x, y = talentCellOrigin(tier, column)
button:SetPoint("TOPLEFT", button:GetParent(), "TOPLEFT", x, y)
end
-- ref TalentFrame_ResetBranches (l.555-568).
-- Back to a blank grid before a repaint re-derives it (spec: ref TalentFrame_ResetBranches,
-- l.555-568). The field set lives at `clearBranchCell`, shared with OnLoad's build.
function BenillaTalentFrame_ResetBranches()
for i = 1, MAX_NUM_TALENT_TIERS do
for j = 1, NUM_TALENT_COLUMNS do
TALENT_BRANCH_ARRAY[i][j].id = nil
TALENT_BRANCH_ARRAY[i][j].up = 0
TALENT_BRANCH_ARRAY[i][j].down = 0
TALENT_BRANCH_ARRAY[i][j].left = 0
TALENT_BRANCH_ARRAY[i][j].right = 0
TALENT_BRANCH_ARRAY[i][j].rightArrow = 0
TALENT_BRANCH_ARRAY[i][j].leftArrow = 0
TALENT_BRANCH_ARRAY[i][j].topArrow = 0
clearBranchCell(TALENT_BRANCH_ARRAY[i][j])
end
end
end
+249 -193
View File
@@ -1,13 +1,17 @@
<Ui>
<!--
benilla's taxi-map window (decision 0484 phase 2): a faithful port of the real 1.12
`TaxiFrame.{xml,lua}` (patch chain; scratchpad `ref-TaxiFrame.xml/.lua`, line citations
inline) onto the taxi engine seam (`crates/benilla-ui/src/script/taxi.rs`) fed by
`crates/benilla/src/ui_taxi.rs`. The window art/geometry are quoted from the reference
verbatim (384×512, the four TaxiFrame quadrant slabs at 256/128 split, the 316×352 map
rect at TOP -13,-75) — the deviations below are all where THIS ENGINE, not taste, draws
the line, each flagged and reasoned like every other transcribed window (Gossip/Merchant/
Trainer's own header notes).
benilla's taxi-map window (decision 0484 phase 2), written on the taxi engine seam
(`crates/benilla-ui/src/script/taxi.rs`) fed by `crates/benilla/src/ui_taxi.rs`.
This is **our implementation**. Its specification is the real 1.12 `TaxiFrame.{xml,lua}`
(patch chain; scratchpad `ref-TaxiFrame.xml/.lua`, line citations inline as `ref l.N`): the
globals, the art paths, the geometry and the behaviour of every branch are the
specification's, because addons and the engine both address this window by those names, and
because the numbers are measurements of the shipped art (384×512, the four TaxiFrame
quadrant slabs at a 256/128 split, the 316×352 map rect at TOP -13,-75) rather than
anything for us to re-derive. How it is written is ours. The deviations below are where
THIS ENGINE, not taste, draws the line, each flagged and reasoned like every other window
of ours (Gossip/Merchant/Trainer's own header notes).
Modeled on MerchantFrame.xml/TrainerFrame.xml for the framing: the NPC-session lifecycle
(RegisterEvent pair, OPENED/CLOSED dispatch, OnShow/OnHide PlaySound with a client-side
@@ -30,8 +34,8 @@
`TaxiNodes.dbc`'s per-continent max (43 nodes on Eastern Kingdoms, 39 on Kalimdor —
counted this session, `crate::ui_taxi`'s module doc). The route-line textures stay
genuinely dynamic (`TaxiRouteMap:CreateTexture(...)`, ref l.101/142) — plain
region creation carries no template/inherits, so THAT half of the reference runs
completely unmodified.
region creation carries no template/inherits, so that half is created on demand exactly
as specified.
2. THE TITLE READS AN EVENT ARG, not `UnitName("npc")` (ref TaxiFrame.lua:27). This
engine's `"npc"` unit token (decision 0421) only feeds the PORTRAIT bake — there is no
backing `UnitState` for `UnitName` to read from it. Gossip/Merchant/Trainer hit the
@@ -40,10 +44,10 @@
(`BenillaTaxiFrame_SetTitle`, fed by `TAXIMAP_OPENED`'s arg1).
3. (Retired.) The "no single-hop destination" refusal (`DrawOneHopLines`, ref l.161-164)
used to call a plain `BenillaErrorsFrame_AddMessage` global because this engine had no
MessageFrame widget for `UIErrorsFrame` to be. It has one now, so the line below is the
reference's own `UIErrorsFrame:AddMessage(ERR_TAXINOPATHS, ...)`. `ErrorsFrame.xml` loads
well before this file in the shipped list (`ui_script/mod.rs`), so the frame already
exists by the time this ever runs.
MessageFrame widget for `UIErrorsFrame` to be. It has one now, so the refusal below posts
to `UIErrorsFrame` under the specified name. `ErrorsFrame.xml` loads well before this file
in the shipped list (`ui_script/mod.rs`), so the frame already exists by the time this
ever runs.
`<TaxiRouteFrame>` (an intrinsic type this engine doesn't have) becomes a plain `<Frame>`
(`TaxiRouteMap`) per the house rule for every non-shipped intrinsic (decision 0246's
@@ -52,34 +56,36 @@
nothing.
Money (the fare) renders as plain text via `SetTooltipMoney` on the shared `GameTooltip`
(`GameTooltip.xml`'s own seam) exactly like the reference — no `BenillaMoney_*` coin rig
needed here (that lives on the merchant/trainer purse displays, a different job).
(`GameTooltip.xml`'s own seam) exactly as specified — no `BenillaMoney_*` coin rig needed
here (that lives on the merchant/trainer purse displays, a different job).
-->
<Script><![CDATA[
-- ref TaxiFrame.lua:2-3, verbatim.
-- The continent map's size in pixels — a published pair, because scaling a node's
-- normalised position onto the map needs them and so does anything that wants to put its own
-- marker there. The values are the specification's (ref TaxiFrame.lua:2-3): a measurement of
-- the shipped map art.
TAXI_MAP_WIDTH = 316
TAXI_MAP_HEIGHT = 352
-- The static node-button pool's size — see deviation 1 above.
BENILLA_TAXI_MAX_NODES = 50
-- ref TaxiFrame.lua:7-16, verbatim (the three icon states — CURRENT green / REACHABLE
-- white / DISTANT yellow — all three files verified present in the MPQ this session).
TaxiButtonTypes = { }
TaxiButtonTypes["CURRENT"] = {
file = "Interface\\TaxiFrame\\UI-Taxi-Icon-Green"
}
TaxiButtonTypes["REACHABLE"] = {
file = "Interface\\TaxiFrame\\UI-Taxi-Icon-White"
}
TaxiButtonTypes["DISTANT"] = {
file = "Interface\\TaxiFrame\\UI-Taxi-Icon-Yellow"
-- What each node state draws (ref TaxiFrame.lua:7-16): CURRENT green, REACHABLE white,
-- DISTANT yellow — all three art files verified present in the MPQ. Published as a table of
-- `{ file = }` keyed by the state string `TaxiNodeGetType` answers, which is both the shape
-- the node paint below reads and the shape an addon adding a state of its own writes.
TaxiButtonTypes = {
CURRENT = { file = "Interface\\TaxiFrame\\UI-Taxi-Icon-Green" },
REACHABLE = { file = "Interface\\TaxiFrame\\UI-Taxi-Icon-White" },
DISTANT = { file = "Interface\\TaxiFrame\\UI-Taxi-Icon-Yellow" },
}
-- Byte-exact 1.12 enUS GlobalStrings (interface.MPQ `GlobalStrings.lua`, line-cited).
-- `TEXT(x)` (ref l.121) is the real client's build-time no-op localization macro — every
-- `TEXT(FOO)` call is transcribed as the bare `FOO` global (CharacterFrame.xml's own
-- convention); with no engine `TEXT` global at all, these are plain locals instead.
-- The two strings this window shows, byte-exact from the 1.12 enUS GlobalStrings
-- (interface.MPQ `GlobalStrings.lua`, line-cited) — user-visible text, so its bytes are the
-- spec, not our expression. `TEXT(x)` (ref l.121) is the real client's build-time no-op
-- localization macro, so every `TEXT(FOO)` resolves to the bare `FOO` global
-- (CharacterFrame.xml's own convention); with no engine `TEXT` global at all, and nothing
-- outside this window reading either string, they are plain locals here instead.
local TAXINODEYOUAREHERE = "You are here" -- GlobalStrings.lua:4232
-- GlobalStrings.lua:1864 — the reference's own byte, including its curly apostrophe
-- (U+2019, "don\226\128\153t"), not a straight one.
@@ -100,35 +106,37 @@
getglobal("TaxiNameText"):SetText(name or "")
end
-- ref TaxiFrame.lua:24-80, restructured only at the node-button loop (deviation 1 — a
-- fixed pool walked 1..BENILLA_TAXI_MAX_NODES instead of grown via CreateFrame).
-- Paint the node buttons (ref l.44-63): every pool slot up to the pushed node count takes
-- that node's state icon and its pushed position, and the rest of the pool hides. Positions
-- arrive normalised 0..1 from the map's BOTTOMLEFT, which is what the map size above scales.
local function PaintNodes()
local pushed = NumTaxiNodes()
for index = 1, BENILLA_TAXI_MAX_NODES do
local button = getglobal("TaxiButton" .. index)
local state = TaxiNodeGetType(index)
if index <= pushed and state ~= "NONE" then
local x, y = TaxiNodePosition(button:GetID())
button:ClearAllPoints()
button:SetPoint("CENTER", "TaxiMap", "BOTTOMLEFT", x * TAXI_MAP_WIDTH, y * TAXI_MAP_HEIGHT)
button:SetNormalTexture(TaxiButtonTypes[state].file)
button:Show()
else
button:Hide()
end
end
end
-- Opening and closing the window (ref TaxiFrame.lua:24-80). Opening repaints everything from
-- the pushed snapshot and then asks the panel manager for a slot; if it will not give one,
-- the session is closed again rather than left running behind a window nobody can see.
function TaxiFrame_OnEvent(event)
local frame = this
if event == "TAXIMAP_OPENED" then
BenillaTaxiFrame_SetTitle(arg1)
-- Set the texture coords on the map, then hand the region to the app's art pick.
-- Reset the map's sampling, then hand the region to the app's continent-art pick.
TaxiMap:SetTexCoord(0, 1, 0, 1)
SetTaxiMap(TaxiMap)
-- Draw nodes: every pool slot up to the pushed count shows its type's icon at its
-- pushed position; the rest hide (ref l.44-63, the static-pool shape).
local num_nodes = NumTaxiNodes()
for index = 1, BENILLA_TAXI_MAX_NODES do
local button = getglobal("TaxiButton" .. index)
local ntype = TaxiNodeGetType(index)
if index <= num_nodes and ntype ~= "NONE" then
local x, y = TaxiNodePosition(button:GetID())
button:ClearAllPoints()
button:SetPoint("CENTER", "TaxiMap", "BOTTOMLEFT", x * TAXI_MAP_WIDTH, y * TAXI_MAP_HEIGHT)
button:SetNormalTexture(TaxiButtonTypes[ntype].file)
button:Show()
else
button:Hide()
end
end
-- All set...
PaintNodes()
ShowUIPanel(frame)
if not frame:IsVisible() then
CloseTaxiMap()
@@ -141,49 +149,70 @@
end
end
-- ref TaxiFrame.lua:82-126, verbatim. `this:GetID()` (l.96) reads the SAME button as the
-- `button` parameter — the engine sets the shared `this` global to the invoking frame
-- before OnEnter runs (decision 0068's event-dispatch convention), exactly like the real
-- client, so the two reads agree.
-- ── The route-line pool ──────────────────────────────────────────────────────────────────
--
-- A flight path draws as a chain of straight segments, one texture per segment. Those
-- textures are a pool that grows to the longest chain ever asked for and is never shrunk:
-- `NUM_TAXI_ROUTES` is how many exist and `TaxiRoute<n>` is each one's global name — both
-- published, since the count is a global the specification exports and the names are what
-- `getglobal` finds. Unlike the node buttons, this half really is created on demand: plain
-- region creation carries no template, so deviation 1's CreateFrame wall never applies.
NUM_TAXI_ROUTES = 0
-- Grow the pool to hold at least `n` lines; answer what it holds.
local function GrowRoutePool(n)
while NUM_TAXI_ROUTES < n do
NUM_TAXI_ROUTES = NUM_TAXI_ROUTES + 1
local line = TaxiRouteMap:CreateTexture("TaxiRoute" .. NUM_TAXI_ROUTES, "BACKGROUND")
line:SetTexture("Interface\\TaxiFrame\\UI-Taxi-Line")
end
return NUM_TAXI_ROUTES
end
-- Draw one line per `{ node, hop }` in `segments`, and hide whatever the pool holds beyond
-- them. Each hop's endpoints arrive normalised on the map, so they scale by the canvas the
-- lines live on. 32 is the specified line thickness (ref l.114/153), in those same pixels.
local function PaintRoutes(segments)
local count = table.getn(segments)
local pool = GrowRoutePool(count)
local w = TaxiRouteMap:GetWidth()
local h = TaxiRouteMap:GetHeight()
for i = 1, pool do
local line = getglobal("TaxiRoute" .. i)
if i <= count then
local node, hop = segments[i][1], segments[i][2]
DrawRouteLine(line, "TaxiRouteMap",
TaxiGetSrcX(node, hop) * w, TaxiGetSrcY(node, hop) * h,
TaxiGetDestX(node, hop) * w, TaxiGetDestY(node, hop) * h, 32)
line:Show()
else
line:Hide()
end
end
end
-- The tooltip, and the route, for the node under the cursor (ref TaxiFrame.lua:82-126). A
-- reachable node prices the flight and draws every hop of it; the node we are standing on
-- says so and draws the whole neighbourhood instead.
--
-- `this:GetID()` in the fare read is the SAME button as the `button` parameter — the engine
-- binds the shared `this` global to the invoking frame before OnEnter runs (decision 0068's
-- event-dispatch convention), exactly like the real client, so the two reads agree.
function TaxiNodeOnButtonEnter(button)
local index = button:GetID()
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
GameTooltip:AddLine(TaxiNodeName(index), "", 1.0, 1.0, 1.0)
-- Setup variables
local numRoutes = GetNumRoutes(index)
local line
local sX, sY, dX, dY
local w = TaxiRouteMap:GetWidth()
local h = TaxiRouteMap:GetHeight()
local ntype = TaxiNodeGetType(index)
if ntype == "REACHABLE" then
local state = TaxiNodeGetType(index)
if state == "REACHABLE" then
SetTooltipMoney(GameTooltip, TaxiNodeCost(this:GetID()))
TaxiNodeSetCurrent(index)
if numRoutes > NUM_TAXI_ROUTES then
for i = NUM_TAXI_ROUTES + 1, numRoutes do
line = TaxiRouteMap:CreateTexture("TaxiRoute" .. i, "BACKGROUND")
line:SetTexture("Interface\\TaxiFrame\\UI-Taxi-Line")
end
NUM_TAXI_ROUTES = numRoutes
local hops = { }
for hop = 1, GetNumRoutes(index) do
hops[hop] = { index, hop }
end
for i = 1, NUM_TAXI_ROUTES do
line = getglobal("TaxiRoute" .. i)
if i <= numRoutes then
sX = TaxiGetSrcX(index, i) * w
sY = TaxiGetSrcY(index, i) * h
dX = TaxiGetDestX(index, i) * w
dY = TaxiGetDestY(index, i) * h
DrawRouteLine(line, "TaxiRouteMap", sX, sY, dX, dY, 32)
line:Show()
else
line:Hide()
end
end
elseif ntype == "CURRENT" then
PaintRoutes(hops)
elseif state == "CURRENT" then
GameTooltip:AddLine(TAXINODEYOUAREHERE, "", 0.5, 1.0, 0.5)
DrawOneHopLines()
end
@@ -191,111 +220,142 @@
GameTooltip:Show()
end
NUM_TAXI_ROUTES = 0
-- Draw all flightpaths within one hop of current location — ref TaxiFrame.lua:129-165,
-- verbatim except the canvas name ("TaxiRouteMap" -> "TaxiRouteMap").
-- Every flight path within one hop of where we stand (ref TaxiFrame.lua:129-165) — what the
-- map shows the moment it opens, before anything is hovered. A node exactly one segment away
-- is one this flight master can send us to directly.
function DrawOneHopLines()
local line
local sX, sY, dX, dY
local w = TaxiRouteMap:GetWidth()
local h = TaxiRouteMap:GetHeight()
local numNodes = NumTaxiNodes()
local numLines = 0
local numSingleHops = 0
for i = 1, numNodes do
if GetNumRoutes(i) == 1 then
numSingleHops = numSingleHops + 1
numLines = numLines + 1
if numLines > NUM_TAXI_ROUTES then
line = TaxiRouteMap:CreateTexture("TaxiRoute" .. numLines, "BACKGROUND")
line:SetTexture("Interface\\TaxiFrame\\UI-Taxi-Line")
NUM_TAXI_ROUTES = numLines
else
line = getglobal("TaxiRoute" .. numLines)
end
if line then
sX = TaxiGetSrcX(i, 1) * w
sY = TaxiGetSrcY(i, 1) * h
dX = TaxiGetDestX(i, 1) * w
dY = TaxiGetDestY(i, 1) * h
DrawRouteLine(line, "TaxiRouteMap", sX, sY, dX, dY, 32)
line:Show()
end
local hops = { }
for node = 1, NumTaxiNodes() do
if GetNumRoutes(node) == 1 then
table.insert(hops, { node, 1 })
end
end
for i = numLines + 1, NUM_TAXI_ROUTES do
getglobal("TaxiRoute" .. i):Hide()
end
if numSingleHops == 0 then
PaintRoutes(hops)
-- A flight point with nothing one hop away is a dead end: say so, and take the window
-- back down rather than leave an empty map up.
if table.getn(hops) == 0 then
UIErrorsFrame:AddMessage(ERR_TAXINOPATHS, 1.0, 0.1, 0.1)
HideUIPanel(TaxiFrame)
end
end
-- The following function is used with permission from Daniel Stephens
-- <iriel@vigilance-committee.org> — ref TaxiFrame.lua:168-226, byte-for-byte verbatim (pure
-- Lua line-quad math over the engine's 8-arg affine SetTexCoord; no engine dependency beyond
-- what the phase-1 seam test already proved: CreateTexture, SetTexCoord's 8-arg form,
-- SetPoint with a string canvas name).
TAXIROUTE_LINEFACTOR = 32/30 -- Multiplying factor for texture coordinates
TAXIROUTE_LINEFACTOR_2 = TAXIROUTE_LINEFACTOR / 2 -- Half o that
-- ── Route lines ───────────────────────────────────────────────────────────────────────
--
-- A flight path is one straight line between two map points, drawn as a texture. The
-- engine can only place an AXIS-ALIGNED rectangle, so the rotation has to live in the
-- texture coordinates instead: SetTexCoord's 8-argument form places each of the four
-- source corners independently, which is exactly enough to spin (and shear) the art
-- inside its bounding box. The bounding box then has to grow to hold the rotated line.
--
-- The geometry — how a rotation angle turns into those four corners and that box — is
-- the specification, and it is the reference's (ref TaxiFrame.lua:168-226; the credit
-- their copy carries is to Daniel Stephens <iriel@vigilance-committee.org>). Every value
-- below is theirs, and the multiplications are grouped the way theirs are so the rounding
-- matches; how it is written — the naming, the shear terms hoisted out of both branches,
-- the anchoring helper — is ours. The reference derives each corner from the last
-- (`BRx = 1 - BLy`, `TRy = BRx`, …); we state each one directly instead, which is the
-- same number everywhere but one: the downward branch's top-left U is `-shearU` here
-- against their `1 - (1 + shearU)`. MEASURED, both implementations run side by side on
-- the real Lua 5.1 over 50k randomised segments plus the degenerate/axis-aligned/reversed
-- edges: every anchor identical to the last bit, and every texture coordinate identical
-- once narrowed to f32 — which the engine does to all eight on the way in
-- (`region::paint`'s `Variadic<f32>`). The lone survivor is the SIGN OF ZERO on an exactly
-- vertical downward line, where that corner is -0 here and +0 there: the same coordinate,
-- and the same sample.
--
-- Engine seam, all three proved by the phase-1 seam test: CreateTexture, SetTexCoord's
-- 8-arg form, and SetPoint against a canvas named by string.
-- T - Texture
-- C - Canvas Frame (for anchoring)
-- sx,sy - Coordinate of start of line
-- ex,ey - Coordinate of end of line
-- w - Width of line
-- relPoint - Relative point on canvas to interpret coords (Default BOTTOMLEFT)
function DrawRouteLine(T, C, sx, sy, ex, ey, w, relPoint)
if (not relPoint) then relPoint = "BOTTOMLEFT"; end
-- The reference's two scale globals, value for value. Only the halved one is ever read —
-- by the bounding-box extents below, which are half-widths, hence the /2; the whole one
-- is a published global that nothing in the reference reads either. 32/30 is unexplained
-- there and carried unchanged here — a measurement of the shipped art, not ours to
-- re-derive.
TAXIROUTE_LINEFACTOR = 32/30
TAXIROUTE_LINEFACTOR_2 = TAXIROUTE_LINEFACTOR / 2
-- Determine dimensions and center point of line
local dx,dy = ex - sx, ey - sy;
local cx,cy = (sx + ex) / 2, (sy + ey) / 2;
-- Seat `texture` as the box centred on (cx, cy) that reaches `halfW` / `halfH` out along
-- each axis, measured from `canvas`'s `relPoint` corner. Both ends always move together,
-- so clearing first is what keeps a stale anchor from outliving a move. (The reference
-- re-seats the two points without clearing on its degenerate path; a route texture only
-- ever holds these two anchors and SetPoint replaces one of the same name, so the box
-- that comes out is the same either way.)
local function AnchorCenteredBox(texture, canvas, relPoint, cx, cy, halfW, halfH)
texture:ClearAllPoints()
texture:SetPoint("BOTTOMLEFT", canvas, relPoint, cx - halfW, cy - halfH)
texture:SetPoint("TOPRIGHT", canvas, relPoint, cx + halfW, cy + halfH)
end
-- Normalize direction if necessary
if (dx < 0) then
dx,dy = -dx,-dy;
end
-- Draw the segment (sx,sy) -> (ex,ey), `width` thick, into `texture`, placed against
-- `canvas` in the coordinate space of its `relPoint` corner (BOTTOMLEFT by default).
function DrawRouteLine(texture, canvas, sx, sy, ex, ey, width, relPoint)
relPoint = relPoint or "BOTTOMLEFT"
-- Calculate actual length of line
local l = sqrt((dx * dx) + (dy * dy));
local dx, dy = ex - sx, ey - sy
local cx, cy = (sx + ex) / 2, (sy + ey) / 2
-- Quick escape if it's zero length
if (l == 0) then
T:SetTexCoord(0,0,0,0,0,0,0,0);
T:SetPoint("BOTTOMLEFT", C, relPoint, cx,cy);
T:SetPoint("TOPRIGHT", C, relPoint, cx,cy);
return;
end
-- A segment drawn back-to-front is the same segment: face it rightwards so only the
-- two upward/downward cases below have to exist.
if dx < 0 then
dx, dy = -dx, -dy
end
-- Sin and Cosine of rotation, and combination (for later)
local s,c = -dy / l, dx / l;
local sc = s * c;
local len = sqrt((dx * dx) + (dy * dy))
if len == 0 then
-- Nothing to draw: collapse the quad onto its own midpoint and show no art.
texture:SetTexCoord(0, 0, 0, 0, 0, 0, 0, 0)
AnchorCenteredBox(texture, canvas, relPoint, cx, cy, 0, 0)
return
end
-- Calculate bounding box size and texture coordinates
local Bwid, Bhgt, BLx, BLy, TLx, TLy, TRx, TRy, BRx, BRy;
if (dy >= 0) then
Bwid = ((l * c) - (w * s)) * TAXIROUTE_LINEFACTOR_2;
Bhgt = ((w * c) - (l * s)) * TAXIROUTE_LINEFACTOR_2;
BLx, BLy, BRy = (w / l) * sc, s * s, (l / w) * sc;
BRx, TLx, TLy, TRx = 1 - BLy, BLy, 1 - BRy, 1 - BLx;
TRy = BRx;
else
Bwid = ((l * c) + (w * s)) * TAXIROUTE_LINEFACTOR_2;
Bhgt = ((w * c) + (l * s)) * TAXIROUTE_LINEFACTOR_2;
BLx, BLy, BRx = s * s, -(l / w) * sc, 1 + (w / l) * sc;
BRy, TLx, TLy, TRy = BLx, 1 - BRx, 1 - BLx, 1 - BLy;
TRx = TLy;
end
-- Sine and cosine of the segment's angle, and the two shear terms every corner is
-- built from — one walking the texture's horizontal axis, one its vertical axis.
local sinA, cosA = -dy / len, dx / len
local sinSq = sinA * sinA
local shear = sinA * cosA
local shearU = (width / len) * shear
local shearV = (len / width) * shear
-- Set texture coordinates and anchors
T:ClearAllPoints();
T:SetTexCoord(TLx, TLy, BLx, BLy, TRx, TRy, BRx, BRy);
T:SetPoint("BOTTOMLEFT", C, relPoint, cx - Bwid, cy - Bhgt);
T:SetPoint("TOPRIGHT", C, relPoint, cx + Bwid, cy + Bhgt);
-- Half the bounding box the rotated line needs, and the four source corners that lay
-- the art across it. Upward and downward are the same algebra mirrored: the four
-- quantities are the same, and which corner each lands on is what flips.
local halfW, halfH
local blU, blV, tlU, tlV, trU, trV, brU, brV
if dy >= 0 then
halfW = ((len * cosA) - (width * sinA)) * TAXIROUTE_LINEFACTOR_2
halfH = ((width * cosA) - (len * sinA)) * TAXIROUTE_LINEFACTOR_2
blU, blV = shearU, sinSq
tlU, tlV = sinSq, 1 - shearV
trU, trV = 1 - shearU, 1 - sinSq
brU, brV = 1 - sinSq, shearV
else
halfW = ((len * cosA) + (width * sinA)) * TAXIROUTE_LINEFACTOR_2
halfH = ((width * cosA) + (len * sinA)) * TAXIROUTE_LINEFACTOR_2
blU, blV = sinSq, -shearV
tlU, tlV = -shearU, 1 - sinSq
trU, trV = 1 - sinSq, 1 + shearV
brU, brV = 1 + shearU, sinSq
end
-- SetTexCoord's corner order is top-left, bottom-left, top-right, bottom-right.
texture:SetTexCoord(tlU, tlV, blU, blV, trU, trV, brU, brV)
AnchorCenteredBox(texture, canvas, relPoint, cx, cy, halfW, halfH)
end
-- Appearing and disappearing. The sounds are the MainMenu pair, not the CharacterInfo pair
-- Merchant/Trainer use — the specification genuinely names a different one for this window
-- (ref l.162-169). Showing also draws the neighbourhood straight away, so an untouched map
-- is not blank; hiding ends the session, which is what fires TAXIMAP_CLOSED back at us.
function BenillaTaxiFrame_OnShow()
PlaySound("igMainMenuOpen")
DrawOneHopLines()
end
function BenillaTaxiFrame_OnHide()
CloseTaxiMap()
PlaySound("igMainMenuClose")
end
function BenillaTaxiCloseButton_OnClick()
@@ -308,12 +368,12 @@
<!--
The node-button pool's virtual template (ref TaxiButtonTemplate, l.4-32): a 16×16 button,
the type icon assigned per repaint via SetNormalTexture (deviation 1's static pool — see
the header note), the 32×32 CENTER highlight glow. OnEnter/OnLeave/OnClick are the ref's,
byte for byte (l.12-20) — including the click firing UNCONDITIONALLY regardless of node
type, exactly like the reference (a DISTANT/CURRENT click still queues TakeTaxiNode; the
app-side drain treats a routeless click as a no-op — there is no RE'd finding for what the
real client's native TakeTaxiNode does with an unreachable index, so this is the safe,
the state icon assigned per repaint via SetNormalTexture (deviation 1's static pool — see
the header note), the 32×32 CENTER highlight glow. The three handlers dispatch to the same
three verbs the specification's do (l.12-20) — including the click firing UNCONDITIONALLY
regardless of node state (a DISTANT/CURRENT click still queues TakeTaxiNode; the app-side
drain treats a routeless click as a no-op — there is no RE'd finding for what the real
client's native TakeTaxiNode does with an unreachable index, so this is the safe,
undocumented-but-reasonable inference, not an asserted fact).
-->
<Button name="BenillaTaxiButtonTemplate" hidden="true" virtual="true">
@@ -325,13 +385,13 @@
</Anchors>
<Scripts>
<OnEnter>
TaxiNodeOnButtonEnter(this);
TaxiNodeOnButtonEnter(this)
</OnEnter>
<OnLeave>
GameTooltip:Hide();
GameTooltip:Hide()
</OnLeave>
<OnClick>
TakeTaxiNode(this:GetID());
TakeTaxiNode(this:GetID())
</OnClick>
</Scripts>
<HighlightTexture file="Interface\TaxiFrame\UI-Taxi-Icon-Highlight">
@@ -486,15 +546,11 @@
<OnEvent>
TaxiFrame_OnEvent(event)
</OnEvent>
<!-- ref l.162-169, verbatim: the MainMenu sound kit (not Merchant/Trainer's
CharacterInfo kit — the reference genuinely uses a different pair here). -->
<OnShow>
PlaySound("igMainMenuOpen");
DrawOneHopLines();
BenillaTaxiFrame_OnShow()
</OnShow>
<OnHide>
CloseTaxiMap();
PlaySound("igMainMenuClose")
BenillaTaxiFrame_OnHide()
</OnHide>
</Scripts>
</Frame>
+83 -60
View File
@@ -1,11 +1,12 @@
<!--
The shared status-bar numerals machinery (ref-TextStatusBar.lua + ref-TextStatusBar.xml,
transcribed whole — decision 1082). A bar opts in by calling TextStatusBar_Initialize() in its
OnLoad (setting `textLockable`), wiring a FontString via SetTextStatusBarText(bar, text), and
routing its OnEnter/OnLeave/OnValueChanged through the Show/Hide/Update functions here. The
text is "value / max", shown while hovered (a lockShow refcount) or always when the
`statusBarText` cvar is "1" ("Status Text" in the options; our GetCVar returns nil for a cvar
the engine doesn't carry, which reads as OFF — the 1.12 default).
The shared status-bar readout machinery — the contract 1.12's TextStatusBar.lua/.xml defines
(decision 1082), written here in our own terms. A bar opts in by calling
TextStatusBar_Initialize() in its OnLoad (setting `textLockable`), wiring a FontString via
SetTextStatusBarText(bar, text), and routing its OnEnter/OnLeave/OnValueChanged through the
Show/Hide/Update functions here. The text is "value / max", shown while hovered (a lockShow
refcount) or always when the `statusBarText` cvar is "1" ("Status Text" in the options; our
GetCVar returns nil for a cvar the engine doesn't carry, which reads as OFF — the 1.12
default).
Customers: the XP bar (ActionBar.xml) and, since decision 1143, the player and pet frames'
health/power bars (UnitFrames.xml). `textLockable` is what the statusBarText cvar pins, and
@@ -16,6 +17,19 @@
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Script><![CDATA[
-- The readout has exactly three reasons to be on screen, and every function below is some
-- combination of them: the Status Text option pins it (only on a bar that opted in with
-- `textLockable`), the pointer is on the bar (a refcount, so nested hovers balance), or the
-- bar is showing its zero text — a phrase rather than a number, which stands on its own.
--
-- The option test is the one of the three that reads from outside the bar, and it is asked
-- from three places, so it lives here once. It takes the option's VALUE rather than calling
-- GetCVar itself: the CVAR_UPDATE handler is handed the new value along with the event and
-- must not go asking the engine for the one it is being told about.
local function optionPins(bar, setting)
return setting == "1" and bar.textLockable
end
-- `bar` is optional and falls back to `this`, the same shape TextStatusBar_UpdateTextString
-- below already has (and for the same reason): the ref calls this from a bar's own OnLoad,
-- where `this` is the bar, but our unit frames wire their children from ONE shared
@@ -28,6 +42,8 @@
bar.lockShow = 0
end
-- Hand a bar the FontString its readout lives in. A bar without one is never an error
-- below — it simply has nothing to say, and every entry point here checks for that.
function SetTextStatusBarText(bar, text)
if not bar or not text then
return
@@ -36,49 +52,58 @@
end
function TextStatusBar_OnEvent(cvar, value)
if event == "CVAR_UPDATE" and cvar == "STATUS_BAR_TEXT" then
if this.TextString then
if value == "1" and this.textLockable then
this.TextString:Show()
elseif this.lockShow == 0 then
this.TextString:Hide()
end
end
if event ~= "CVAR_UPDATE" or cvar ~= "STATUS_BAR_TEXT" or not this.TextString then
return
end
-- Switching the option ON pins the readout up at once. Switching it off only puts the
-- readout away when nothing else is holding it there: a hover in progress keeps its own,
-- and gets to hide it when it ends.
if optionPins(this, value) then
this.TextString:Show()
elseif this.lockShow == 0 then
this.TextString:Hide()
end
end
-- Repaint one bar's readout from the value it is currently carrying. `textStatusBar` is
-- optional and falls back to `this` for the same reason TextStatusBar_Initialize's is.
function TextStatusBar_UpdateTextString(textStatusBar)
if not textStatusBar then
textStatusBar = this
end
local string = textStatusBar.TextString
if string then
local value = textStatusBar:GetValue()
local valueMin, valueMax = textStatusBar:GetMinMaxValues()
if valueMax > 0 then
textStatusBar:Show()
if value == 0 and textStatusBar.zeroText then
string:SetText(textStatusBar.zeroText)
textStatusBar.isZero = 1
string:Show()
else
textStatusBar.isZero = nil
if textStatusBar.prefix then
string:SetText(textStatusBar.prefix .. " " .. value .. " / " .. valueMax)
else
string:SetText(value .. " / " .. valueMax)
end
if GetCVar("statusBarText") == "1" and textStatusBar.textLockable then
string:Show()
elseif textStatusBar.lockShow > 0 then
string:Show()
else
string:Hide()
end
end
else
textStatusBar:Hide()
end
local readout = textStatusBar.TextString
if not readout then
return
end
local value = textStatusBar:GetValue()
local _, valueMax = textStatusBar:GetMinMaxValues()
-- No ceiling is not the same thing as a bar at zero (below): it means the bar does not
-- apply to this unit at all, so the BAR leaves the screen rather than reading out a
-- number. Its readout is left exactly as it stands and comes back with it.
if valueMax <= 0 then
textStatusBar:Hide()
return
end
textStatusBar:Show()
if value == 0 and textStatusBar.zeroText then
-- Empty, and this bar has a phrase for empty. The phrase displaces the numbers and
-- is shown outright: it is a message, so neither the option nor a hover gates it,
-- and `isZero` is the flag that tells the hover not to take it away again.
textStatusBar.isZero = 1
readout:SetText(textStatusBar.zeroText)
readout:Show()
return
end
textStatusBar.isZero = nil
if textStatusBar.prefix then
readout:SetText(textStatusBar.prefix .. " " .. value .. " / " .. valueMax)
else
readout:SetText(value .. " / " .. valueMax)
end
if optionPins(textStatusBar, GetCVar("statusBarText")) or textStatusBar.lockShow > 0 then
readout:Show()
else
readout:Hide()
end
end
@@ -98,31 +123,29 @@
end
end
-- One more hold on the readout. `lockShow` counts rather than flags because two things can
-- be holding it open at once: the character sheet takes a hold for as long as it is open
-- (CharacterFrame_OnShow, decision 1146) and a hover on the same bar takes a second one on
-- top. Only the last release puts the readout away, so the hover still balances.
function ShowTextStatusBarText(bar)
if bar and bar.TextString then
if not bar.lockShow then
bar.lockShow = 0
end
bar.lockShow = (bar.lockShow or 0) + 1
bar.TextString:Show()
bar.lockShow = bar.lockShow + 1
end
end
-- One hold released. The count floors at none rather than going negative, so an unbalanced
-- release — a frame hidden out from under a hover — cannot strand the readout on screen.
function HideTextStatusBarText(bar)
if bar and bar.TextString then
if not bar.lockShow then
bar.lockShow = 0
end
if bar.lockShow > 0 then
bar.lockShow = bar.lockShow - 1
end
-- The ref reads `this.isZero` here — a leak of the handler's own frame into a
-- helper that otherwise takes `bar`. Equivalent at every call site (each caller
-- passes its handler's own frame), so it is written as `bar` — which also keeps
-- the function callable outside a handler, where `this` is nil.
if bar.lockShow > 0 or bar.isZero == 1 then
bar.TextString:Show()
elseif GetCVar("statusBarText") == "1" and bar.textLockable then
bar.lockShow = math.max((bar.lockShow or 0) - 1, 0)
-- Releasing a hold only puts the readout away if none of the three reasons is left.
-- The ref reads `this.isZero` for the middle one — a leak of the handler's own frame
-- into a helper that otherwise takes `bar`. Equivalent at every call site (each
-- caller passes its handler's own frame), so it is written as `bar`, which also
-- keeps the function callable outside a handler, where `this` is nil.
if bar.lockShow > 0 or bar.isZero == 1
or optionPins(bar, GetCVar("statusBarText")) then
bar.TextString:Show()
else
bar.TextString:Hide()
+37 -23
View File
@@ -23,6 +23,20 @@
<Script><![CDATA[
BENILLA_MAX_TRADE_ITEMS = 7 -- MAX_TRADE_ITEMS (ref TradeFrame.lua l.1)
-- One side's accept glow. A column's highlight and the enchant slot's below it are one
-- indicator in two frames — they light and drop together, never apart — so both the repaint
-- and TradeFrame_SetAcceptState go through here rather than each spelling out four calls.
-- The four frames are TradeHighlight{Player,Recipient}[Enchant], declared at the bottom.
local function setTradeGlow(column, enchantSlot, lit)
if lit then
column:Show()
enchantSlot:Show()
else
column:Hide()
enchantSlot:Hide()
end
end
-- ── Load / event dispatch (ref TradeFrame_OnLoad l.4 / TradeFrame_OnEvent l.18) ───────────
function TradeFrame_OnLoad()
local frame = this
@@ -100,10 +114,10 @@
-- Our gold is the editable input, reflected via PLAYER_TRADE_MONEY (not repainted here, so
-- the repaint never clobbers mid-typing); only the partner's read-only gold repaints.
BenillaMoney_Set("TradeRecipientMoneyFrame", GetTargetTradeMoney())
TradeHighlightPlayer:Hide()
TradeHighlightRecipient:Hide()
TradeHighlightPlayerEnchant:Hide()
TradeHighlightRecipientEnchant:Hide()
-- A repaint puts both glows down. TRADE_ACCEPT_UPDATE is the only thing that raises
-- them, so whatever is standing comes straight back on the next one.
setTradeGlow(TradeHighlightPlayer, TradeHighlightPlayerEnchant, false)
setTradeGlow(TradeHighlightRecipient, TradeHighlightRecipientEnchant, false)
end
-- One slot's paint, shared by both columns (ref TradeFrame_UpdatePlayerItem l.54 /
@@ -178,36 +192,36 @@
end
end
-- The accept glow (ref TradeFrame_SetAcceptState l.116): each side's column + enchant-slot
-- highlight tracks who has pressed Trade; our own accept disables the Trade button.
-- Who has pressed Trade (spec: ref TradeFrame_SetAcceptState l.116). The event carries BOTH
-- sides' states, so this is a straight repaint of two indicators rather than a toggle — and
-- our own half is kept on the frame, because the Cancel button below is the only reader of
-- whether there is an accept of ours to withdraw.
--
-- The extra thing our own accept does is lock the Trade button, so a standing accept cannot
-- be sent a second time; Cancel is what takes it back.
function TradeFrame_SetAcceptState(playerState, targetState)
TradeFrame.acceptState = playerState
if playerState == 1 then
TradeHighlightPlayer:Show()
TradeHighlightPlayerEnchant:Show()
local weAccepted = playerState == 1
setTradeGlow(TradeHighlightPlayer, TradeHighlightPlayerEnchant, weAccepted)
setTradeGlow(TradeHighlightRecipient, TradeHighlightRecipientEnchant, targetState == 1)
if weAccepted then
TradeFrameTradeButton:Disable()
else
TradeHighlightPlayer:Hide()
TradeHighlightPlayerEnchant:Hide()
TradeFrameTradeButton:Enable()
end
if targetState == 1 then
TradeHighlightRecipient:Show()
TradeHighlightRecipientEnchant:Show()
else
TradeHighlightRecipient:Hide()
TradeHighlightRecipientEnchant:Hide()
end
end
-- Cancel (ref TradeFrameCancelButton_OnClick l.136): with our accept up, drop it (stay in the
-- trade); otherwise close the window (→ OnHide → CloseTrade).
-- Cancel is two verbs on one button (spec: ref TradeFrameCancelButton_OnClick l.136). With
-- an accept of ours standing it withdraws only THAT and the trade stays open — the button
-- is the only way back out of the lock above. With no accept up there is nothing to
-- withdraw, so it means "leave", and closing the window is what sends that (→ OnHide →
-- CloseTrade).
function TradeFrameCancelButton_OnClick()
if TradeFrame.acceptState == 1 then
CancelTradeAccept()
else
if TradeFrame.acceptState ~= 1 then
HideUIPanel(TradeFrame)
return
end
CancelTradeAccept()
end
]]></Script>
@@ -1,9 +1,9 @@
<Ui>
<!--
benilla's crafting window (decision 0437 phase 2): the 1.12 `TradeSkillFrame`, transcribed from
benilla's crafting window (decision 0437 phase 2): the 1.12 `TradeSkillFrame`, re-expressed from
the reference `Blizzard_TradeSkillUI.xml`/`.lua` (session scratchpad `ref-ui/`, decision 0246
discipline — never committed) onto our own engine/feeds. Geometry/art-paths/structure below are
QUOTED from the ref (line citations inline); the Lua is transcribed logic turned onto benilla's
QUOTED from the ref (line citations inline); the Lua is the ref's logic turned onto benilla's
Era API (`GetTradeSkillLine`/`GetNumTradeSkills`/`GetTradeSkillInfo`/reagents/tools/cooldown/
`DoTradeSkill`/`CloseTradeSkill`, decision 0437's engine surface, built alongside this file).
@@ -43,7 +43,7 @@
earlier revision named it an INTERIM pinned to the dispatch; folded back this session).
`GetTradeSkillInfo`'s `skillType` is `"header"` for a group row (the created item's
`(ItemClass, ItemSubClass)`, named from `ItemSubClass.dbc`, engine-sorted) — the ref's
header branch is restored below verbatim (ref l.118-127: `SetText`, the +/- fold icon +
header branch is restored below (ref l.118-127: `SetText`, the +/- fold icon +
hover-glow swap on `isExpanded`, `TradeSkillTypeColor["header"]` now reachable) and so is
`SetSelection`'s header branch (ref l.184-192: a header click folds its group and never
selects). `Expand/CollapseTradeSkillSubClass` are real now (engine-side); the row
@@ -76,7 +76,7 @@
the same frame (`ui_tradeskill.rs`'s `drain_trade_skill`) — so neither the dropdown click
handlers nor the CollapseAll click below call `BenillaTradeSkillFrame_Update()` directly,
matching the ref's own restraint (ref l.303-312/406-414 never do either).
2. THE SPELL-FOCUS TOOL NEVER REDDENS. `BuildColoredListString` (below) is transcribed
2. THE SPELL-FOCUS TOOL NEVER REDDENS. `BuildColoredListString` (below) is
byte-faithful — it reds an unmet tool — but `GetTradeSkillTools`'s `hasTool` has no
client-side proximity check yet (0437 INTERIM, pinned to the §5 dispatch), so v1 never
actually reds a tool in practice. The util is ready for when the check lands.
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,10 @@
<Ui>
<!--
The reference's SHARED widget kit — Interface\FrameXML\UIPanelTemplates.xml, transcribed
from the extracted 1.12.1 file (line numbers below are that file's).
The reference's SHARED widget kit, in our own terms. Interface\FrameXML\UIPanelTemplates.xml
is the SPECIFICATION — the template names, the child names, the art, every offset and every
handler's observable effect are read off the extracted 1.12.1 file, and the line numbers
cited below are that file's. Nothing here is required to be *spelled* the way it spells it,
and the two Lua handlers are not.
WHY THIS FILE EXISTS AT ALL. Every window benilla has built so far declared its own private
copy of these — `AddonListPanelButtonTemplate`, `BenillaTrainerPanelButtonTemplate`,
@@ -32,8 +35,8 @@
own name in ScrollTemplates.xml, built as a plain `<Frame>` over `BenillaScrollBarTemplate`
(decisions 0247/0250/0251). Two declarations of one name would race; re-pointing ours at
the reference's `<ScrollFrame>` shape is its own job with its own risk, not this pass's.
· `TabButtonTemplate` (ref l.303-402) — already transcribed verbatim in UiPanels.xml, with
its fit/highlight deviations recorded there (decision 1004).
· `TabButtonTemplate` (ref l.303-402) — already declared in UiPanels.xml, with its
fit/highlight deviations recorded there (decision 1004).
(`GameMenuButtonTemplate`, ref l.403-410, was on this list as "already in GameMenuFrame.xml"
until the colour picker needed it from above that file's LAST-in-the-toc seat. It is below
now, in the reference's own file for it, and GameMenuFrame.xml's private copy is gone.)
@@ -48,77 +51,85 @@
-->
<Script><![CDATA[
-- The two scroll-frame handlers `UIPanelScrollFrameTemplate` below calls by name (ref
-- UIPanelTemplates.lua l.150-157 and l.254-305). `ScrollFrame_OnLoad` and the whole
-- `FauxScrollFrame_*` family already live in ScrollTemplates.xml, which loads immediately
-- before this file; these two are the remainder of that kit.
-- The two scroll-frame handlers `UIPanelScrollFrameTemplate` below calls by name. Their
-- contract — the names, the arguments, and every branch's observable effect — is the
-- reference's (UIPanelTemplates.lua l.150-157 and l.254-305); the code is ours.
-- `ScrollFrame_OnLoad` and the whole `FauxScrollFrame_*` family already live in
-- ScrollTemplates.xml, which loads immediately before this file; these two are the
-- remainder of that kit.
-- ref l.150-157, verbatim.
-- One wheel notch pans by half a bar-height, up or down. The wheel is SOUNDLESS, unlike the
-- arrow buttons — the reference's own split, and the director's (trainer_tests.rs).
function ScrollFrameTemplate_OnMouseWheel(value)
local scrollBar = getglobal(this:GetName() .. "ScrollBar")
if (value > 0) then
scrollBar:SetValue(scrollBar:GetValue() - (scrollBar:GetHeight() / 2))
else
scrollBar:SetValue(scrollBar:GetValue() + (scrollBar:GetHeight() / 2))
local step = scrollBar:GetHeight() / 2
if value > 0 then
step = -step
end
scrollBar:SetValue(scrollBar:GetValue() + step)
end
-- ref l.254-305, verbatim — including `getglobal(scrollbar:GetName().."ThumbTexture")`,
-- which is what `<ThumbTexture name="$parentThumbTexture">` publishes. (That publication
-- did not exist when this file was first written: `apply_slider` applied a named thumb's
-- layout and never set the global, the one asymmetry with a Button's state textures. It
-- was the reference's own line that found the gap, and the gap is fixed rather than
-- routed around.)
-- Reconcile a ScrollFrame's bar, arrows and thumb to a range the engine has just
-- recomputed. `scrollrange` is the engine's own second argument when this runs as the
-- handler; a caller who omits it gets the frame's current range instead.
--
-- All four children hang off the OWNER's name two `$parent` levels down
-- (`MyScrollScrollBarScrollUpButton`), so they are resolved ONCE here — the reference walks
-- back to getglobal at each of the ten points it touches one. `$parentThumbTexture` is what
-- publishes the last of them; that publication did not exist when this file was first
-- written (`apply_slider` applied a named thumb's layout and never set the global, the one
-- asymmetry with a Button's state textures), and the gap was fixed rather than routed
-- around.
function ScrollFrame_OnScrollRangeChanged(scrollrange)
local scrollbar = getglobal(this:GetName() .. "ScrollBar")
if (not scrollrange) then
scrollrange = this:GetVerticalScrollRange()
end
local value = scrollbar:GetValue()
if (value > scrollrange) then
value = scrollrange
end
local frame = this
local scrollbar = getglobal(frame:GetName() .. "ScrollBar")
local up = getglobal(scrollbar:GetName() .. "ScrollUpButton")
local down = getglobal(scrollbar:GetName() .. "ScrollDownButton")
local thumb = getglobal(scrollbar:GetName() .. "ThumbTexture")
scrollrange = scrollrange or frame:GetVerticalScrollRange()
local value = min(scrollbar:GetValue(), scrollrange)
scrollbar:SetMinMaxValues(0, scrollrange)
scrollbar:SetValue(value)
if (floor(scrollrange) == 0) then
if (this.scrollBarHideable) then
scrollbar:Hide()
getglobal(scrollbar:GetName() .. "ScrollDownButton"):Hide()
getglobal(scrollbar:GetName() .. "ScrollUpButton"):Hide()
else
getglobal(scrollbar:GetName() .. "ScrollDownButton"):Disable()
getglobal(scrollbar:GetName() .. "ScrollUpButton"):Disable()
getglobal(scrollbar:GetName() .. "ScrollDownButton"):Show()
getglobal(scrollbar:GetName() .. "ScrollUpButton"):Show()
end
getglobal(scrollbar:GetName() .. "ThumbTexture"):Hide()
else
getglobal(scrollbar:GetName() .. "ScrollDownButton"):Show()
getglobal(scrollbar:GetName() .. "ScrollUpButton"):Show()
scrollbar:Show()
getglobal(scrollbar:GetName() .. "ScrollDownButton"):Enable()
getglobal(scrollbar:GetName() .. "ThumbTexture"):Show()
end
-- Hide/show scrollframe borders (ref l.285-304). Named regions an OWNER declares, so
-- all three are optional.
local top = getglobal(this:GetName() .. "Top")
local bottom = getglobal(this:GetName() .. "Bottom")
local middle = getglobal(this:GetName() .. "Middle")
if (top and bottom and this.scrollBarHideable) then
if (this:GetVerticalScrollRange() == 0) then
top:Hide()
bottom:Hide()
else
top:Show()
bottom:Show()
end
-- Nothing to scroll, and two ways of saying so — which one is the OWNER's call:
-- a hideable bar leaves altogether, a fixed one stays in place and greys out. On the
-- way back only the DOWN arrow wakes, because the offset is still zero and there is
-- nowhere up to go; the frame's own vertical-scroll handler below wakes the up arrow
-- the moment that stops being true.
local empty = floor(scrollrange) == 0
if not empty then
down:Show()
up:Show()
scrollbar:Show()
down:Enable()
elseif frame.scrollBarHideable then
scrollbar:Hide()
down:Hide()
up:Hide()
else
down:Disable()
up:Disable()
down:Show()
up:Show()
end
if (middle and this.scrollBarHideable) then
if (this:GetVerticalScrollRange() == 0) then
middle:Hide()
else
middle:Show()
if empty then thumb:Hide() else thumb:Show() end
-- The frame's own border (ref l.285-304), if it declares one: all three regions are the
-- OWNER's to name, so all three are optional, and the pair is decided together because
-- a half-declared border is no border. Note this leg asks the FRAME for its range
-- rather than trusting the argument, and wants an exact zero where the bar above floors
-- first — two different questions, and the reference keeps them apart.
if frame.scrollBarHideable then
local bordered = frame:GetVerticalScrollRange() ~= 0
local top = getglobal(frame:GetName() .. "Top")
local bottom = getglobal(frame:GetName() .. "Bottom")
local middle = getglobal(frame:GetName() .. "Middle")
if top and bottom then
if bordered then top:Show() bottom:Show() else top:Hide() bottom:Hide() end
end
if middle then
if bordered then middle:Show() else middle:Hide() end
end
end
end
@@ -278,12 +289,14 @@
<Slider name="UIPanelScrollBarTemplate" virtual="true">
<Size><AbsDimension x="16" y="0"/></Size>
<Frames>
<!-- Both arrows step by half a bar-height and differ only in sign; unlike the wheel,
both click. -->
<Button name="$parentScrollUpButton" inherits="UIPanelScrollUpButtonTemplate">
<Anchors><Anchor point="BOTTOM" relativePoint="TOP"/></Anchors>
<Scripts>
<OnClick>
local parent = this:GetParent()
parent:SetValue(parent:GetValue() - (parent:GetHeight() / 2))
local bar = this:GetParent()
bar:SetValue(bar:GetValue() - bar:GetHeight() / 2)
PlaySound("UChatScrollButton")
</OnClick>
</Scripts>
@@ -292,8 +305,8 @@
<Anchors><Anchor point="TOP" relativePoint="BOTTOM"/></Anchors>
<Scripts>
<OnClick>
local parent = this:GetParent()
parent:SetValue(parent:GetValue() + (parent:GetHeight() / 2))
local bar = this:GetParent()
bar:SetValue(bar:GetValue() + bar:GetHeight() / 2)
PlaySound("UChatScrollButton")
</OnClick>
</Scripts>
@@ -312,10 +325,10 @@
scroll child, with the bar above pinned down its right edge (inset 16 top and bottom so
the arrow buttons sit inside the track span). 6 call sites across 2 corpus addons.
`ScrollFrame_OnLoad` is ScrollTemplates.xml's (the reference's own function, already
transcribed there); the other two handlers are this file's script block. An instance
supplies its own `<ScrollChild>` — without one the frame has no scroll range at all, which
the loader now says out loud. -->
`ScrollFrame_OnLoad` — the reference's own name for it — is declared in ScrollTemplates.xml;
the other two handlers are this file's script block. An instance supplies its own
`<ScrollChild>` — without one the frame has no scroll range at all, which the loader now
says out loud. -->
<ScrollFrame name="UIPanelScrollFrameTemplate" virtual="true">
<Frames>
<Slider name="$parentScrollBar" inherits="UIPanelScrollBarTemplate">
@@ -328,24 +341,20 @@
<Scripts>
<OnLoad>ScrollFrame_OnLoad()</OnLoad>
<OnScrollRangeChanged>ScrollFrame_OnScrollRangeChanged(arg2)</OnScrollRangeChanged>
<!-- The reference's own comment here reads "The code to disable the ScrollDownButton is
pretty hacked, but it works". Transcribed as it stands. -->
<!-- Each arrow is live only while there is somewhere its way to go: up dies at offset
zero, down dies once the bar has been seated at the top of its range. The bar's
own value is re-read for the down test rather than arg1 being reused, because
SetValue above may have clamped it. (The reference's comment on this pair reads
"The code to disable the ScrollDownButton is pretty hacked, but it works" — it
asks the same question as `(value - max) == 0`.) -->
<OnVerticalScroll>
local scrollbar = getglobal(this:GetName() .. "ScrollBar")
scrollbar:SetValue(arg1)
local min
local max
min, max = scrollbar:GetMinMaxValues()
if (arg1 == 0) then
getglobal(scrollbar:GetName() .. "ScrollUpButton"):Disable()
else
getglobal(scrollbar:GetName() .. "ScrollUpButton"):Enable()
end
if ((scrollbar:GetValue() - max) == 0) then
getglobal(scrollbar:GetName() .. "ScrollDownButton"):Disable()
else
getglobal(scrollbar:GetName() .. "ScrollDownButton"):Enable()
end
local up = getglobal(scrollbar:GetName() .. "ScrollUpButton")
local down = getglobal(scrollbar:GetName() .. "ScrollDownButton")
local _, maxScroll = scrollbar:GetMinMaxValues()
if arg1 == 0 then up:Disable() else up:Enable() end
if scrollbar:GetValue() == maxScroll then down:Disable() else down:Enable() end
</OnVerticalScroll>
<OnMouseWheel>ScrollFrameTemplate_OnMouseWheel(arg1)</OnMouseWheel>
</Scripts>
+160 -162
View File
@@ -167,22 +167,42 @@
end
end
UIPARENT_MANAGED_FRAME_POSITIONS = {}
-- Frames
UIPARENT_MANAGED_FRAME_POSITIONS["MultiBarBottomLeft"] = {baseY = 17, reputation = 9, maxLevel = -5, anchorTo = "ActionButton1", point = "BOTTOMLEFT", rpoint = "TOPLEFT"}
UIPARENT_MANAGED_FRAME_POSITIONS["GroupLootFrame1"] = {baseY = 60, bottomEither = 42, pet = 42, reputation = 9}
UIPARENT_MANAGED_FRAME_POSITIONS["TutorialFrameParent"] = {baseY = 55, bottomEither = 47, pet = 42, reputation = 9}
UIPARENT_MANAGED_FRAME_POSITIONS["FramerateLabel"] = {baseY = 64, bottomEither = 42, pet = 42, reputation = 9}
UIPARENT_MANAGED_FRAME_POSITIONS["CastingBarFrame"] = {baseY = 60, bottomEither = 40, pet = 40, reputation = 9}
UIPARENT_MANAGED_FRAME_POSITIONS["ChatFrame1"] = {baseY = 85, bottomLeft = 17, pet = 17, reputation = 9, maxLevel = -5, anchorTo = "UIParent", point = "BOTTOMLEFT", rpoint = "BOTTOMLEFT", xOffset = 32}
UIPARENT_MANAGED_FRAME_POSITIONS["ChatFrame2"] = {baseY = 85, bottomRight = 17, rightLeft = -88, rightRight = -43, reputation = 9, maxLevel = -5, anchorTo = "UIParent", point = "BOTTOMRIGHT", rpoint = "BOTTOMRIGHT", xOffset = -32}
UIPARENT_MANAGED_FRAME_POSITIONS["ShapeshiftBarFrame"] = {baseY = 0, bottomLeft = 45, reputation = 9, maxLevel = -5, anchorTo = "MainMenuBar", point = "BOTTOMLEFT", rpoint = "TOPLEFT", xOffset = 30}
-- ── Everything that has to move when the bottom of the screen fills up ──────────────────
--
-- One row per thing whose seat depends on which bars are raised. A row reads:
--
-- baseY where it sits on the y axis with nothing in the way
-- baseX / xOffset the same for the x axis, under two names: the rows that write a var
-- spell it `baseX`, the rows that seat a frame spell it `xOffset`, and
-- baseX wins if a row ever names both
-- bottomLeft, bottomRight, bottomEither, pet, reputation, maxLevel (y axis)
-- rightLeft, rightRight (x axis)
-- what this row owes each raised bar, ADDED for every flag standing
-- anchorTo / point / rpoint who to hang off; defaulted to UIParent BOTTOM-to-BOTTOM
-- isVar the row is not a frame at all: it names a GLOBAL, and the pass writes
-- the computed offset into it for other files to read
--
-- The KEY is the global name — a frame to seat, or the var to write — and other windows'
-- OnShow/OnHide drive the pass by it, so both the keys and the fields are a published
-- shape. Written as one constructor rather than a dozen index assignments: it is a
-- constant, and a constant reads better as a table than as a program that builds one.
UIPARENT_MANAGED_FRAME_POSITIONS = {
-- Frames to seat
MultiBarBottomLeft = {baseY = 17, reputation = 9, maxLevel = -5, anchorTo = "ActionButton1", point = "BOTTOMLEFT", rpoint = "TOPLEFT"},
GroupLootFrame1 = {baseY = 60, bottomEither = 42, pet = 42, reputation = 9},
TutorialFrameParent = {baseY = 55, bottomEither = 47, pet = 42, reputation = 9},
FramerateLabel = {baseY = 64, bottomEither = 42, pet = 42, reputation = 9},
CastingBarFrame = {baseY = 60, bottomEither = 40, pet = 40, reputation = 9},
ChatFrame1 = {baseY = 85, bottomLeft = 17, pet = 17, reputation = 9, maxLevel = -5, anchorTo = "UIParent", point = "BOTTOMLEFT", rpoint = "BOTTOMLEFT", xOffset = 32},
ChatFrame2 = {baseY = 85, bottomRight = 17, rightLeft = -88, rightRight = -43, reputation = 9, maxLevel = -5, anchorTo = "UIParent", point = "BOTTOMRIGHT", rpoint = "BOTTOMRIGHT", xOffset = -32},
ShapeshiftBarFrame = {baseY = 0, bottomLeft = 45, reputation = 9, maxLevel = -5, anchorTo = "MainMenuBar", point = "BOTTOMLEFT", rpoint = "TOPLEFT", xOffset = 30},
-- Vars
UIPARENT_MANAGED_FRAME_POSITIONS["CONTAINER_OFFSET_X"] = {baseX = 0, rightLeft = 90, rightRight = 45, isVar = "xAxis"}
UIPARENT_MANAGED_FRAME_POSITIONS["CONTAINER_OFFSET_Y"] = {baseY = 70, bottomEither = 27, bottomRight = 0, reputation = 9, isVar = "yAxis", pet = 23}
UIPARENT_MANAGED_FRAME_POSITIONS["BATTLEFIELD_TAB_OFFSET_Y"] = {baseY = 210, bottomRight = 40, reputation = 9, isVar = "yAxis"}
UIPARENT_MANAGED_FRAME_POSITIONS["PETACTIONBAR_YPOS"] = {baseY = 97, bottomLeft = 43, reputation = 9, maxLevel = -5, isVar = "yAxis"}
-- Globals to write
CONTAINER_OFFSET_X = {baseX = 0, rightLeft = 90, rightRight = 45, isVar = "xAxis"},
CONTAINER_OFFSET_Y = {baseY = 70, bottomEither = 27, bottomRight = 0, reputation = 9, isVar = "yAxis", pet = 23},
BATTLEFIELD_TAB_OFFSET_Y = {baseY = 210, bottomRight = 40, reputation = 9, isVar = "yAxis"},
PETACTIONBAR_YPOS = {baseY = 97, bottomLeft = 43, reputation = 9, maxLevel = -5, isVar = "yAxis"},
}
function UIParent_ManageFramePositions()
-- Frames that affect offsets in y axis
@@ -220,72 +240,48 @@
table.insert(yOffsetFrames, "maxLevel")
end
-- Iterate through frames and set anchors according to the flags set
local frame, xOffset, yOffset, anchorTo, point, rpoint
for index, value in pairs(UIPARENT_MANAGED_FRAME_POSITIONS) do
frame = getglobal(index)
-- Walk the rows and settle each one against the flags now standing. Rows are
-- independent — nothing here reads a var another row writes — so the arbitrary order
-- `pairs` hands back is not a hazard.
for index, row in pairs(UIPARENT_MANAGED_FRAME_POSITIONS) do
local frame = getglobal(index)
if ( frame ) then
-- Always start with base as the base offset or default to zero if no "none" specified
xOffset = 0
if ( value["baseX"] ) then
xOffset = value["baseX"]
elseif ( value["xOffset"] ) then
xOffset = value["xOffset"]
end
yOffset = 0
if ( value["baseY"] ) then
yOffset = value["baseY"]
end
-- Start from where the row sits with nothing in its way. A zero base is a real
-- base, not an absent one, which is why `or` chains do the right thing here.
local xOffset = row.baseX or row.xOffset or 0
local yOffset = row.baseY or 0
-- Iterate through frames that affect y offsets
local hasBottomLeft, hasPetBar, hasBottomRight
for flag, flagValue in pairs(yOffsetFrames) do
if ( value[flagValue] ) then
if ( flagValue == "bottomLeft" ) then
hasBottomLeft = 1
elseif ( flagValue == "pet" ) then
hasPetBar = 1
elseif ( flagValue == "bottomRight" ) then
hasBottomRight = 1
end
yOffset = yOffset + value[flagValue]
-- Then add what the row owes each raised bar, keeping note of which ones it
-- actually paid — two of them interact below.
local paid = {}
for _, flag in pairs(yOffsetFrames) do
if ( row[flag] ) then
yOffset = yOffset + row[flag]
paid[flag] = true
end
end
for _, flag in pairs(xOffsetFrames) do
if ( row[flag] ) then
xOffset = xOffset + row[flag]
end
end
if ( hasBottomLeft and hasPetBar ) then
-- The bottom-left bar and the pet/stance row do not simply stack: clearing both
-- costs 23px more than the two figures added together. The reference carries the
-- same correction (l.1657-1658) with no derivation, and it is a fact about the
-- bars' art rather than arithmetic we can recompute.
if ( paid.bottomLeft and paid.pet ) then
yOffset = yOffset + 23
end
-- Iterate through frames that affect x offsets
for flag, flagValue in pairs(xOffsetFrames) do
if ( value[flagValue] ) then
xOffset = xOffset + value[flagValue]
end
end
-- Set up anchoring info
anchorTo = value["anchorTo"]
point = value["point"]
rpoint = value["rpoint"]
if ( not anchorTo ) then
anchorTo = "UIParent"
end
if ( not point ) then
point = "BOTTOM"
end
if ( not rpoint ) then
rpoint = "BOTTOM"
end
-- Anchor frame
if ( value["isVar"] ) then
if ( value["isVar"] == "xAxis" ) then
_G[index] = xOffset
else
_G[index] = yOffset
end
-- A row either NAMES A GLOBAL to leave the number in, or is a frame to seat.
if ( row.isVar == "xAxis" ) then
_G[index] = xOffset
elseif ( row.isVar ) then
_G[index] = yOffset
else
frame:SetPoint(point, anchorTo, rpoint, xOffset, yOffset)
frame:SetPoint(row.point or "BOTTOM", row.anchorTo or "UIParent",
row.rpoint or "BOTTOM", xOffset, yOffset)
end
end
end
@@ -377,71 +373,77 @@
end
end
-- ref UIParent.lua:1819-1878, transcribed 1:1 (decision 1008 — closes 0997's
-- "abbreviates through KEY_ABBR" residue with the REAL mechanism: it never was a
-- KEY_ABBR set; the whole abbreviation is this Lua). The action bar's HotKey corners
-- call it with returnAbbr=1: ONE modifier abbreviates (CTRL→c, SHIFT→s, ALT→a — so
-- SHIFT-2 reads "s-2", the director's ask), TWO OR MORE collapse the whole label to
-- "·" — the ref's own multi-modifier dot. The full form (tooltips, the Keybindings
-- page) keeps the canonical prefixes and reads the base key's KEY_* global.
-- Transcription notes: the ref's dead `gsub(tempName, "CAPSLOCK", "Caps")` line
-- (result discarded — gsub doesn't mutate) is dropped; its GetLocale() deDE
-- CTRL→STRG swap and IsMacClient() KEY_*_MAC lookup ride behind existence guards
-- (this VM defines neither yet, and the getglobal fallback covers the gap).
-- The published binding-label API — benilla's implementation of the behaviour ref
-- UIParent.lua:1819-1878 specifies (decision 1008, which closed 0997's "abbreviates
-- through KEY_ABBR" residue with the REAL mechanism: there never was a KEY_ABBR set, the
-- whole abbreviation is this one function). Addons call it by name, so the contract is:
--
-- * a nil name reads as the empty string;
-- * "CTRL-SHIFT-2" splits at its LAST dash — up to and including that dash is the
-- modifier run ("CTRL-SHIFT-"), what follows is the key ("2"). A name with no dash is
-- all key;
-- * the key renders through the `prefix..key` global (the KEY_* family), preferring a
-- `_MAC` twin on a Mac client, and falls back to the raw key name;
-- * returnAbbr — what the action bar's HotKey corners pass — shrinks ONE modifier to
-- its initial, so SHIFT-2 reads "s-2" (the director's ask), and collapses TWO OR MORE
-- to "·". The full form (tooltips, the Keybindings page) keeps the whole prefix.
--
-- Two knowing departures from the reference, both about its code rather than its
-- behaviour: its `gsub(tempName, "CAPSLOCK", "Caps")` line is dead — gsub returns a new
-- string and that result is discarded, so nothing can observe it — and has no counterpart
-- here; and its GetLocale()/IsMacClient() calls ride behind existence guards because this
-- VM defines neither yet, with the getglobal fallback covering the gap.
-- What a single modifier shrinks to on a button corner. German calls Control "STRG" (the
-- deDE swap below runs first), which is the one that needs two letters to stay legible.
-- The order is fixed so the pass is deterministic, but it does not matter: no abbreviation
-- occurs inside another modifier's name, so no substitution can feed the next.
local MODIFIER_ABBREVIATIONS = {
{ "CTRL", "c" },
{ "SHIFT", "s" },
{ "ALT", "a" },
{ "STRG", "st" },
}
function GetBindingText(name, prefix, returnAbbr)
if ( not name ) then
return "";
end
local tempName = name;
local i = strfind(name, "-");
local dashIndex = nil;
local count = 0;
while ( i ) do
if ( not dashIndex ) then
dashIndex = i;
else
dashIndex = dashIndex + i;
end
count = count + 1;
tempName = strsub(tempName, i + 1);
i = strfind(tempName, "-");
if not name then
return ""
end
local modKeys = '';
if ( not dashIndex ) then
dashIndex = 0;
else
modKeys = strsub(name, 1, dashIndex);
if ( GetLocale and GetLocale() == "deDE" ) then
modKeys = gsub(modKeys, "CTRL", "STRG");
local modifiers, key = "", name
local modifierCount = 0
-- The greedy `.*` walks to the LAST dash, which is the one that divides the modifier
-- run from the key; gsub's second return then counts the dashes, one per modifier —
-- plus one more when the key ITSELF is a dash ("CTRL--"), which is why that binding
-- abbreviates as if it had two. The reference's dash-walking loop reaches both of the
-- same answers, and both are pinned by keybindings_tests.
local _, lastDash = strfind(name, "^.*%-")
if lastDash then
modifiers = strsub(name, 1, lastDash)
key = strsub(name, lastDash + 1)
_, modifierCount = gsub(modifiers, "%-", "-")
if GetLocale and GetLocale() == "deDE" then
modifiers = gsub(modifiers, "CTRL", "STRG")
end
end
if ( returnAbbr ) then
if ( count > 1 ) then
return "·";
else
modKeys = gsub(modKeys, "CTRL", "c");
modKeys = gsub(modKeys, "SHIFT", "s");
modKeys = gsub(modKeys, "ALT", "a");
modKeys = gsub(modKeys, "STRG", "st");
if returnAbbr then
if modifierCount > 1 then
-- Two modifiers never fit a corner: the whole label becomes the dot.
return "·"
end
for _, abbreviation in ipairs(MODIFIER_ABBREVIATIONS) do
modifiers = gsub(modifiers, abbreviation[1], abbreviation[2])
end
end
if ( not prefix ) then
prefix = "";
prefix = prefix or ""
local label
if IsMacClient and IsMacClient() then
label = getglobal(prefix .. key .. "_MAC")
end
local localizedName = nil;
if ( IsMacClient and IsMacClient() ) then
localizedName = getglobal(prefix..tempName.."_MAC");
end
if ( not localizedName ) then
localizedName = getglobal(prefix..tempName);
end
if ( not localizedName ) then
localizedName = tempName;
end
return modKeys..localizedName;
label = label or getglobal(prefix .. key) or key
return modifiers .. label
end
-- ref UIParent.lua:1051-1057, transcribed 1:1 (decision 1062). The reference's own
@@ -520,24 +522,26 @@
frame:SetFrameLevel(frame:GetFrameLevel() + 1)
end
-- ── MouseIsOver (ref UIParent.lua l.1388-1417), verbatim ──────────────────────────────
-- ── MouseIsOver: is the cursor inside this frame, with optional per-edge padding ─────────
--
-- "is the cursor inside this frame, optionally with per-edge padding" — the idiom every
-- hover-driven addon writes, and **9 corpus addons call it**. Missing, each is
-- `attempt to call global 'MouseIsOver'`.
-- The idiom every hover-driven addon writes, and **9 corpus addons call it** by name.
-- Missing, each is `attempt to call global 'MouseIsOver'`. So the CONTRACT is 1.12's, quirks
-- and all (ref UIParent.lua l.1388-1417) — three of which look like bugs and are not:
--
-- Transcribed rather than approximated, including the two things that look like bugs and
-- are not:
-- · the offsets are ADDED to every edge, so a positive `bottomOffset` shrinks the box
-- from below while a positive `topOffset` grows it upward. The reference's own caller
-- proves the intent — `MouseIsOver(chatFrame, 45, -10, -5, 5)` means 45 up into the
-- tab strip, 10 in from the bottom, 5 out each side.
-- · a nil `topOffset` zeroes ALL FOUR, so `MouseIsOver(f, nil, 5)` silently ignores the 5.
-- That is the reference's `if ( not topOffset ) then` block exactly; an addon written
-- against 1.12 may depend on it.
-- The reference's `-- Hack to fix a symptom not the real issue` guard on an unresolved rect
-- is kept with its comment, because a frame that has never been laid out really does answer
-- nil here and returning nil is what callers expect.
-- · each offset is ADDED to its own edge, in that axis's positive direction. A positive
-- `topOffset` raises the ceiling (growing the box upward) while a positive
-- `bottomOffset` raises the floor (shrinking it from below). The reference's own caller
-- proves the intent: `MouseIsOver(chatFrame, 45, -10, -5, 5)` is 45 up into the tab
-- strip, 10 in from the bottom, 5 out each side.
-- · the padding is ALL OR NOTHING on `topOffset`: pass none and the other three are
-- dropped with it, so `MouseIsOver(f, nil, 5)` silently ignores the 5. Pass a topOffset
-- and omit any of the rest and it is an arithmetic error, not a zero — 1.12 raises
-- there too, and an addon written against it may lean on either half.
-- · the bounds are strict, so a cursor exactly on an edge is outside.
--
-- A frame that has never been laid out answers nil for its rect; nil is the right answer to
-- hand back for it, and the reference's own comment on that guard ("a symptom not the real
-- issue") is about where the unresolved frame came from, not about the guard.
--
-- ONE DIVERGENCE, and it is a no-op: the reference divides the cursor by
-- `frame:GetEffectiveScale()` because 1.12 hands Lua screen pixels. benilla's UI space is
@@ -546,29 +550,23 @@
-- forever. If a real UI scale ever lands, this is one of the sites that needs it back.
function MouseIsOver(frame, topOffset, bottomOffset, leftOffset, rightOffset)
local x, y = GetCursorPosition()
local left = frame:GetLeft()
local right = frame:GetRight()
local top = frame:GetTop()
local bottom = frame:GetBottom()
if not topOffset then
topOffset = 0
bottomOffset = 0
leftOffset = 0
rightOffset = 0
end
-- Hack to fix a symptom not the real issue
local left, right = frame:GetLeft(), frame:GetRight()
local top, bottom = frame:GetTop(), frame:GetBottom()
if not left then
return
end
left = left + leftOffset
right = right + rightOffset
top = top + topOffset
bottom = bottom + bottomOffset
if (x > left and x < right) and (y > bottom and y < top) then
return 1
else
return nil
if not topOffset then
topOffset, bottomOffset, leftOffset, rightOffset = 0, 0, 0, 0
end
-- All four edges are padded before any of them is tested, and deliberately so: folding
-- the additions into the comparison would let a short circuit swallow the arithmetic
-- error that a half-supplied offset set is supposed to raise.
left, right = left + leftOffset, right + rightOffset
top, bottom = top + topOffset, bottom + bottomOffset
if x > left and x < right and y > bottom and y < top then
return 1
end
return nil
end
]]></Script>
</Ui>
+206 -179
View File
@@ -1,21 +1,24 @@
<Ui>
<!--
The UIPanel slot manager (decision 0084 §2) — benilla's own transcription of Blizzard's
UIParent.lua panel-window plumbing, script-only (no frames of its own). Loaded FIRST in the
shipped-XML list (ui_script.rs) so `UIPanelWindows`/`ShowUIPanel`/`HideUIPanel` exist before
any panel frame's OnLoad/OnEvent references them.
The UIPanel slot manager (decision 0084 §2) — benilla's own implementation of the
panel-window plumbing the reference keeps in UIParent.lua. Loaded FIRST in the shipped-XML
list (ui_script.rs) so `UIPanelWindows`/`ShowUIPanel`/`HideUIPanel` exist before any panel
frame's OnLoad/OnEvent references them.
Ground truth: the 1.12 `UIParent.lua` extract (scratchpad/UIParent.lua, patch-2.MPQ), quoted
verbatim in the line-numbered comments below — never re-derived. v1 scope (pin §3): the
**left + center two-slot logic only**. The real client's `full`/`doublewide` areas, the
`CanOpenPanels`/full-screen/native-center gates and `UIChildWindows` are all out of scope —
no benilla panel uses them yet — but the registration table keeps their exact shape
(`area`, `pushable`, `whileDead`) so a later row drops in unchanged. **`UISpecialFrames`
left that list in decision 1206**: it is not ours to use, it is the table an ADDON writes
its own window into, and 34 corpus call sites do.
Specification: the 1.12 `UIParent.lua` extract (patch-2.MPQ), cited by line number
throughout so every rule below can be checked against it. What is quoted VERBATIM is the
**data** — each `UIPanelWindows` row's `area`/`pushable`/`whileDead`, which IS the
reference's window arbitration (a different number is a different window order), and the
`UISpecialFrames` seeds. The code around that data is ours.
Frame names here are OUR shipped names (`Benilla*`); UIParent.lua's names (`GossipFrame`,
`MerchantFrame`, `LootFrame`) are the semantic reference the pin cites, not what we register.
Scope: `left`, `center` and `full` are all live. `doublewide` and `UIChildWindows` are not
— nothing benilla ships wants them — but the registration table keeps their exact shape so
a later row drops in unchanged. **`UISpecialFrames` is not ours to write into** (decision
1206): it is the table an ADDON puts its own window in, and 34 corpus call sites do.
Rows are keyed by the frame names we actually ship. Most of those ARE the reference's own
(`GossipFrame`, `MerchantFrame`, `LootFrame`); where ours differs, the row is `Benilla*` and
its comment names the reference row it stands in for.
-->
<Script><![CDATA[
-- UIParent.lua l.14-42: UIPanelWindows["FrameName"] = { area, pushable, whileDead }. Only the
@@ -216,101 +219,66 @@
return slots.fullscreen
end
-- The area a frame is REGISTERED for — or nil, which is equally the answer for no frame at
-- all and for a frame this manager has never heard of. Four rules below turn on exactly
-- that question ("is a native-center window holding the screen?", "is the frame parked at
-- center a left-area one that should slide back?"), and all four want an absent frame and
-- an unregistered one to answer the same way. The reference open-codes the pair of nil
-- checks at each site, which is four chances to leave one of them out.
local function AreaOf(frame)
local info = frame and UIPanelWindows[frame:GetName()]
return info and info.area
end
-- CanOpenPanels() — UIParent.lua l.887-910. Its dead-player arms are commented out in the
-- reference itself (both of them, with the note that the fear/out-of-control gate was
-- "previously"); what survives is one rule: **a native-center frame open ⇒ nothing else
-- opens**. The game menu is the only such frame (decision 0674), so this reads: with the
-- ESC menu up, no window opens — not from a keybind, not from an NPC's OPEN_WINDOW.
function CanOpenPanels()
local centerFrame = GetCenterFrame()
if not centerFrame then
return 1
end
local info = UIPanelWindows[centerFrame:GetName()]
if info and info.area == "center" then
if AreaOf(GetCenterFrame()) == "center" then
return nil
end
return 1
end
-- ShowUIPanel(frame) — UIParent.lua l.649-751, the left+center slice (pin §3 steps 1,3-5)
-- plus the native-center mode decision 0674 needed. Still skipped by scope: full-screen
-- (l.668-675, l.692-696 — no `full` row is registered; benilla's world map is a plain
-- toplevel, not a panel) and `doublewide` (l.707-710 — likewise none).
function ShowUIPanel(frame, force)
if not frame or frame:IsVisible() then
return
end
local info = UIPanelWindows[frame:GetName()]
-- l.655-657. Note the reference's own precedence: an UNREGISTERED frame is not gated
-- (the `info and` term), so a bare toplevel still shows with the menu up.
if not CanOpenPanels() and info and info.area ~= "center" then
return
end
if not info then
frame:Show()
return
end
-- UnitIsDead doesn't exist yet (pin §3) — guarded rather than assumed absent-forever.
-- Everything that can REFUSE an open, asked in the reference's own order (UIParent.lua
-- l.663-689). Answers false when the frame must not open. Not a pure predicate, and that
-- is the point: `force` is precisely the right to throw the incumbent out instead of being
-- turned away, so each refusal and its eviction are one decision and are written as one.
local function AdmitPanel(info, force)
-- A dead player gets only the windows that asked for it. UnitIsDead doesn't exist yet
-- (pin §3) — guarded rather than assumed absent-forever.
if type(UnitIsDead) == "function" and UnitIsDead("player") and not info.whileDead then
return
return false
end
-- l.668-675: a full-screen frame open ignores every non-full open request (`force`
-- evicts it instead). The world map is the only `full` row, so this reads: nothing opens
-- behind the map.
-- l.668-675: nothing opens behind a full-screen frame. The world map is the only
-- `full` row we register, so this reads: nothing opens behind the map.
if GetFullScreenFrame() and info.area ~= "full" then
if force then
SetFullScreenFrame(nil)
else
return
if not force then
return false
end
SetFullScreenFrame(nil)
end
-- l.677-689: with a native-center frame open, only another center request gets through
-- — `force` evicts it instead of refusing. Redundant with the CanOpenPanels gate above
-- for every caller that doesn't force (the reference double-checks it too); kept because
-- it is the ONLY path that can displace the menu, and dropping it would silently turn
-- `force` into a no-op. No benilla caller passes `force` yet.
local centerFrame = GetCenterFrame()
if centerFrame then
local centerInfo = UIPanelWindows[centerFrame:GetName()]
if centerInfo and centerInfo.area == "center" and info.area ~= "center" then
if force then
SetCenterFrame(nil)
else
return
end
-- l.677-689: with a native-center frame open, only another center request gets
-- through. Redundant with the caller's CanOpenPanels gate for anything that doesn't
-- force (the reference double-checks it too); kept because it is the ONLY path that
-- can displace the menu, and dropping it would silently turn `force` into a no-op.
-- No benilla caller passes `force` yet.
if AreaOf(GetCenterFrame()) == "center" and info.area ~= "center" then
if not force then
return false
end
SetCenterFrame(nil)
end
return true
end
-- l.692-696: full-screen frames just replace each other, and they take everything with
-- them on the way in.
if info.area == "full" then
CloseAllWindows()
SetFullScreenFrame(frame)
return
end
-- l.697-702: native-center frames just replace each other and take the screen —
-- everything else closes first. `skipSetPoint` is the load-bearing argument: the menu
-- keeps its own CENTER anchor instead of being dropped at the left-area seat.
if info.area == "center" then
CloseWindows()
CloseAllBags()
SetCenterFrame(frame, 1)
return
end
if info.area ~= "left" then
-- No full/doublewide row is registered — defensive fallback only.
frame:Show()
return
end
-- Try to put it on the left (l.719-723).
-- Seating a **left-area** panel (l.719-751). The left slot is what it wants; `pushable`
-- settles what happens to whoever is already sitting there. Read the comparisons as "the
-- higher number is the one that gets moved along" — and two zeroes will not move for each
-- other at all, which is what makes one NPC window replace another outright.
local function SeatLeftAreaPanel(frame, info)
local leftFrame = GetLeftFrame()
if not leftFrame then
SetLeftFrame(frame)
@@ -319,64 +287,101 @@
local leftInfo = UIPanelWindows[leftFrame:GetName()]
local centerFrame = GetCenterFrame()
if not centerFrame then
-- If neither is pushable, replace (l.729-732 — the gossip<->merchant case).
if leftInfo.pushable == 0 and info.pushable == 0 then
SetLeftFrame(frame)
return
end
-- Highest priority goes to center (l.735-741).
if leftInfo.pushable > info.pushable then
MovePanelToCenter()
SetLeftFrame(frame)
else
if centerFrame then
-- Both seats taken (l.744-751): outrank the center occupant and it slides back to
-- the left while we take center; otherwise settle for replacing the left one.
if info.pushable > UIPanelWindows[centerFrame:GetName()].pushable then
MovePanelToLeft()
SetCenterFrame(frame)
else
SetLeftFrame(frame)
end
return
end
-- Center is already taken too (l.744-751).
local centerInfo = UIPanelWindows[centerFrame:GetName()]
if info.pushable > centerInfo.pushable then
MovePanelToLeft()
SetCenterFrame(frame)
else
elseif leftInfo.pushable == 0 and info.pushable == 0 then
-- Neither will budge — two NPC sessions — so we replace it outright (l.729-732,
-- the gossip<->merchant case).
SetLeftFrame(frame)
elseif leftInfo.pushable > info.pushable then
-- The incumbent outranks us, so it is promoted to center and we take the left slot
-- it vacates (l.735-741 — highest priority goes to center).
MovePanelToCenter()
SetLeftFrame(frame)
else
SetCenterFrame(frame)
end
end
-- HideUIPanel(frame) — UIParent.lua l.754-786, v1 left+center slice (the real fn's
-- full-screen branch, l.759-763, is dropped: no full-screen row ships).
-- ShowUIPanel(frame) — UIParent.lua l.649-751, the left+center+full slice (pin §3 steps
-- 1,3-5, plus the native-center mode decision 0674 needed and the `full` slot the world
-- map took in 0203/0674). Still skipped by scope: `doublewide` (l.707-710 — no such row is
-- registered), which is why the last arm below is a bare Show.
--
-- Two questions, in order: may it open at all, and where does it sit.
function ShowUIPanel(frame, force)
if not frame or frame:IsVisible() then
return
end
local info = UIPanelWindows[frame:GetName()]
-- l.655-657. Note the reference's own precedence: an UNREGISTERED frame is not gated
-- (the `info and` term), so a bare toplevel still shows with the menu up. The gate is
-- asked unconditionally, because addons replace CanOpenPanels and expect to be asked.
local panelsAllowed = CanOpenPanels()
if not panelsAllowed and info and info.area ~= "center" then
return
end
if not info then
frame:Show()
return
end
if not AdmitPanel(info, force) then
return
end
if info.area == "full" then
-- l.692-696: full-screen frames just replace each other, and they take everything
-- else with them on the way in.
CloseAllWindows()
SetFullScreenFrame(frame)
elseif info.area == "center" then
-- l.697-702: native-center frames likewise replace each other and take the
-- screen. `skipSetPoint` is the load-bearing argument here — the menu keeps its
-- own CENTER anchor instead of being dropped at the left-area seat.
CloseWindows()
CloseAllBags()
SetCenterFrame(frame, 1)
elseif info.area == "left" then
SeatLeftAreaPanel(frame, info)
else
-- No `doublewide` row is registered — defensive fallback only.
frame:Show()
end
end
-- HideUIPanel(frame) — UIParent.lua l.754-786. Which seat is the frame sitting in? That is
-- the whole function: vacate that one. A frame in no seat at all was never this manager's
-- to place, so it is simply hidden.
function HideUIPanel(frame)
if not frame or not frame:IsShown() then
return
end
-- l.759-763.
if frame == GetFullScreenFrame() then
SetFullScreenFrame(nil)
return
end
if frame == GetCenterFrame() then
elseif frame == GetCenterFrame() then
SetCenterFrame(nil)
return
end
if frame == GetLeftFrame() then
local centerFrame = GetCenterFrame()
if centerFrame then
local info = UIPanelWindows[centerFrame:GetName()]
if info and info.area == "left" then
MovePanelToLeft()
return
end
elseif frame == GetLeftFrame() then
-- l.777-782: a LEFT-area panel parked at center (it was pushed there) slides back
-- into the seat we are giving up, rather than being stranded beside an empty left
-- slot. A native-center frame stays where it is.
if AreaOf(GetCenterFrame()) == "left" then
MovePanelToLeft()
else
SetLeftFrame(nil)
end
SetLeftFrame(nil)
return
else
-- Seated nowhere: a plain toplevel, or an addon's frame we never placed.
frame:Hide()
end
frame:Hide()
end
-- UISpecialFrames (UIParent.lua l.52-55) — **the addon's own ESC list**, and the reason
@@ -414,11 +419,10 @@
HideUIPanel(leftFrame)
HideUIPanel(fullScreenFrame)
if centerFrame then
local info = UIPanelWindows[centerFrame:GetName()]
if not info or info.area ~= "center" or not ignoreCenter then
HideUIPanel(centerFrame)
end
-- `ignoreCenter` spares a NATIVE-center frame and nothing else: a left-area panel
-- merely parked at the center seat closes with the rest of them.
if centerFrame and not (ignoreCenter and AreaOf(centerFrame) == "center") then
HideUIPanel(centerFrame)
end
-- The special frames, the reference's own loop (l.947-954): a plain Hide, never
@@ -715,12 +719,12 @@
end
end
-- ── The inspect-cursor pair (ref UIParent.lua l.1805-1817, VERBATIM) ──────────────────
-- ── The inspect-cursor pair (ref UIParent.lua l.1805-1817) ────────────────────────────
-- `CursorUpdate` is the ctrl-hover "can I inspect this?" cursor swap, and `CursorOnUpdate`
-- is the OnUpdate wrapper that only re-runs it while the tooltip is still ours. Both are
-- **FrameXML, not engine** (`reference/1.12-globals.tsv` marks them `framexml`, while
-- `ResetCursor` and `ShowInspectCursor` beside them are `engine`) — so they are ours to
-- transcribe, and every piece they reach for already resolves here: `IsControlKeyDown`,
-- write, and every piece they reach for already resolves here: `IsControlKeyDown`,
-- `ShowInspectCursor`, `ResetCursor` and `GameTooltip:IsOwned` are all present.
--
-- Two corpus addons call `CursorUpdate` (AdvancedTradeSkillWindow, WhoHas) and one calls
@@ -745,7 +749,9 @@
end
end
-- SetDesaturation(texture, desaturation) — ref UIParent.lua l.1499-1510, verbatim. The
-- SetDesaturation(texture, desaturation) — ref UIParent.lua l.1499-1510, plus two guards
-- it does not have: a nil texture, and a texture carrying no `SetDesaturated` method at
-- all (the reference calls it unconditionally, l.1501; ours cannot, see below). The
-- reference itself has a no-shader fallback, and OURS IS ALWAYS THAT PATH: this engine has
-- no SetDesaturated, so `shaderSupported` is false exactly as on a 1.12 card without the
-- desaturation shader, and the grey comes from the ref's own vertex-colour branch. Written
@@ -767,7 +773,7 @@
end
-- ── The shared dialog IS the ref's StaticPopup engine (decision 0308 §3) ─────────────────
-- Transcribed from the patch-chain StaticPopup.lua (mpqx extraction, decision 0246):
-- Re-expressed from the patch-chain StaticPopup.lua (mpqx extraction, decision 0246):
-- the StaticPopupDialogs registry + StaticPopup_Show(which, arg1, arg2) + the OnUpdate
-- countdown/StartDelay machinery — the death arc's dialogs exercise nearly all of it, and
-- StaticPopup_Show/StaticPopupDialogs are 1.14 addon-API surface (0068). v1 scope:
@@ -1201,61 +1207,84 @@
end
end
-- ── The shared frame-fade kit — ref UIParent.lua:1103-1219, transcribed VERBATIM (the
-- while-index iteration included; tinsert/tDeleteItem spelled as table.insert / the
-- remove loop). The ref drives UIFrameFadeUpdate from UIParent's OnUpdate; our driver is
-- the BenillaFadeDriver frame below. Callers: the quest detail write-on
-- (QuestFrame.xml) fades TextAlphaDependentFrame in over QUESTINFO_FADE_IN. ──────────────
-- ── The shared frame-fade kit — ref UIParent.lua:1103-1219 is the specification (the
-- function names are its published API and the arithmetic is the same lerp); the code is
-- ours. FADEFRAMES is a plain ARRAY walked by index rather than by `pairs`, because
-- UIFrameFadeUpdate removes from it mid-walk. The ref drives UIFrameFadeUpdate from
-- UIParent's OnUpdate; our driver is the BenillaFadeDriver frame below. Callers: the quest
-- detail write-on (QuestFrame.xml) fades TextAlphaDependentFrame in over
-- QUESTINFO_FADE_IN. ─────────────────────────────────────────────────────────────────────
FADEFRAMES = {}
function UIFrameFadeRemoveFrame(frame)
local index = 1
-- Where `frame` sits in the fade list, searching from `from` onward, or nil. Three of the
-- verbs below ask exactly this question, and the reference spells the index walk out
-- longhand inside each of them.
local function FadeIndexOf(frame, from)
local index = from or 1
while FADEFRAMES[index] do
if FADEFRAMES[index] == frame then
table.remove(FADEFRAMES, index)
else
index = index + 1
return index
end
index = index + 1
end
return nil
end
function UIFrameFadeRemoveFrame(frame)
local index = FadeIndexOf(frame)
while index do
table.remove(FADEFRAMES, index)
-- Resume from the hole rather than from the start: a frame listed twice goes twice.
index = FadeIndexOf(frame, index)
end
end
-- Where each mode starts and ends when the caller doesn't say. The tests below are
-- `not fadeInfo.startAlpha`, so an explicit 0 — falsey in most languages, TRUTHY in Lua —
-- is the caller's and stands.
local FADE_ENDPOINTS = {
IN = { startAlpha = 0, endAlpha = 1.0 },
OUT = { startAlpha = 1.0, endAlpha = 0 },
}
function UIFrameFade(frame, fadeInfo)
if not fadeInfo.mode then fadeInfo.mode = "IN" end
if fadeInfo.mode == "IN" then
if not fadeInfo.startAlpha then fadeInfo.startAlpha = 0 end
if not fadeInfo.endAlpha then fadeInfo.endAlpha = 1.0 end
elseif fadeInfo.mode == "OUT" then
if not fadeInfo.startAlpha then fadeInfo.startAlpha = 1.0 end
if not fadeInfo.endAlpha then fadeInfo.endAlpha = 0 end
if not fadeInfo.mode then
fadeInfo.mode = "IN"
end
local endpoints = FADE_ENDPOINTS[fadeInfo.mode]
if endpoints then
if not fadeInfo.startAlpha then fadeInfo.startAlpha = endpoints.startAlpha end
if not fadeInfo.endAlpha then fadeInfo.endAlpha = endpoints.endAlpha end
end
frame:SetAlpha(fadeInfo.startAlpha)
frame.fadeInfo = fadeInfo
-- Already fading? Leave the running fade alone (the ref's early return).
local index = 1
while FADEFRAMES[index] do
if FADEFRAMES[index] == frame then return end
index = index + 1
-- Already in the list? The new info is installed above and takes over on the next
-- tick; the frame keeps its place instead of being listed twice (the ref's early
-- return).
if FadeIndexOf(frame) then
return
end
frame:Show()
table.insert(FADEFRAMES, frame)
end
-- The two directions are one call with the mode flipped; whatever endpoints the caller
-- leaves out, UIFrameFade fills in above.
local function FadeToMode(mode, frame, timeToFade, startAlpha, endAlpha)
UIFrameFade(frame, {
mode = mode,
timeToFade = timeToFade,
startAlpha = startAlpha,
endAlpha = endAlpha,
})
end
function UIFrameFadeIn(frame, timeToFade, startAlpha, endAlpha)
local fadeInfo = {}
fadeInfo.mode = "IN"
fadeInfo.timeToFade = timeToFade
fadeInfo.startAlpha = startAlpha
fadeInfo.endAlpha = endAlpha
UIFrameFade(frame, fadeInfo)
FadeToMode("IN", frame, timeToFade, startAlpha, endAlpha)
end
function UIFrameFadeOut(frame, timeToFade, startAlpha, endAlpha)
local fadeInfo = {}
fadeInfo.mode = "OUT"
fadeInfo.timeToFade = timeToFade
fadeInfo.startAlpha = startAlpha
fadeInfo.endAlpha = endAlpha
UIFrameFade(frame, fadeInfo)
FadeToMode("OUT", frame, timeToFade, startAlpha, endAlpha)
end
function UIFrameFadeUpdate(elapsed)
@@ -1291,10 +1320,8 @@
end
function UIFrameIsFading(frame)
local index = 1
while FADEFRAMES[index] do
if FADEFRAMES[index] == frame then return 1 end
index = index + 1
if FadeIndexOf(frame) then
return 1
end
return nil
end
+180 -115
View File
@@ -67,7 +67,7 @@
the app's single write (ui_unit.rs enrich_unit), not per reader. And UNIT_CLASSIFICATION_
CHANGED is the repaint wire, edge-fired on that rank — without it the border would only be
right on re-target, since Update is otherwise the sole caller.
- The level slot runs the full CheckLevel law (ref-TargetFrame.lua l.119-142, transcribed in
- The level slot runs the full CheckLevel law (ref-TargetFrame.lua l.119-142, re-expressed in
Update below): corpse or "can't tell" (UnitLevel ≤ 0) → the HighLevelTexture skull;
otherwise the number, GetDifficultyColor-tinted when attackable, gold when not.
- The PVP icon landed with decision 0646 (the three-branch law in BenillaUnitFrame_UpdatePvP).
@@ -153,7 +153,7 @@
-- We answer `GetRaidTargetIndex(unit)` already (0434 §6); this is the other half — what an
-- addon DRAWS with that number — and without it those lines raise while redrawing a mark.
--
-- TRANSCRIBED VERBATIM, INCLUDING THE REFERENCE'S OWN SLIP: the row is derived with
-- KEPT WHOLE, INCLUDING THE REFERENCE'S OWN SLIP: the row is derived with
-- `/ RAID_TARGET_TEXTURE_ROWS` while the column uses `mod(.., RAID_TARGET_TEXTURE_COLUMNS)`.
-- Rows and columns are both 4, so the two agree for every index this sheet holds and the
-- output is right; "fixing" it to divide by COLUMNS computes the same coords today and would
@@ -503,12 +503,12 @@
end
-- ── Combat feedback: the portrait hit indicator (decision 0576) ──────────────────────
-- Ref CombatFeedback.lua, transcribed onto frame-param style (the ref reads `this`).
-- Timings/colors/size factors verbatim. The words come from the GLOBALS, exactly as the ref
-- builds this table (CombatFeedback.lua l.6-17: CombatFeedbackText["MISS"] = TEXT(MISS)),
-- so a localized install shows its own — the app runs the real GlobalStrings.lua off the
-- patch chain before this file (ui_script/mod.rs). The enUS literals are the fallback for
-- the test harnesses, which load no patch chain.
-- Our implementation of what ref CombatFeedback.lua specifies, on frame-param style (the
-- ref reads `this`). Timings, colors and size factors are its MEASURED values. The words
-- come from the GLOBALS, exactly as the ref builds this table (CombatFeedback.lua l.6-17:
-- CombatFeedbackText["MISS"] = TEXT(MISS)), so a localized install shows its own — the app
-- runs the real GlobalStrings.lua off the patch chain before this file (ui_script/mod.rs).
-- The enUS literals are the fallback for the test harnesses, which load no patch chain.
COMBATFEEDBACK_FADEINTIME = 0.2
COMBATFEEDBACK_HOLDTIME = 0.7
COMBATFEEDBACK_FADEOUTTIME = 0.3
@@ -527,62 +527,91 @@
REFLECT = REFLECT or "Reflect",
}
-- The indicator says one of two things: a NUMBER, when something moved a bar, or a WORD,
-- when nothing did. Everything below is that split. `scale` is a multiple of the frame's
-- own indicator height — 1 is "as authored" — and the multiples themselves are the
-- reference's measurements: half again for a big hit, three quarters for one that was
-- stopped, half for one that could never land.
-- The two events that move a bar the friendly way, and the colour each prints in. (A
-- WOUND prints a number too, but its colour depends on the school, so it is settled in
-- place below.)
local COMBAT_FEEDBACK_GAIN = {
HEAL = { r = 0.0, g = 1.0, b = 0.0 },
ENERGIZE = { r = 0.41, g = 0.8, b = 0.94 },
}
-- The words that do not print at the indicator's authored height. Everything else does.
local COMBAT_FEEDBACK_WORD_SCALE = {
IMMUNE = 0.5,
BLOCK = 0.75,
}
-- A wound that did no damage was stopped by one of three things, and in each case the
-- flag naming it is also the key of the word for it. Anything else was a plain miss.
local COMBAT_FEEDBACK_SOAKED = { ABSORB = 1, BLOCK = 1, RESIST = 1 }
-- What a zero-damage wound reads as: the word, and the size it prints at. Being stopped
-- reads small; missing outright reads full size, because a miss is the ordinary case.
local function CombatFeedback_Stopped(flags)
if COMBAT_FEEDBACK_SOAKED[flags] then
return CombatFeedbackText[flags], 0.75
end
return CombatFeedbackText["MISS"], 1.0
end
-- The size a landed blow reads at. A crushing blow is as loud as a critical; a glancing
-- one is as quiet as a soak.
local function CombatFeedback_HitScale(flags)
if flags == "CRITICAL" or flags == "CRUSHING" then
return 1.5
elseif flags == "GLANCING" then
return 0.75
end
return 1.0
end
function CombatFeedback_OnCombatEvent(frame, event, flags, amount, type)
local feedbackText = frame.feedbackText
if not feedbackText then return end
local fontHeight = frame.feedbackFontHeight
local text = ""
-- The default reading, which most events take unchanged: this event's own word, at the
-- authored height, in white. An event with no word at all prints nothing, which is the
-- honest thing to do with a feedback kind we do not have a string for.
local text = CombatFeedbackText[event]
local scale = 1.0
local r, g, b = 1.0, 1.0, 1.0
if event == "IMMUNE" then
fontHeight = fontHeight * 0.5
text = CombatFeedbackText[event]
elseif event == "WOUND" then
if amount ~= 0 then
if flags == "CRITICAL" or flags == "CRUSHING" then
fontHeight = fontHeight * 1.5
elseif flags == "GLANCING" then
fontHeight = fontHeight * 0.75
end
-- type > 0 = a spell school: the number reads yellow, physical white.
if event == "WOUND" then
if amount == 0 then
text, scale = CombatFeedback_Stopped(flags)
else
text = amount
scale = CombatFeedback_HitScale(flags)
-- A non-zero school means a spell landed it: spell damage prints yellow where
-- a physical hit prints white.
if type > 0 then
r, g, b = 1.0, 1.0, 0.0
end
text = amount
elseif flags == "ABSORB" then
fontHeight = fontHeight * 0.75
text = CombatFeedbackText["ABSORB"]
elseif flags == "BLOCK" then
fontHeight = fontHeight * 0.75
text = CombatFeedbackText["BLOCK"]
elseif flags == "RESIST" then
fontHeight = fontHeight * 0.75
text = CombatFeedbackText["RESIST"]
else
text = CombatFeedbackText["MISS"]
end
elseif event == "BLOCK" then
fontHeight = fontHeight * 0.75
text = CombatFeedbackText[event]
elseif event == "HEAL" then
text = amount
r, g, b = 0.0, 1.0, 0.0
if flags == "CRITICAL" then
fontHeight = fontHeight * 1.5
end
elseif event == "ENERGIZE" then
text = amount
r, g, b = 0.41, 0.8, 0.94
if flags == "CRITICAL" then
fontHeight = fontHeight * 1.5
end
else
text = CombatFeedbackText[event]
local gain = COMBAT_FEEDBACK_GAIN[event]
if gain then
text = amount
r, g, b = gain.r, gain.g, gain.b
-- A critical heal or power gain reads big. Note this is the ONLY flag that
-- resizes one — a wound's crushing/glancing legs do not apply here.
if flags == "CRITICAL" then
scale = 1.5
end
else
scale = COMBAT_FEEDBACK_WORD_SCALE[event] or 1.0
end
end
frame.feedbackStartTime = GetTime()
feedbackText:SetTextHeight(fontHeight)
feedbackText:SetTextHeight(frame.feedbackFontHeight * scale)
feedbackText:SetText(text)
feedbackText:SetTextColor(r, g, b)
feedbackText:SetAlpha(0.0)
@@ -615,7 +644,7 @@
-- Pull the unit's current state and repaint. A non-existent unit hides the whole frame.
-- The classification border art (TargetFrame_CheckClassification, ref-TargetFrame.lua
-- l.205-218) — transcribed branch for branch, and the shape is the surprise: 1.12 has FIVE
-- l.205-218) — re-expressed branch for branch, and the shape is the surprise: 1.12 has FIVE
-- classifications but only THREE border textures, because elite, rareelite and worldboss all
-- take the same Elite art. There is no rare-elite border in this build and no rare-elite
-- tooltip word either (the level line's rank table is {"", ELITE, ELITE, BOSS, ""}), so
@@ -774,7 +803,7 @@
MAX_TARGET_DEBUFFS = 16
MAX_TARGET_BUFFS = 5
-- TargetDebuffButton_Update (ref-TargetFrame.lua l.263-387), transcribed onto the Era API
-- TargetDebuffButton_Update (ref-TargetFrame.lua l.263-387), re-expressed onto the Era API
-- (decision 0255: UnitBuff returns name,icon,count,debuffType,… — the 1.12 original returned
-- the texture first). Only called with a live target (UnitFrame_Update guards).
-- DebuffTypeColor is defined by BuffFrame.xml, which loads AFTER this file — safe, because
@@ -883,7 +912,7 @@
end
end
-- The status flash's paint (ref PlayerFrame_UpdateStatus, transcribed onto frame-param
-- The status flash's paint (ref PlayerFrame_UpdateStatus, re-expressed onto frame-param
-- style; decision 1082). Priority is the ref's: RESTING wins over combat — sitting in an
-- inn mid-auto-attack shows the zzz — then the auto-attack state (gold ring/zzz vs red
-- ring/swords/red disc), then bare swords for on-someone's-aggro-list, then nothing.
@@ -976,26 +1005,34 @@
UIDropDownMenu_Initialize(this, TargetFrameDropDown_Initialize, "MENU")
end
function TargetFrameDropDown_Initialize()
local menu
local name
-- Which menu the CURRENT target gets, and the heading that menu needs (only the bare mark
-- list carries one — the unit menus title themselves from the unit). Most specific first:
-- yourself → SELF
-- your own pet → PET. Selecting your pet and right-clicking the TARGET
-- frame is still the pet's menu; the ref's own second
-- branch (TargetFrame.lua l.440-441), which sat as a
-- comment here until the "pet" token (0990) and then the
-- menu itself (1066).
-- anything that is not a player → an NPC, which gets only the raid-mark list
-- a player, in your party or not → PARTY / PLAYER
local function TargetFrameMenu()
if UnitIsUnit("target", "player") then
menu = "SELF"
elseif UnitIsUnit("target", "pet") then
-- Selecting your own pet and right-clicking the TARGET frame gives you the pet
-- menu — the ref's own second branch (TargetFrame.lua l.440-441), which sat as a
-- comment here until the "pet" token (0990) and then the menu itself (1066).
menu = "PET"
elseif UnitIsPlayer("target") then
if UnitInParty("target") then
menu = "PARTY"
else
menu = "PLAYER"
end
else
menu = "RAID_TARGET_ICON"
name = RAID_TARGET_ICON
return "SELF"
end
if UnitIsUnit("target", "pet") then
return "PET"
end
if not UnitIsPlayer("target") then
return "RAID_TARGET_ICON", RAID_TARGET_ICON
end
if UnitInParty("target") then
return "PARTY"
end
return "PLAYER"
end
function TargetFrameDropDown_Initialize()
local menu, name = TargetFrameMenu()
if menu then
UnitPopup_ShowMenu(TargetFrameDropDown, menu, "target", name)
end
@@ -1594,7 +1631,8 @@
</Frame>
<!--
THE PET FRAME (decision 0990; ref PetFrame.xml + PetFrame.lua, quoted). 128×53 parented to
THE PET FRAME (decision 0990). Geometry and art are MEASURED off ref PetFrame.xml +
PetFrame.lua and the behaviour is theirs; the code is our own. 128×53 parented to
the player frame at TOPLEFT (80,-60) — a static anchor, deliberately: the reference does not
list PetFrame in UIPARENT_MANAGED_FRAME_POSITIONS at all, because it rides its parent.
@@ -1611,8 +1649,8 @@
The PET RIGHT-CLICK MENU is no longer deferred either — decision 1066 built the last of
the five things it was waiting on (the pet paper doll, the fifth, landed as 1057). The
frame's else arm opens PetFrameDropDown below; the rows, their fork and their dialogs are
UnitPopup.xml's.
frame's non-left-click arm opens PetFrameDropDown below; the rows, their fork and their
dialogs are UnitPopup.xml's.
STILL DEFERRED, with its reason:
- PartyMemberBuffTooltip on hover (the ref's OnEnter panel): it belongs to the party
@@ -1719,32 +1757,44 @@
PetFrame_SetHappiness()
end
-- ref PetFrame_SetHappiness (PetFrame.lua l.132-156), quoted (decision 1005).
-- ── The happiness face (decision 1005; ref PetFrame_SetHappiness, PetFrame.lua l.132-156)
--
-- `Interface\PetPaperDollFrame\UI-PetHappiness` is one row of three faces, and each cut is
-- a MEASURED value off that file. The buckets run RIGHT to left across the strip — bucket
-- 1 (unhappy) takes the rightmost cut and bucket 3 (happy) sits at x=0 — which is why the
-- left edges below descend as the index climbs. A bucket outside 1..3 selects no cell at
-- all and the icon keeps whatever face it was already wearing, deliberately: see the note
-- on bucket 0 below.
local PET_HAPPINESS_CELL = {
{ 0.375, 0.5625, 0, 0.359375 },
{ 0.1875, 0.375, 0, 0.359375 },
{ 0, 0.1875, 0, 0.359375 },
}
-- `GetPetHappiness` hands back a PRE-BUCKETED 1/2/3 — the client does the thresholding
-- against PetPersonality.dbc, not this function — plus the two numbers that bucket
-- selects. Three things here are load-bearing and each is the reference's own:
-- * the hide test is `not happiness or not isHunterPet`, so a bucket of **0** (truthy in
-- Lua) leaves the icon SHOWING with whatever texcoords it had. Testing `happiness == 0`
-- or coercing 0 to nil would hide a frame the reference shows.
-- * the three texcoord cuts run 3/2/1 left-to-right across the strip — happy is at x=0.
-- * the hide test is on the bucket being ABSENT, not on it being zero, so a bucket of
-- **0** (truthy in Lua) leaves the icon SHOWING with whatever texcoords it had.
-- Testing `happiness == 0` or coercing 0 to nil would hide a frame the reference shows.
-- * only a HUNTER's pet has a face at all — `HasPetUI`'s second return is the whole gate,
-- and its first return is nothing to do with this frame.
-- * the loyalty line is chosen by the SIGN of loyaltyRate, which is why that return is
-- unscaled and allowed to be negative.
-- unscaled and allowed to be negative. Exactly zero means "steady", and says nothing.
function PetFrame_SetHappiness()
local happiness, damagePercentage, loyaltyRate = GetPetHappiness()
local hasPetUI, isHunterPet = HasPetUI()
if not happiness or not isHunterPet then
local _, isHunterPet = HasPetUI()
if not (happiness and isHunterPet) then
PetFrameHappiness:Hide()
return
end
PetFrameHappiness:Show()
if happiness == 1 then
PetFrameHappinessTexture:SetTexCoord(0.375, 0.5625, 0, 0.359375)
elseif happiness == 2 then
PetFrameHappinessTexture:SetTexCoord(0.1875, 0.375, 0, 0.359375)
elseif happiness == 3 then
PetFrameHappinessTexture:SetTexCoord(0, 0.1875, 0, 0.359375)
local cell = PET_HAPPINESS_CELL[happiness]
if cell then
PetFrameHappinessTexture:SetTexCoord(cell[1], cell[2], cell[3], cell[4])
end
PetFrameHappiness.tooltip = getglobal("PET_HAPPINESS" .. happiness)
PetFrameHappiness.tooltipDamage = format(PET_DAMAGE_PERCENTAGE, damagePercentage)
if loyaltyRate < 0 then
@@ -1756,6 +1806,27 @@
end
end
-- The happiness face's hover. Both extra lines are drawn the same way — plain white,
-- nothing in the right-hand column — and either can be absent, so the stanza is written
-- once. The whole tooltip waits on the headline: until SetHappiness has run there is
-- nothing to say, and an empty plate is worse than none.
local function AddHappinessTooltipLine(text)
if text then
GameTooltip:AddLine(text, "", 1, 1, 1)
end
end
function BenillaPetHappiness_OnEnter(frame)
if not frame.tooltip then
return
end
GameTooltip:SetOwner(frame, "ANCHOR_RIGHT")
GameTooltip:SetText(frame.tooltip)
AddHappinessTooltipLine(frame.tooltipDamage)
AddHappinessTooltipLine(frame.tooltipLoyalty)
GameTooltip:Show()
end
-- The four debuff buttons (ref PetFrame.xml's $parentDebuff1..4 over RefreshBuffs). The pet
-- carries no aura timers — no unit but yourself does on the 1.12 wire (decision 0257 B6) —
-- so these are icon + border + stack count, the party rows' shape exactly.
@@ -1801,26 +1872,30 @@
PetFrameAttackMode:SetVertexColor(1.0, 1.0, 1.0, alpha)
end
-- ref PetFrame_OnClick, now whole (decision 1066 built the right branch). Its FIRST leg is
-- the one that is easy to drop: with a spell armed, a RIGHT click disarms rather than
-- opening the menu, and it precedes both branches.
-- Clicking the pet frame (decision 1066 built the right-hand half). The LEFT button hands
-- the pet whatever the cursor is carrying; every other button opens its menu — except for
-- the one case that is easy to drop, a RIGHT click with a spell already armed, which puts
-- the spell away instead. That exception is the right button's alone: a middle click while
-- casting still opens the menu.
function PetFrame_OnClick(button)
if SpellIsTargeting() and button == "RightButton" then
SpellStopTargeting()
if button ~= "LeftButton" then
if button == "RightButton" and SpellIsTargeting() then
SpellStopTargeting()
else
-- No anchor arguments, unlike the player and target frames: this menu opens at
-- the CURSOR (ref PetFrame.lua l.128).
ToggleDropDownMenu(1, nil, PetFrameDropDown)
end
return
end
if button == "LeftButton" then
if SpellIsTargeting() then
SpellTargetUnit("pet")
elseif CursorHasItem() then
DropItemOnUnit("pet")
else
TargetUnit("pet")
end
-- Most specific first: an armed spell binds to the pet, then a held item drops on it
-- (this is how you feed one), and with an empty cursor the click simply selects it.
if SpellIsTargeting() then
SpellTargetUnit("pet")
elseif CursorHasItem() then
DropItemOnUnit("pet")
else
-- No anchor arguments, unlike the player and target frames: the ref opens this one
-- at the CURSOR (PetFrame.lua l.128).
ToggleDropDownMenu(1, nil, PetFrameDropDown)
TargetUnit("pet")
end
end
@@ -2006,17 +2081,7 @@
</Layers>
<Scripts>
<OnEnter>
if this.tooltip then
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetText(this.tooltip)
if this.tooltipDamage then
GameTooltip:AddLine(this.tooltipDamage, "", 1, 1, 1)
end
if this.tooltipLoyalty then
GameTooltip:AddLine(this.tooltipLoyalty, "", 1, 1, 1)
end
GameTooltip:Show()
end
BenillaPetHappiness_OnEnter(this)
</OnEnter>
<OnLeave>
GameTooltip:Hide()
File diff suppressed because it is too large Load Diff
+120 -112
View File
@@ -1,12 +1,15 @@
<!--
The fullscreen world map — transcribed from the 1.12 FrameXML WorldMapFrame.xml/.lua
(decision 0203 phase 2), restored verbatim by decision 0221 (the windowed / 1.14-header
direction of 0214/0217/0219 is parked; this faithful fullscreen sheet is the baseline we
return to). The map is pure FrameXML over the worldmap host API (benilla-ui
script/worldmap.rs): detail tiles are plain textures whose art the Lua swaps per selection
(GetMapInfo → Interface\WorldMap\<name>\<name>1..12), the player blip is positioned from
GetPlayerMapPosition exactly like the reference, navigation is SetMapZoom/-ToCurrentZone/
ProcessMapClick.
The fullscreen world map — our implementation of the behaviour the 1.12 FrameXML
WorldMapFrame.xml/.lua specifies (decision 0203 phase 2; decision 0221 settled the layout on
the fullscreen sheet — the windowed / 1.14-header direction of 0214/0217/0219 is parked, and
this faithful fullscreen sheet is the baseline we return to). The reference is the SPEC, not
the source of this text: the names are its contract (globals addons call, frame and template
names, art paths, every constant and texcoord) and the code that uses them is ours. The map
is pure FrameXML over the worldmap host API (benilla-ui script/worldmap.rs): detail tiles are
plain textures whose art the Lua swaps per selection (GetMapInfo →
Interface\WorldMap\<name>\<name>1..12), the player blip is positioned from
GetPlayerMapPosition in the same 0..1 map space the reference uses, navigation is
SetMapZoom/-ToCurrentZone/ProcessMapClick.
IN (this file): the FULLSCREEN frame + blackout, the 12-piece parchment chrome, the title,
the 1002×668 detail frame with its 12 tiles + ADD highlight, the discovery-overlay pool
@@ -33,7 +36,7 @@
NUM_WORLDMAP_DETAIL_TILES = 12;
NUM_WORLDMAP_OVERLAYS = 0;
-- ref WorldMapFrame.lua l.509-515, verbatim. Through the PANEL MANAGER, not a bare
-- ref WorldMapFrame.lua l.509-515 is the spec. Through the PANEL MANAGER, not a bare
-- Show/Hide: the map is registered `area = "full"` (UiPanels.xml), which is what makes it
-- take the screen from the other windows on the way in and — the reason this changed —
-- what stops it opening from behind the game menu, the one window that still could
@@ -60,9 +63,30 @@
WorldMapFrame:SetScale(scale);
end
-- ref-WorldMapFrame.lua WorldMapFrame_Update(): the tile swap, the discovery-overlay
-- pool, and the zoom-out arming (POI landmarks are still deferred). The "World" fallback
-- is the reference's own hack for the nil GetMapInfo at the world level — kept verbatim.
-- One overlay tile's extent along one axis: how many pixels of art it draws, and how wide
-- the FILE holding them is. Every tile is a full 256 except the last of a row or column,
-- which carries whatever remains of the overlay — and its file is padded up to the next
-- power of two (16 the smallest the art uses), so that tile shows only the leading
-- pixels/file fraction of it. Both axes want the same answer, so they ask the same way.
local function overlayTileExtent(index, count, span)
if ( index < count ) then
return 256, 256;
end
local pixels = mod(span, 256);
if ( pixels == 0 ) then
pixels = 256;
end
local file = 16;
while ( file < pixels ) do
file = file * 2;
end
return pixels, file;
end
-- The repaint (spec: ref WorldMapFrame.lua WorldMapFrame_Update): the tile swap, the
-- discovery-overlay pool, and the zoom-out arming (POI landmarks are still deferred). The
-- "World" fallback is the reference's own hack for the nil GetMapInfo at the world level,
-- kept because our host answers nil at that level for the same reason.
function WorldMapFrame_Update()
local mapFileName = GetMapInfo();
if ( not mapFileName ) then
@@ -72,72 +96,46 @@
getglobal("WorldMapDetailTile"..i):SetTexture("Interface\\WorldMap\\"..mapFileName.."\\"..mapFileName..i);
end
-- The discovery overlays (ref WorldMapFrame.lua:94-153): the base tiles are the
-- unexplored parchment; each REVEALED sub-area (GetNumMapOverlays / GetMapOverlayInfo,
-- filtered host-side by the explored bitset) paints its detailed art on top. Pool
-- ARTWORK textures on WorldMapDetailFrame on demand, slice each overlay into 256-px
-- tiles whose file sizes round up to the next power of two, park the leftovers hidden.
-- Native offsets — the map renders at its native 1024×768 (no WORLDMAP_SX/SY scale,
-- unlike the parked windowed layout); the overlays share the detail tiles' coord space.
local numOverlays = GetNumMapOverlays();
local textureName, textureWidth, textureHeight, offsetX, offsetY;
local textureCount = 0;
local texture;
local texturePixelWidth, textureFileWidth, texturePixelHeight, textureFileHeight;
local numTexturesWide, numTexturesTall, neededTextures;
for i=1, numOverlays do
textureName, textureWidth, textureHeight, offsetX, offsetY = GetMapOverlayInfo(i);
numTexturesWide = ceil(textureWidth/256);
numTexturesTall = ceil(textureHeight/256);
neededTextures = textureCount + (numTexturesWide * numTexturesTall);
if ( neededTextures > NUM_WORLDMAP_OVERLAYS ) then
for j=NUM_WORLDMAP_OVERLAYS+1, neededTextures do
WorldMapDetailFrame:CreateTexture("WorldMapOverlay"..j, "ARTWORK");
-- The discovery overlays (spec: ref WorldMapFrame.lua, "Setup the overlays"): the base
-- tiles are the unexplored parchment, and each REVEALED sub-area (GetNumMapOverlays /
-- GetMapOverlayInfo, filtered host-side by the explored bitset) paints its detailed
-- art over them. That art is cut into a grid of 256-px tiles, numbered 1..n across
-- then down, which we lay onto ARTWORK textures pooled on WorldMapDetailFrame — the
-- pool grows on demand, never shrinks, and the tail left over from a busier map is
-- parked hidden below. Offsets are native pixels: the map renders at its native
-- 1024×768 (no WORLDMAP_SX/SY scale, unlike the parked windowed layout), so the
-- overlays share the detail tiles' coordinate space untouched.
local used = 0;
for overlay = 1, GetNumMapOverlays() do
local art, artWidth, artHeight, originX, originY = GetMapOverlayInfo(overlay);
local columns = ceil(artWidth/256);
local rows = ceil(artHeight/256);
local wanted = used + (columns * rows);
if ( wanted > NUM_WORLDMAP_OVERLAYS ) then
for slot = NUM_WORLDMAP_OVERLAYS + 1, wanted do
WorldMapDetailFrame:CreateTexture("WorldMapOverlay"..slot, "ARTWORK");
end
NUM_WORLDMAP_OVERLAYS = neededTextures;
NUM_WORLDMAP_OVERLAYS = wanted;
end
for j=1, numTexturesTall do
if ( j < numTexturesTall ) then
texturePixelHeight = 256;
textureFileHeight = 256;
else
texturePixelHeight = mod(textureHeight, 256);
if ( texturePixelHeight == 0 ) then
texturePixelHeight = 256;
end
textureFileHeight = 16;
while(textureFileHeight < texturePixelHeight) do
textureFileHeight = textureFileHeight * 2;
end
end
for k=1, numTexturesWide do
textureCount = textureCount + 1;
texture = getglobal("WorldMapOverlay"..textureCount);
if ( k < numTexturesWide ) then
texturePixelWidth = 256;
textureFileWidth = 256;
else
texturePixelWidth = mod(textureWidth, 256);
if ( texturePixelWidth == 0 ) then
texturePixelWidth = 256;
end
textureFileWidth = 16;
while(textureFileWidth < texturePixelWidth) do
textureFileWidth = textureFileWidth * 2;
end
end
texture:SetWidth(texturePixelWidth);
texture:SetHeight(texturePixelHeight);
texture:SetTexCoord(0, texturePixelWidth/textureFileWidth, 0, texturePixelHeight/textureFileHeight);
texture:ClearAllPoints();
texture:SetPoint("TOPLEFT", offsetX + (256 * (k-1)), -(offsetY + (256 * (j - 1))));
texture:SetTexture(textureName..(((j - 1) * numTexturesWide) + k));
texture:Show();
end
-- Tile n of the overlay is file <art>n, and its place in the grid follows from n.
for tile = 1, columns * rows do
local column = mod(tile - 1, columns) + 1;
local row = ceil(tile / columns);
local wide, fileWide = overlayTileExtent(column, columns, artWidth);
local tall, fileTall = overlayTileExtent(row, rows, artHeight);
used = used + 1;
local texture = getglobal("WorldMapOverlay"..used);
texture:SetWidth(wide);
texture:SetHeight(tall);
texture:SetTexCoord(0, wide/fileWide, 0, tall/fileTall);
texture:ClearAllPoints();
texture:SetPoint("TOPLEFT", originX + (256 * (column - 1)), -(originY + (256 * (row - 1))));
texture:SetTexture(art..tile);
texture:Show();
end
end
for i=textureCount+1, NUM_WORLDMAP_OVERLAYS do
getglobal("WorldMapOverlay"..i):Hide();
for slot = used + 1, NUM_WORLDMAP_OVERLAYS do
getglobal("WorldMapOverlay"..slot):Hide();
end
if ( GetCurrentMapContinent() == 0 ) then
@@ -147,7 +145,7 @@
end
end
-- ref verbatim: zone view → its continent; continent view → the world.
-- The reference's rule: zone view → its continent; continent view → the world.
function WorldMapZoomOutButton_OnClick()
if ( GetCurrentMapZone() ~= 0 ) then
SetMapZoom(GetCurrentMapContinent());
@@ -156,9 +154,9 @@
end
end
-- ── The continent/zone dropdowns (ref verbatim, except the vararg walk: the reference's
-- `arg.n` is written as select("#", ...), the 5.1-native spelling and this tree's house
-- style for it). ──────────────────────────────────────────────────────────────────────
-- ── The continent/zone dropdowns (the reference's wiring; its `arg.n` vararg walk is
-- written here as select("#", ...), the 5.1-native spelling and this tree's house style
-- for it). ────────────────────────────────────────────────────────────────────────────
function WorldMapContinentsDropDown_OnLoad()
UIDropDownMenu_Initialize(this, WorldMapContinentsDropDown_Initialize);
UIDropDownMenu_SetWidth(130);
@@ -223,50 +221,60 @@
end
end
-- ref verbatim: left click drills in via ProcessMapClick on normalized button coords;
-- anything else zooms out.
-- Where the cursor sits inside a map-sized frame, as a fraction of it measured from the
-- TOPLEFT: (0,0) the top-left corner of the map art, (1,1) the bottom-right. Both sides of
-- the host's map API — the click (ProcessMapClick) and the hover (UpdateMapHighlight) —
-- take a position in exactly that space, and the cursor arrives in screen pixels with the
-- y axis pointing the other way, so this is the one conversion between them: undo the
-- frame's effective scale, then measure from its edges, which a frame gives up as its
-- center and its size (each edge being half the size away from the center).
local function cursorMapPosition(frame)
local cursorX, cursorY = GetCursorPosition();
local scale = frame:GetEffectiveScale();
local midX, midY = frame:GetCenter();
local frameWidth = frame:GetWidth();
local frameHeight = frame:GetHeight();
local fromLeft = (cursorX / scale) - (midX - (frameWidth/2));
local fromTop = (midY + (frameHeight/2)) - (cursorY / scale);
return fromLeft / frameWidth, fromTop / frameHeight;
end
-- Left click drills into whatever the host finds at that map position; every other button
-- is the zoom-out shortcut (ref WorldMapButton_OnClick — ours takes no button argument,
-- since nothing but the map button itself routes here).
function WorldMapButton_OnClick(mouseButton)
CloseDropDownMenus();
if ( mouseButton == "LeftButton" ) then
local x, y = GetCursorPosition();
x = x / WorldMapButton:GetEffectiveScale();
y = y / WorldMapButton:GetEffectiveScale();
local centerX, centerY = WorldMapButton:GetCenter();
local width = WorldMapButton:GetWidth();
local height = WorldMapButton:GetHeight();
local adjustedY = (centerY + (height/2) - y) / height;
local adjustedX = (x - (centerX - (width/2))) / width;
ProcessMapClick( adjustedX, adjustedY);
ProcessMapClick(cursorMapPosition(WorldMapButton));
else
WorldMapZoomOutButton_OnClick();
end
end
-- ref WorldMapButton_OnUpdate, reduced to hover + the player blip (party/raid/flags/
-- corpse/ping are deferred with their wire). UpdateMapHighlight is a stub until the
-- area-bitmap source lands (wow-re dispatch) — nil name/fileName take the hide paths.
-- The per-frame hover pass (spec: ref WorldMapButton_OnUpdate), reduced to the area
-- highlight + the player blip (party/raid/flags/ping are deferred with their wire).
-- UpdateMapHighlight is a stub until the area-bitmap source lands (wow-re dispatch) — a
-- nil area/art takes the hide paths.
function WorldMapButton_OnUpdate(elapsed)
local x, y = GetCursorPosition();
x = x / this:GetEffectiveScale();
y = y / this:GetEffectiveScale();
local centerX, centerY = this:GetCenter();
local width = this:GetWidth();
local height = this:GetHeight();
local adjustedY = (centerY + (height/2) - y ) / height;
local adjustedX = (x - (centerX - (width/2))) / width;
local name, fileName, texPercentageX, texPercentageY, textureX, textureY, scrollChildX, scrollChildY = UpdateMapHighlight( adjustedX, adjustedY );
WorldMapFrameAreaLabel:SetText(name or "");
if ( fileName ) then
WorldMapHighlight:SetTexCoord(0, texPercentageX, 0, texPercentageY);
WorldMapHighlight:SetTexture("Interface\\WorldMap\\"..fileName.."\\"..fileName.."Highlight");
textureX = textureX * width;
textureY = textureY * height;
scrollChildX = scrollChildX * width;
scrollChildY = -scrollChildY * height;
if ( (textureX > 0) and (textureY > 0) ) then
WorldMapHighlight:SetWidth(textureX);
WorldMapHighlight:SetHeight(textureY);
WorldMapHighlight:SetPoint("TOPLEFT", "WorldMapDetailFrame", "TOPLEFT", scrollChildX, scrollChildY);
local area, art, cropX, cropY, sizeX, sizeY, atX, atY =
UpdateMapHighlight(cursorMapPosition(this));
WorldMapFrameAreaLabel:SetText(area or "");
if ( art ) then
-- The sub-area under the cursor lights up: <zone>Highlight holds one shape per
-- sub-area, of which crop names the fraction actually used (the file is padded to
-- a power of two), while size and at are the shape's extent and top-left corner as
-- fractions of the map — so both scale by the button's pixel size. Nothing to draw
-- for a degenerate shape, but the art is still swapped in for the next frame.
WorldMapHighlight:SetTexCoord(0, cropX, 0, cropY);
WorldMapHighlight:SetTexture("Interface\\WorldMap\\"..art.."\\"..art.."Highlight");
local drawWidth = sizeX * width;
local drawHeight = sizeY * height;
if ( drawWidth > 0 and drawHeight > 0 ) then
WorldMapHighlight:SetWidth(drawWidth);
WorldMapHighlight:SetHeight(drawHeight);
WorldMapHighlight:SetPoint("TOPLEFT", "WorldMapDetailFrame", "TOPLEFT", atX * width, -atY * height);
WorldMapHighlight:Show();
end
else
@@ -283,7 +291,7 @@
WorldMapPlayer:SetPoint("CENTER", "WorldMapDetailFrame", "TOPLEFT", playerX, playerY);
WorldMapPlayer:Show();
end
-- Position corpse (ref WorldMapFrame.lua:443-452, verbatim law: (0,0) = hide).
-- Position corpse (ref WorldMapFrame.lua:443-452 — the law is that (0,0) means hide).
local corpseX, corpseY = GetCorpseMapPosition();
if ( corpseX == 0 and corpseY == 0 ) then
WorldMapCorpse:Hide();
+208 -123
View File
@@ -6,14 +6,19 @@ territory line) and SubZoneTextFrame (the 26px subzone line + the FFA "PvP Area"
by the FadingFrame kit when the app fires the ZONE_CHANGED family off the player's area
(`area.rs`).
Quoted from ref-ZoneText.xml as the live 5875 patch chain ships it (patch.MPQ overrides the
interface.MPQ copy — decision 0246) — now all THREE of its frames, the third being
AutoFollowStatus (the /follow status text, and the only thing ref-ZoneText.lua actually
contains), which waited here until follow support existed and no longer does. The FadingFrame
kit (ref-FadingFrame.lua, its own TOC line right before ZoneText.xml) is inlined verbatim below
too: ZoneTextFrame/SubZoneTextFrame are its only consumers in the whole 1.12 FrameXML
(grep-verified over the extraction), so it rides with them. AutoFollowStatus is NOT one of
them — it rolls its own fade, which is why the kit stays a two-frame affair.
The Lua below is OURS: our own implementation of the behaviour ref-ZoneText.xml specifies, as
the live 5875 patch chain ships that file (patch.MPQ overrides the interface.MPQ copy — decision
0246). What is quoted from it, deliberately, is the published surface and the measurements — the
global names addons call (SetZoneText, the three Zone*Duration constants, every frame and
fontstring name), the event names, the colours, and the frame geometry — because those are facts
about how the real interface looks and behaves rather than choices of ours. All THREE of the
reference file's frames are here, the third being AutoFollowStatus (the /follow status text, and
the only thing ref-ZoneText.lua actually contains), which waited here until follow support
existed and no longer does. The FadingFrame kit (ref-FadingFrame.lua, its own TOC line right
before ZoneText.xml) is implemented below too — our own code behind that file's published API,
since ZoneTextFrame/SubZoneTextFrame are its only consumers in the whole 1.12 FrameXML
(grep-verified over the extraction), so it rides with them. AutoFollowStatus is NOT one of them —
it rolls its own fade, which is why the kit stays a two-frame affair.
The subtle law worth naming (it looks like a bug and is not): SubZoneTextString — a child of
SubZoneTextFrame — anchors to regions of the *other* frame (ZoneTextString / PVPInfoTextString),
@@ -21,7 +26,7 @@ which is usually hidden when only the subzone changes. The resolver computes hid
rects all the same, so a subzone-only splash seats where it would sit *under the invisible zone
name* — the authentic lower position. ZoneTextFrame's OnEvent caches GetZoneText() silently on
plain ZONE_CHANGED and splashes only when an INDOORS/NEW_AREA event finds the zone text changed;
PvP info shows only on NEW_AREA (SetZoneText(1)).
the territory line is asked for on NEW_AREA alone.
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.blizzard.com/wow/ui/
..\FrameXML\UI.xsd">
@@ -33,14 +38,23 @@ PvP info shows only on NEW_AREA (SetZoneText(1)).
AUTOFOLLOWSTART = AUTOFOLLOWSTART or "Following %s.";
AUTOFOLLOWSTOP = AUTOFOLLOWSTOP or "You stop following %s.";
-- The ref's localization identity (UIParent.lua), so the AutoFollowStatus body below can
-- stay verbatim. Guarded like everything else here rather than leaning on UnitPopup.xml
-- happening to load first — a one-line helper is no place to acquire a load-order edge.
-- The ref's localization identity (UIParent.lua), which the AutoFollowStatus body below
-- puts its message through as the reference does. Guarded like everything else here rather
-- than leaning on UnitPopup.xml happening to load first — a one-line helper is no place to
-- acquire a load-order edge.
if ( not TEXT ) then
TEXT = function(s) return s end
end
-- ── ref-FadingFrame.lua, verbatim ──────────────────────────────────────────────────────
-- ── The FadingFrame kit ────────────────────────────────────────────────────────────────
-- Our implementation of the helper ref-FadingFrame.lua specifies. The global names and the
-- `this.*` fields ARE that file's published API — addons and the frames below reach for them
-- by name — so those are fixed; the code behind them is ours.
--
-- The contract: FadingFrame_Show() starts one timeline on the frame — ramp alpha up over
-- fadeInTime, hold at full for holdTime, ramp back down over fadeOutTime, then hide. A leg
-- configured to zero is simply skipped, and a frame that has never been configured (all
-- three zero, per OnLoad) hides on its very first update.
function FadingFrame_SetFadeInTime(this, time)
this.fadeInTime = time;
end
@@ -51,9 +65,10 @@ PvP info shows only on NEW_AREA (SetZoneText(1)).
this.fadeOutTime = time;
end
function FadingFrame_OnLoad()
this.fadeInTime = 0;
this.holdTime = 0;
this.fadeOutTime = 0;
-- Through the setters, so the field names are written down in exactly one place.
FadingFrame_SetFadeInTime(this, 0);
FadingFrame_SetHoldTime(this, 0);
FadingFrame_SetFadeOutTime(this, 0);
this:Hide();
end
function FadingFrame_Show()
@@ -61,76 +76,164 @@ PvP info shows only on NEW_AREA (SetZoneText(1)).
this:Show();
end
function FadingFrame_OnUpdate()
-- One timeline, one alpha: which leg `elapsed` has reached decides the value, and
-- running off the end of the last leg is the only way the frame stops.
local elapsed = GetTime() - this.startTime;
local fadeInTime = this.fadeInTime;
if ( elapsed < fadeInTime ) then
local alpha = (elapsed / fadeInTime);
this:SetAlpha(alpha);
local fadeIn = this.fadeInTime;
local holdEnds = fadeIn + this.holdTime;
local alpha;
if ( elapsed < fadeIn ) then
alpha = elapsed / fadeIn;
elseif ( elapsed < holdEnds ) then
alpha = 1.0;
elseif ( elapsed < (holdEnds + this.fadeOutTime) ) then
alpha = 1.0 - ((elapsed - holdEnds) / this.fadeOutTime);
else
this:Hide();
return;
end
local holdTime = this.holdTime;
if ( elapsed < (fadeInTime + holdTime) ) then
this:SetAlpha(1.0);
return;
end
local fadeOutTime = this.fadeOutTime;
if ( elapsed < (fadeInTime + holdTime + fadeOutTime) ) then
local alpha = 1.0 - ((elapsed - holdTime - fadeInTime) / fadeOutTime);
this:SetAlpha(alpha);
return;
end
this:Hide();
this:SetAlpha(alpha);
end
-- ── ref-ZoneText.xml <Script>, verbatim ────────────────────────────────────────────────
-- ── The zone splash ───────────────────────────────────────────────────────────────────
-- Our implementation of what ref-ZoneText.xml's own <Script> block and its two splash
-- frames' handlers specify. The names below are that file's published API — addons re-render
-- the splash through SetZoneText and retune it by writing the three durations — so the
-- names and the numbers are fixed; the code between them is ours.
ZoneFadeInDuration = 0.5;
ZoneHoldDuration = 1.0;
ZoneFadeOutDuration = 2.0;
-- How a HELD zone is tinted, keyed by who holds it. Three cases differing only in three
-- floats want to be data — the same shape MinimapCluster.xml keeps for the minimap's zone
-- label, which reads the same GetZonePVPInfo; duplicated there and here rather than
-- published as a global the reference does not have. A pvpType that is not in the table is
-- unheld (sanctuary, or a type we do not know), and gets no territory line and the zone
-- font's own parchment colour back — ZoneTextFont's <Color> in Fonts.xml is this triple.
local ZONE_PVP_TINT = {
friendly = { r = 0.1, g = 1.0, b = 0.1 },
hostile = { r = 1.0, g = 0.1, b = 0.1 },
contested = { r = 1.0, g = 0.7, b = 0 },
};
local UNHELD_ZONE_TINT = { r = 1.0, g = 0.9294, b = 0.7607 };
-- The line under the zone name saying who holds the place. The localisation globals are
-- read here, at call time, rather than folded into the table above: something later in the
-- load order is allowed to replace them.
local function ZoneTerritoryLine(pvpType, factionName)
if ( pvpType == "contested" ) then
return CONTESTED_TERRITORY;
end
return format(FACTION_CONTROLLED_TERRITORY, factionName);
end
-- Re-render the whole splash from the host's idea of where the player is: the zone name,
-- its tint, the territory line under it, the free-for-all call-out, and where the subzone
-- line hangs. `showPVPInfo` is the caller's answer to "is this a NEW zone?" — a hop between
-- subzones of one zone re-renders without it and gets the same tint with no territory line.
function SetZoneText(showPVPInfo)
ZoneTextString:SetText(GetZoneText());
local pvpType, factionName, isArena = GetZonePVPInfo();
if ( pvpType == "friendly" ) then
PVPInfoTextString:SetText(format(FACTION_CONTROLLED_TERRITORY, factionName));
ZoneTextString:SetTextColor(0.1, 1.0, 0.1);
SubZoneTextString:SetTextColor(0.1, 1.0, 0.1);
PVPInfoTextString:SetTextColor(0.1, 1.0, 0.1);
SubZoneTextString:SetPoint("TOP", "PVPInfoTextString", "BOTTOM", 0, 0);
elseif ( pvpType == "hostile" ) then
PVPInfoTextString:SetText(format(FACTION_CONTROLLED_TERRITORY, factionName));
ZoneTextString:SetTextColor(1.0, 0.1, 0.1);
SubZoneTextString:SetTextColor(1.0, 0.1, 0.1);
PVPInfoTextString:SetTextColor(1.0, 0.1, 0.1);
SubZoneTextString:SetPoint("TOP", "PVPInfoTextString", "BOTTOM", 0, 0);
elseif ( pvpType == "contested" ) then
PVPInfoTextString:SetText(CONTESTED_TERRITORY);
ZoneTextString:SetTextColor(1.0, 0.7, 0);
SubZoneTextString:SetTextColor(1.0, 0.7, 0);
PVPInfoTextString:SetTextColor(1.0, 0.7, 0);
local held = ZONE_PVP_TINT[pvpType];
local tint = held or UNHELD_ZONE_TINT;
ZoneTextString:SetTextColor(tint.r, tint.g, tint.b);
SubZoneTextString:SetTextColor(tint.r, tint.g, tint.b);
if ( held ) then
-- The territory line wears the tint too — including when the branch below finds it
-- has nothing to say. Nothing on screen can tell (a blank string has no colour to
-- show), but an addon that reads the tint off the blank line reads this zone's.
-- Guarded, because the unheld tint is the zone FONT's parchment and this line, a
-- SubZoneTextFont string, is never given it.
PVPInfoTextString:SetTextColor(tint.r, tint.g, tint.b);
end
-- The territory line and the subzone line's seat are one decision, not two: the line
-- is there only when somebody holds this zone AND the caller asked for it, and the
-- subzone line drops to hang off the line exactly when there is one to hang off.
if ( held and showPVPInfo ) then
PVPInfoTextString:SetText(ZoneTerritoryLine(pvpType, factionName));
SubZoneTextString:SetPoint("TOP", "PVPInfoTextString", "BOTTOM", 0, 0);
else
PVPInfoTextString:SetText("");
ZoneTextString:SetTextColor(1.0, 0.9294, 0.7607);
SubZoneTextString:SetTextColor(1.0, 0.9294, 0.7607);
SubZoneTextString:SetPoint("TOP", "ZoneTextString", "BOTTOM", 0, 0);
end
-- A free-for-all pit is called out under the subzone line and drags that line to the
-- hostile red whatever the zone around it is — inside the pit, everyone is hostile.
if ( isArena ) then
local ffa = ZONE_PVP_TINT.hostile;
PVPArenaTextString:SetText(FREE_FOR_ALL_TERRITORY);
SubZoneTextString:SetTextColor(1.0, 0.1, 0.1);
SubZoneTextString:SetTextColor(ffa.r, ffa.g, ffa.b);
else
PVPArenaTextString:SetText("");
end
if ( not showPVPInfo ) then
PVPInfoTextString:SetText("");
SubZoneTextString:SetPoint("TOP", "ZoneTextString", "BOTTOM", 0, 0);
end
end
-- ── ref-ZoneText.lua, verbatim ─────────────────────────────────────────────────────────
-- The auto-follow status line. Note it does NOT use the FadingFrame kit above: its own
-- OnUpdate rolls a plain linear alpha fade with no fade-in and no hold, started only on
-- AUTOFOLLOW_END. BEGIN is a full reset (fadeTime cleared, alpha restored, shown), which
-- is why a follow that switches subject needs no END first.
-- Both splash frames are the same kind of thing — one fade timeline, and all three of the
-- events the client splits an area change into (a move within the current zone, an interior
-- threshold crossed, a genuinely new zone) — so the arming is written once and neither can
-- drift from the other. Each decides for itself, in its OnEvent, whether the line it owns
-- actually changed.
function ZoneSplashFrame_OnLoad()
FadingFrame_OnLoad();
FadingFrame_SetFadeInTime(this, ZoneFadeInDuration);
FadingFrame_SetHoldTime(this, ZoneHoldDuration);
FadingFrame_SetFadeOutTime(this, ZoneFadeOutDuration);
this:RegisterEvent("ZONE_CHANGED");
this:RegisterEvent("ZONE_CHANGED_INDOORS");
this:RegisterEvent("ZONE_CHANGED_NEW_AREA");
end
-- The big zone name. A plain ZONE_CHANGED never splashes it: that event fires whenever the
-- player crosses a subzone boundary inside one zone, so all it may do is keep the cached
-- name honest. That silent cache write IS the subtle law in the header — it is what makes a
-- later INDOORS/NEW_AREA that lands on the same name find nothing left to say.
function ZoneTextFrame_OnEvent(event)
local zoneText = GetZoneText();
local changed = zoneText ~= this.zoneText;
this.zoneText = zoneText;
if ( event == "ZONE_CHANGED" or not changed ) then
return;
end
-- Only a genuinely new zone earns the territory line under the name.
SetZoneText(event == "ZONE_CHANGED_NEW_AREA");
FadingFrame_Show();
end
function SubZoneTextFrame_OnLoad()
ZoneSplashFrame_OnLoad();
-- The free-for-all call-out is red for good, so it is tinted once here rather than on
-- every splash.
local ffa = ZONE_PVP_TINT.hostile;
PVPArenaTextString:SetTextColor(ffa.r, ffa.g, ffa.b);
-- Prime both lines from wherever the player already is: this runs at load, and the next
-- area event may not arrive until they walk somewhere.
SetZoneText(true);
SubZoneTextString:SetText(GetSubZoneText());
end
-- The small subzone line. Any of the three events can splash it, and the text it is already
-- showing IS its cache — it has no silent path to keep a separate one honest.
function SubZoneTextFrame_OnEvent()
local subZoneText = GetSubZoneText();
if ( subZoneText == SubZoneTextString:GetText() ) then
return;
end
SubZoneTextString:SetText(subZoneText);
-- Re-render the zone half too, territory line and all. Registered second, this frame is
-- dispatched last of the two (FIFO — wow-re `event-dispatch-order.md`), so on a new zone
-- it is this call that leaves the territory line standing under the name.
SetZoneText(true);
FadingFrame_Show();
end
-- ── The auto-follow status line ────────────────────────────────────────────────────────
-- Our implementation of what ref-ZoneText.lua specifies — the only thing that .lua actually
-- contains, and the reason it exists at all. Note it does NOT use the FadingFrame kit
-- above: it rolls a plain linear alpha fade with no fade-in and no hold, armed only by
-- AUTOFOLLOW_END. BEGIN is a full reset (fade cancelled, alpha restored, shown), which is
-- why a follow that switches subject needs no END first. The 4s is the reference's.
AUTOFOLLOW_STATUS_FADETIME = 4.0;
function AutoFollowStatus_OnLoad()
@@ -140,36 +243,51 @@ PvP info shows only on NEW_AREA (SetZoneText(1)).
end
function AutoFollowStatus_OnEvent(event)
if ( event == "PLAYER_ENTERING_WORLD" ) then
-- A world load clears the line outright: nothing to say, and nothing to fade.
this:Hide();
return;
end
-- Both follow events land the same way — put up a line naming this.unit and show it —
-- so all they differ in is the message and what they do to the fade.
local message;
if ( event == "AUTOFOLLOW_BEGIN" ) then
this.unit = arg1;
this.fadeTime = nil;
this:SetAlpha(1.0);
AutoFollowStatusText:SetText(format(TEXT(AUTOFOLLOWSTART),this.unit));
this:Show();
end
if ( event == "AUTOFOLLOW_END" ) then
message = AUTOFOLLOWSTART;
elseif ( event == "AUTOFOLLOW_END" ) then
this.fadeTime = AUTOFOLLOW_STATUS_FADETIME;
AutoFollowStatusText:SetText(format(TEXT(AUTOFOLLOWSTOP),this.unit));
this:Show();
end
if ( event == "PLAYER_ENTERING_WORLD" ) then
this:Hide();
message = AUTOFOLLOWSTOP;
else
return;
end
AutoFollowStatusText:SetText(format(TEXT(message), this.unit));
this:Show();
end
function AutoFollowStatus_OnUpdate(elapsed)
if( this.fadeTime ) then
if( elapsed >= this.fadeTime ) then
this:Hide();
else
this.fadeTime = this.fadeTime - elapsed;
local alpha = this.fadeTime / AUTOFOLLOW_STATUS_FADETIME;
this:SetAlpha(alpha);
end
-- Only AUTOFOLLOW_END arms the countdown; until then the line just sits at full alpha.
local remaining = this.fadeTime;
if ( not remaining ) then
return;
end
-- Out of time: hide, and leave fadeTime alone. It is stale from here until the next
-- BEGIN clears it or the next END re-arms it, and a hidden frame gets no OnUpdate.
if ( elapsed >= remaining ) then
this:Hide();
return;
end
remaining = remaining - elapsed;
this.fadeTime = remaining;
this:SetAlpha(remaining / AUTOFOLLOW_STATUS_FADETIME);
end
]]></Script>
<!-- These frames are shown when the current zone or subzone changes.
<!-- The two splash frames. Same box, same seat on screen, same fade: one carries the zone name
and the territory line under it, the other the subzone line and the free-for-all call-out.
Geometry is the reference's measurement, not a choice of ours — the 512-wide strings inside
a 128 box anchored BOTTOM +512 are what the real splash occupies.
(ref carries toplevel="true" on both; still dropped here, but the reason has changed.
`SetToplevel` is no longer a gap — the raise law is built, `script::object::toplevel` —
and its real trigger is Show, not a click, so "click-raise is meaningless on mouse-less
@@ -208,29 +326,10 @@ PvP info shows only on NEW_AREA (SetZoneText(1)).
</Layers>
<Scripts>
<OnLoad>
FadingFrame_OnLoad();
FadingFrame_SetFadeInTime(this, ZoneFadeInDuration);
FadingFrame_SetHoldTime(this, ZoneHoldDuration);
FadingFrame_SetFadeOutTime(this, ZoneFadeOutDuration);
this:RegisterEvent("ZONE_CHANGED");
this:RegisterEvent("ZONE_CHANGED_INDOORS");
this:RegisterEvent("ZONE_CHANGED_NEW_AREA");
ZoneSplashFrame_OnLoad();
</OnLoad>
<OnEvent>
local newText = GetZoneText();
if ( event == "ZONE_CHANGED" ) then
this.zoneText = newText;
return;
end
if ( newText ~= this.zoneText ) then
this.zoneText = newText;
if ( event == "ZONE_CHANGED_NEW_AREA" ) then
SetZoneText(1);
else
SetZoneText();
end
FadingFrame_Show();
end
ZoneTextFrame_OnEvent(event);
</OnEvent>
<OnUpdate>
FadingFrame_OnUpdate();
@@ -270,36 +369,22 @@ PvP info shows only on NEW_AREA (SetZoneText(1)).
</Layers>
<Scripts>
<OnLoad>
FadingFrame_OnLoad();
FadingFrame_SetFadeInTime(this, ZoneFadeInDuration);
FadingFrame_SetHoldTime(this, ZoneHoldDuration);
FadingFrame_SetFadeOutTime(this, ZoneFadeOutDuration);
this:RegisterEvent("ZONE_CHANGED");
this:RegisterEvent("ZONE_CHANGED_INDOORS");
this:RegisterEvent("ZONE_CHANGED_NEW_AREA");
PVPArenaTextString:SetTextColor(1.0, 0.1, 0.1);
SetZoneText(1);
SubZoneTextString:SetText(GetSubZoneText());
SubZoneTextFrame_OnLoad();
</OnLoad>
<OnEvent>
local text = SubZoneTextString:GetText();
local newText = GetSubZoneText();
if(text ~= newText) then
SubZoneTextString:SetText(newText);
SetZoneText(1);
FadingFrame_Show();
end
SubZoneTextFrame_OnEvent();
</OnEvent>
<OnUpdate>
FadingFrame_OnUpdate();
</OnUpdate>
</Scripts>
</Frame>
<!-- The auto-follow status line, quoted from ref-ZoneText.xml. The only thing ref-ZoneText.lua
actually contains, and the reason that .lua exists at all; it waited here until follow
support did (decisions 0890, 0893). `setAllPoints` over UIParent, so the line sits dead
centre of the screen. BACKGROUND strata: it is under everything, which is why a full-screen
panel hides it.
<!-- The auto-follow status line — the third frame ref-ZoneText.xml declares, laid out as that
file specifies it (the handlers behind it, like every other line of Lua here, are ours).
It is the only thing ref-ZoneText.lua actually contains, and the reason that .lua exists at
all; it waited here until follow support did (decisions 0890, 0893). `setAllPoints` over
UIParent, so the line sits dead centre of the screen. BACKGROUND strata: it is under
everything, which is why a full-screen panel hides it.
ONE DEVIATION, forced: the ref anchors the string CENTER to **WorldFrame**, and benilla has
no WorldFrame — it is the reference's 3D-scene layer frame, which our renderer does not
+132 -3
View File
@@ -6,7 +6,9 @@
//! ([`CharListMessage`]); the **pick policy** here decides what answers it — the pending pick
//! (seamless reconnect, decision 0065), the `WOW_CHAR` env fast path, or the director's choice on
//! the screen. The pick travels the [`CharPick`] channel; `Connected` flips us `InWorld`;
//! a `/logout` round-trip ([`LoggedOutMessage`]) flips back with the pending pick cleared.
//! a `/logout` round-trip ([`LoggedOutMessage`]) flips back to select with the pending pick
//! cleared, and a **lost** session flips all the way back to the login screen (decision 1262 —
//! the reference's `DISCONNECTED_FROM_SERVER`).
//!
//! The screen is the reference's own arrangement (`CharacterSelect.xml/.lua`, extracted off the
//! patch chain — decision 0465): the fullscreen `UI_<Race>` glue scene with the **selected
@@ -103,7 +105,15 @@ impl Plugin for CharSelectPlugin {
(
// The policy + transitions run in BOTH states: the roster auto-answer (reconnect
// relogin) happens while `InWorld`, and the logout edge arrives there too.
(apply_roster_policy, enter_on_connected, back_on_logout).chain(),
// `back_on_disconnect` LAST: a session that died during entry queues both
// edges into one frame, and the dead one has to win (decision 1262).
(
apply_roster_policy,
enter_on_connected,
back_on_logout,
back_on_disconnect,
)
.chain(),
(
screen::materialize_screen,
input::select_input,
@@ -279,7 +289,15 @@ fn apply_roster_policy(
}
None
}
None => std::env::var("WOW_CHAR").ok(),
// `WOW_CHAR`, or the login smoke's optional third field (decision 1262): the smoke's
// seat exists because `WOW_CHAR` is also an *unattended* marker
// ([`crate::run_mode::unattended_login`]), so using it to reach the world would switch
// the very branch a session test is trying to exercise. Same fast path, no marker.
None => std::env::var("WOW_CHAR").ok().or_else(|| {
std::env::var("WOW_LOGIN_SMOKE")
.ok()
.and_then(|s| crate::login::smoke_character(&s))
}),
};
}
for msg in msgs.read() {
@@ -354,6 +372,35 @@ fn enter_on_connected(
}
}
/// **The session died** → all the way back to the login screen (decision 1262), which is where the
/// reference's `GlueParent.lua` puts it: `SetGlueScreen("login")` on `DISCONNECTED_FROM_SERVER`,
/// with [`crate::login`] raising the "Disconnected from server" dialog over it.
///
/// Chained **after** [`enter_on_connected`] on purpose. Both edges can land in the same frame — the
/// IO thread emits `Connected` before its read loop starts, so a stream that dies during entry
/// queues `Connected` and `Disconnected` back to back and the app drains both at once. That is the
/// race the report caught: the entry half won, and the client walked into a world whose session had
/// already been torn down — a camera with no avatar, `SelfGuid` set, an entry awaiting a snap that
/// could never arrive. Ordering it last makes the dead session the last word by construction.
fn back_on_disconnect(
mut msgs: MessageReader<crate::net::DisconnectedMessage>,
mut roster: ResMut<Roster>,
mut next: ResMut<NextState<ClientState>>,
mut ui_hover: ResMut<crate::ui_script::PlayerUiHover>,
mut ui_keys: ResMut<crate::ui_script::UiKeyboardCapture>,
) {
if !msgs.read().any(|m| m.session_over) {
return;
}
// The pending pick is the reconnect's memory (0065). With no reconnect coming it would only
// auto-answer the *next* roster — sending the player straight back into the world they were
// just thrown out of, without ever seeing the screen.
roster.pending_pick = None;
ui_hover.0 = None;
ui_keys.0 = false;
next.set(ClientState::Login);
}
/// A confirmed `/logout` → back to the glue layer, pick cleared (the follow-up roster must be
/// shown, not auto-answered). Also releases the in-world UI's input latches — `feed_ui_input`
/// stops running outside `InWorld`, so whatever it last wrote would otherwise stick.
@@ -639,6 +686,88 @@ mod tests {
}
}
/// **A session that dies during world entry does not leave the client in the world**
/// (decision 1262).
///
/// The IO thread emits `Connected` *before* its read loop starts, so a socket that dies mid-
/// entry — which is exactly what a displacement kick looks like, a bare EOF and nothing else —
/// queues `Connected` and `Disconnected` back to back, and the drain (`try_iter`) hands the app
/// both in one frame. Both edges then fire in the same `Update`. Whichever writes `NextState`
/// last wins, and before this the entry did: the client flipped `InWorld` against a world the
/// same frame's teardown had already emptied, and stood there in a camera with no avatar.
///
/// The guard is the *ordering*, which nothing else can catch: each system is correct alone, the
/// build is green either way, and the failure needs two logins racing on one account to
/// reproduce by hand.
#[test]
fn a_session_lost_during_entry_beats_the_entry() {
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
.insert_state(ClientState::CharSelect)
.init_resource::<Roster>()
.init_resource::<crate::ui_script::PlayerUiHover>()
.init_resource::<crate::ui_script::UiKeyboardCapture>()
.add_message::<EnteredWorldMessage>()
.add_message::<crate::net::DisconnectedMessage>()
.add_systems(Update, (enter_on_connected, back_on_disconnect).chain());
app.world_mut().resource_mut::<Roster>().pending_pick = Some(7);
// One frame carrying both halves of the race, in the order the drain produces them.
app.world_mut().write_message(EnteredWorldMessage);
app.world_mut()
.write_message(crate::net::DisconnectedMessage {
reason: "disconnected: world stream closed: failed to fill whole buffer".into(),
end: benilla_protocol::SessionEnd::Lost,
session_over: true,
});
app.update();
app.update(); // `StateTransition` applies the pending state at the next frame
assert_eq!(
*app.world().resource::<State<ClientState>>().get(),
ClientState::Login,
"the dead session must be the last word — the reference's DISCONNECTED_FROM_SERVER \
puts the client back on the account screen, not into a world with no session",
);
assert_eq!(
app.world().resource::<Roster>().pending_pick,
None,
"and the reconnect's pending pick goes with it, or the next roster walks straight \
back into the world the player was just thrown out of",
);
}
/// The same frame with the session *not* over (a clean logout's teardown, or an unattended run
/// keeping 0065's seamless reconnect) still enters the world — the guard above must not have
/// turned every disconnect into a bounce to the login screen.
#[test]
fn a_teardown_that_is_not_the_end_leaves_the_entry_alone() {
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
.insert_state(ClientState::CharSelect)
.init_resource::<Roster>()
.init_resource::<crate::ui_script::PlayerUiHover>()
.init_resource::<crate::ui_script::UiKeyboardCapture>()
.add_message::<EnteredWorldMessage>()
.add_message::<crate::net::DisconnectedMessage>()
.add_systems(Update, (enter_on_connected, back_on_disconnect).chain());
app.world_mut().write_message(EnteredWorldMessage);
app.world_mut()
.write_message(crate::net::DisconnectedMessage {
reason: "logged out".into(),
end: benilla_protocol::SessionEnd::LoggedOut,
session_over: false,
});
app.update();
app.update();
assert_eq!(
*app.world().resource::<State<ClientState>>().get(),
ClientState::InWorld,
);
}
/// B119 — a created character is selected against the roster **already in hand**. `net::io`
/// re-enumerates and emits the fresh roster BEFORE the create result, so by the time
/// `note_created` runs the row exists and the roster message is spent; a version that only armed
+25 -3
View File
@@ -311,11 +311,21 @@ fn drive_loading_screen(
MessageReader<crate::net::WorldportMessage>,
MessageReader<crate::net::TeleportMessage>,
MessageReader<crate::net::LoggedOutMessage>,
MessageReader<crate::net::DisconnectedMessage>,
Option<Res<crate::net::PendingTransfer>>,
Option<Res<crate::char_select::Roster>>,
),
) {
let (state, mut entered, mut worldports, mut teleports, mut logouts, transfer, roster) = edges;
let (
state,
mut entered,
mut worldports,
mut teleports,
mut logouts,
mut lost,
transfer,
roster,
) = edges;
let now = time.elapsed_secs();
let map_id = current_map.as_ref().map(|m| m.0);
// The physics hold releases on the same residency signal that clears this screen (decision
@@ -362,13 +372,25 @@ fn drive_loading_screen(
// down, but `CharSelect` only applies at the NEXT frame's state transition — cover the dead
// world with plain black until the glue owns the screen. No art, no bar: the ref's
// world→glue swap is a black cut, not a loading screen.
if logouts.read().next().is_some() {
// A dead session is the same world→glue cut (decision 1262), and it needs this arm more than
// logout does: the entry race raises the cover on `EnteredWorldMessage` a few lines up and
// arms `awaiting_snap` for a snap the dead socket will never send, so without the disarm here
// the cover is what the player would have been left staring at.
let session_over = lost.read().any(|m| m.session_over);
if logouts.read().next().is_some() || session_over {
screen.active = true;
screen.blackout = true;
screen.awaiting_snap = false;
screen.pending_map = None;
screen.ready_frames = 0;
info!("loading screen: blackout (logout)");
info!(
"loading screen: blackout ({})",
if session_over {
"session lost"
} else {
"logout"
}
);
}
if screen.blackout && *state.get() != crate::char_select::ClientState::InWorld {
// The glue is up (it renders above this root); the cover's job is done.
+204 -21
View File
@@ -6,10 +6,15 @@
//! This module owns the **credential policy** — the 0193 §3 mirror for the IO thread's pre-logon
//! park: the env fast path (any of `WOW_USER`/`WOW_PASS`/`WOW_CHAR` explicitly set auto-submits
//! with the old `one`/`pone` defaults, so every probe/smoke invocation keeps working), the
//! pending-credentials resubmit that preserves 0065's seamless reconnect (paced at the flat 3 s,
//! app-side — the IO thread never sleeps), and the director's typed submit. A *refused* code
//! (bad password) clears the intent and shows the authored `AUTH_*` dialog — never an auto-retry
//! against a refusal.
//! pending-credentials resubmit (paced at the flat 3 s, app-side — the IO thread never sleeps),
//! and the director's typed submit. A *refused* code (bad password) clears the intent and shows
//! the authored `AUTH_*` dialog — never an auto-retry against a refusal.
//!
//! **A session that is lost is over** (decision 1262): the reference's `GlueParent.lua` answers
//! `DISCONNECTED_FROM_SERVER` with `SetGlueScreen("login")` + `GlueDialog_Show("DISCONNECTED")`,
//! and so does this. 0065's seamless reconnect survives only where nobody is here to type — an
//! unattended run ([`crate::run_mode::unattended_login`]) — because a client that
//! re-authenticates on its own takes the account back off whoever just displaced it.
//!
//! Module split: this file (state, policy, input, dialogs, the saved-account persistence),
//! [`screen`] (the authored layout, transcribed from `AccountLogin.xml`).
@@ -99,8 +104,9 @@ enum IoPark {
/// authenticated (or asked) with, the in-flight/park bookkeeping, and the resubmit timer.
#[derive(Resource, Default)]
pub(crate) struct LoginIntent {
/// The session's credentials — kept while in-world so a reconnect re-authenticates silently
/// (0065); cleared by select's Back, a refusal code, or a Cancel.
/// The session's credentials — kept while in-world so the logout relist and an unattended
/// run's reconnect re-authenticate silently (0065); cleared by select's Back, a refusal code,
/// a Cancel, and by a lost session (1262 — they are the session's, and the session is over).
creds: Option<(String, String)>,
/// A submit is in flight (between our send and its LoginFailed/CharacterList answer).
in_flight: bool,
@@ -209,10 +215,10 @@ fn drive_policy(
// The login smoke drives its own credentials instead.
if !intent.env_read {
intent.env_read = true;
let any_set = ["WOW_USER", "WOW_PASS", "WOW_CHAR"]
.iter()
.any(|k| std::env::var_os(k).is_some());
if any_set && std::env::var_os("WOW_LOGIN_SMOKE").is_none() {
// The same fact a lost session asks about (decision 1262) — read from the one place that
// owns it, so "the harness logs in for us" and "the harness logs back in for us" can never
// be two different answers.
if crate::run_mode::unattended_login() && std::env::var_os("WOW_LOGIN_SMOKE").is_none() {
let user = std::env::var("WOW_USER").unwrap_or_else(|_| "one".into());
let pass = std::env::var("WOW_PASS").unwrap_or_else(|_| "pone".into());
// The account guard (decision 0649): a vmangos login KICKS whoever holds the account,
@@ -276,13 +282,28 @@ fn drive_policy(
}
}
for msg in disconnects.read() {
// The IO thread is heading back to its pre-logon park. With session credentials the
// re-auth is silent: immediate after a clean logout (the roster IS the select screen the
// app now shows), paced after a stream death (the old reconnect delay, app-side).
// The IO thread is heading back to its pre-logon park.
intent.park = IoPark::AtLogin;
intent.in_flight = false;
if msg.session_over {
// The reference's `DISCONNECTED_FROM_SERVER` (decision 1262): `GlueParent.lua` answers
// it with `SetGlueScreen("login")` + `GlueDialog_Show("DISCONNECTED")` — the account
// screen and one Okay button. Nothing retries, and the credentials go with the session:
// a client that re-authenticates on its own steals the account back from whoever just
// displaced it, which is the ping-pong the report described.
warn!(
"login: {} — session over, back to the login screen",
msg.reason
);
intent.clear();
dialog.open_error(strings.text("DISCONNECTED", "Disconnected from server"));
continue;
}
// Otherwise the session continues through the park and the re-auth is silent: immediate
// after a clean logout (the roster IS the select screen the app now shows), paced after a
// stream death an unattended run must recover from on its own (0065, paced app-side).
if intent.creds.is_some() {
let delay = if msg.reason == "logged out" {
let delay = if msg.end == benilla_protocol::SessionEnd::LoggedOut {
0.0
} else {
RETRY_DELAY_SECS
@@ -570,7 +591,7 @@ fn drive_quit(arm: Option<Res<QuitArm>>, time: Res<Time>, mut exit: MessageWrite
/// Which dialog is up: the connecting status (Cancel button, text driven by the stages) or an
/// error (Okay button).
#[derive(Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum DialogKind {
Status,
Error,
@@ -741,9 +762,36 @@ fn save_account(name: &str) {
// ── Instruments ──────────────────────────────────────────────────────────────────────────────────
/// The login smoke (`WOW_LOGIN_SMOKE=user:pass`, decision 0539 §7): once the screen is up, submit
/// those credentials through the real screen path; exit success on reaching CharSelect, log + exit
/// failure on a refusal — the wrong-password path is provable headlessly.
/// Split `WOW_LOGIN_SMOKE=user:pass[:Character]` into its three fields. **Three-way, once**: a
/// `split_once(':')` here would hand the character name to the password, which is exactly what it
/// did on this instrument's first live run (`result 0x04`, and the wrong-password path is what
/// this smoke exists to prove, so it looked plausible).
fn smoke_spec(spec: &str) -> (&str, &str, Option<&str>) {
let mut parts = spec.splitn(3, ':');
let user = parts.next().unwrap_or_default();
let pass = parts.next().unwrap_or_default();
let character = parts.next().map(str::trim).filter(|n| !n.is_empty());
(user, pass, character)
}
/// The optional third field — the body to enter as, or `None` for the plain
/// reach-character-select smoke. [`crate::char_select`]'s roster policy is what actually answers
/// the roster with it.
pub(crate) fn smoke_character(spec: &str) -> Option<String> {
smoke_spec(spec).2.map(str::to_string)
}
/// The login smoke (`WOW_LOGIN_SMOKE=user:pass[:Character]`, decision 0539 §7): once the screen is
/// up, submit those credentials through the real screen path; exit success on reaching CharSelect,
/// log + exit failure on a refusal — the wrong-password path is provable headlessly.
///
/// **Naming a character keeps the run going into the world instead of exiting** (decision 1262).
/// This is the only headless way to reach the world down the *player's* path: `WOW_CHAR` also
/// enters the world, but setting it makes the run **unattended**
/// ([`crate::run_mode::unattended_login`]), and an unattended run takes the other branch at every
/// session decision — so the attended half was untestable without a person at the keyboard. The
/// pick itself stays `char_select`'s (`apply_roster_policy` reads the third field the same way it
/// reads `WOW_CHAR`); this only declines to exit. Pair with `WOW_PROBE_EXIT_AT` to bound the run.
#[allow(clippy::too_many_arguments)]
fn debug_login_smoke(
state: Res<State<ClientState>>,
@@ -760,7 +808,7 @@ fn debug_login_smoke(
};
match *phase {
0 if *state.get() == ClientState::Login && time.elapsed_secs() > 2.0 => {
let (user, pass) = spec.split_once(':').unwrap_or((spec.as_str(), ""));
let (user, pass, _) = smoke_spec(&spec);
info!("login-smoke: submitting as {user}");
let (user, pass) = (user.to_string(), pass.to_string());
send_login(&mut intent, &submit, &abandon, &user, &pass, true);
@@ -777,8 +825,15 @@ fn debug_login_smoke(
}
*phase = 2;
} else if *state.get() == ClientState::CharSelect {
info!("login-smoke: reached character select — done");
exit.write(AppExit::Success);
match smoke_character(&spec) {
Some(name) => {
info!("login-smoke: reached character select — entering as {name}");
}
None => {
info!("login-smoke: reached character select — done");
exit.write(AppExit::Success);
}
}
*phase = 2;
}
}
@@ -790,6 +845,134 @@ fn debug_login_smoke(
mod tests {
use super::*;
/// Stand `drive_policy` up on its own with the env fast path already spent, so the policy
/// under test is the disconnect arm and nothing else — and so an ambient `WOW_USER` in
/// whatever shell runs the suite cannot seed credentials behind the assertions.
fn policy_app() -> App {
let (tx, rx) = crossbeam_channel::unbounded();
// Held for the App's life: a dropped receiver would turn every submit into an `Err` and
// hide a policy that sent one.
std::mem::forget(rx);
let mut app = App::new();
app.init_resource::<Time>()
.init_resource::<LoginIntent>()
.init_resource::<LoginDialog>()
.insert_resource(LoginSubmit(tx))
.insert_resource(LoginAbandon(std::sync::Arc::new(
std::sync::atomic::AtomicU64::new(0),
)))
.add_message::<LoginStageMessage>()
.add_message::<LoginFailedMessage>()
.add_message::<DisconnectedMessage>()
.add_systems(Update, drive_policy);
app.world_mut().resource_mut::<LoginIntent>().env_read = true;
app
}
/// **A lost session does not log itself back in** (decision 1262).
///
/// This is the whole of the displacement report: log into the same account from the reference
/// client, vmangos kicks us with a bare socket close, and 0065's paced resubmit — which cannot
/// see *why* the socket died, because nothing on the wire says — re-authenticated three seconds
/// later and kicked the client that had just displaced us. The account ping-ponged. The
/// reference's `GlueParent.lua` answers `DISCONNECTED_FROM_SERVER` with the login screen and a
/// one-button dialog, and retries nothing.
#[test]
fn a_lost_session_clears_the_credentials_and_shows_the_dialog() {
let mut app = policy_app();
app.world_mut().resource_mut::<LoginIntent>().creds = Some(("one".into(), "pone".into()));
app.world_mut().write_message(DisconnectedMessage {
reason: "disconnected: world stream closed: failed to fill whole buffer".into(),
end: benilla_protocol::SessionEnd::Lost,
session_over: true,
});
app.update();
let intent = app.world().resource::<LoginIntent>();
assert!(
intent.creds.is_none(),
"the session's credentials die with the session — keeping them is what won the \
account back off the client that displaced us",
);
assert!(intent.retry_at.is_none(), "and nothing is scheduled");
let dialog = app.world().resource::<LoginDialog>();
assert_eq!(dialog.kind, Some(DialogKind::Error));
// The fallback literal: this App has no GlueStrings, and the table's own row is
// `DISCONNECTED = "Disconnected from server";` (GlueStrings.lua) — the same words.
assert_eq!(dialog.text, "Disconnected from server");
}
/// A **clean logout's** teardown rides the same message and must keep its silent relist: the
/// IO thread returns to the pre-logon park, and the roster it comes back with IS the character
/// select the player asked for. Breaking this would strand `/logout` on the login screen.
#[test]
fn a_logout_teardown_still_relists_at_once() {
let mut app = policy_app();
app.world_mut().resource_mut::<LoginIntent>().creds = Some(("one".into(), "pone".into()));
app.world_mut().write_message(DisconnectedMessage {
reason: "logged out".into(),
end: benilla_protocol::SessionEnd::LoggedOut,
session_over: false,
});
app.update();
let intent = app.world().resource::<LoginIntent>();
assert!(
intent.creds.is_some(),
"a logout keeps the account signed in"
);
assert!(
intent.in_flight,
"and the same tick resubmits — the delay for a logout is 0, so the roster comes \
straight back",
);
assert!(
app.world().resource::<LoginDialog>().kind.is_none(),
"with no dialog: nothing went wrong",
);
}
/// An **unattended** run keeps 0065's paced reconnect on a lost session — the verdict rides
/// the message, so the policy honours it without re-reading the environment.
#[test]
fn an_unattended_run_still_reconnects_on_its_own() {
let mut app = policy_app();
app.world_mut().resource_mut::<LoginIntent>().creds =
Some(("probe1".into(), "pprobe1".into()));
app.world_mut().write_message(DisconnectedMessage {
reason: "disconnected: connection reset".into(),
end: benilla_protocol::SessionEnd::Lost,
session_over: false,
});
app.update();
let intent = app.world().resource::<LoginIntent>();
assert!(intent.creds.is_some());
assert_eq!(
intent.retry_at,
Some(RETRY_DELAY_SECS),
"paced by the flat 3 s off a zeroed clock, not fired on the spot",
);
assert!(app.world().resource::<LoginDialog>().kind.is_none());
}
/// The smoke spec splits three ways — the character name must never end up in the password.
/// A `split_once` did exactly that on the instrument's first live run, and the refusal it
/// produced (`0x04`) is indistinguishable from the wrong-password case this smoke exists to
/// exercise.
#[test]
fn the_smoke_spec_splits_three_ways() {
assert_eq!(
smoke_spec("probe1:pprobe1:Probeone"),
("probe1", "pprobe1", Some("Probeone"))
);
assert_eq!(smoke_spec("probe1:pprobe1"), ("probe1", "pprobe1", None));
// A trailing colon names no body; a password may not itself contain one (the client caps
// both fields at 16 letters and vmangos accounts have none).
assert_eq!(smoke_spec("one:pass:"), ("one", "pass", None));
assert_eq!(smoke_spec("one"), ("one", "", None));
}
/// The code→string map quotes the client's own strings for the vmangos-verified rows.
#[test]
fn fail_text_maps_the_verified_codes() {
+32 -2
View File
@@ -1383,11 +1383,41 @@ pub(crate) struct LoginStageMessage {
/// The session ended (socket death, logout's teardown edge) — bridged from the Net drain's
/// `Disconnected` arm. [`crate::login`]'s policy reads it as "the IO thread is back at its
/// pre-logon park": with pending credentials it schedules the silent re-auth (decision 0539 §3 —
/// 0065's seamless reconnect, paced app-side).
/// pre-logon park"; whether anything re-authenticates is [`Self::ends_the_session`]'s answer.
#[derive(Message, Clone)]
pub(crate) struct DisconnectedMessage {
pub(crate) reason: String,
/// How it ended — the fact the whole post-session behaviour turns on (decision 1262).
pub(crate) end: benilla_protocol::SessionEnd,
/// **Is this the reference's `DISCONNECTED_FROM_SERVER`** — the session over, the client back
/// at the account screen behind a "Disconnected from server" dialog, and nothing retried?
///
/// Decided **once**, by [`Self::new`], at the one edge where the event enters the app
/// (decision 1262). Four readers act on it — the world teardown, the screen flip, the black
/// cover, the credential policy — and they may not disagree: a teardown that keeps the avatar
/// for a reconnect that never comes is exactly the avatar-less free camera the report
/// described. Carrying the verdict rather than re-deriving it four times is what makes that
/// structural instead of conventional.
pub(crate) session_over: bool,
}
impl DisconnectedMessage {
/// The session-end event as the app sees it, with [`Self::session_over`] settled here and
/// nowhere else.
///
/// It is `false` twice over. A [`SessionEnd::LoggedOut`](benilla_protocol::SessionEnd::LoggedOut)
/// teardown is the relist *inside* one session — the roster that follows IS the character
/// select the player asked for. And an unattended run ([`crate::run_mode::unattended_login`])
/// keeps 0065's seamless reconnect: a probe has nobody to press Okay.
pub(crate) fn new(reason: String, end: benilla_protocol::SessionEnd) -> Self {
let session_over =
end == benilla_protocol::SessionEnd::Lost && !crate::run_mode::unattended_login();
Self {
reason,
end,
session_over,
}
}
}
/// A login attempt failed before the roster (decision 0539): `code` is the server's auth result
+3 -2
View File
@@ -405,12 +405,13 @@ pub(super) fn apply_net_updates(
logout.apply_response(reason, instant)
}
SessionEvent::LogoutCancelled => logout.apply_cancelled(),
SessionEvent::Disconnected { reason } => {
SessionEvent::Disconnected { reason, end } => {
session::disconnected(
reason,
end,
&mut commands,
&mut index,
&self_guid,
&mut self_guid,
&mut status,
&mut names,
&mut items,
+17 -5
View File
@@ -135,9 +135,10 @@ pub(super) fn logged_out(
#[allow(clippy::too_many_arguments)]
pub(super) fn disconnected(
reason: String,
end: benilla_protocol::SessionEnd,
commands: &mut Commands,
index: &mut GuidIndex,
self_guid: &SelfGuid,
self_guid: &mut SelfGuid,
status: &mut NetStatus,
names: &mut NameCache,
items: &mut Items,
@@ -164,9 +165,11 @@ pub(super) fn disconnected(
) {
// The reconnect-policy feed first (decision 0539): [`crate::login`] reads it as "the IO thread
// is back at its pre-logon park".
disconnects.write(DisconnectedMessage {
reason: reason.clone(),
});
// Is the session over, or is this the pause inside one? Settled once, here, and carried on the
// message to every other reader (decision 1262).
let msg = DisconnectedMessage::new(reason.clone(), end);
let over = msg.session_over;
disconnects.write(msg);
warn!("net: {reason} — tearing down the streamed world");
// An announced-but-unfinished far teleport died with the socket.
pending_transfer.0 = None;
@@ -181,7 +184,16 @@ pub(super) fn disconnected(
// connection loss is not a world event, and index-less fading entities would race
// the reconnect's re-creates. Entities already mid-fade left the index earlier and
// finish fading on their own.
let keep = self_guid.0;
//
// **Unless the session is over** (decision 1262), and then the avatar goes too, exactly as
// [`logged_out`] takes it: 0065 spares it *for the reconnect*, and with no reconnect coming
// that spared body is a puppet with no server behind it. Keeping it was the free camera —
// `SelfGuid` set, no entity, an entry that never finished — so the fact that decides whether
// anything reconnects has to be the same fact that decides whether the body stays.
let keep = if over { None } else { self_guid.0 };
if over {
self_guid.0 = None;
}
index.0.retain(|guid, e| {
if Some(*guid) == keep {
return true;
+12 -7
View File
@@ -10,10 +10,11 @@
//! pacing, the env fast path, auto-relogin on reconnect, the director's click) is app-side
//! ([`crate::login`], [`crate::char_select`]); this thread is a pure sequencer — it never sleeps.
//!
//! On a stream failure it emits a [`SessionEvent::Disconnected`] and returns to the login park;
//! the app's pending-credentials policy re-establishes the session (decision 0065's seamless
//! reconnect, paced app-side). A clean in-game logout ([`SessionEvent::LoggedOut`]) does the same
//! with no failure. A single long-lived sibling write thread drains
//! On a stream failure it emits a [`SessionEvent::Disconnected`] carrying
//! [`SessionEnd::Lost`] and returns to the login park; a clean in-game logout
//! ([`SessionEvent::LoggedOut`]) does the same with [`SessionEnd::LoggedOut`]. What happens next is
//! the app's, and the two answers differ (decision 1262): the logout relists, while a loss ends the
//! session at the account screen unless nobody is there to type. A single long-lived sibling write thread drains
//! [`ClientCommand`](super::ClientCommand)s down to the server; each successful connection hands it
//! the fresh [`WorldWriter`] over a swap channel, so "exactly one writer" is structural, not
//! signalled. The ECS half (draining the events into components each frame, and tearing the
@@ -27,8 +28,8 @@ use std::time::{Duration, Instant};
use anyhow::{anyhow, bail, Result};
use benilla_protocol::{
host_port, messages, AuthReject, CharAction, LoginStage, Poll, SessionEvent, WardenRequired,
WorldSession, WorldWriter, WORLD_PORT,
host_port, messages, AuthReject, CharAction, LoginStage, Poll, SessionEnd, SessionEvent,
WardenRequired, WorldSession, WorldWriter, WORLD_PORT,
};
use crossbeam_channel::{Receiver, Sender};
@@ -190,6 +191,7 @@ pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles {
if events_tx
.send(SessionEvent::Disconnected {
reason: "logged out".into(),
end: SessionEnd::LoggedOut,
})
.is_err()
{
@@ -197,11 +199,14 @@ pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles {
}
}
Err(e) => {
// A live-stream failure. No sleep — the app paces resubmits (0539 §3).
// A live-stream failure — including a displacement kick, which reaches
// us as a bare EOF and nothing else (decision 1262). No sleep: what
// happens next is the app's policy, not this thread's (0539 §3).
bevy::log::error!("net: {e:#}");
if events_tx
.send(SessionEvent::Disconnected {
reason: format!("disconnected: {e:#}"),
end: SessionEnd::Lost,
})
.is_err()
{
+69 -20
View File
@@ -20,7 +20,6 @@ use benilla_world::model_render::{
};
use benilla_world::sky::SkyMaterial;
use benilla_world::sun::{CelestialMaterial, StarMaterial};
use benilla_world::wmo_sky::{WmoSkyboxExt, WmoSkyboxMaterial};
use super::WarmRig;
@@ -44,8 +43,8 @@ use super::WarmRig;
/// celestial rig, the star dome, the cloud dome, the gradient dome, the liquid frame sets), so
/// by the time the menagerie spawns behind the entry cover, iterating the store IS the complete
/// reachable set — warming can never drift from what's spawned. The one exception is the WMO
/// skybox, whose materials are built on first need: its pipeline key is texture-independent, so
/// one representative material covers the lane.
/// skybox, whose materials are built on first need; since decision 1264 it is not a lane of its own
/// at all but a cross of `WowModelMaterial` keys, warmed with the model cross below.
#[derive(SystemParam)]
pub(super) struct WarmLanes<'w> {
celestial: ResMut<'w, Assets<CelestialMaterial>>,
@@ -53,7 +52,6 @@ pub(super) struct WarmLanes<'w> {
clouds: ResMut<'w, Assets<CloudMaterial>>,
sky: ResMut<'w, Assets<SkyMaterial>>,
liquid: ResMut<'w, Assets<LiquidMaterial>>,
skybox: ResMut<'w, Assets<WmoSkyboxMaterial>>,
/// The plain-`StandardMaterial` lanes (0938): representatives for the on-demand nameplate
/// and raid-mark materials go through this store the way their builders do.
standard: ResMut<'w, Assets<StandardMaterial>>,
@@ -121,6 +119,10 @@ pub(super) fn spawn_menagerie(
// milliseconds behind a loading bar once per run, while a missed one is a director-felt live
// stall. The observed set (28) is the floor, not the target.
let mut mats: Vec<Handle<WowModelMaterial>> = Vec::new();
// The sky lane is kept apart from `mats`: it rides the STATIC layout only and is never
// far-twinned (a camera-anchored backdrop has no side of the water plane —
// `model_render::classify_water_side` skips it) nor booth-duplicated (no portrait sees a sky).
let mut sky_mats: Vec<Handle<WowModelMaterial>> = Vec::new();
for two_sided in [false, true] {
for blend in [
ModelBlend::Opaque,
@@ -155,6 +157,7 @@ pub(super) fn spawn_menagerie(
None,
None,
false,
false, // warms the WORLD lane; the sky lane warms with its own model
light,
));
}
@@ -193,6 +196,7 @@ pub(super) fn spawn_menagerie(
None,
None,
false,
false, // warms the WORLD lane; the sky lane warms with its own model
light,
));
}
@@ -204,6 +208,49 @@ pub(super) fn spawn_menagerie(
cache, materials, None, two_sided, cutout, light,
));
}
// The **WMO-skybox lane** (decision 1264): `sky_depth` is a `WowModelKey` axis — it
// compiles the shader's forced-far-depth branch — so every skybox batch is a pipeline the
// world cross above does not cover, however identical the rest of its key. Built exactly
// the way `M2BatchMaterials::skybox` builds them: depth-write pinned off, depth-test
// pinned on, the batch's own blend and sidedness. `CavernsOfTimeSky.m2` authors Opaque,
// AlphaTest, Blend and additive-Blend across both sidednesses; Mod/Mod2x are warmed too
// because nothing stops a skybox authoring them. Unwarmed, walking into the Caverns of
// Time crater is 21 live compiles in the frame the sky appears.
for blend in [
ModelBlend::Opaque,
ModelBlend::AlphaTest,
ModelBlend::Blend,
ModelBlend::Mod,
ModelBlend::Mod2x,
] {
for additive in [false, true] {
sky_mats.push(model_material(
cache,
materials,
None,
blend,
two_sided,
false,
false,
true, // every skybox batch the chain ships authors UNLIT
additive,
false,
true, // …depth-write off
false, // …depth-test on
FogPolicy::Off,
false,
ShadeSel::Lit,
0,
None,
None,
None,
None,
false,
true, // the sky lane
light,
));
}
}
}
// The ground-clutter lane (specialize's over-blend), both sidednesses — the first
// verification leg caught the two-sided one compiling live. Its material is built by
@@ -240,6 +287,7 @@ pub(super) fn spawn_menagerie(
None,
None,
false,
false, // warms the WORLD lane; the sky lane warms with its own model
light,
);
if let Some(m) = materials.get(&plain) {
@@ -287,6 +335,7 @@ pub(super) fn spawn_menagerie(
None,
None,
false,
false, // warms the WORLD lane; the sky lane warms with its own model
light,
);
if let Some(m) = materials.get(&h) {
@@ -354,6 +403,18 @@ pub(super) fn spawn_menagerie(
}
}
// The WMO-skybox rows (decision 1264): the STATIC PLAIN layout only, and only on the world
// camera. `wmo_sky::build_skybox` inserts POSITION + NORMAL + UV_0 — `layouts[0]` exactly — a
// skybox is never skinned (the asteroid belts' bones are a deferral, not a shipped lane), never
// vertex-coloured (M2 carries no MOCV), never far-twinned, and never reaches a portrait booth.
{
let (plain_mesh, plain_aabb, _) = &layouts[0];
for mat in &sky_mats {
spawn_model_rig(commands, cam, None, plain_mesh, plain_aabb, false, mat);
count += 1;
}
}
// The sky and water lanes (decision 0945 — 0837's scope was model-lane-only, and every hole
// was a director-felt stall: the sun disc first drawn on stepping outdoors, the first water
// in view, a spell's shards mid-cast). Every material that EXISTS in these Startup-populated
@@ -417,19 +478,6 @@ pub(super) fn spawn_menagerie(
for mat in lane_handles(&mut lanes.liquid) {
spawn_lane_rig(commands, cam, None, &liquid_mesh, None, mat, &mut count);
}
// WMO skybox: the one on-demand lane — its materials are built at first need, but the
// pipeline key is texture-independent, so one representative built the way
// `wmo_sky::build_skybox` builds them covers the lane.
let skybox = lanes.skybox.add(WmoSkyboxMaterial {
base: StandardMaterial {
unlit: true,
cull_mode: None,
..default()
},
extension: WmoSkyboxExt::default(),
});
spawn_lane_rig(commands, cam, None, &posuv, None, skybox, &mut count);
// The plain-`StandardMaterial` lanes (0938 — the director's evening log). The fallback cube
// (`entities::CubeAssets`, drawn while any entity's model streams) uses the production mesh
// + materials, on the world camera AND a booth layer (a cube-bodied target can reach a
@@ -594,9 +642,10 @@ fn spawn_lane_rig<M: Material>(
*count += 1;
}
/// A tiny triangle carrying POSITION + UV_0 only — the layout the real `Stars.m2` patches and
/// the WMO skybox batches ship (`sun::setup` / `wmo_sky::build_skybox` insert exactly these
/// two attributes). Main-world-resident (`RenderAssetUsages::default()`), so `calculate_bounds`
/// A tiny triangle carrying POSITION + UV_0 only — the layout the real `Stars.m2` patches ship
/// (`sun::setup` inserts exactly these two attributes). The WMO skybox used to share it; since
/// decision 1264 it draws on the model lane and carries that lane's NORMAL too, so it rides
/// `layouts[0]` instead. Main-world-resident (`RenderAssetUsages::default()`), so `calculate_bounds`
/// covers it — no explicit Aabb needed.
fn warm_pos_uv_mesh() -> Mesh {
let mut m = Mesh::new(
+8 -4
View File
@@ -216,10 +216,14 @@ impl Plugin for PlayerPlugin {
.run_if(not(resource_exists::<crate::run_mode::CaptureMode>))
.run_if(in_state(crate::char_select::ClientState::InWorld)),
)
// A confirmed `/logout` releases the avatar: the streamed entity is despawned by the
// net drain, and dropping `active` re-arms the take-control latch for the next login
// (possibly a different character). Ungated — the message lands as the state flips.
.add_systems(Update, wire_in::release_on_logout.in_set(WorldStage::Input))
// A session END releases the avatar — a confirmed `/logout`, or a lost session
// (decision 1262): the streamed entity is despawned by the net drain either way, and
// dropping `active` re-arms the take-control latch for the next login (possibly a
// different character). Ungated — the message lands as the state flips.
.add_systems(
Update,
wire_in::release_on_session_end.in_set(WorldStage::Input),
)
// Mirror our body's collision height onto `Player` for the swim arm (decision 0645).
// A *continuous* sync rather than a one-shot at take-control, for the reason the
// take-control branch itself records: a cross-map worldport re-streams the `SelfPlayer`
+15 -6
View File
@@ -397,14 +397,23 @@ fn apply_self_move(
player.wedge_still = 0;
}
/// A confirmed `/logout` (decision 0193): drop control so the next login re-takes it from its own
/// streamed `SelfPlayer` — possibly a different character on a different map (the boot path). The
/// avatar entity itself is despawned by the net drain the same frame this message is written.
pub(super) fn release_on_logout(
mut msgs: MessageReader<crate::net::LoggedOutMessage>,
/// **The avatar went away with the session** — drop control, so the next login re-takes it from
/// its own streamed `SelfPlayer` (possibly a different character on a different map — the boot
/// path). The entity itself is despawned by the net drain the same frame these messages are
/// written.
///
/// Two edges, one answer (decision 1262): a confirmed `/logout` (decision 0193), and a **lost**
/// session, which since 1262 takes the avatar too — there is no reconnect left for it to be the
/// puppet of. Missing the second edge would leave `Player.active` true over a despawned entity: a
/// controller driving nothing, which is the shape of the free camera this arc is about.
pub(super) fn release_on_session_end(
mut logouts: MessageReader<crate::net::LoggedOutMessage>,
mut lost: MessageReader<crate::net::DisconnectedMessage>,
mut player: ResMut<Player>,
) {
if msgs.read().next().is_some() {
// Both readers drain unconditionally — `|`, not `||`: a short-circuit would leave the other
// message unread, and its cursor would carry it into the next frame.
if logouts.read().next().is_some() | lost.read().any(|m| m.session_over) {
player.active = false;
player.move_flags = 0;
player.airborne_since = None;
+24
View File
@@ -51,6 +51,30 @@ pub(crate) fn scenario_active() -> bool {
std::env::var("WOW_CAPTURE").is_ok()
}
/// **Is anything but a person answering this session's login?** (`$WOW_USER` / `$WOW_PASS` /
/// `$WOW_CHAR` — the login screen's env fast path.)
///
/// The player answer is `false`, and it is the default: with none of these set the client opens at
/// the login screen and waits for somebody to type. Setting any of them *is* a harness saying "log
/// in without me" — a probe, a smoke, a rig — so it is also the honest answer to the question a
/// dead session asks: **is there anybody here to log back in?**
///
/// That question decides what a lost session does (decision 1262). A person gets the reference's
/// answer — the world torn down, the account screen, "Disconnected from server", and nothing
/// retried until they say so. An unattended run keeps 0065's seamless reconnect, because the
/// alternative is a probe parked on a login dialog for the rest of its wall-clock. One environment
/// fact, several readers, no shared symbol — the module doc's pattern, same as [`scenario_active`],
/// and the reason the login policy's own fast path asks here instead of re-reading the three names.
///
/// The session-loss readers never call this directly: [`crate::net::DisconnectedMessage::new`]
/// asks once, at the wire edge, and every reader acts on the verdict it carries — so the teardown,
/// the screen flip and the credential policy cannot answer it three different ways.
pub(crate) fn unattended_login() -> bool {
["WOW_USER", "WOW_PASS", "WOW_CHAR"]
.iter()
.any(|k| std::env::var_os(k).is_some())
}
/// **Which screen the client starts on.** A player starts at the login screen, always; a capture
/// boots straight into the world (no net, no picker), and a *glue* capture boots onto the very
/// screen it photographs.
+2 -2
View File
@@ -57,8 +57,8 @@ pub(super) struct PetBarMemory {
///
/// Both are returned as **the names of globals**, not values — the reference's token convention.
/// The name keys ship in `GlobalStrings.lua` (`PET_ACTION_ATTACK` = "Attack" at l.3029-3032); the
/// texture globals are declared by `PetActionBar.xml`, quoting the reference's
/// `PetActionBarFrame.lua:6-12` verbatim.
/// texture globals are declared by `PetActionBar.xml` — our own code, under the names and with the
/// art paths the reference's `PetActionBarFrame.lua:6-12` specifies (1260).
pub(super) fn command_token(action: u32) -> Option<(&'static str, &'static str)> {
Some(match action {
PET_COMMAND_STAY => ("PET_ACTION_WAIT", "PET_WAIT_TEXTURE"),
@@ -63,6 +63,27 @@ macro_rules! corpus_or_skip {
};
}
/// The corpus **and** a client install — for the tests whose subject is a global that comes from
/// the reference file this client SOURCES off the player's own patch chain rather than shipping
/// ([`super::reference_ui`], decision 1234).
///
/// `corpus_or_skip!` alone is the wrong precondition for them. With the corpus present but no
/// install, `ContainerFrameItemButton_OnEnter`/`_OnClick` are *legitimately* nil — that is the
/// documented "no install, no file" behaviour, not a defect — and the test then fails for a reason
/// that is not a bug. `reference_ui`'s own header already says tests that need the file gate on the
/// install the way every other client-data test does; these four never got that gate when 1234
/// added the sourcing seam and the tests in one landing.
///
/// It stayed invisible because it only bites where the install is *not* found, which on a dev
/// machine is only `scripts/gates.sh`'s `player-tests` rung: `--no-default-features` compiles out
/// the `dev` project-folder candidate, so the `WoW` symlink beside the worktree stops being visible.
macro_rules! corpus_and_install_or_skip {
() => {{
let _data = benilla_formats::wow_data_or_skip!();
corpus_or_skip!()
}};
}
fn read_toc(root: &Path, name: &str) -> Toc {
let path = root.join(name).join(format!("{name}.toc"));
Toc::parse(&benilla_ui::source::decode(
@@ -537,7 +558,7 @@ fn the_roster_seat_names_the_character_the_addons_will_meet() {
/// hover over every slot raised and no tooltip ever appeared.
#[test]
fn hovering_a_bagnon_slot_shows_the_items_tooltip() {
let root = corpus_or_skip!();
let root = corpus_and_install_or_skip!();
let mut s = open_bagnon(&root);
let occupied = bagnon_button_for_slot(&s, 0, 1);
@@ -574,7 +595,7 @@ fn hovering_a_bagnon_slot_shows_the_items_tooltip() {
/// `PickupContainerItem(this:GetParent():GetID(), this:GetID())`. The observable is the cursor.
#[test]
fn left_clicking_a_bagnon_slot_picks_the_item_up() {
let root = corpus_or_skip!();
let root = corpus_and_install_or_skip!();
let mut s = open_bagnon(&root);
let occupied = bagnon_button_for_slot(&s, 0, 1);
@@ -615,7 +636,7 @@ fn left_clicking_a_bagnon_slot_picks_the_item_up() {
/// `UseContainerItem(bag, slot)` — use/equip the item.
#[test]
fn right_clicking_a_bagnon_slot_uses_the_item() {
let root = corpus_or_skip!();
let root = corpus_and_install_or_skip!();
let mut s = open_bagnon(&root);
let occupied = bagnon_button_for_slot(&s, 0, 1);
@@ -638,7 +659,7 @@ fn right_clicking_a_bagnon_slot_uses_the_item() {
/// `ignoreModifiers` arm. Driven as a real gesture: press, move past the threshold, release.
#[test]
fn dragging_a_bagnon_slot_picks_the_item_up() {
let root = corpus_or_skip!();
let root = corpus_and_install_or_skip!();
let mut s = open_bagnon(&root);
let occupied = bagnon_button_for_slot(&s, 0, 1);
let empty = bagnon_button_for_slot(&s, 0, 2);
@@ -1,4 +1,4 @@
//! The combo-point dots (decisions 0869, 0875) — the reference `ComboFrame` transcription driven
//! The combo-point dots (decisions 0869, 0875) — our `ComboFrame` implementation driven
//! through the real loader: the show/hide edges, the per-point highlight/shine fade chain, the
//! "only newly-earned points flare" rule that `COMBO_FRAME_LAST_NUM_POINTS` exists to enforce, and
//! the two gates that live in `GetComboPoints` rather than in this Lua — rogue-or-druid only, and
@@ -37,8 +37,8 @@ fn minimap_zoom_buttons_resync_when_switching_inside_and_outside() {
.unwrap();
s.run("function PlaySound() end").unwrap();
load_xml(&s, "Fonts.xml");
// The shipped load order provides GameTooltip before the cluster; Minimap_Update's verbatim
// ref block (the PVP tint slice, decision 0287) touches it from OnLoad on.
// The shipped load order provides GameTooltip before the cluster; Minimap_Update's tooltip
// half (the PVP tint slice, decision 0287) touches it from OnLoad on.
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "MinimapCluster.xml");
@@ -155,7 +155,7 @@ fn tod_window(s: &UiScript) -> (f64, f64, f64, f64) {
.unwrap()
}
/// The verbatim `GameTimeFrame_Update` law: the 50-px window over the 128×64 UI-TOD-Indicator
/// `GameTimeFrame_Update`'s law, exactly: the 50-px window over the 128×64 UI-TOD-Indicator
/// sits on the LEFT half (the sun) through the game day and slides +0.5 to the RIGHT half (the
/// moon) outside it — night is before 5:30 AM or from 9:00 PM, boundaries included exactly as
/// the ref compares (`< DAWN or >= DUSK`).
@@ -189,7 +189,7 @@ fn game_time_frame_slides_the_sun_moon_window_on_the_game_clock() {
/// Hovering the indicator through the REAL pointer path (hit-test → OnEnter) shows the game-time
/// tooltip, live-updates it while owned (the `IsOwned` refresh branch), and hides it on leave.
/// This also pins the two loader-side pieces the transcription leans on: the `<Scripts>` walker's
/// This also pins the two loader-side pieces this frame leans on: the `<Scripts>` walker's
/// mouse auto-enable (the frame declares no enableMouse, like the reference — without the law the
/// hit-test never captures) and the `<HitRectInsets>` hull.
#[test]
@@ -210,7 +210,7 @@ fn hovering_the_indicator_shows_and_live_updates_the_game_time_tooltip() {
s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"OnEnter owns the tooltip (the Scripts-walker auto-enable capturing at {x},{y})"
);
// TwentyFourHourTime = 1 (enGB LocalizeFrames, the transcription header): 21:07, not 9:07 PM.
// TwentyFourHourTime = 1 (enGB LocalizeFrames, per GameTime.xml's header): 21:07, not 9:07 PM.
let text = |s: &UiScript| {
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap()
@@ -1,6 +1,6 @@
//! The shipped `assets/ui/MoneyFrame.xml` — the reference's `MoneyFrameTemplate` /
//! `SmallMoneyFrameTemplate` kit and the `MoneyTypeInfo` table behind it (decision 1190: a name
//! the shipped 1.12 UI defines is FrameXML's to transcribe).
//! `SmallMoneyFrameTemplate` kit and the `MoneyTypeInfo` table behind it (decision 1190: a name the
//! shipped 1.12 UI defines is a name we publish too — under our own implementation of it, 1260).
//!
//! Nothing benilla ships consumes this file yet — our own eight coin displays run on
//! MerchantFrame.xml's `BenillaMoney_*` slot kit, and MoneyFrame.xml's header is where the two are
@@ -302,11 +302,17 @@ fn an_input_box_from_the_template_carries_its_border_and_takes_text() {
/// `MyScrollScrollBarScrollUpButton` through **two** levels of `$parent` — which is precisely what
/// `ScrollFrame_OnLoad` getglobals on the way past.
///
/// The `UpdateScrollChildRect` leg is also the falsifier for this file's one Lua divergence:
/// `ScrollFrame_OnScrollRangeChanged` reaches the thumb through `GetThumbTexture()` rather than the
/// reference's `getglobal(bar:GetName().."ThumbTexture")`, because our loader never publishes a
/// named `<ThumbTexture>` as a global. If that divergence were reverted, this call would raise on a
/// The `UpdateScrollChildRect` leg is also the falsifier for the `<ThumbTexture>` publication:
/// `ScrollFrame_OnScrollRangeChanged` reaches the thumb through
/// `getglobal(bar:GetName().."ThumbTexture")`, which resolves only because our loader publishes a
/// named `<ThumbTexture>` as a global. If that publication regressed, this call would raise on a
/// nil index and `s.errors()` would not be empty.
///
/// (This used to claim the opposite — that we diverged to `GetThumbTexture()` "because our loader
/// never publishes a named `<ThumbTexture>`", and called it "this file's one Lua divergence". It
/// was already false when written: the XML has used `getglobal` throughout, at HEAD and now.
/// Corrected rather than deleted, because a doc comment asserting a divergence that does not exist
/// is exactly how someone later introduces a real one by "restoring" it.)
#[test]
fn a_scroll_frame_from_the_template_wires_its_bar_two_parents_deep() {
let mut s = harness();
+52
View File
@@ -110,6 +110,16 @@ pub struct WowModelKey {
/// `StandardMaterial` derived from the same field (at −4e4 that constant is a ~0.5% relative
/// depth pull — enough to clip a blade's coplanar sheen/glow layers behind the blade itself).
far_side: bool,
/// The **WMO-skybox lane** (`clutter_fade.z` bit 13 — `model_render::SKY_DEPTH_MARKER`): the
/// building-owned painted sky, drawn as the ordinary M2 it is. `specialize` compiles the
/// `WOW_SKY_DEPTH` fragment def, which emits `@builtin(frag_depth) = 0.0` — reverse-Z far, the
/// sky depth law every other sky shader already obeys (`benilla_world::sky_order`). It is a key
/// axis because writing `frag_depth` costs the whole pipeline its early-Z, and the model lane
/// draws every doodad and wall in the frame; only this one camera-anchored model may pay it.
/// Like [`Self::far_side`] it also zeroes the rasterizer bias constant: the lane's rung is a
/// SORT rung (`sky_order::WMO_SKYBOX_BIAS`, ~−6e4), and the depth it writes is a constant the
/// rasterizer must not be perturbing behind the shader's back.
sky_depth: bool,
// NB: the WMO authored batch order is deliberately NOT a key axis. It used to be (a
// per-batch-index `DepthBiasState` constant), which made every batch index its own pipeline —
// the city first-sight compile stall (decision 0837). The coplanar-layering nudge now rides
@@ -130,6 +140,7 @@ impl From<&WowModelExt> for WowModelKey {
modulate2x: markers & 0x100 != 0,
zfill: markers & 0x200 != 0,
far_side: markers & 0x800 != 0,
sky_depth: markers & 0x2000 != 0,
}
}
}
@@ -270,6 +281,18 @@ impl MaterialExtension for WowModelExt {
ds.bias.constant = 0;
}
}
// The WMO-skybox lane: force the sky's far depth in the fragment, and — like the far-side
// twin above — keep its big negative rung sort-only. The rung (`sky_order::WMO_SKYBOX_BIAS`)
// exists to sink a camera-anchored backdrop under every world transparent; as a rasterizer
// constant it would be perturbing an interpolated depth this pipeline discards anyway.
if key.bind_group_data.sky_depth {
if let Some(fragment) = descriptor.fragment.as_mut() {
fragment.shader_defs.push("WOW_SKY_DEPTH".into());
}
if let Some(ds) = descriptor.depth_stencil.as_mut() {
ds.bias.constant = 0;
}
}
// The WMO authored-batch-order depth nudge (the coplanar MOBA layering determinism) is NOT
// here any more: as a fixed-function `DepthBiasState` constant it made every batch index its
// own PIPELINE — Stormwind alone queued ~3000 variants of this one shader, each a
@@ -523,3 +546,32 @@ impl MaterialExtension for TerrainExtension {
"embedded://benilla_assets/shaders/terrain.wgsl".into()
}
}
#[cfg(test)]
mod tests {
/// The sky depth law, for the one sky element that draws on the MODEL lane: the WMO skybox
/// ([`WowModelKey::sky_depth`]). Every other sky shader is checked the same way, together, in
/// `benilla_world::sky_order::every_sky_shader_forces_the_far_depth` — this half lives here
/// because the shader does. Without it a skybox silently goes back to being occluded by its own
/// 94-yard shell radius instead of by world geometry (the regression decision 0588 fixed).
#[test]
fn the_sky_lane_forces_the_far_depth() {
let src = include_str!("shaders/wow_model.wgsl");
assert!(
src.contains("#ifdef WOW_SKY_DEPTH\n out.depth = 0.0;\n#endif"),
"the model lane's sky branch no longer forces the far depth — a WMO skybox's shell \
radius is deciding occlusion again (benilla_world::sky_order, \"The depth law\")"
);
// The declaration must stay BEHIND the ifdef: writing `frag_depth` unconditionally costs
// every doodad, creature and wall in the frame its early-Z, and this lane draws all of
// them. Matched with its guard attached, so ungating it fails here rather than in a
// frame-time regression nobody attributes to this file. (Counting occurrences instead
// would trip over the prose above the struct, which names the builtin too.)
assert!(
src.contains("#ifdef WOW_SKY_DEPTH\n // Reverse-Z \"infinitely far\"",)
&& src.contains(" @builtin(frag_depth) depth: f32,\n#endif"),
"the model lane's sky output no longer declares frag_depth behind WOW_SKY_DEPTH — \
either the sky writes no depth, or every model draw just lost its early-Z"
);
}
}
@@ -46,11 +46,29 @@
pbr_fragment::pbr_input_from_standard_material,
pbr_functions::alpha_discard,
pbr_bindings,
forward_io::{VertexOutput, FragmentOutput},
forward_io::VertexOutput,
mesh_view_bindings::view,
mesh_functions,
}
// Our own fragment output, so the SKY lane can force the far depth. Bevy's `forward_io::FragmentOutput`
// is `@location(0) color` and nothing else (bevy_pbr 0.18.1 `forward_io.wgsl`), so this is that struct
// plus one optional builtin — not a divergence from it.
//
// **`WOW_SKY_DEPTH` is a pipeline-key branch, never an unconditional field.** Declaring
// `@builtin(frag_depth)` disables early-Z for the whole pipeline, and the model lane draws every
// doodad, creature and WMO wall in the frame. The def is pushed only for the WMO-skybox lane
// (`clutter_fade.z` bit 13, `model_render::SKY_DEPTH_MARKER`), which is one camera-anchored model.
struct WowFragOut {
@location(0) color: vec4<f32>,
#ifdef WOW_SKY_DEPTH
// Reverse-Z "infinitely far", under bevy's `GreaterEqual` test — the sky depth law
// (`benilla_world::sky_order`, "The depth law"): the world paints over the sky whatever the
// shell's radius, and the sky can never land in front of world geometry.
@builtin(frag_depth) depth: f32,
#endif
}
// Per-material model uniforms packed at binding 100 (see `WowModelExt` in terrain.rs). Light + fog + the
// SH coeffs moved OUT to the shared global-light storage buffer (below); only the per-material draw flags
// remain. Field order MUST match the Rust struct.
@@ -492,7 +510,7 @@ fn vertex(vertex: WowVertex) -> WowVsOut {
}
@fragment
fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutput {
fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
// HARD FAR-CLIP WALL (faithful `farclip` ~777 yd) — see terrain.wgsl. Per-pixel discard beyond the
// projection far plane (planar eye-Z), so distant buildings/trees reveal closest-part-first and the
// sky/WDL shows behind. `wow_light.fog_params.w` = farclip (0 ⇒ disabled). Clutter (≤70 yd) never hits it.
@@ -986,7 +1004,10 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp
}
var out: FragmentOutput;
var out: WowFragOut;
#ifdef WOW_SKY_DEPTH
out.depth = 0.0;
#endif
// Raw gamma out (GAMMA LANE, 0161 — the frame's one decode is the FFXGlow combine). Alpha = the
// faded cutout alpha (tex × fade) for the blend twin; ignored (blend off) on steady/opaque draws.
// OPAQUE-INTENT alpha pin (clutter_fade.z bit 3, set in model_render for steady opaque/alpha-key
@@ -15,15 +15,23 @@
//! enforces* as much as what we send. That is the point: it is the A/B that closes B241 and the one
//! to run against any server a strict-mode rejection is reported from.
//!
//! Usage: `cargo run -p benilla-protocol --example version_check_probe -- <host[:port]> <user> <pass>`
//! Give it an **install directory** as a fourth argument and it also derives that install's own
//! integrity digest the way the real client does — HMAC-SHA1 keyed by the challenge over five
//! binaries (decision 1265, mechanism in wow-re `system/net/scratch/logon-integrity-hash.md`) — and
//! says whether it is the stock one. Diagnostic only: benilla always sends the published constant,
//! so a patched `WoW.exe` shows up here without changing a single login.
//!
//! Usage: `cargo run -p benilla-protocol --example version_check_probe -- <host[:port]> <user> <pass> [install-dir]`
//! (the local strict-mode realmd of decision 1263 is `127.0.0.1:3725`; the ordinary one is 3724.)
use std::net::TcpStream;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use benilla_protocol::{auth, host_port, AuthReject, AUTH_PORT, CLIENT_BUILD};
use benilla_srp::{NormalizedString, PublicKey, SrpClientChallenge};
use sha1::{Digest, Sha1};
/// Which `crc_hash` the arm puts in the proof packet.
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -96,12 +104,93 @@ fn attempt(host: &str, port: u16, user: &str, pass: &str, arm: Arm) -> Result<Op
Err(anyhow!("eight dials, every `B` ambiguous — try again"))
}
/// The five files the real client hashes, **in this order** — `ChecksumExecutables` at `0x5b1170`,
/// walking the `char*` table at `0x85def0` (wow-re `system/net/scratch/logon-integrity-hash.md`).
/// Order is load-bearing: exactly 1 of the 120 permutations reproduces the published digest.
const SCANNED: [&str; 5] = [
"WoW.exe",
"fmod.dll",
"ijl15.dll",
"dbghelp.dll",
"unicows.dll",
];
/// HMAC-SHA1 with the 16-byte `crc_salt` as the key — the framing the client uses (`rep stos` of
/// 64×`0x36` / 64×`0x5c`, salt XORed into both, `0x5b117e`–`0x5b11b6`). No long-key shortening,
/// because the key is always the 16-byte salt; the client has no such step either.
fn hmac_sha1(key: &[u8; 16], msg: &[u8]) -> [u8; 20] {
let mut ipad = [0x36u8; 64];
let mut opad = [0x5cu8; 64];
for (i, k) in key.iter().enumerate() {
ipad[i] ^= k;
opad[i] ^= k;
}
let mut inner = Sha1::new();
inner.update(ipad);
inner.update(msg);
let mut outer = Sha1::new();
outer.update(opad);
outer.update(inner.finalize());
outer.finalize().into()
}
/// Derive `H` from a 1.12.1 install the way the real client does, and say what it means. Purely a
/// diagnostic: benilla sends the published constant for its build and OS (decision 1265), never this.
/// A file that cannot be read is **skipped**, contributing no bytes — the client's own behaviour
/// (four early-outs at `0x5b11eb`/`0x5b1212`/`0x5b121f`/`0x5b1246`), and the reason a Data-only
/// install could never answer this challenge for itself.
fn report_install(install_dir: &Path, crc_salt: &[u8; 16]) {
println!("\n install {}", install_dir.display());
let mut msg = Vec::new();
let mut missing = Vec::new();
for name in SCANNED {
match std::fs::read(install_dir.join(name)) {
Ok(bytes) => {
println!(" {name:<12} {} bytes", bytes.len());
msg.extend_from_slice(&bytes);
}
Err(e) => {
println!(" {name:<12} UNREADABLE ({e}) — skipped, as the client skips it");
missing.push(name);
}
}
}
let h = hmac_sha1(crc_salt, &msg);
let hex: String = h.iter().map(|b| format!("{b:02X}")).collect();
println!(" H = {hex}");
// What we actually send is `SHA1(A ‖ H)`, so compare the digests through that: equal proofs for
// one `A` means equal `H`.
let a = [0u8; 32];
let matches = {
let mut sha = Sha1::new();
sha.update(a);
sha.update(h);
let derived: [u8; 20] = sha.finalize().into();
derived == auth::version_proof(crc_salt, &a)
};
println!(
" → {}",
if matches {
"matches the digest we send: this install is the stock one for the OS we declare"
} else if !missing.is_empty() {
"differs, and files are missing — an install like this could never answer the \
challenge for itself, which is why we send a constant instead"
} else {
"differs from the digest we send. On macOS that is expected and harmless: we declare \
OSX, so we send the Mac client's digest, while your install is the Windows one. \
Otherwise one of the five is patched. Login is unaffected either way — the digest we \
send does not come from here"
}
);
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
let [host_arg, user, pass] = match args.as_slice() {
[h, u, p] => [h.clone(), u.clone(), p.clone()],
let (host_arg, user, pass, install) = match args.as_slice() {
[h, u, p] => (h.clone(), u.clone(), p.clone(), None),
[h, u, p, i] => (h.clone(), u.clone(), p.clone(), Some(PathBuf::from(i))),
_ => {
eprintln!("usage: version_check_probe <host[:port]> <user> <pass>");
eprintln!("usage: version_check_probe <host[:port]> <user> <pass> [install-dir]");
std::process::exit(2);
}
};
@@ -133,5 +222,8 @@ fn main() -> Result<()> {
};
println!(" crc_hash {} → {verdict}", arm.label());
}
if let Some(dir) = install {
report_install(&dir, &crc_salt);
}
Ok(())
}
+26 -2
View File
@@ -82,6 +82,30 @@ pub enum LoginStage {
Handshaking,
}
/// **How a world session ended** — the fact [`SessionEvent::Disconnected`] carries alongside its
/// reason string, and the one the client's whole post-session behaviour hangs on (decision 1262).
///
/// The two are not shades of the same thing. A logout is a door the player walked through, and the
/// roster on the other side of it is the character-select screen they asked for. A loss is a door
/// that slammed: the reference's engine fires `DISCONNECTED_FROM_SERVER`, and its `GlueParent.lua`
/// answers `SetGlueScreen("login")` + `GlueDialog_Show("DISCONNECTED")` — back to the account
/// screen, one Okay button, no retry. Before this the app told them apart by comparing the reason
/// *string* to `"logged out"`, which is how "the session died" and "the session ended" came to share
/// one code path.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionEnd {
/// A clean, client-initiated logout the server confirmed (`SMSG_LOGOUT_COMPLETE`). The IO
/// thread cycles the connection and a fresh [`SessionEvent::CharacterList`] follows — the
/// teardown here is bookkeeping between two halves of one continuous session.
LoggedOut,
/// The stream died under us: a socket error, an EOF, or a post-roster handshake failure. A
/// **displacement kick is exactly this** and carries nothing else — vmangos's
/// `WorldSession::KickPlayer` is a bare `CloseSocket()`, no packet (verified in
/// `vmangos-core/src/game/Server/WorldSession.cpp`), so "another client took the account" and
/// "the server went away" are the same event on the wire and get the same answer.
Lost,
}
/// One decoded event from the world stream. Carries only primitives + the coarse [`EntityKind`]
/// classification — no wire types leak to the app, and no running state lives here.
#[derive(Debug, Clone)]
@@ -131,8 +155,8 @@ pub enum SessionEvent {
LogoutResponse { reason: u32, instant: bool },
/// The server dropped a pending logout at our `CMSG_LOGOUT_CANCEL` (`SMSG_LOGOUT_CANCEL_ACK`).
LogoutCancelled,
/// The session ended (socket closed / handshake failure); carries a human-readable reason.
Disconnected { reason: String },
/// The session ended; carries a human-readable reason and **how** it ended ([`SessionEnd`]).
Disconnected { reason: String, end: SessionEnd },
/// An object entered range / was created: a unit, player, or GameObject at a raw-WoW pose. Carries
/// the interpreted spawn identity (`display_id`/`scale`, decoded per object type here so the app
/// needn't know the wire layout) plus the full descriptor `fields` mask the ECS seeds its per-object
+3 -1
View File
@@ -13,7 +13,9 @@ pub mod messages;
pub mod wire;
pub mod world;
pub use auth::AuthReject;
pub use events::{decode, CharAction, EntityKind, LoginStage, MoveSpeeds, Poll, SessionEvent};
pub use events::{
decode, CharAction, EntityKind, LoginStage, MoveSpeeds, Poll, SessionEnd, SessionEvent,
};
pub use messages::{
CharCreateReq, CharEnumItem, Character, CreateSpline, ItemInfo, JumpInfo, MonsterMoveFacing,
MoveMode, ObjectFields, OwnerFallback, ServerPacket, SpeedKind, TransportPose,
+117 -5
View File
@@ -104,6 +104,13 @@ pub struct MatKey {
/// z-write on, drawn before its model's colour batches. Its own key axis so a twin can never
/// dedupe onto a colour material.
zfill: bool,
/// This batch is part of a **WMO skybox** ([`crate::wmo_sky`]) — the building-owned painted sky,
/// drawn on this lane like any other M2 since decision 1264. Its own key axis because the lane
/// changes pipeline state ([`SKY_DEPTH_MARKER`]: forced far depth, no early-Z) and sort rung
/// ([`skybox_sort_bias`]), so a skybox batch must never dedupe onto the identical-looking world
/// batch — `CavernsOfTimeSky` and Elwynn share no texture today, but nothing enforces that and
/// the collision would be a world doodad silently drawn at the far plane.
sky_depth: bool,
}
/// The per-material static terrain-shade **selector** baked into `sun_scale.x` — NOT an intensity
@@ -204,6 +211,9 @@ pub fn model_material(
// WINDOW midpoint-light flag (`WowModelExt::sidn`).
sidn: Option<[u8; 3]>,
window: bool,
// This batch belongs to a **WMO skybox** — the sky lane: forced far depth, no rasterizer bias,
// and the skybox sort rung instead of the ordinary batch-order eps. See [`MatKey::sky_depth`].
sky_depth: bool,
light: &Buffer,
) -> Handle<WowModelMaterial> {
let key = MatKey {
@@ -228,6 +238,7 @@ pub fn model_material(
sidn,
window,
zfill: false,
sky_depth,
};
if let Some(h) = cache.fetch(&key) {
return h;
@@ -279,10 +290,17 @@ pub fn model_material(
// load-bearing only for SAME-CENTRE stacks (M2 item overlays, always low-index); WMO batches
// sort by their own distinct mesh centres. The cap stays under `FAR_KEY_PULL` so a far twin
// of a capped batch still lands on the far band's one key integer.
let depth_bias = if matches!(alpha_mode, AlphaMode::Blend) {
(f32::from(batch_order) * BATCH_ORDER_SORT_EPS).min(BATCH_ORDER_SORT_CAP)
} else {
//
// The SKY lane takes the same shape one rung down: a skybox is camera-anchored, so its view-z
// is a few tens of yards and it would sort after the world's transparents rather than behind
// them ([`crate::sky_order::WMO_SKYBOX_BIAS`]). Its opaque batches still take no bias — the
// painted cube faces are in the opaque pass, which is exactly where the reference puts them.
let depth_bias = if !matches!(alpha_mode, AlphaMode::Blend) {
0.0
} else if sky_depth {
skybox_sort_bias(batch_order)
} else {
(f32::from(batch_order) * BATCH_ORDER_SORT_EPS).min(BATCH_ORDER_SORT_CAP)
};
let base = match texture {
Some(image) => StandardMaterial {
@@ -351,7 +369,8 @@ pub fn model_material(
| (u16::from(blend == ModelBlend::Mod) << 7)
| (u16::from(blend == ModelBlend::Mod2x) << 8)
| (u16::from(fade_variant && source_cutout) * TWIN_CUTOUT_MARKER)
| (u16::from(env_map) * ENV_MAP_MARKER),
| (u16::from(env_map) * ENV_MAP_MARKER)
| (u16::from(sky_depth) * SKY_DEPTH_MARKER),
),
0.0,
),
@@ -460,6 +479,40 @@ pub(crate) const TWIN_CUTOUT_MARKER: u16 = 1 << 10;
/// only what the fragment stage samples, so it needs no pipeline of its own (decision 0837).
pub(crate) const ENV_MAP_MARKER: u16 = 1 << 12;
/// `clutter_fade.z` marker bit 13: the **WMO-skybox lane** — this batch is part of a building's
/// painted sky, so `specialize` compiles the shader's `WOW_SKY_DEPTH` branch (forced far depth) and
/// drops the rasterizer bias constant. It *is* a
/// [`benilla_assets::materials::WowModelKey`] axis, unlike [`ENV_MAP_MARKER`]: writing
/// `@builtin(frag_depth)` costs the pipeline its early-Z, so exactly one camera-anchored model may
/// pay it and every other draw in the frame must keep the pipeline that doesn't.
pub(crate) const SKY_DEPTH_MARKER: u16 = 1 << 13;
/// Sort-bias step per authored batch on the **WMO-skybox lane** — [`BATCH_ORDER_SORT_EPS`]'s job,
/// resized for the rung it has to survive.
///
/// The plain 1e-3 eps cannot be used here: added to [`crate::sky_order::WMO_SKYBOX_BIAS`] (−6e4) it
/// falls *under the f32 ulp at that magnitude* (2¹⁵·2⁻²³ = 0.0039), so every batch rounds onto the
/// same bias and the sort tie the eps exists to break comes back in silence. This step is 4 ulps
/// there — exactly representable, strictly monotone. The tie is not a rare coplanar case on this
/// lane: a skybox is anchored at the eye, so *every* pair of its batches shares a sort distance,
/// every frame.
pub(crate) const SKYBOX_ORDER_EPS: f32 = 0.015625;
/// The skybox band's ceiling. With [`FAR_KEY_PULL`] it keeps the whole band on ONE pipeline-key
/// integer — 0837's law: the base `StandardMaterialKey` packs `depth_bias as i32`, so a band
/// straddling an integer is a live pipeline compile per side. Pulled by just under 1 and capped at
/// 0.9, every batch lands in `(RUNG − 0.99, RUNG − 0.09]`, which truncates toward zero onto the
/// same integer for all of them. The cap ties batches past index 57 (`0.9 / SKYBOX_ORDER_EPS`); the
/// largest skybox the chain ships is 21 batches (`CavernsOfTimeSky.m2`).
pub(crate) const SKYBOX_ORDER_CAP: f32 = 0.9;
/// This batch's slot in the skybox band: the lane rung, pulled onto one pipeline key, plus the
/// authored batch order. See [`SKYBOX_ORDER_EPS`] and [`crate::sky_order::WMO_SKYBOX_BIAS`].
fn skybox_sort_bias(batch_order: u16) -> f32 {
crate::sky_order::WMO_SKYBOX_BIAS - FAR_KEY_PULL
+ (f32::from(batch_order) * SKYBOX_ORDER_EPS).min(SKYBOX_ORDER_CAP)
}
/// The twin's `Transparent3d` sort bias (yards; **negative = drawn earlier** — the sign law is
/// `sky_order`'s module doc). It must clear the spread of one model's part AABB centres, so **all**
/// of a fading model's twins draw before **any** of its colour parts — the reference achieves the
@@ -524,6 +577,9 @@ pub fn zfill_material(
sidn: None,
window: false,
zfill: true,
// A depth-prime twin exists to write depth for a fading model; the sky writes none, so no
// skybox batch ever has one (`M2BatchMaterials::skybox` builds no twins at all).
sky_depth: false,
};
if let Some(h) = cache.fetch(&key) {
return h;
@@ -871,7 +927,12 @@ fn classify_part(
let far_now = marked || swapped;
let qualifies = match materials.get(near.id()) {
Some(m) => {
matches!(m.base.alpha_mode, AlphaMode::Blend) && m.extension.model_flags.x <= 0.5
matches!(m.base.alpha_mode, AlphaMode::Blend)
&& m.extension.model_flags.x <= 0.5
// A WMO skybox is anchored at the eye and forces the far depth: it has no side of
// the water plane to be on, and the far twin's −4e4 would sink its own sort rung
// into the world band. The sky is never part of the interleave.
&& (m.extension.clutter_fade.z as u16) & SKY_DEPTH_MARKER == 0
}
None => false,
};
@@ -1118,6 +1179,57 @@ mod tests {
);
}
/// The **WMO-skybox band**: the four properties the lane's whole ordering rests on, none of
/// which survives being reasoned about in prose (decision 1264). The band is arithmetic at a
/// magnitude where f32 stops being able to represent the steps, which is exactly how the
/// ordinary eps would have failed here in silence.
#[test]
fn the_skybox_band_orders_its_batches_on_one_pipeline_key() {
// `CavernsOfTimeSky.m2` is 21 batches; `order` is the index + 1.
let band: Vec<f32> = (0..=21).map(super::skybox_sort_bias).collect();
// 1. STRICTLY INCREASING — the whole point. At −6e4 the ordinary 1e-3 eps rounds every
// one of these onto the same f32 and the sort tie the eps exists to break comes back.
for pair in band.windows(2) {
assert!(
pair[1] > pair[0],
"skybox batch order does not resolve at the rung's magnitude: {pair:?} — \
SKYBOX_ORDER_EPS is under the f32 ulp there"
);
}
// 2. ONE PIPELINE KEY for the whole band (0837's law: `StandardMaterialKey` packs
// `depth_bias as i32`, so a band straddling an integer is a live compile per side).
// Checked for ANY u16 order, not just the 21 real ones — the cap is what holds it.
let key = band[0] as i32;
for order in [0u16, 1, 21, 57, 58, 1000, u16::MAX] {
assert_eq!(
super::skybox_sort_bias(order) as i32,
key,
"batch order {order} mints a second skybox pipeline"
);
}
// 3. UNDER EVERY WORLD TRANSPARENT. The deepest a world draw can sort is the far-side
// water rung plus a far-plane view-z; the skybox's own camera-anchored view-z (its
// shell radius, ~94 yd for Caverns of Time) rides on top of the rung and must not
// close the gap.
const FAR_PLANE: f32 = 3000.0;
const SHELL: f32 = 128.0;
let deepest_world = crate::sky_order::FAR_SIDE_BIAS - FAR_PLANE - super::FAR_KEY_PULL;
assert!(
band.last().unwrap() + SHELL < deepest_world,
"a skybox can sort in front of a world transparent — the painted sky would paint \
over it (sky_order::WMO_SKYBOX_BIAS)"
);
// 4. The rung is the SMALLEST job it can be, per `sky_order`'s "as small as its ordering
// job allows": a magnitude an order over the world band is margin, two is drift.
const {
assert!(crate::sky_order::WMO_SKYBOX_BIAS > 10.0 * crate::sky_order::FAR_SIDE_BIAS);
}
}
/// The far-side twin is its source shifted exactly one water rung with the marker bit added —
/// and every OTHER marker bit rides along untouched (a fade twin's cutout, a zfill twin's
/// prime, the multiply blends and the fog policy all keep their pipeline identity on the far
@@ -101,6 +101,72 @@ impl M2BatchMaterials<'_> {
))
}
/// One material for a batch of a **WMO skybox** — the painted sky a building owns
/// ([`crate::wmo_sky`]), which is an ordinary M2 and is drawn as one (decision 1264).
///
/// Everything that makes it a *skybox* rather than a doodad is here, and it is only three
/// things. Blend mode, sidedness, the UNLIT and UNFOGGED bits, the alpha-key reference, the
/// multiply factors and the additive gamma premultiply all come off the authored batch, like
/// any other model's — which is the whole point: the lane this replaced read positions, UVs and
/// one texture and drew every batch opaque, so `CavernsOfTimeSky`'s six ADDITIVE star sheets
/// (near-white RGB whose stars live in the ALPHA channel) painted a flat white sheet over the
/// painted sky, and its planets and asteroid belts drew as dark opaque cards.
///
/// - **The far depth** (`sky_depth`): the sky's whole occlusion law is that it writes the
/// reverse-Z far value and lets the world paint over it ([`crate::sky_order`], "The depth
/// law").
/// - **Depth-write OFF, always**, whatever the batch authored — a sky element leaves the
/// z-buffer at its clear value so the forced-far glare quads stay occluded by world geometry
/// alone (the rule [`crate::sky`] gives the gradient dome).
/// - **Depth-test ON, always**, and this one *overrides* the batch. Every `CavernsOfTimeSky`
/// batch sets M2 flag `0x08` (no depth test), which on this lane becomes
/// `CompareFunction::Always` and would paint the sky straight over the room around you. The
/// flag is how the reference orders the sky pass *within* its own squashed depth slice
/// (`[0.975, 0.98]`, painter's order, depth-write off); our port of that ordering is the sort
/// rung ([`super::skybox_sort_bias`]), so honouring the flag here would be reading it twice.
///
/// No variant set: a skybox never feathers, never primes depth, and is never indoors or
/// outdoors — it *is* the outdoors.
pub fn skybox(
&mut self,
sub: &benilla_formats::RenderSubmesh,
texture: Option<Handle<Image>>,
order: u16,
) -> Option<Handle<WowModelMaterial>> {
let light = self.light.as_ref()?.0.clone();
Some(model_material(
&mut self.cache.0,
&mut self.materials,
texture,
sub.blend,
sub.two_sided,
false, // an M2, whichever building names it
false,
sub.emissive,
sub.additive,
false, // never feathers
true, // …never writes depth
false, // …and never skips the test — see above
sub.fog_policy,
sub.env_map,
ShadeSel::Lit, // unread: every skybox batch the chain ships authors UNLIT (0x01)
order,
// No texture-transform or M2Color loop is wired on this lane, and neither shipped
// skybox authors one (`CavernsOfTimeSky` has 0 texture transforms and 0 animated colour
// tracks; `StratholmeSkybox` is fully static). Wiring them means loading the skybox
// through the `M2Model` asset lane — which holds these as the `Arc`s the material key
// identifies them by, and is also what the asteroid belts' BONE animation needs. One
// move, when that lands; a locally-minted `Arc` here would be a dedup key with no owner.
None,
None,
None, // M2: no MOBA class, no SIDN, no WINDOW
None,
false,
true, // the sky lane
&light,
))
}
/// One steady material for a batch drawn **off-world**, against a render target's own light
/// buffer instead of the world's: a portrait booth's frozen studio rig, the model pane's, the
/// char-select stage's. The buffer is a key axis ([`super::MatKey::light`]), so these share
@@ -251,6 +317,7 @@ impl M2BatchMaterials<'_> {
None, // worn/held part: light selection anchors at the instance origin
None, // M2 carries no MOMT SIDN colour
false, // …nor the WINDOW flag
false, // a character composite is never a skybox
&light,
)
};
@@ -356,6 +423,9 @@ impl M2BatchMaterials<'_> {
sub.wmo_batch,
sub.sidn,
sub.window,
// The sky lane is [`Self::skybox`]'s alone: it overrides depth state the authored batch
// asks for, so it cannot be one more variant built from `sub` here.
false,
light,
)
}
@@ -107,6 +107,7 @@ pub(super) fn update_model_particles(
None,
None,
false,
false, // an effect model is never a skybox
&light.0,
);
// The owner-last draw-order rung, stamped on after the fact: a 3-D model
+4 -2
View File
@@ -34,7 +34,6 @@ pub(crate) fn plugin(app: &mut App) {
bevy::asset::embedded_asset!(app, "shaders/star.wgsl");
bevy::asset::embedded_asset!(app, "shaders/cloud.wgsl");
bevy::asset::embedded_asset!(app, "shaders/celestial.wgsl");
bevy::asset::embedded_asset!(app, "shaders/wmo_skybox.wgsl");
bevy::asset::embedded_asset!(app, "shaders/ffx_glow.wgsl");
bevy::asset::embedded_asset!(app, "shaders/wow_effect.wgsl");
}
@@ -77,6 +76,9 @@ mod tests {
`shaders::plugin`, or was registered from a file that is not directly under src/"
);
}
assert_eq!(found, 7, "the engine's shader set changed size");
// 6 since decision 1264 retired `wmo_skybox.wgsl`: a WMO skybox draws on the shared model
// lane, whose forced-far-depth branch lives in `benilla_assets`' `wow_model.wgsl` and is
// pinned by that crate's own `the_sky_lane_forces_the_far_depth`.
assert_eq!(found, 6, "the engine's shader set changed size");
}
}
@@ -1,40 +0,0 @@
// The WMO **skybox** (`wmo_sky.rs`) — the authored sky a building swaps in for the Light.dbc
// gradient dome while the camera stands in one of its `0x40000` groups. Stratholme's burning city is
// the one place 1.12 content reaches this: `StratholmeSkybox.m2`, a static emissive cube whose three
// texture pairs are the painted red sky.
//
// GAMMA LANE (decision 0161): the batches are authored material flags `0x05` = UNLIT (`0x01`) |
// TWO_SIDED (`0x04`), blend 0 (`benilla-extract m2batch`), so the texel's own gamma bytes, unlit and
// unmodulated, is what the *material* asks for.
//
// UNVERIFIED, and flagged as such: we draw it **unfogged** — and the M2 does not set the UNFOGGED bit
// (`0x02`), so by its own authoring this model IS fogged. The no-fog treatment was an analogy with the
// gradient dome, not a byte law: wow-re has `CMapObj+0x12c` (the MOSB pointer) carved with no renderer
// behind it, so nothing yet says which pass this draws in or under what state. An RE dispatch is open
// on exactly that. Do not harden this into a claim before it lands.
//
// SKY-PASS DEPTH (see `sky_order.rs`, "The depth law"): the box is drawn camera-anchored at model
// scale, so its geometry sits *inside* the world — the shell radius must not decide occlusion.
// Forcing the far depth makes it a true backdrop: it fills only the pixels no world geometry claimed.
#import bevy_pbr::{
pbr_fragment::pbr_input_from_standard_material,
forward_io::VertexOutput,
}
/// Reverse-Z "infinitely far" — the sky pass's forced depth (`sky_order.rs`).
const SKY_FAR_DEPTH: f32 = 0.0;
struct WmoSkyboxOutput {
@location(0) color: vec4<f32>,
@builtin(frag_depth) depth: f32,
}
@fragment
fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> WmoSkyboxOutput {
let pbr_input = pbr_input_from_standard_material(in, is_front);
var out: WmoSkyboxOutput;
// Opaque backdrop: the texel's raw gamma bytes, alpha forced to 1 (the sky art carries none).
out.color = vec4<f32>(pbr_input.material.base_color.rgb, 1.0);
out.depth = SKY_FAR_DEPTH;
return out;
}
+22 -5
View File
@@ -73,11 +73,26 @@
/// Stars — the first celestial draw (`0x6d4a3f`): everything else in the sky paints over them, so
/// theirs is the ladder's LOWEST rung (see the sign law above).
///
/// The WMO skybox ([`crate::wmo_sky`]) takes no rung: like the gradient dome it replaces, it is an
/// **opaque** backdrop, so it draws in the opaque pass and needs no transparent-sort bias — its
/// forced far depth (the depth law below) is the whole of its ordering, and the two never coexist.
pub(crate) const STARS_BIAS: f32 = -1.0e6;
/// **The WMO skybox** ([`crate::wmo_sky`]) — the building-owned painted sky, which replaces this
/// whole ladder while it draws, so its rung never competes with one above. It needs a rung at all
/// because a skybox is **an ordinary M2 and not an opaque backdrop**, which is what this module
/// asserted until decision 1264: `CavernsOfTimeSky.m2` is 21 batches across four blend modes — a
/// painted cube, six additive star sheets on the cube's own faces, five alpha-blended planet cards
/// and three alpha-tested asteroid belts on rotating bones. Drawn opaque (the old lane read
/// positions, UVs and one texture and nothing else) the additive star sheet — near-white RGB whose
/// stars live in its ALPHA — paints a flat white sheet over the painted sky. That is the director's
/// "the whole ceiling is white" in Caverns of Time.
///
/// The blended half of a skybox therefore lands in the transparent pass, camera-anchored, so its
/// view-z is a few tens of yards and it would sort *after* the world. This rung sinks it under
/// every world transparent: the deepest of those is [`FAR_SIDE_BIAS`] plus a far-plane view-z,
/// ≈ −4.3e4, and the shell's own view-z rides on top of the rung — all four properties of the band
/// are machine-checked in `model_render`'s
/// `the_skybox_band_orders_its_batches_on_one_pipeline_key`. Kept as small as that job allows (the
/// magnitude rationale above), because the rung is also what the batch-order epsilon has to
/// survive: see `model_render::SKYBOX_ORDER_EPS`.
pub(crate) const WMO_SKYBOX_BIAS: f32 = -6.0e4;
/// The sun disc — second (`0x7e5b90` via `0x6d4a47`).
pub(crate) const SUN_DISC_BIAS: f32 = -8.2e5;
/// The white moon — third; where the discs cross, the moon paints over the sun.
@@ -198,7 +213,9 @@ fn every_sky_shader_forces_the_far_depth() {
("star.wgsl", include_str!("shaders/star.wgsl")),
("cloud.wgsl", include_str!("shaders/cloud.wgsl")),
("celestial.wgsl", include_str!("shaders/celestial.wgsl")),
("wmo_skybox.wgsl", include_str!("shaders/wmo_skybox.wgsl")),
// The WMO skybox is no longer a shader of its own: it draws on the shared model lane, whose
// `WOW_SKY_DEPTH` branch obeys the same law and is asserted beside it
// (`benilla_assets::materials`, `the_sky_lane_forces_the_far_depth`).
] {
assert!(
src.contains("const SKY_FAR_DEPTH: f32 = 0.0;"),
@@ -202,6 +202,7 @@ pub fn spawn_model_entities(
sub.wmo_batch,
sub.sidn,
sub.window,
false, // the world streamer never spawns a skybox
light,
);
// The blend twin for the distance-fade feather pass (reuse the cutout when already blend, or when
@@ -243,6 +244,7 @@ pub fn spawn_model_entities(
sub.wmo_batch,
sub.sidn,
sub.window,
false, // the world streamer never spawns a skybox
light,
)
};
+64 -64
View File
@@ -19,19 +19,41 @@
//! without a MOSB, 0 counter-examples (`benilla-extract skyboxscan`) — was true and still is; it just
//! never established *which* group the renderer tests, and it was over-read as if it had.
//!
//! Five roots exercise both halves: the four Caverns of Time shells (unreleased in 1.12) and
//! **`Stratholme_B`**, the burning city. Four more name a skybox no group ever asks for (DireMaul's
//! Five roots exercise both halves: **`Stratholme_B`**, the burning city, and the four Caverns of
//! Time shells — which are *not* out of reach in 1.12, whatever the instance portals do.
//! `CavernsofTime.wmo` is placed in the live world at Kalimdor tile (39, 47), MODF `uniqueId`
//! 398759, and standing in the crater at `.go xyz -8437.16 -4222.44 -211.58 1` the cull's down-ray
//! seeds group 29 (`flags 0x42805` — `SHOW_SKYBOX` set) and this resolve fires. An earlier note here
//! read "unreleased" as "unreachable" and concluded this branch never actually picks; the director
//! walked into it.
//!
//! Four more roots name a skybox no group ever asks for (DireMaul's
//! instance shell, `Stratholme_A`, and the two Sunken Temple roots — whose MOSB isn't even a model
//! path, it's the string "the temple of atal'hakkar"), so keying off the chunk alone would still
//! paint skies the reference never shows. This is why Stratholme's sky is red where the zone light
//! says otherwise: map 329's only reachable `Light.dbc` atmosphere (global row 341 → `LightParams`
//! 336) is a khaki-brown gradient with a near-black apex, and it is not what draws in there.
//!
//! What we draw is the model as authored: `StratholmeSkybox.m2` is a static, emissive, two-sided cube
//! (three batches × 8 verts, one texture pair per axis), anchored at the camera with identity rotation
//! — the same treatment [`crate::sky`] gives its dome. Occlusion is **not** the box's radius: like
//! every sky element it forces the far depth (`wmo_skybox.wgsl`; the law is in [`crate::sky_order`]),
//! so the world paints over it and the 26-yard shell is free to sit inside the room's own geometry.
//! **A skybox is an ordinary M2, and is drawn as one** (decision 1264). This lane used to have a
//! private mesh builder and a private material — positions, UVs, one texture, everything opaque —
//! which is a faithful drawing of `StratholmeSkybox.m2` (three opaque batches × 8 verts, one texture
//! pair per axis, no animation) and of nothing else. `CavernsOfTimeSky.m2` is the counter-example the
//! chain also ships: **21 batches across four blend modes** — a painted cube, six ADDITIVE star
//! sheets on the cube's own faces, five alpha-blended planet cards, three alpha-tested asteroid belts
//! on rotating bones. Drawn opaque, the star sheet — whose RGB is near-white and whose stars live in
//! its ALPHA channel — paints a flat white sheet over the painted sky, and the planets and belts
//! become dark cards. That is the director's *"the whole ceiling is white … some of the cool effects
//! seem missing"* in Caverns of Time.
//!
//! So the batches go through [`crate::model_render`]'s material lane like every other model
//! ([`M2BatchMaterials::skybox`]), which is where the blend law, the 224/255 alpha-key reference, the
//! additive gamma premultiply and the authored batch order already live, byte-verified. Three things
//! are the *sky's* and are set here, not read off the batch: the forced far depth, depth-write off,
//! and depth-test on — the rationale is on `skybox()`.
//!
//! Occlusion is **not** the box's radius: like every sky element it forces the far depth (the law is
//! in [`crate::sky_order`]), so the world paints over it and the shell is free to sit inside the
//! room's own geometry.
//!
//! **Scope — the skybox replaces the WHOLE celestial pass, not just the backdrop.** `CSky::Render`
//! carries one shared boolean (`0x6d49cd` sets it, `0x6d49fb`/`0x6d4a2e` clear it once any slot's
@@ -48,17 +70,10 @@
use std::collections::HashSet;
use bevy::asset::RenderAssetUsages;
use bevy::mesh::{Indices, MeshVertexBufferLayoutRef, PrimitiveTopology};
use bevy::pbr::{
ExtendedMaterial, MaterialExtension, MaterialExtensionKey, MaterialExtensionPipeline,
MaterialPlugin,
};
use bevy::mesh::{Indices, PrimitiveTopology};
use bevy::prelude::*;
use bevy::render::render_resource::{
AsBindGroup, RenderPipelineDescriptor, SpecializedMeshPipelineError,
};
use bevy::shader::ShaderRef;
use crate::model_render::M2BatchMaterials;
use crate::view::WorldCamera;
use benilla_assets::coords::wow_to_bevy;
use benilla_assets::WmoModel;
@@ -69,36 +84,6 @@ use benilla_assets::{LockRecover, WorldAssets};
/// file's MOGP header; we read the MOGP copy the loader already keeps in `WmoGroupNav::flags`.
const SHOW_SKYBOX: u32 = 0x40000;
/// Unlit textured backdrop over a `StandardMaterial` shell — the shell supplies `unlit` +
/// `cull_mode: None` (the box is viewed from inside) and the texture; the extension's fragment shader
/// emits the texel raw and forces the sky pass's far depth.
pub type WmoSkyboxMaterial = ExtendedMaterial<StandardMaterial, WmoSkyboxExt>;
/// No per-material uniforms — the fragment reads the base-colour texture and nothing else.
#[derive(Asset, AsBindGroup, Clone, TypePath, Default)]
pub struct WmoSkyboxExt {}
impl MaterialExtension for WmoSkyboxExt {
fn fragment_shader() -> ShaderRef {
"embedded://benilla_world/shaders/wmo_skybox.wgsl".into()
}
/// Depth-write OFF, exactly as [`crate::sky::SkyExt`] does it: the backdrop must leave the
/// z-buffer at its clear value over sky pixels so the forced-far glare quads stay occluded by
/// *world* geometry alone.
fn specialize(
_pipeline: &MaterialExtensionPipeline,
descriptor: &mut RenderPipelineDescriptor,
_layout: &MeshVertexBufferLayoutRef,
_key: MaterialExtensionKey<Self>,
) -> Result<(), SpecializedMeshPipelineError> {
if let Some(depth) = descriptor.depth_stencil.as_mut() {
depth.depth_write_enabled = false;
}
Ok(())
}
}
/// The skybox model the camera's room asks for this frame — `None` (the overwhelming default) means
/// the [`crate::sky`] gradient dome is the backdrop. Resolved from [`CameraInteriorClaim`]: the same
/// down-ray seed that already names the camera's room for the MFOG fog resolve.
@@ -129,8 +114,9 @@ pub(crate) struct WmoSkyPlugin;
impl Plugin for WmoSkyPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(MaterialPlugin::<WmoSkyboxMaterial>::default())
.init_resource::<CameraWmoSkybox>()
// No `MaterialPlugin` of its own any more: a skybox batch is a `WowModelMaterial` like every
// other model batch, and that plugin is already loaded.
app.init_resource::<CameraWmoSkybox>()
.init_resource::<BuiltSkyboxes>()
.add_systems(
Update,
@@ -197,9 +183,10 @@ fn resolve_camera_skybox(
}
}
/// Build the wanted skybox's entities the first time it is asked for. The model is tiny (three static
/// batches) and there are five in the whole game, so a built one is kept for the session rather than
/// torn down on leaving the room — walking in and out of Stratholme's gate must not re-decode art.
/// Build the wanted skybox's entities the first time it is asked for. The models are small (3 batches
/// for Stratholme, 21 for Caverns of Time) and there are five in the whole game, so a built one is
/// kept for the session rather than torn down on leaving the room — walking in and out of
/// Stratholme's gate must not re-decode art.
fn build_skybox(
mut commands: Commands,
want: Res<CameraWmoSkybox>,
@@ -207,7 +194,7 @@ fn build_skybox(
world_assets: Option<ResMut<WorldAssets>>,
mut meshes: ResMut<Assets<Mesh>>,
mut images: ResMut<Assets<Image>>,
mut materials: ResMut<Assets<WmoSkyboxMaterial>>,
mut mats: M2BatchMaterials,
) {
let Some(path) = want.0.as_deref() else {
return;
@@ -215,6 +202,12 @@ fn build_skybox(
if built.0.contains(path) {
return;
}
// The shared light buffer is created in the render world's shadow at startup; a build that ran
// before it would bake materials against nothing. Retry — and do NOT latch `built` here, or the
// first frame in the room would permanently give up on the sky.
if !mats.ready() {
return;
}
let Some(mut world_assets) = world_assets else {
return; // assetless dev run — the gradient dome stays the backdrop
};
@@ -232,7 +225,7 @@ fn build_skybox(
return;
}
};
for sub in &subs {
for (i, sub) in subs.iter().enumerate() {
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
@@ -243,23 +236,30 @@ fn build_skybox(
.map(|p| wow_to_bevy(*p).to_array())
.collect();
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
// The model lane's vertex stage declares `world_normal` unconditionally and fills it under
// `VERTEX_NORMALS`; the sky is unlit and never reads it, but the attribute has to be there
// for the layout the shared shader was compiled against.
mesh.insert_attribute(
Mesh::ATTRIBUTE_NORMAL,
sub.normals
.iter()
.map(|n| wow_to_bevy(*n).to_array())
.collect::<Vec<_>>(),
);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, sub.uvs.clone());
mesh.insert_indices(Indices::U32(sub.indices.clone()));
// The batch's own authored address mode (decision 0763) — a skybox's UVs sit inside 0..1, but
// reading the flags costs nothing and keeps this lane off a private convention.
// The batch's own authored address mode (decision 0763) — a skybox's UVs sit inside 0..1 for
// the cube faces, but Caverns of Time's asteroid belts tile theirs over ±21 wraps.
let texture = sub
.texture
.as_deref()
.and_then(|t| world_assets.texture(t, (sub.wrap_x, sub.wrap_y), &mut images));
let material = materials.add(WmoSkyboxMaterial {
base: StandardMaterial {
base_color_texture: texture,
unlit: true,
cull_mode: None, // authored two-sided, and we view the box from inside
..default()
},
extension: WmoSkyboxExt {},
});
// `i + 1` is the authored-batch-order convention (`0` = unordered): the transparent half of
// a skybox is camera-anchored, so every one of its batches shares a sort distance and the
// order is the only thing keeping the layers from re-flipping every frame.
let Some(material) = mats.skybox(sub, texture, u16::try_from(i + 1).unwrap_or(0)) else {
return; // light buffer vanished mid-build; `built` is unlatched, so we retry
};
commands.spawn((
Mesh3d(meshes.add(mesh)),
MeshMaterial3d(material),