diff --git a/crates/benilla-app/assets/ui/ActionBar.xml b/crates/benilla-app/assets/ui/ActionBar.xml index 5bccfd6a..d27416c2 100644 --- a/crates/benilla-app/assets/ui/ActionBar.xml +++ b/crates/benilla-app/assets/ui/ActionBar.xml @@ -1241,238 +1241,11 @@ ExhaustionTick.timer = nil end - -- ══ The reputation WATCH BAR (ref-ReputationFrame.xml l.869-1036 + ref-ReputationFrame.lua - -- l.3-14 and l.161-270) ═══════════════════════════════════════════════════════════════ - -- - -- **THE PLACEMENT IS A DELIBERATE CALL, and this is its justification.** The reference - -- declares `ReputationWatchBar` and defines its update, its two text verbs, MAX_PLAYER_LEVEL - -- and FACTION_BAR_COLORS in `ReputationFrame.{xml,lua}`, because FrameXML loads MainMenuBar - -- and ReputationFrame in one breath and neither can exist without the other. benilla's - -- manifest is not one breath: this file is entry 94, `ReputationFrame.xml` is 213, and the - -- engine's per-window test harnesses deliberately load FILE SUBSETS — the XP bar's harness - -- loads no character window, the character window's loads no action bar. - -- - -- Every frame `ReputationWatchBar_Update` touches except the watch bar itself is declared - -- in THIS file: MainMenuExpBar, MainMenuBarMaxLevelBar, ExhaustionTick, MainMenuBarArtFrame - -- and MainMenuBar. So does every frame that reads the bar back: `ExhaustionTick_Update` - -- below consults `ReputationWatchBar:IsShown()` twice, which is the reference's own pair of - -- guards. Splitting the bar's FRAME from the bar's UPDATE across a 120-entry manifest gap - -- would leave each subset holding half a mechanism and would put a nil-guard on every one - -- of those sites — the exact "special case to dodge a problem" shape the contract forbids. - -- The bar therefore stays whole, in the file that owns its collaborators, and - -- `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-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 - -- only driver — and the event fan that sat on it is now the watch bar's own OnLoad/OnEvent, - -- which is where the reference puts it. - MAX_PLAYER_LEVEL = 60 -- ref-ReputationFrame.lua l.14 (with the ref's own "Hard coded =(") - - -- 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 }, - [3] = { r = 0.75, g = 0.27, b = 0 }, - [4] = { r = 0.9, g = 0.7, b = 0 }, - [5] = { r = 0, g = 0.6, b = 0.1 }, - [6] = { r = 0, g = 0.6, b = 0.1 }, - [7] = { r = 0, g = 0.6, b = 0.1 }, - [8] = { r = 0, g = 0.6, b = 0.1 }, - } - - -- 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 `0` .. `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. 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. - -- - -- The tail used to call `updateContainerFrameAnchors()` itself, guarded, because that - -- global was a benilla no-op standing in for a body that had no windows to anchor. - -- Decision 1751 gave it the reference's real body and put the call where the reference - -- puts it — the end of `UIParent_ManageFramePositions` (UIParent.lua:1774) — so this - -- caller gets it for free, along with every other one. - function ReputationWatchBar_Update(newLevel) - 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 - -- 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() - ReputationWatchStatusBar:SetFrameLevel(MainMenuBarArtFrame:GetFrameLevel() - 1) - - -- 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 - -- 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() - local capped = newLevel == MAX_PLAYER_LEVEL - BenillaExpStrip_SetShown(not capped) - if capped then - MainMenuBarMaxLevelBar:Show() - else - MainMenuBarMaxLevelBar:Hide() - end - end - if wasShown ~= ReputationWatchBar:IsShown() then - UIParent_ManageFramePositions() - end - end - - -- 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 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; the shapes below (early exits where the ref nests) are ours. - function ShowWatchedReputationBarText(lock) - if lock then ReputationWatchBar.cvarLocked = lock end - if UnitLevel("player") ~= MAX_PLAYER_LEVEL or not ReputationWatchBar:IsVisible() then - HideWatchedReputationBarText() - return - end - ReputationWatchBar.textLocked = 1 - ReputationWatchStatusBarText:Show() - end - - function HideWatchedReputationBarText(unlock) - -- Same gate as the ref's `unlock or not cvarLocked`, spun as the early exit. - if not unlock and ReputationWatchBar.cvarLocked then return end - ReputationWatchStatusBarText:Hide() - ReputationWatchBar.cvarLocked = nil - ReputationWatchBar.textLocked = nil - 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 watch bar's own script — `ReputationWatchBar_Update`, the two text verbs, + -- `MAX_PLAYER_LEVEL` and `FACTION_BAR_COLORS` — is the REFERENCE's, in the same file that + -- declares the bar (1875). `ExhaustionTick_Update` below still reads + -- `ReputationWatchBar:IsShown()` twice: that coupling is the reference's own, and it is why + -- every action-bar-shaped harness now loads the reputation pane. -- ── The exhaustion tick (ref-MainMenuBar.lua ExhaustionTick_*) ──────────────────────── -- The round marker riding the XP bar at the point where rested XP runs out, plus the @@ -1928,7 +1701,8 @@ flat brass rail that replaces the XP strip at MAX_PLAYER_LEVEL — four 256×7 quarters of UI-MainMenuBar-MaxLevel, riding just above the frame's own 7 px rect (each quarter's BOTTOM sits on the frame's TOP, the ref's own geometry). - Shown and hidden by ReputationWatchBar_Update (above) and by nothing else — which + Shown and hidden by the reference's `ReputationWatchBar_Update` (its own file since + 1875) and by nothing else — which is the reference's own arrangement, and why this frame carries NO Scripts. It held a benilla OnLoad/OnEvent pair while the watch bar was driven by nothing (decision 1094); those were a transcription of the WATCH BAR's own handlers parked here, and @@ -2309,7 +2083,7 @@ 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 + DRIVEN now, by the reference's `ReputationWatchBar_Update` (its own file since 1875), whose comment carries the reason the reputation watch-bar slice lives here rather than in ReputationFrame.xml where the reference declares it. @@ -2321,107 +2095,10 @@ replaces-the-XP-bar posture at max level, where the strip has to look like the bar it took over from. The numerals live on their own DIALOG-strata overlay child so they draw over both. --> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - BenillaWatchBar_OnLoad(self) - BenillaWatchBar_OnEvent(self, event) - BenillaWatchBar_OnShow() - UIParent_ManageFramePositions() - ReputationWatchStatusBarText:Show() - - if not ReputationWatchBar.textLocked then - ReputationWatchStatusBarText:Hide() - end - - - + diff --git a/crates/benilla-app/assets/ui/AuctionFrame.xml b/crates/benilla-app/assets/ui/AuctionFrame.xml index 066b3f1a..bc1b15c9 100644 --- a/crates/benilla-app/assets/ui/AuctionFrame.xml +++ b/crates/benilla-app/assets/ui/AuctionFrame.xml @@ -199,22 +199,6 @@ collapse = 1, } - -- ── The money-entry kit (ref MoneyInputFrame.lua) ─────────────────────────────────────── - -- The three boxes ride the shared BenillaMoneyInput_* helpers (MerchantFrame.xml), which - -- own the gold/silver/copper fold and the SetCopper suppression bracket. All this adds is - -- the per-instance value-changed callback the reference sets with - -- `MoneyInputFrame_SetOnvalueChangedFunc`: the create-auction pane re-validates on every - -- keystroke, the two bid boxes do not. - function BenillaAuctionMoneyInput_OnChanged(box) - local frame = box:GetParent() - -- False while a programmatic SetCopper is in flight — the reference's `expectChanges` - -- counter, as MerchantFrame.xml's bracket flag. - if not BenillaMoneyInput_Consume(frame:GetName()) then return end - if frame.onValueChanged then - frame.onValueChanged() - end - end - -- ── Load / show / hide / events (ref-lua l.65-125) ────────────────────────────────────── function AuctionFrame_OnLoad(frame) PanelTemplates_SetNumTabs(frame, 3) @@ -227,8 +211,8 @@ AuctionsBuyoutText:SetText(gs("BUYOUT_PRICE") .. " |cff808080(" .. gs("OPTIONAL") .. ")|r") -- The create pane's two inputs re-validate on every keystroke (ref-xml l.1811/1877); -- the gold box is capped at six digits there, which is the price ceiling in practice. - StartPrice.onValueChanged = AuctionsFrameAuctions_ValidateAuction - BuyoutPrice.onValueChanged = AuctionsFrameAuctions_ValidateAuction + MoneyInputFrame_SetOnvalueChangedFunc(StartPrice, AuctionsFrameAuctions_ValidateAuction) + MoneyInputFrame_SetOnvalueChangedFunc(BuyoutPrice, AuctionsFrameAuctions_ValidateAuction) StartPriceGold:SetMaxLetters(6) -- Each faux list repaints through its own tab's Update. end @@ -925,8 +909,8 @@ -- The bid box is SEATED at the required bid, and the gate is read back off -- the box rather than off the number — so a player who types a different -- amount is gated on what they typed (ref-lua l.555-559). - BenillaMoneyInput_SetCopper("BrowseBidPrice", requiredBid) - local offered = BenillaMoneyInput_GetCopper("BrowseBidPrice") + MoneyInputFrame_SetCopper(BrowseBidPrice, requiredBid) + local offered = MoneyInputFrame_GetCopper(BrowseBidPrice) if not highBidder and owner ~= UnitName("player") and GetMoney() >= offered and offered <= BENILLA_MAX_BID then BrowseBidButton:Enable() @@ -1040,11 +1024,11 @@ elseif not (buyoutPrice > 0 and buyoutPrice >= currentBid) then AuctionFrame.buyoutPrice = nil end - BenillaMoneyInput_SetCopper("BidBidPrice", currentBid + minIncrement) + MoneyInputFrame_SetCopper(BidBidPrice, currentBid + minIncrement) -- Looser than Browse's: this list is auctions you already bid on, so the -- "not your own auction" test is redundant and the ceiling is not applied -- (ref-lua l.737). - if not highBidder and GetMoney() >= BenillaMoneyInput_GetCopper("BidBidPrice") then + if not highBidder and GetMoney() >= MoneyInputFrame_GetCopper(BidBidPrice) then BidBidButton:Enable() end end @@ -1249,9 +1233,9 @@ local name, texture, count, quality, canUse, price = GetAuctionSellItemInfo() -- The suggested opening price: half again the stack's vendor value, floored at one -- silver (ref-lua l.1023). - BenillaMoneyInput_SetCopper("StartPrice", max(100, floor(price * 1.5))) + MoneyInputFrame_SetCopper(StartPrice, max(100, floor(price * 1.5))) UpdateDeposit() - BenillaMoneyInput_SetCopper("BuyoutPrice", 0) + MoneyInputFrame_SetCopper(BuyoutPrice, 0) AuctionsFrameAuctions_ValidateAuction() end @@ -1279,8 +1263,8 @@ if not GetAuctionSellItemInfo() then return end - local start = BenillaMoneyInput_GetCopper("StartPrice") - local buyout = BenillaMoneyInput_GetCopper("BuyoutPrice") + local start = MoneyInputFrame_GetCopper(StartPrice) + local buyout = MoneyInputFrame_GetCopper(BuyoutPrice) -- A buyout under the opening price is the one error this form explains out loud. if buyout > 0 and start > buyout then AuctionsBuyoutErrorText:Show() @@ -1293,141 +1277,12 @@ end function AuctionsCreateAuctionButton_OnClick() - StartAuction(BenillaMoneyInput_GetCopper("StartPrice"), - BenillaMoneyInput_GetCopper("BuyoutPrice"), + StartAuction(MoneyInputFrame_GetCopper(StartPrice), + MoneyInputFrame_GetCopper(BuyoutPrice), AuctionFrameAuctions.duration) end ]]> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - getglobal(this:GetParent():GetName() .. "Silver"):SetFocus() - getglobal(this:GetParent():GetName() .. "Silver"):SetFocus() - this:ClearFocus() - BenillaAuctionMoneyInput_OnChanged(this) - this:HighlightText(0, 0) - this:HighlightText() - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - getglobal(this:GetParent():GetName() .. "Copper"):SetFocus() - getglobal(this:GetParent():GetName() .. "Copper"):SetFocus() - this:ClearFocus() - BenillaAuctionMoneyInput_OnChanged(this) - this:HighlightText(0, 0) - this:HighlightText() - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - this:ClearFocus() - BenillaAuctionMoneyInput_OnChanged(this) - this:HighlightText(0, 0) - this:HighlightText() - - - - - @@ -2173,7 +2028,7 @@ BrowseDropDown_OnLoad() - + - + - + @@ -2474,7 +2329,7 @@ AuctionsRadioButton_OnClick(3) - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/crates/benilla-app/assets/ui/MailFrame.xml b/crates/benilla-app/assets/ui/MailFrame.xml index 216097e1..04c766b6 100644 --- a/crates/benilla-app/assets/ui/MailFrame.xml +++ b/crates/benilla-app/assets/ui/MailFrame.xml @@ -48,8 +48,10 @@ MAX_COD_AMOUNT = 10000 -- MAX_COD_AMOUNT (ref l.6) STATIONERY_PATH = "Interface\\Stationery\\" -- STATIONERY_PATH (ref l.5) - -- The money entry uses the shared BenillaMoneyInput_* kit (MerchantFrame.xml, loaded first) — - -- the send tab's three numeric edit boxes are named SendMailMoneyGold/Silver/Copper. + -- The money entry is the reference's own `MoneyInputFrame_*` off the chain (1882 retired + -- our BenillaMoneyInput_* duplicate) — the send tab's three numeric edit boxes are named + -- SendMailMoneyGold/Silver/Copper, which is what both kits read and why the swap was a + -- rename plus an `inherits=`. -- ── Load / event dispatch (ref MailFrame_Onload l.8-24 / MailFrame_OnEvent l.26-62) ────── function BenillaMailFrame_OnLoad(frame) @@ -538,7 +540,7 @@ -- ── Send tab (ref SendMail* l.484-611 / l.936-960) ────────────────────────────────────── function SendMailMailButton_OnClick() - local copper = BenillaMoneyInput_GetCopper("SendMailMoney") + local copper = MoneyInputFrame_GetCopper(SendMailMoney) if SendMailSendMoneyButton:GetChecked() then SetSendMailCOD(0) if copper > 0 then @@ -593,7 +595,7 @@ SendMailNameEditBox:SetText("") SendMailSubjectEditBox:SetText("") SendMailBodyEditBox:SetText("") - BenillaMoneyInput_ResetMoney("SendMailMoney") + MoneyInputFrame_ResetMoney(SendMailMoney) SendMailRadioButton_OnClick(1) SendMailFrame_Update() end @@ -607,7 +609,7 @@ if string.len(SendMailSubjectEditBox:GetText()) > 0 then checks = checks + 1 end if SendMailCODButton:GetChecked() then checksRequired = 3 - if BenillaMoneyInput_GetCopper("SendMailMoney") > MAX_COD_AMOUNT * 10000 then + if MoneyInputFrame_GetCopper(SendMailMoney) > MAX_COD_AMOUNT * 10000 then SendMailErrorText:Show() SendMailErrorCoin:Show() else @@ -684,7 +686,7 @@ button1 = "Accept", button2 = "Cancel", OnAccept = function() - if SetSendMailMoney(BenillaMoneyInput_GetCopper("SendMailMoney")) then + if SetSendMailMoney(MoneyInputFrame_GetCopper(SendMailMoney)) then SendMailFrame_SendMail() end end, @@ -1114,136 +1116,18 @@ - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - getglobal(this:GetParent():GetName().."Silver"):SetFocus() - getglobal(this:GetParent():GetName().."Silver"):SetFocus() - this:ClearFocus() - SendMailFrame_CanSend() - this:HighlightText(0, 0) - this:HighlightText() - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - getglobal(this:GetParent():GetName().."Copper"):SetFocus() - getglobal(this:GetParent():GetName().."Copper"):SetFocus() - this:ClearFocus() - SendMailFrame_CanSend() - this:HighlightText(0, 0) - this:HighlightText() - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - this:ClearFocus() - SendMailFrame_CanSend() - this:HighlightText(0, 0) - this:HighlightText() - - - - + + MoneyInputFrame_SetOnvalueChangedFunc(this, SendMailFrame_CanSend) + diff --git a/crates/benilla-app/assets/ui/MoneyFrame.xml b/crates/benilla-app/assets/ui/MoneyFrame.xml index 3708b358..40d36788 100644 --- a/crates/benilla-app/assets/ui/MoneyFrame.xml +++ b/crates/benilla-app/assets/ui/MoneyFrame.xml @@ -164,56 +164,6 @@ end end - -- ── The editable money-input kit (ref MoneyInputFrame.lua) ──────────────────────────────── - -- A naming three numeric EditBoxes Gold/Silver/Copper — the shared money entry - -- both the send-mail and trade windows inline (decision 0592 P2 builds it here, the money home, - -- so both reuse it). GetCopper folds the three digits into a copper total; SetCopper splits a - -- copper amount back across the boxes. SetCopper is a *programmatic* fill, so it must not re-fire - -- a box's OnTextChanged value-changed callback (the loop the reference's `expectChanges` counter - -- guards, MoneyInputFrame.lua l.44-73). Because benilla fires OnTextChanged synchronously inside - -- SetText, a bracket flag is exact and simpler than the counter: a window's box calls - -- BenillaMoneyInput_Consume(prefix) at the top of its OnTextChanged — false (swallow) while a - -- SetCopper is in flight, true for a genuine keystroke. - BENILLA_MONEYINPUT_SUPPRESS = {} - - function BenillaMoneyInput_ResetMoney(prefix) - getglobal(prefix .. "Gold"):SetText("") - getglobal(prefix .. "Silver"):SetText("") - getglobal(prefix .. "Copper"):SetText("") - end - - function BenillaMoneyInput_GetCopper(prefix) - local function n(box) return tonumber(getglobal(prefix .. box):GetText()) or 0 end - return n("Gold") * 10000 + n("Silver") * 100 + n("Copper") - end - - function BenillaMoneyInput_ClearFocus(prefix) - getglobal(prefix .. "Gold"):ClearFocus() - getglobal(prefix .. "Silver"):ClearFocus() - getglobal(prefix .. "Copper"):ClearFocus() - end - - function BenillaMoneyInput_SetCopper(prefix, money) - money = money or 0 - local parts = { - Gold = math.floor(money / 10000), - Silver = math.floor((money % 10000) / 100), - Copper = money % 100, - } - BENILLA_MONEYINPUT_SUPPRESS[prefix] = true - for box, val in pairs(parts) do - local eb = getglobal(prefix .. box) - if (tonumber(eb:GetText()) or 0) ~= val then - eb:SetText(val == 0 and "" or tostring(val)) - end - end - BENILLA_MONEYINPUT_SUPPRESS[prefix] = false - end - - function BenillaMoneyInput_Consume(prefix) - return not BENILLA_MONEYINPUT_SUPPRESS[prefix] - end - -- ── 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. @@ -394,7 +344,10 @@ function MoneyFrame_SetType(type) local info = MoneyTypeInfo[type] if not info then - -- ref l.145 diagnoses this with `message()`, which we do not ship (see the header). + -- ref l.145 diagnoses this with `message()`, which we DO have: it comes off the + -- chain in `Interface\FrameXML\BasicControls.xml` (1881 migrated that file; before + -- it, our own copy carried the same function). The `if` is therefore belt-and-braces + -- for a harness that loads this file without BasicControls, not a missing verb. if message then message("Invalid money type: " .. type) end diff --git a/crates/benilla-app/assets/ui/RaidFrame.xml b/crates/benilla-app/assets/ui/RaidFrame.xml deleted file mode 100644 index ff5a2adb..00000000 --- a/crates/benilla-app/assets/ui/RaidFrame.xml +++ /dev/null @@ -1,1118 +0,0 @@ - - - - - - - - - - - this:SetFrameLevel(this:GetFrameLevel() + 2) - getglobal(this:GetName() .. "Class"):SetFrameLevel(this:GetFrameLevel() + 2) - this:RegisterForDrag("LeftButton") - this:RegisterForClicks("LeftButtonUp", "RightButtonUp") - - RaidGroupButton_OnClick(arg1) - RaidGroupButton_OnDragStart() - RaidGroupButton_OnDragStop() - RaidGroupButton_OnEnter() - GameTooltip:Hide() - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - getglobal(this:GetName() .. "Label"):SetText(GROUP .. " " .. this:GetID()) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - this:RegisterEvent("RAID_ROSTER_UPDATE") - - -- The raid ending ends the question with it. - if event == "RAID_ROSTER_UPDATE" and GetNumRaidMembers() == 0 then - ReadyCheckFrame.timer = nil - ReadyCheckFrame:Hide() - end - - ReadyCheck_OnUpdate(arg1) - - - diff --git a/crates/benilla-app/assets/ui/ReputationFrame.xml b/crates/benilla-app/assets/ui/ReputationFrame.xml deleted file mode 100644 index 2d5196b7..00000000 --- a/crates/benilla-app/assets/ui/ReputationFrame.xml +++ /dev/null @@ -1,922 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - GameTooltip:SetOwner(this, "ANCHOR_RIGHT") - GameTooltip:SetText(REPUTATION_STATUS_AT_WAR, nil, nil, nil, nil, 1) - - GameTooltip:Hide() - - - - - - - if this.tooltip then - getglobal(this:GetName() .. "FactionStanding"):SetText(this.tooltip) - end - BenillaReputationBar_SetGlow(this:GetName(), 1) - - - getglobal(this:GetName() .. "FactionStanding"):SetText(this.standingText) - BenillaReputationBar_SetGlow(this:GetName(), GetSelectedFaction() == this.id) - - ReputationBar_OnClick() - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - FauxScrollFrame_OnVerticalScroll(REPUTATIONFRAME_FACTIONHEIGHT, ReputationFrame_Update) - - - - - - - - - - - GameTooltip_AddNewbieTip(FACTION, 1.0, 1.0, 1.0, REPUTATION_FACTION_DESCRIPTION, 1) - GameTooltip:Hide() - - - - - - - - - GameTooltip_AddNewbieTip(STANDING, 1.0, 1.0, 1.0, REPUTATION_STANDING_DESCRIPTION, 1) - GameTooltip:Hide() - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - getglobal(this:GetName() .. "Text"):SetText(AT_WAR) - getglobal(this:GetName() .. "Text"):SetVertexColor(RED_FONT_COLOR.r, RED_FONT_COLOR.g, RED_FONT_COLOR.b) - - BenillaReputationAtWarBox_OnClick(self) - - GameTooltip:SetOwner(this, "ANCHOR_RIGHT") - GameTooltip:SetText(REPUTATION_AT_WAR_DESCRIPTION, nil, nil, nil, nil, 1) - - GameTooltip:Hide() - - - - - - - - - - - - - - - - - - - - - getglobal(this:GetName() .. "Text"):SetText(MOVE_TO_INACTIVE) - BenillaReputationInactiveBox_OnClick(self) - - - - - - - - - - - - getglobal(this:GetName() .. "Text"):SetText(SHOW_FACTION_ON_MAINSCREEN) - BenillaReputationMainScreenBox_OnClick(self) - - - - - - - ReputationFrame_OnLoad() - ReputationFrame_OnShow() - ReputationFrame_OnEvent(event) - BenillaReputationFrame_OnMouseWheel(arg1) - - - diff --git a/crates/benilla-app/assets/ui/TradeFrame.xml b/crates/benilla-app/assets/ui/TradeFrame.xml index e99083e5..3c31b707 100644 --- a/crates/benilla-app/assets/ui/TradeFrame.xml +++ b/crates/benilla-app/assets/ui/TradeFrame.xml @@ -63,7 +63,7 @@ TradeFrameTradeButton:Enable() TradeFrame_Update() elseif event == "PLAYER_TRADE_MONEY" then - BenillaMoneyInput_SetCopper("TradePlayerInputMoney", GetPlayerTradeMoney()) + MoneyInputFrame_SetCopper(TradePlayerInputMoney, GetPlayerTradeMoney()) elseif event == "TRADE_CLOSED" then HideUIPanel(frame) elseif event == "TRADE_PLAYER_ITEM_CHANGED" then @@ -82,17 +82,17 @@ function TradeFrame_OnHide() -- The client-side close verb (ref TradeFrame_OnHide l.144) → CMSG_CANCEL_TRADE + a local -- clear (the app's drain); clear our money entry so a later trade opens fresh (ref l.146). - BenillaMoneyInput_ResetMoney("TradePlayerInputMoney") + MoneyInputFrame_ResetMoney(TradePlayerInputMoney) CloseTrade() end -- A keystroke in our money input (ref TradeFrame_UpdateMoney l.162): read the copper total, -- clamp to what we actually carry, and offer it → SetTradeMoney → CMSG_SET_TRADE_GOLD. function TradeFrame_UpdateMoney() - local copper = BenillaMoneyInput_GetCopper("TradePlayerInputMoney") + local copper = MoneyInputFrame_GetCopper(TradePlayerInputMoney) if GetMoney and copper > GetMoney() then copper = GetPlayerTradeMoney() - BenillaMoneyInput_SetCopper("TradePlayerInputMoney", copper) + MoneyInputFrame_SetCopper(TradePlayerInputMoney, copper) end SetTradeMoney(copper) end @@ -468,77 +468,28 @@ HideUIPanel(TradeFrame) - - + + - - - - - - - - - - - - - - getglobal(this:GetParent():GetName().."Silver"):SetFocus() - getglobal(this:GetParent():GetName().."Silver"):SetFocus() - this:ClearFocus() - if ( BenillaMoneyInput_Consume("TradePlayerInputMoney") ) then TradeFrame_UpdateMoney() end - this:HighlightText(0, 0) - this:HighlightText() - - - - - - - - - - - - - - - - getglobal(this:GetParent():GetName().."Copper"):SetFocus() - getglobal(this:GetParent():GetName().."Copper"):SetFocus() - this:ClearFocus() - if ( BenillaMoneyInput_Consume("TradePlayerInputMoney") ) then TradeFrame_UpdateMoney() end - this:HighlightText(0, 0) - this:HighlightText() - - - - - - - - - - - - - - - - this:ClearFocus() - if ( BenillaMoneyInput_Consume("TradePlayerInputMoney") ) then TradeFrame_UpdateMoney() end - this:HighlightText(0, 0) - this:HighlightText() - - - - + + + MoneyInputFrame_SetMode(this, "compact") + MoneyInputFrame_SetOnvalueChangedFunc(this, TradeFrame_UpdateMoney) + + diff --git a/crates/benilla-app/assets/ui/UIParent.xml b/crates/benilla-app/assets/ui/UIParent.xml index 11789567..572a53de 100644 --- a/crates/benilla-app/assets/ui/UIParent.xml +++ b/crates/benilla-app/assets/ui/UIParent.xml @@ -215,6 +215,41 @@ -- statements on the same event). `MultiActionBar_Update` is existence-guarded because the -- ~25 per-window harnesses that load this file load no bars; the binding itself is engine -- level and always installed. Decision 1500. + -- ── UIParentLoadAddOn (ref UIParent.lua l.638-647, and now our slot for it) ────── + -- It lived in our own BasicControls.xml until 1881 migrated that file to the chain; + -- the reference never put it there, so the move is back to where it belongs. + -- The on-demand loader every "open the auction house / tradeskill / talent panel" path + -- goes through, and the one 5 corpus addons name — FuBar_MCPFu, Fubar_RareTrackerFu, MCP, + -- MikScrollingBattleText, SpecialTalent: five separate addons, not one library replicated + -- (1207/1240). + -- + -- Every piece was already here and only the join was missing: `LoadAddOn` answers the + -- reference's own `(1, nil)` / `(nil, reason)` pair, `message` and `TEXT` are directly + -- above, and the `ADDON_*` reason words come from the real `GlobalStrings.lua` we RUN off + -- the patch chain rather than copy (1234). + -- + -- **`FailedAddOnLoad` is the reference's own latch, and it is not decoration.** Without it + -- an addon that retries the load from a handler raises a modal on every attempt; with it + -- the failure shows once per addon name per session. It is exactly the detail this would + -- have lacked had it been reconstructed from the name instead of read. + -- + -- Every reason our `LoadAddOn` can answer resolves to a string: MISSING, DISABLED, + -- NOT_DEMAND_LOADED, DEP_MISSING and DEP_DISABLED all have an `ADDON_..reason` word + -- (GlobalStrings l.16-31), the same reason set wow-re's `ui.md` l.2396 carves off + -- `0x51e780`. So the `getglobal` below cannot hand `format` a nil. + FailedAddOnLoad = {}; + + function UIParentLoadAddOn(name) + local loaded, reason = LoadAddOn(name) + if not loaded then + if not FailedAddOnLoad[name] then + message(format(TEXT(ADDON_LOAD_FAILED), name, TEXT(getglobal("ADDON_" .. reason)))) + FailedAddOnLoad[name] = true + end + end + return loaded + end + -- ref UIParent.lua:223, whose slot this is — immediately above `UIParent_OnEvent`, the -- same neighbour it has there. It moved here from our own InspectFrame.xml when 1832 -- retired that file: the name belongs to UIParent in the reference, and the window it @@ -263,6 +298,20 @@ end end + -- ref UIParent.lua's own `RaidFrame_LoadUI` (l.182-184), verbatim — the fourth squatter, + -- after `InspectUnit` (1832), `ToggleTalentFrame` (1833) and `ShowMacroFrame` (1848). + -- + -- **Unlike `MacroFrame_LoadUI`, this one cannot be left out.** That one was omitted because + -- nothing on the chain calls it; the reference's own `RaidFrame.lua` calls THIS on + -- `RAID_ROSTER_UPDATE` and again at `PLAYER_LOGIN` with a raid up (l.34, l.39), so its + -- absence is a raise every time the roster moves. It is a FrameXML function, not an engine + -- verb, and this file is our stand-in for `UIParent.lua` — the same relationship the three + -- above have. `UIParentLoadAddOn` is just above, and it no-ops on an addon that is + -- already loaded, which ours is: benilla.toc takes `Blizzard_RaidUI` eagerly (1874). + function RaidFrame_LoadUI() + UIParentLoadAddOn("Blizzard_RaidUI"); + end + function UIParent_OnEvent(event) -- The reference's own first arm (UIParent.lua l.230-235). `LocalizeFrames` comes from -- the sourced `Interface\FrameXML\Localization.xml` (1751 window 5) and is the file diff --git a/crates/benilla-app/assets/ui/UiPanels.xml b/crates/benilla-app/assets/ui/UiPanels.xml index 891017cb..aa92c6c3 100644 --- a/crates/benilla-app/assets/ui/UiPanels.xml +++ b/crates/benilla-app/assets/ui/UiPanels.xml @@ -1583,6 +1583,57 @@ end end + -- ── Two dialogs rehomed from our retired RaidFrame.xml (1874) ────────────────────────── + -- Their reference home is `StaticPopup.lua`, which we do not load — this file is our + -- stand-in for that engine, so this is where they belong. They came here because the raid + -- pane went to the chain and the reference's own `RaidFrame.lua` does not declare them: + -- deleting our file took `CONFIRM_RESET_INSTANCES` with it, and `UnitPopup.xml`'s "Reset + -- all instances" row calls `StaticPopup_Show` on exactly that name. + -- ── Add Member's dialog (ref StaticPopup.lua l.871-895) ───────────────────────────────── + StaticPopupDialogs["ADD_RAIDMEMBER"] = { + text = ADD_RAIDMEMBER_LABEL, + button1 = "Accept", + button2 = "Cancel", + hasEditBox = 1, + maxLetters = 12, + timeout = 0, + hideOnEscape = 1, + OnAccept = function() + InviteByName(getglobal("StaticPopup1EditBox"):GetText()) + end, + OnShow = function() + getglobal("StaticPopup1EditBox"):SetFocus() + end, + EditBoxOnEnterPressed = function() + InviteByName(getglobal("StaticPopup1EditBox"):GetText()) + StaticPopup_Hide("ADD_RAIDMEMBER") + end, + } + + -- ── "Reset all instances" (ref StaticPopup.lua l.23-33; decision 1748) ────────────────── + -- Raised by UnitPopup's SELF menu, and registered HERE because this file is the instance + -- house — the same rule that keeps the pet dialogs in PetActionBar.xml. `ResetInstances()` + -- is the only call in the client that sends `CMSG_RESET_INSTANCES`; everything else in the + -- lockout family is the server talking. + -- + -- The answer is per instance rather than one verdict, and it arrives as CHAT_MSG_SYSTEM + -- lines the engine composes: an `SMSG_INSTANCE_RESET` per dungeon that reset, and — on + -- the group path only — an `SMSG_INSTANCE_RESET_FAILED` per dungeon that would not. + -- Nothing back at all is the ordinary answer for a character with nothing resettable. So + -- there is nothing to show here and no result to wait for. + StaticPopupDialogs["CONFIRM_RESET_INSTANCES"] = { + text = CONFIRM_RESET_INSTANCES, + button1 = YES, + button2 = NO, + timeout = 0, + whileDead = 1, + hideOnEscape = 1, + OnAccept = function() + ResetInstances() + end, + } + + StaticPopupDialogs["PARTY_INVITE"] = { text = INVITATION, button1 = ACCEPT, @@ -2042,6 +2093,110 @@ return nil end + -- ── The FLASH kit (ref UIParent.lua l.1166, l.1231-1333), transcribed ──────────────── + -- Its home is `UIParent.lua`, which we do not load; this file is our stand-in for that + -- engine's half, and the FADE kit it drives through already lives here for the same reason. + -- Every field it sets on the frame — `fadeInTime`, `fadeOutTime`, `flashDuration`, + -- `showWhenDone`, `flashTimer`, `flashMode`, `flashInHoldTime`, `flashOutHoldTime` — is the + -- reference's own, and it reaches the fade kit through `fadeHoldTime`/`finishedFunc`, both + -- of which `UIFrameFade` above already honours. + -- + -- Built because two stock windows call it and nothing answered: `SpellBookFrame` and + -- `HelpFrame` (1879). It is FrameXML, not an engine verb, so this is a transcription and + -- not the stub 1203 forbids. + FLASHFRAMES = {} + + function UIFrameFlashRemoveFrame(frame) + local index = 1 + while FLASHFRAMES[index] do + if FLASHFRAMES[index] == frame then + table.remove(FLASHFRAMES, index) + else + index = index + 1 + end + end + end + + function UIFrameFlash(frame, fadeInTime, fadeOutTime, flashDuration, showWhenDone, flashInHoldTime, flashOutHoldTime) + if not frame then return end + -- Already flashing: the reference returns rather than re-arming (l.1234-1240). + local index = 1 + while FLASHFRAMES[index] do + if FLASHFRAMES[index] == frame then return end + index = index + 1 + end + frame.fadeInTime = fadeInTime + frame.fadeOutTime = fadeOutTime + frame.flashDuration = flashDuration + frame.showWhenDone = showWhenDone + frame.flashTimer = 0 + frame.flashMode = "IN" + frame.flashInHoldTime = flashInHoldTime + frame.flashOutHoldTime = flashOutHoldTime + table.insert(FLASHFRAMES, frame) + BenillaFlashDriver:Show() + end + + function UIFrameFlashSwitch(frameName, mode) + local frame = getglobal(frameName) + if frame then frame.flashMode = mode end + end + + function UIFrameIsFlashing(frame) + local index = 1 + while FLASHFRAMES[index] do + if FLASHFRAMES[index] == frame then return 1 end + index = index + 1 + end + return nil + end + + function UIFrameFlashStop(frame) + frame.flashDuration = 0 + frame:Hide() + end + + -- `flashDuration == -1` means flash forever, which is why the timer check carries that + -- guard rather than a plain `>` (ref l.1272). + function UIFrameFlashUpdate(elapsed) + local index = 1 + while FLASHFRAMES[index] do + local frame = FLASHFRAMES[index] + frame.flashTimer = (frame.flashTimer or 0) + elapsed + if frame.flashTimer > frame.flashDuration and frame.flashDuration ~= -1 then + UIFrameFadeRemoveFrame(frame) + UIFrameFlashRemoveFrame(frame) + frame:SetAlpha(1.0) + frame.flashTimer = nil + if frame.showWhenDone then frame:Show() else frame:Hide() end + else + -- A flashMode is only set once the previous fade finished (ref l.1283). + if frame.flashMode then + local fadeInfo = {} + if frame.flashMode == "IN" then + fadeInfo.timeToFade = frame.fadeInTime + fadeInfo.mode = "IN" + fadeInfo.finishedFunc = UIFrameFlashSwitch + fadeInfo.finishedArg1 = frame:GetName() + fadeInfo.finishedArg2 = "OUT" + fadeInfo.fadeHoldTime = frame.flashOutHoldTime + UIFrameFade(frame, fadeInfo) + elseif frame.flashMode == "OUT" then + fadeInfo.timeToFade = frame.fadeOutTime + fadeInfo.mode = "OUT" + fadeInfo.finishedFunc = UIFrameFlashSwitch + fadeInfo.finishedArg1 = frame:GetName() + fadeInfo.finishedArg2 = "IN" + fadeInfo.fadeHoldTime = frame.flashInHoldTime + UIFrameFade(frame, fadeInfo) + end + frame.flashMode = nil + end + index = index + 1 + end + end + end + function UIFrameFadeRemoveFrame(frame) local index = FadeIndexOf(frame) while index do @@ -2319,6 +2474,16 @@ It runs only while FADEFRAMES has entries: the engine dispatches OnUpdate to visible frames only (tick.rs), so hiding itself on an empty list stops the per-frame walk cold, and UIFrameFade — the single inserter — shows it again (decision 1396's class). --> + + + + + if not FLASHFRAMES[1] then this:Hide() return end UIFrameFlashUpdate(arg1) + + diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc index b47de93a..b7edb5fc 100644 --- a/crates/benilla-app/assets/ui/benilla.toc +++ b/crates/benilla-app/assets/ui/benilla.toc @@ -96,7 +96,7 @@ Interface\FrameXML\LocaleProperties.lua # ScriptErrors dialog, and the DialogBoxFrame + DialogButton*Texture kit it is built from. # Straight after Fonts.xml because those are the only things it needs (GameFontRedLarge, # DialogButtonNormalText/HighlightText) and everything above it in FrameXML.toc is engine. -BasicControls.xml +Interface\FrameXML\BasicControls.xml # **UIParent itself, and it must come before every frame that declares `parent="UIParent"`** — # a parent name is resolved at LOAD, and a name that does not exist yet does not error: the loader # warns and silently falls back to the enclosing frame (`loader/mod.rs`, "names no frame"). With @@ -678,7 +678,19 @@ ContainerFrameAdapters.xml # file's own comment carries the justification — so `FACTION_BAR_COLORS` and # `ReputationWatchBar_Update` are already defined when this page paints a row or ticks the # "Show as Experience Bar" box. -ReputationFrame.xml +# ══ THE REPUTATION PANE — the reference's own, off the player's chain (1751, scoped by 1844) ══ +# One line replaces one of ours: 47 of our 48 declared names were already the reference's, and the +# one that was not (`ReputationListScrollFrameScrollBarTrough`) is our own scroll kit's. Nothing +# gated it — the stock file inherits its own two row templates plus `UIPanelCloseButton`, fonts and +# `OptionsCheckButtonTemplate`, and that last came off the chain with 1841. +# +# THE CO-EDIT was compulsory. The reference declares the reputation WATCH BAR in this same file; +# ours lived in `ActionBar.xml` behind a note defending the placement. Frame globals publish +# NON-overwritingly and ActionBar.xml is manifest line 340 against this file's — so ours would have +# kept the name and the chain's bar would have been built and unreachable, which is exactly what +# `GroupLootFrame` did for months (1838). The bar, its update, its two text verbs, +# `MAX_PLAYER_LEVEL` and `FACTION_BAR_COLORS` all went home with it (1875). +Interface\FrameXML\ReputationFrame.xml # The Skills tab (decision 0437 phase 4): a CharacterFrame child page, but a separate # top-level file — it declares `parent="CharacterFrame"`, which really attaches (1734), and # is positioned by name via ``, the geometry it ships @@ -767,10 +779,21 @@ Interface\AddOns\Blizzard_TalentUI\Blizzard_TalentUI.xml # GameTooltip (the newbie tips) and UIDropDownMenu (the who list's variable column) — # all loaded above. FriendsFrame.xml +# ══ THE RAID PANE — the reference's own, off the player's chain (1751, scoped by 1839) ══ # The social window's fourth tab, in its own file because RaidFrame is a child of FriendsFrame and # a child cannot be declared before its parent — the reference splits it the same way and for the # same reason (its FrameXML.toc has RaidFrame.xml on the very next line). Decision 1549. -RaidFrame.xml +# +# TWO entries replace one of ours: the pane itself, and the LoadOnDemand half that carries the +# group frames and the tear-off pullouts. 1839 measured the swap — 83 of our 84 frame names were +# already the reference's, 20 of our 22 Lua functions were, and NOTHING needed building. The one +# name that was not is `BenillaRaidDropDown`, and the reference has no raid drop-down at all: it +# reuses `FriendsDropDown`, which FriendsFrame.xml declares four manifest lines above. +# +# GAINED: `RaidPulloutFrameTemplate`, `RaidPulloutButtonTemplate`, `RaidBuffButtonTemplate` and the +# fifteen `RaidPullout*` verbs — the tear-off group pullouts we never transcribed. +Interface\FrameXML\RaidFrame.xml +Interface\AddOns\Blizzard_RaidUI\Blizzard_RaidUI.xml # ══ THE GOSSIP WINDOW — the reference's own, off the player's chain (1751) ══════════════════ # No engine verb, no widget method, no shadowed name — all three instruments clean. It was # deferred once on `GossipResize` reading `GetTextHeight()` in the same repaint that sets the @@ -877,7 +900,7 @@ Interface\FrameXML\PetStable.xml # windows grouped" cluster beside Trainer/Bank/Taxi, and loaded AFTER MoneyFrame (its eight # money displays are SmallMoneyFrameTemplate, and it registers the AUCTION_DEPOSIT money type # into that file's MoneyTypeInfo), after MerchantFrame (grouping only now — that file's global -# BenillaMoneyInput_* helpers for its four money inputs), after ScrollTemplates (four faux lists), after +# chain's MoneyInputFrame_* for its four money inputs, 1882), after ScrollTemplates (four faux lists), after # UIPanelTemplates (UIPanelButtonTemplate / UIPanelCloseButton / InputBoxTemplate / # UICheckButtonTemplate), after UIDropDownMenu (the rarity capsule) and after UiPanels (the # panel manager's `doublewide` seat, `CharacterFrameTabButtonTemplate`, the PanelTemplates diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index bcc0eae1..ce31791d 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -98,6 +98,7 @@ mod probe_chest; mod probe_clam; mod probe_crossing; mod probe_gm_ticket; +mod probe_goquest; mod probe_guard_poi; mod probe_mail; mod probe_melee; @@ -124,6 +125,7 @@ pub(crate) use probe_chest::ProbeChestPlugin; pub(crate) use probe_clam::ProbeClamPlugin; pub(crate) use probe_crossing::ProbeCrossingPlugin; pub(crate) use probe_gm_ticket::ProbeGmTicketPlugin; +pub(crate) use probe_goquest::ProbeGoQuestPlugin; pub(crate) use probe_guard_poi::ProbeGuardPoiPlugin; pub(crate) use probe_mail::ProbeMailPlugin; pub(crate) use probe_melee::ProbeMeleePlugin; diff --git a/crates/benilla-app/src/capture/probe_goquest.rs b/crates/benilla-app/src/capture/probe_goquest.rs new file mode 100644 index 00000000..f25e4796 --- /dev/null +++ b/crates/benilla-app/src/capture/probe_goquest.rs @@ -0,0 +1,434 @@ +//! The GameObject-questgiver live probe (`WOW_PROBE_GOQUEST=1`) — the instrument for *"wanted +//! posters and quest objects are never status-queried"*, and for the answer that question turned +//! out to have (decision 1872). +//! +//! A quest-giving **GameObject** — a wanted poster, a half-eaten body, a suspicious barrel — is a +//! questgiver on the wire exactly as a creature is, and the reference client asks about it: the +//! sweep callback tests typemask bit 5 and sends `CMSG_QUESTGIVER_STATUS_QUERY` for any GameObject +//! carrying `GAMEOBJECT_FLAGS` bit 2 whose reaction toward us is `> 1`. benilla asked about no +//! GameObject at all, which is a real wire gap. +//! +//! But **the answer is refused, and nothing is ever drawn**: the reference's handler `0x5dc9f0` +//! resolves the GUID with typemask 8, a GameObject's `0x21` misses bit 3, and the packet dies at +//! `0x5dca2f`. And the real 1.12 service never sent one anyway — zero GameObject GUIDs across the +//! 1292 in the genuine sniff corpus. **vmangos does send one** (`TYPEMASK_CREATURE_OR_GAMEOBJECT`, +//! `QuestHandler.cpp:41`), so on our server the refusal is load-bearing rather than theoretical: +//! without it a wanted poster would wear a gold `!` the reference client leaves bare. +//! +//! That makes this probe's job the three-way distinction a unit test cannot make at the real +//! object: **the query went out**, **the answer came back**, and **we refused it** — where "never +//! asked" and "asked and correctly refused" both read as an empty status map. The refusal readout +//! ([`QuestGiver::refused`]) is what separates them, and an arriving status can only exist if our +//! query did. +//! +//! Four readings, in order, and the last two are the controls that make it a regression test: +//! +//! 1. **LOW** — at level [`LOW_LEVEL`], below the quest's own `MinLevel`: the server must answer +//! `1` (`UNAVAILABLE`, the grey `!` a *creature* would wear) and we must refuse it. +//! 2. **HIGH** — after a ding past `MinLevel`: the answer must change to `5` (`AVAILABLE`). A probe +//! that only ever saw one value could not tell a live query from a single stale packet. +//! 3. **NO STATUS** — `QuestGiver::status()` for the poster stays `None` throughout, sampled every +//! frame rather than at the windows. The marker layer and the minimap dot both draw from exactly +//! that map, so an empty entry *is* "no `!`, no dot". +//! 4. **THE UNIT CONTROL** — a creature questgiver in the same scene still gets and keeps its own +//! status. This is the half that must not change, and it is what would catch a refusal written +//! one bit too wide. +//! +//! ## The run recipe +//! +//! ```text +//! WOW_NOSOUND=1 WOW_USER=probe4 WOW_PASS=pprobe4 WOW_CHAR=Probefour \ +//! WOW_PROBE_GOQUEST=1 cargo run -q -p benilla +//! ``` +//! (the slot-keyed probe identity — `pool-N` → `probeN`/`pprobeN`/`Probe`, `method.md` +//! "The local vmangos server"). `WOW_PROBE_GOQUEST=,,[,]` aims it elsewhere; the +//! default is the **Goldshire wanted poster** — `gameobject.guid` 26843, template 68 +//! `Wanted Poster`, `type` 2 `GAMEOBJECT_TYPE_QUESTGIVER`, `flags` 4 `INTERACT_COND`, offering +//! quest 176 `Wanted: "Hogger"` (`MinLevel` 5, `QuestLevel` 11). It is a single **unpooled** spawn +//! with a 900 s respawn, so it is there every run — unlike the chest probe's first target, which +//! was a spawn-pool member and read "missing" most of the time (decision 1471's trap). Goldshire +//! also supplies the unit control (Marshal Dughan and the innkeeper are both questgiver-flagged), +//! which is why both halves can be read at one spot. +//! +//! Each window forces its own sweep, because that is the *only* way a GameObject GUID reaches the +//! wire: it sets the level (the reference's own `UNIT_FIELD_LEVEL` watch, and what makes the answer +//! change) and then bumps the packet epoch, so the window sweeps even if the character already +//! happened to be standing at the level the window wants. The starting level is restored on exit. +//! +//! **One trap the scan line will show you:** every probe character is permanently in GM mode +//! (`characters.extra_flags` bit `0x1`), and vmangos ORs `target->IsGameMaster()` into a +//! GameObject's `IsActivateToQuest` (`Object.cpp`) — so `GAMEOBJECT_DYN_FLAGS` reads `0x1` on +//! every quest object regardless of whether the character could actually take the quest. The +//! *dialog status* is computed without any GM term, so the windows below are unaffected; but do +//! not read the dyn-flag as "this quest is available to me", and do not gate a query on it. +//! +//! Grep `PROBE_GOQUEST:` for the verdict; the probe self-exits when it lands. + +use bevy::prelude::*; + +use benilla_protocol::EntityKind; + +use super::probes::ProbeClock; +use crate::net::{ChatKind, ClientCommand, Guid, NetCommands, NetEntity, ObjectStore, SelfPlayer}; +use crate::player::Player; +use crate::ui_quest::QuestGiver; + +/// The probe's default object: the `Wanted Poster` outside the Goldshire inn (`gameobject` 26843, +/// template 68, quest 176). `[x, y, z, map]`. +const POSTER_AT: [f32; 4] = [-9668.23, 683.39, 36.33, 0.0]; +/// `GAMEOBJECT_TYPE_QUESTGIVER` — the strategy type a wanted poster carries. +const GO_TYPE_QUESTGIVER: i32 = 2; +/// `GAMEOBJECT_FLAGS` bit 2 — the reference's whole GameObject query gate (`0x5eb0f2 shr eax,0x2`). +const GO_FLAG_INTERACT_COND: u32 = 0x4; +/// `UNIT_NPC_FLAGS` questgiver bit — how the unit control names itself. +const NPC_FLAG_QUESTGIVER: u32 = 0x2; +/// The two levels the two windows are read at, chosen against quest 176's own `MinLevel` 5 and +/// `QuestLevel` 11: below it the server answers `UNAVAILABLE`, above it `AVAILABLE` (and not +/// `CHAT`, which would need `level > QuestLevel + Quests.LowLevelHideDiff`). +const LOW_LEVEL: u32 = 2; +const HIGH_LEVEL: u32 = 8; +/// `DialogStatus` ids the verdict is written in (wow-re `questgiver-marker.md` §Q1's own map). +const STATUS_UNAVAILABLE: u32 = 1; +const STATUS_AVAILABLE: u32 = 5; + +const SETTLE_SECS: f64 = 5.0; +const SCAN_TIMEOUT_SECS: f64 = 25.0; +/// How long a window waits for the refused answer. The query goes out on the sweep the window +/// itself forces; nothing arriving in this long means it was never sent. +const STATUS_TIMEOUT_SECS: f64 = 15.0; +/// How long a `.levelup` is given to come back down the wire as a `UNIT_FIELD_LEVEL` change. +const LEVEL_TIMEOUT_SECS: f64 = 15.0; +/// Scan radius around the landing spot, in yards. +const SCAN_RANGE: f32 = 40.0; + +pub(crate) struct ProbeGoQuestPlugin; + +impl Plugin for ProbeGoQuestPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(Update, goquest_probe); + } +} + +#[derive(Resource, Default)] +struct GoQuestProbe { + phase: Phase, + /// The poster's guid, once the scan finds it. + poster: Option, + /// The unit control's guid — a questgiver-flagged creature in the same scene. + control: Option, + /// The level the character was at when the probe started — restored before it exits. + start_level: Option, + /// The refused status read in each window. + low: Option, + high: Option, + /// Whether the poster ever acquired a stored status (it must not) and the control's own stored + /// status (it must have one). + poster_ever_stored: bool, + control_stored: Option, + fails: u32, + exited: bool, +} + +#[derive(Default, Clone, Copy, PartialEq)] +enum Phase { + #[default] + Wait, + /// `.go` issued; letting the world stream the poster in. + Settling { + sent_at: f64, + }, + /// Levelling into a window's level, then sweeping. + Level { + to: u32, + since: f64, + }, + /// Waiting for the refused answer at the window's level. + Read { + level: u32, + since: f64, + }, + /// Levelling back to where the character started. + Restore { + since: f64, + }, + Done, +} + +/// Where the probe is aimed: `WOW_PROBE_GOQUEST=,,[,]`, else [`POSTER_AT`]. Anything +/// unparseable falls back to the default rather than failing the run — the common value is `1`. +fn target() -> [f32; 4] { + let Ok(raw) = std::env::var("WOW_PROBE_GOQUEST") else { + return POSTER_AT; + }; + let parts: Vec = raw + .split(',') + .filter_map(|s| s.trim().parse().ok()) + .collect(); + match parts.len() { + 3 => [parts[0], parts[1], parts[2], POSTER_AT[3]], + 4 => [parts[0], parts[1], parts[2], parts[3]], + _ => POSTER_AT, + } +} + +/// `.levelup ` on the probe's own character. Nothing is ever selected by this probe, so the +/// command's creature branch (`GetSelectedCreature`) can't fire. +fn level_to(net: &NetCommands, from: u32, to: u32) { + let delta = i64::from(to) - i64::from(from); + if delta == 0 { + return; + } + let _ = net.0.send(ClientCommand::Chat { + kind: ChatKind::Say, + target: None, + text: format!(".levelup {delta}"), + }); +} + +/// One window's verdict line. +fn report(label: &str, expect: u32, seen: Option) -> u32 { + match seen { + Some(s) if s == expect => { + info!("PROBE_GOQUEST: {label:<4} PASS — the server answered {s}, and we refused it"); + 0 + } + Some(s) => { + error!("PROBE_GOQUEST: {label:<4} FAIL — refused status {s}, expected {expect}"); + 1 + } + None => { + error!( + "PROBE_GOQUEST: {label:<4} FAIL — no status ever arrived for the object (expected \ + {expect}). vmangos answers every status query it can resolve, so nothing arriving \ + means the query was never SENT — the sweep's GameObject leg is not firing" + ); + 1 + } + } +} + +#[allow(clippy::too_many_arguments)] +fn goquest_probe( + time: ProbeClock, + mut probe: ResMut, + self_store: Query<&ObjectStore, With>, + player: Res, + objects: Query<(&Guid, &NetEntity, &ObjectStore, &Transform), Without>, + mut quest: ResMut, + net: Res, + mut exit: MessageWriter, +) { + let Ok(me) = self_store.single() else { + return; // not in-world yet + }; + let Some(level) = me.0.unit_level() else { + return; // our own descriptor hasn't landed + }; + let now = time.elapsed_secs_f64(); + // Sampled every frame, not just at the windows: a status that flashed on for one frame and was + // pruned would still have been a rendered `!`, and the claim is that none is ever stored. + if probe.poster.is_some_and(|p| quest.status(p).is_some()) { + probe.poster_ever_stored = true; + } + if let Some(s) = probe.control.and_then(|c| quest.status(c)) { + probe.control_stored = Some(s); + } + + match probe.phase { + Phase::Wait => { + let [x, y, z, map] = target(); + probe.start_level = Some(level); + info!( + "PROBE_GOQUEST: heading to the quest object ({x} {y} {z} map {map}) — \ + GameObject type {GO_TYPE_QUESTGIVER}, starting level {level}" + ); + let _ = net.0.send(ClientCommand::Chat { + kind: ChatKind::Say, + target: None, + text: format!(".go xyz {x} {y} {z} {map}"), + }); + probe.phase = Phase::Settling { sent_at: now }; + } + Phase::Settling { sent_at } => { + if now - sent_at < SETTLE_SECS { + return; + } + let here = player.pos; + let in_range = |tf: &Transform| tf.translation.distance(here) < SCAN_RANGE; + let poster = objects.iter().find(|(_, net_e, store, tf)| { + net_e.kind == EntityKind::GameObject + && store.0.gameobject_type_id() == GO_TYPE_QUESTGIVER + && in_range(tf) + }); + let control = objects.iter().find(|(_, net_e, store, tf)| { + net_e.kind == EntityKind::Unit + && store.0.unit_npc_flags() & NPC_FLAG_QUESTGIVER != 0 + && in_range(tf) + }); + let Some((guid, _, store, _)) = poster else { + if now - sent_at > SCAN_TIMEOUT_SECS { + error!( + "PROBE_GOQUEST: FAIL — no type-{GO_TYPE_QUESTGIVER} GameObject within \ + {SCAN_RANGE} yd in {SCAN_TIMEOUT_SECS}s. This is NOT a passing run" + ); + probe.fails += 1; + probe.phase = Phase::Done; + } + return; + }; + let flags = store.0.gameobject_flags(); + info!( + "PROBE_GOQUEST: quest object {:#x} in range — GAMEOBJECT_FLAGS {flags:#x} \ + (INTERACT_COND {}), GAMEOBJECT_DYN_FLAGS {:#x}", + guid.0, + flags & GO_FLAG_INTERACT_COND != 0, + store.0.gameobject_dynamic_flags(), + ); + if flags & GO_FLAG_INTERACT_COND == 0 { + error!( + "PROBE_GOQUEST: FAIL — this object does not carry GAMEOBJECT_FLAGS bit 2, so \ + the reference would never query it either. Aim the probe at a quest object" + ); + probe.fails += 1; + probe.phase = Phase::Done; + return; + } + probe.poster = Some(guid.0); + match control { + Some((g, ..)) => { + info!( + "PROBE_GOQUEST: unit control — questgiver creature {:#x}", + g.0 + ); + probe.control = Some(g.0); + } + None => warn!( + "PROBE_GOQUEST: no questgiver-flagged creature within {SCAN_RANGE} yd — the \ + unit control cannot be read at this spot" + ), + } + probe.phase = Phase::Level { + to: LOW_LEVEL, + since: now, + }; + level_to(&net, level, LOW_LEVEL); + } + Phase::Level { to, since } => { + if level == to { + // Force the sweep this window depends on. A GameObject GUID reaches the wire ONLY + // from a sweep (wow-re §W14.8), and the level change above is one — but it is a + // no-op when the character already stood at this level, so the epoch bump makes + // the window's sweep unconditional. + quest.bump_reask(); + info!("PROBE_GOQUEST: at level {to}, swept — reading the window"); + probe.phase = Phase::Read { + level: to, + since: now, + }; + } else if now - since > LEVEL_TIMEOUT_SECS { + error!("PROBE_GOQUEST: FAIL — still level {level} after a `.levelup` to {to}"); + probe.fails += 1; + probe.phase = Phase::Done; + } + } + Phase::Read { level: at, since } => { + let (refused, count) = quest.refused(); + let mine = refused + .filter(|(g, _)| Some(*g) == probe.poster) + .map(|(_, s)| s); + // The HIGH window must see a *different* answer than LOW, or one stale refusal would + // satisfy both. + let settled = if at == LOW_LEVEL { + mine + } else { + mine.filter(|s| Some(*s) != probe.low) + }; + if settled.is_none() && now - since <= STATUS_TIMEOUT_SECS { + return; + } + if at == LOW_LEVEL { + probe.low = settled; + info!( + "PROBE_GOQUEST: LOW refused {settled:?} (refusals so far: {count}) — dinging \ + to level {HIGH_LEVEL}" + ); + probe.phase = Phase::Level { + to: HIGH_LEVEL, + since: now, + }; + level_to(&net, level, HIGH_LEVEL); + } else { + probe.high = settled; + let back = probe.start_level.unwrap_or(level); + info!( + "PROBE_GOQUEST: HIGH refused {settled:?} (refusals so far: {count}) — \ + restoring level {back}" + ); + level_to(&net, level, back); + probe.phase = Phase::Restore { since: now }; + } + } + Phase::Restore { since } => { + let back = probe.start_level.unwrap_or(level); + if level == back { + probe.phase = Phase::Done; + } else if now - since > LEVEL_TIMEOUT_SECS { + warn!("PROBE_GOQUEST: left the character at level {level}, not {back}"); + probe.phase = Phase::Done; + } + } + Phase::Done => { + if probe.exited { + return; + } + probe.exited = true; + let mut fails = probe.fails; + fails += report("LOW", STATUS_UNAVAILABLE, probe.low); + fails += report("HIGH", STATUS_AVAILABLE, probe.high); + if probe.poster_ever_stored { + error!( + "PROBE_GOQUEST: STORE FAIL — a status was stored for the GameObject. The \ + marker layer and the minimap dot both read that map, so this is a `!` the \ + reference client does not draw" + ); + fails += 1; + } else { + info!( + "PROBE_GOQUEST: STORE PASS — no status ever stored for the GameObject, so no \ + marker and no minimap dot" + ); + } + match (probe.control, probe.control_stored) { + (Some(g), Some(s)) => info!( + "PROBE_GOQUEST: CTRL PASS — the questgiver creature {g:#x} still has its own \ + status ({s})" + ), + (Some(g), None) => { + error!( + "PROBE_GOQUEST: CTRL FAIL — the questgiver creature {g:#x} never got a \ + status. The refusal is one bit too wide and units lost theirs" + ); + fails += 1; + } + (None, _) => warn!("PROBE_GOQUEST: CTRL SKIPPED — no creature questgiver in range"), + } + info!( + "PROBE_GOQUEST: DONE object={:#x} low={:?} high={:?} refusals={} fail={fails}", + probe.poster.unwrap_or_default(), + probe.low, + probe.high, + quest.refused().1, + ); + // The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit + // plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie + // client holding the probe account. + exit.write(AppExit::Success); + std::thread::spawn(|| { + std::thread::sleep(std::time::Duration::from_secs(5)); + warn!("PROBE_GOQUEST: still alive 5s after AppExit — hard exit"); + std::process::exit(0); + }); + } + } +} diff --git a/crates/benilla-app/src/dev.rs b/crates/benilla-app/src/dev.rs index 57efe851..1902f7e2 100644 --- a/crates/benilla-app/src/dev.rs +++ b/crates/benilla-app/src/dev.rs @@ -363,6 +363,13 @@ impl Plugin for DevProbesPlugin { if std::env::var("WOW_PROBE_CHEST").is_ok() { app.add_plugins(crate::capture::ProbeChestPlugin); } + // The GameObject-questgiver live probe: `WOW_PROBE_GOQUEST=1` parks at the Goldshire + // wanted poster and reports the dialog status the server answers for it, below and + // above the quest's own MinLevel — the numeric answer to "quest objects are never + // status-queried" (decision 1872; see `capture::ProbeGoQuestPlugin`). + if std::env::var("WOW_PROBE_GOQUEST").is_ok() { + app.add_plugins(crate::capture::ProbeGoQuestPlugin); + } // The openable-item live probe: `WOW_PROBE_CLAM=1` stocks a clam, right-clicks it // through the live VM's own `UseContainerItem` and reports whether a loot window opens // on the item's own guid — the numeric answer to the director's "clams don't open" diff --git a/crates/benilla-app/src/minimap/blips/dots.rs b/crates/benilla-app/src/minimap/blips/dots.rs index 47cf3ef6..bedfff19 100644 --- a/crates/benilla-app/src/minimap/blips/dots.rs +++ b/crates/benilla-app/src/minimap/blips/dots.rs @@ -244,10 +244,19 @@ pub(in crate::minimap) fn emit_tracking_dots( } }; // Cell 0 — tracked GameObjects (gold): template lockId through Lock.dbc. + // + // **No quest-status precedence on this leg** (decision 1872). This loop used to skip a + // GameObject whose status was 7, mirroring the unit loop below — but the classifier's + // `cmp dword ptr [edi+0xcb8],7` at `0x4eac31` is reachable **only** from the UNIT and PLAYER + // legs (machine-enumerated predecessors, wow-re `minimap-poi-questdot.md` via + // `questgiver-marker.md` §W14.9): the GameObject leg at `0x4eab43` falls straight into + // `0x5ed2b0`, GameObject *tracking*, and emits category 0. A GameObject never draws a quest + // dot in the reference, so nothing about a quest status may suppress its tracking dot — and + // a GameObject can no longer *hold* a status here anyway (`net/apply` drops it, §W14.1). if tracking.resources != 0 { if let Some(locks) = locks { for (guid, net, tf, _) in candidates.iter() { - if net.kind != EntityKind::GameObject || statuses.get(&guid.0).copied() == Some(7) { + if net.kind != EntityKind::GameObject { continue; } let Some(t) = templates.get(guid.0) else { diff --git a/crates/benilla-app/src/net/apply/quests.rs b/crates/benilla-app/src/net/apply/quests.rs index ea30bc43..7c44c6f7 100644 --- a/crates/benilla-app/src/net/apply/quests.rs +++ b/crates/benilla-app/src/net/apply/quests.rs @@ -18,9 +18,36 @@ use crate::ui_quest_share::QuestShare; use super::super::{ClientCommand, NetCommands}; /// A questgiver dialog status for one NPC (`SMSG_QUESTGIVER_STATUS`) — the `!`/`?` marker's -/// [`crate::messages::dialog_status`] value. Stored per guid now; the world marker is a later -/// slice (decision 0088). +/// [`crate::messages::dialog_status`] value, stored per guid for the marker layer and the minimap +/// quest dot. +/// +/// **A GameObject's answer is DROPPED, and that is the reference's own behaviour** (decision 1872, +/// wow-re `questgiver-marker.md` §W14.1): the handler `0x5dc9f0` resolves the packet's GUID with +/// `0x5dca22 mov ecx,8` — typemask `TYPEMASK_UNIT` — and `0x468460` is a bitmask AND against +/// `OBJECT_FIELD_TYPE`, so a GameObject's `0x21` misses bit 3, returns NULL, and the handler exits +/// at `0x5dca2f` without ever reaching the `UNIT_NPC_FLAGS` test, let alone the marker. +/// +/// This is not a hypothetical branch here: benilla now *sends* the query for quest-flagged +/// GameObjects, exactly as the reference does ([`crate::quest_markers`]), and **vmangos answers +/// it** — `GetObjectByTypeMask(guid, TYPEMASK_CREATURE_OR_GAMEOBJECT)`, `QuestHandler.cpp:41` — +/// where the real 1.12 service never did (zero GameObject GUIDs across 1292 in the sniff corpus). +/// So the drop is what keeps a vmangos-only answer from putting a `!` over a wanted poster that +/// the reference client leaves bare. +/// +/// The test is the GUID's own shape rather than a live type lookup on purpose: it gives the same +/// partition as typemask 8 for anything a server can send us, and it cannot be defeated by a status +/// that arrives in the same drain as its object's create block (descriptors are flushed at the end +/// of the drain, so a type test would read a not-yet-seeded store and drop a *unit's* answer that +/// would then never be re-asked for). The reference's second conjunct — `UNIT_NPC_FLAGS & 0x2` on +/// the resolved unit — is deliberately not modelled here for that same ordering reason; +/// [`crate::quest_markers::query`]'s teardown leg covers the flag-clearing case from the other end. pub(super) fn quest_giver_status(npc: u64, status: u32, quest: &mut QuestGiver) { + use benilla_protocol::guid; + if !(guid::is_player(npc) || guid::is_creature_or_pet(npc)) { + debug!("net: dropping a non-unit questgiver status ({npc:#x} → {status}) — typemask 8"); + quest.refuse_status(npc, status); + return; + } quest.set_status(npc, status); } @@ -248,6 +275,53 @@ pub(super) fn quest_confirm_accept(c: QuestConfirmAccept, share: &mut QuestShare mod tests { use super::*; + /// **The typemask-8 refusal** (decision 1872, wow-re `questgiver-marker.md` §W14.1). benilla + /// now sends `CMSG_QUESTGIVER_STATUS_QUERY` for quest-flagged GameObjects because the reference + /// does — and vmangos, unlike the real 1.12 service, *answers*. The reference's handler drops + /// that answer at the lookup (`0x5dca22 mov ecx,8`), so we must too: otherwise a wanted poster + /// would wear a gold `!` the reference client never puts there. + /// + /// The control is the half that must not change: a creature's answer, and a player's, still + /// land — typemask 8 is `TYPEMASK_UNIT`, which a creature (`0x9`) and a player (`0x19`) both + /// carry. + #[test] + fn a_gameobjects_dialog_status_is_dropped_and_a_creatures_is_not() { + use benilla_protocol::messages::dialog_status; + + // The Goldshire `Wanted Poster` (HIGHGUID_GAMEOBJECT), an elevator (HIGHGUID_TRANSPORT — + // a GameObject in every respect but its high word), a creature, and a player. + const POSTER: u64 = 0xf110_0000_0044_68db; + const ELEVATOR: u64 = 0xf120_0000_0384_1092; + const CREATURE: u64 = 0xf130_0000_0060_0abc; + const PLAYER: u64 = 0x0000_0000_0000_0007; + + let mut quest = QuestGiver::default(); + for (guid, status) in [ + (POSTER, dialog_status::AVAILABLE), + (ELEVATOR, dialog_status::AVAILABLE), + (CREATURE, dialog_status::AVAILABLE), + (PLAYER, dialog_status::REWARD2), + ] { + quest_giver_status(guid, status, &mut quest); + } + assert_eq!( + quest.status(POSTER), + None, + "a GameObject GUID misses typemask bit 3 and the handler exits at 0x5dca2f" + ); + assert_eq!(quest.status(ELEVATOR), None, "…and so does a transport GO"); + assert_eq!( + quest.status(CREATURE), + Some(dialog_status::AVAILABLE), + "the control: a creature's answer is what this packet is FOR" + ); + assert_eq!( + quest.status(PLAYER), + Some(dialog_status::REWARD2), + "and a player passes the same typemask, as it does in the reference" + ); + } + fn open_detail(quest_id: u32) -> QuestGiver { let mut giver = QuestGiver::default(); giver.open( diff --git a/crates/benilla-app/src/quest_markers/query.rs b/crates/benilla-app/src/quest_markers/query.rs index d2cee234..ea191849 100644 --- a/crates/benilla-app/src/quest_markers/query.rs +++ b/crates/benilla-app/src/quest_markers/query.rs @@ -1,5 +1,14 @@ //! The **query** half of the overhead questgiver markers: when to ask the server what an NPC's -//! `!`/`?` should be. The rendering half is [`super`]. +//! `!`/`?` should be — and, for a GameObject, when to ask a question whose answer is thrown away. +//! The rendering half is [`super`]. +//! +//! **A GameObject is queried and never rendered** (decision 1872, wow-re `questgiver-marker.md` +//! §W14). The reference sends `CMSG_QUESTGIVER_STATUS_QUERY` for a quest-flagged GameObject from +//! both object sweeps, but its answer handler `0x5dc9f0` resolves the GUID with typemask **8** +//! (`0x468460` is a bitmask AND against `OBJECT_FIELD_TYPE`), so a GameObject's `0x21` returns NULL +//! and the packet dies at `0x5dca2f` — and even if it did not, a GameObject has no `+0xcb8` status +//! slot, no `+0xb2c` marker slot, and (11 models out of ~1600, none of them a poster) no +//! attachment 18 to hang a marker from. So: **send the query, drop the answer, render nothing.** //! //! The server only ever *answers* `CMSG_QUESTGIVER_STATUS_QUERY` (vmangos `QuestHandler.cpp`) — it //! never pushes — so every refresh point is the client's own to trigger, and a status that is never @@ -11,11 +20,27 @@ use std::collections::HashMap; use bevy::prelude::*; +use benilla_protocol::EntityKind; + use crate::net::{ClientCommand, GuidIndex, NetCommands, NetEntity, ObjectStore, SelfPlayer}; +use crate::target::cursor_mode::go_reaction; +use crate::target::ring::Factions; use crate::ui_quest::QuestGiver; /// `UNIT_NPC_FLAGS` questgiver service bit (vanilla; `update_object.rs`'s accessor doc). const NPC_FLAG_QUESTGIVER: u32 = 0x2; +/// `GAMEOBJECT_FLAGS` bit **2** (mask `0x4`) — the whole of the sweep's GameObject gate, and the +/// one place a GameObject GUID can reach this opcode at all. +/// +/// Byte-pinned (wow-re `questgiver-marker.md` §W14.6): `0x5eb0ef mov eax,[edx+0xc]; +/// 0x5eb0f2 shr eax,0x2; 0x5eb0f5 test al,1` — shift **2**, so mask `0x4`, not the `0x2` the unit +/// leg tests 25 bytes later. `[+0xc]` is absolute field index 9 = `GAMEOBJECT_FLAGS`, +/// named from the binary's own UpdateField name table (`0x83b8a8` → the string at `0x83be84`). +/// The *name* `GO_FLAG_INTERACT_COND` is vmangos's, not the image's — but the bit is settled from +/// shipped content as well as from the branch: across the genuine 2005-06 sniff corpus, all 122 +/// GameObjects the real client sent a status query for carry it, and none without it does, against +/// a 79.2 % base rate. +const GO_FLAG_INTERACT_COND: u32 = 0x4; /// Ask the dialog status of every questgiver-flagged creature, and re-ask when the answer could /// have changed. The server only ever *answers* queries (vmangos `QuestHandler.cpp`), so every @@ -42,8 +67,28 @@ const NPC_FLAG_QUESTGIVER: u32 = 0x2; /// leaves [`GuidIndex`], which is the create-path query (the reference caches the answer *on* the /// unit at `unit+0xcb8`, so its cache dies with the object — ours is a map and needs the prune). /// +/// **Per GameObject — on a sweep, and ONLY on a sweep.** A quest-giving GameObject (a wanted +/// poster, a suspicious barrel) is a questgiver on the wire exactly as a creature is: the same +/// sweep callback tests typemask bit 5 and sends the same opcode for it (`0x5eb0a0` @ `0x5eb159`, +/// and its sibling `0x5eb3f0` @ `0x5eb456`), gated on [`GO_FLAG_INTERACT_COND`] and on the +/// GameObject's own reaction toward us being **> 1** ([`go_reaction`], the reference's `0x5f7fd0`). +/// The wire corpus shows it happening in 20 of 62 real sessions. +/// +/// But there is **no bring-up query for a GameObject** — the contrast with units is exact and it +/// is the point (wow-re §W14.8, a closed caller census: a GameObject GUID can reach this opcode +/// through exactly two instructions in the whole image and both are inside a sweep callback; the +/// `CGGameObject_C` ctor and its vtable slot 3 call no sender, where `CGUnit_C`'s slot 3 does). So +/// a GameObject is asked about only when a sweep happens to run while it is in view, and a player +/// who completes a quest elsewhere and then walks up to the turn-in object never asks about it at +/// all. That is a real, slightly surprising reference behaviour, and **a per-guid asked-set that +/// queries on first sight is a deviation, not a fix** — hence the `swept` gate below rather than +/// an [`unit_ask_key`] analogue. Nothing visible turns on any of this: the answer is dropped +/// ([`crate::net::apply`]'s own typemask gate), which is decision 1872's whole point. +/// /// **The teardown leg** — an NPC whose questgiver bit goes off loses its cached status, so the -/// marker goes with it (`0x5eb0a0`'s own branch), and is re-asked if the bit returns. +/// marker goes with it (`0x5eb0a0`'s own branch), and is re-asked if the bit returns. There is no +/// GameObject counterpart: a GameObject has no status to invalidate (`+0xcb8` is `0x9a8` bytes past +/// the end of a `0x310`-byte `CGGameObject_C`), and all 8 of `0x6073f0`'s call sites are unit-side. /// /// **Still not covered**, and why: the reference also sweeps when an *item* moves between /// containers (an `ITEM` typeid watch, `0x5d9375` — quest availability can depend on carried @@ -55,8 +100,9 @@ const NPC_FLAG_QUESTGIVER: u32 = 0x2; #[allow(clippy::type_complexity)] // a Bevy system: each param is one resource, the app's convention pub(super) fn query_statuses( self_q: Query, With>, - units: Query<(&crate::net::Guid, &ObjectStore), (With, Without)>, + objects: Query<(&crate::net::Guid, &NetEntity, &ObjectStore), Without>, index: Res, + factions: Option>, mut quest: ResMut, commands: Res, mut state: Local, @@ -71,7 +117,13 @@ pub(super) fn query_statuses( state.fields = self_generation(&store.0); } let generation = state.fields ^ (u64::from(quest.reask_epoch()) << 32); - if state.generation != generation { + // **This frame IS the sweep.** Both of the reference's object sweeps (`0x5eb070` and the full + // re-query `0x5eb3c0`) walk every object in the manager once, synchronously, when one of the + // 13 local-player state changes fires them; a changed generation is that moment, here. The + // GameObject leg below fires only on it, because that is the only way a GameObject GUID ever + // reaches the wire (§W14.8). + let swept = state.generation != generation; + if swept { state.generation = generation; state.asked.clear(); } @@ -79,29 +131,59 @@ pub(super) fn query_statuses( // its "already asked" mark and its cached status, so re-entering view re-asks from scratch. state.asked.retain(|guid, _| index.0.contains_key(guid)); quest.retain_statuses(|npc| index.0.contains_key(&npc)); - for (guid, obj) in &units { - if obj.0.unit_npc_flags() & NPC_FLAG_QUESTGIVER == 0 { - // No longer a questgiver: drop the stale answer (and the marker with it) — - // **unconditionally**, never "only if we had asked". The reference's sweep callback - // (`0x5eb0a0`) tears the marker down on the flag test alone, with no prior-asked - // precondition (decision 0647), and the gate we used to put here was disarmed by the - // very sweep that arrives with it: an escort's giver drops `UNIT_NPC_FLAGS` in the same - // server tick as the quest-log write (vmangos `FollowerAI::StartFollow`, - // `ScriptedEscortAI`'s "disable npcflags"), so the quest-log change bumps the - // generation, `asked` is cleared above, `remove` finds nothing, and the cached - // AVAILABLE status — with its `!` — was frozen over the NPC for the whole escort, with - // no way back: the flag stays off, so this arm never queries either (B257). - state.asked.remove(&guid.0); - quest.clear_status(guid.0); - continue; - } - // Re-ask when this unit's own key moves — its service bits or its faction template. The - // reference does the same per-unit query off those field watches. - let key = unit_ask_key(&obj.0); - if state.asked.insert(guid.0, key) != Some(key) { - let _ = commands - .0 - .send(ClientCommand::QuestgiverStatusQuery { npc: guid.0 }); + for (guid, net, obj) in &objects { + match net.kind { + // `0x5eb0a0` @ `0x5eb0d3`: `cmp eax,9; je` — typemask EXACTLY `OBJECT|UNIT`, a plain + // creature. A player is `0x19` and falls out of the sweep here, before any flag test. + EntityKind::Unit => { + if obj.0.unit_npc_flags() & NPC_FLAG_QUESTGIVER == 0 { + // No longer a questgiver: drop the stale answer (and the marker with it) — + // **unconditionally**, never "only if we had asked". The reference's sweep + // callback (`0x5eb0a0`) tears the marker down on the flag test alone, with no + // prior-asked precondition (decision 0647), and the gate we used to put here + // was disarmed by the very sweep that arrives with it: an escort's giver drops + // `UNIT_NPC_FLAGS` in the same server tick as the quest-log write (vmangos + // `FollowerAI::StartFollow`, `ScriptedEscortAI`'s "disable npcflags"), so the + // quest-log change bumps the generation, `asked` is cleared above, `remove` + // finds nothing, and the cached AVAILABLE status — with its `!` — was frozen + // over the NPC for the whole escort, with no way back: the flag stays off, so + // this arm never queries either (B257). + state.asked.remove(&guid.0); + quest.clear_status(guid.0); + continue; + } + // Re-ask when this unit's own key moves — its service bits or its faction template. + // The reference does the same per-unit query off those field watches. + let key = unit_ask_key(&obj.0); + if state.asked.insert(guid.0, key) != Some(key) { + let _ = commands + .0 + .send(ClientCommand::QuestgiverStatusQuery { npc: guid.0 }); + } + } + // `0x5eb0da shr ecx,5; test cl,1` — typemask bit 5, `TYPEMASK_GAMEOBJECT`. Sweep-only, + // no asked-set, no teardown: see this function's doc and §W14.8. + EntityKind::GameObject if swept => { + if obj.0.gameobject_flags() & GO_FLAG_INTERACT_COND == 0 { + continue; + } + // `0x5eb101 cmp eax,1; jle` — the GameObject's reaction toward us must be at least + // **2**. Not a friendliness test: Unfriendly(2) and Neutral(3) both pass, and a + // faction-less GameObject resolves Neutral. `None` = unresolvable (no catalog yet) + // and passes, the same "no opinion" rule the cursor's own gate uses. + let reaction = go_reaction( + factions.as_deref(), + obj.0.gameobject_faction(), + Some(&store), + ); + if !reaction.is_none_or(|r| r > 1) { + continue; + } + let _ = commands + .0 + .send(ClientCommand::QuestgiverStatusQuery { npc: guid.0 }); + } + _ => {} } } } @@ -141,10 +223,15 @@ fn self_generation(fields: &benilla_protocol::ObjectFields) -> u64 { // packets, 70 → 2 HP, with zero bursts; the single packet crossing to 0 has the only burst in // the stretch). // - // One named over-refresh: our bit flips on BOTH crossings, so we sweep units on resurrect too. - // The reference sweeps GameObject questgivers there (`0x5eb3c0`) plus one conditional unit - // query — and benilla has no GameObject markers at all, so a unit sweep is the nearest thing we - // can do with what we actually render. It costs one extra sweep per resurrect. 0654. + // Our bit flips on BOTH crossings, and that is now known to be exactly right — the note this + // comment used to carry ("one named over-refresh... the reference sweeps GameObject questgivers + // there") was wrong on both halves and is CORRECTED by wow-re §W14.5/§W14.11. `0x5eb3c0` is not + // a GameObject sweep: `0x468380` applies no type filter at all, so it walks **every** object in + // the manager — it is the FULL re-query sweep, and its creature arm is the heavier one + // (`0x607380`: an unconditional marker teardown plus two queries, questgiver `0x182` and taxi + // `0x1aa`), the GameObject arm the lighter. So the death edge sweeps (`0x5eb070`) and the + // **revive** edge sweeps too (`0x5eb3c0`), and one sweep per crossing is the reference's own + // behaviour rather than a deviation we were paying for. 0654, 1872. fold(u64::from(fields.unit_health().unwrap_or(1) == 0)); fold(u64::from(fields.player_flags())); fold(u64::from(fields.player_money().unwrap_or(0))); @@ -399,6 +486,146 @@ mod tests { ); } + /// **A GameObject is asked about on a SWEEP, and only on a sweep** (decision 1872, wow-re + /// `questgiver-marker.md` §W14.6/§W14.8). Three things are being pinned here, and the third is + /// the one a client "improves" without noticing: + /// + /// - the gate is `GAMEOBJECT_FLAGS` bit 2 (mask `0x4`) — `0x5eb0ef shr eax,0x2; test al,1` — + /// and a quest-less GameObject beside it is never asked about at all; + /// - a sweep asks exactly once, and the frames between sweeps are silent; + /// - **there is no bring-up query.** A GameObject entering view asks nothing; it waits for the + /// next sweep. The unit control in the same world does the opposite on the same frame, which + /// is what makes this a contrast and not an accident of ordering. + #[test] + fn a_gameobject_is_asked_on_a_sweep_and_never_at_first_sight() { + use crate::net::{Guid, NetEntity}; + use benilla_protocol::ObjectFields; + + /// The Goldshire `Wanted Poster`'s real guid (`HIGHGUID_GAMEOBJECT`, template 68, spawn + /// 26843) and its real `GAMEOBJECT_FLAGS` — INTERACT_COND | NODESPAWN, read off the live + /// wire by `WOW_PROBE_GOQUEST`. + const POSTER: u64 = 0xf110_0000_0044_68db; + const POSTER_FLAGS: u32 = 0x24; + /// A GameObject next to it with no quest condition: a plain door, `NODESPAWN` only. + const DOOR: u64 = 0xf110_0000_0045_0001; + const DOOR_FLAGS: u32 = 0x20; + /// The control: an ordinary creature questgiver, which the same sweep treats differently. + const NPC: u64 = 0x2222; + const FIELD_LEVEL: u16 = 34; + const FIELD_NPC_FLAGS: u16 = 147; + const FIELD_GAMEOBJECT_FLAGS: u16 = 9; + + let (tx, rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.insert_resource(NetCommands(tx)) + .init_resource::() + .init_resource::() + .add_systems(Update, query_statuses); + + let spawn = |app: &mut App, guid: u64, kind: EntityKind, fields: &[(u16, u32)]| { + let e = app + .world_mut() + .spawn(( + NetEntity { + kind, + display_id: None, + scale: 1.0, + }, + Guid(guid), + ObjectStore(ObjectFields::from_pairs(fields)), + )) + .id(); + app.world_mut() + .resource_mut::() + .0 + .insert(guid, e); + }; + app.world_mut().spawn(( + SelfPlayer, + Guid(1), + ObjectStore(ObjectFields::from_pairs(&[(FIELD_LEVEL, 5)])), + )); + // Drain per update, then count per guid — several objects are in flight at once here. + let asked = |app: &mut App| -> Vec { + app.update(); + rx.try_iter() + .filter_map(|c| match c { + ClientCommand::QuestgiverStatusQuery { npc } => Some(npc), + _ => None, + }) + .collect() + }; + + // Settle the generation first: the very first frame in a fresh world IS a sweep (nothing + // has been folded yet), so "first sight" can only be asked of a later frame. + assert!(asked(&mut app).is_empty(), "nothing in view yet"); + + spawn( + &mut app, + POSTER, + EntityKind::GameObject, + &[(FIELD_GAMEOBJECT_FLAGS, POSTER_FLAGS)], + ); + spawn( + &mut app, + DOOR, + EntityKind::GameObject, + &[(FIELD_GAMEOBJECT_FLAGS, DOOR_FLAGS)], + ); + spawn( + &mut app, + NPC, + EntityKind::Unit, + &[(FIELD_NPC_FLAGS, NPC_FLAG_QUESTGIVER)], + ); + + assert_eq!( + asked(&mut app), + vec![NPC], + "first sight: the creature is asked from its own create path, the poster is NOT — \ + the GameObject class has no bring-up query (§W14.8)" + ); + assert!( + asked(&mut app).is_empty(), + "and the frames after it stay silent" + ); + + // A sweep: any of the reference's 13 local-player triggers. The packet epoch stands in for + // its four packet handlers. + app.world_mut().resource_mut::().bump_reask(); + let swept = asked(&mut app); + assert!( + swept.contains(&POSTER), + "the sweep asks about the poster — {swept:x?}" + ); + assert!( + swept.contains(&NPC), + "and re-asks the creature, as it always did — {swept:x?}" + ); + assert!( + !swept.contains(&DOOR), + "but never the GameObject without GAMEOBJECT_FLAGS bit 2 — {swept:x?}" + ); + assert_eq!( + swept.iter().filter(|g| **g == POSTER).count(), + 1, + "exactly once per sweep" + ); + + assert!( + asked(&mut app).is_empty(), + "between sweeps, nothing — a GameObject has no per-object key to re-ask on" + ); + + // ...and it is not one-shot: the next sweep asks again, because the reference keeps no + // per-GameObject memo of having asked (there is nowhere on a CGGameObject_C to keep one). + app.world_mut().resource_mut::().bump_reask(); + assert!( + asked(&mut app).contains(&POSTER), + "and again on the next sweep" + ); + } + /// The rest of the reference's trigger set (0654): the remaining self-descriptor watches, the /// packet epoch that stands in for its four packet handlers, and the per-unit key that catches /// a unit's own service bits or faction moving. Each leg must re-ask exactly once. diff --git a/crates/benilla-app/src/ui_quest.rs b/crates/benilla-app/src/ui_quest.rs index 8f8a9ffa..16e11f82 100644 --- a/crates/benilla-app/src/ui_quest.rs +++ b/crates/benilla-app/src/ui_quest.rs @@ -71,6 +71,18 @@ pub(crate) struct QuestGiver { messages: Vec, /// The re-ask epoch — see [`Self::bump_reask`]. reask: u32, + /// The last `SMSG_QUESTGIVER_STATUS` we **refused**, and how many we have refused this session + /// — `(guid, status)`. + /// + /// The only trace of a packet class benilla deliberately throws on the floor. benilla asks the + /// server about quest-flagged GameObjects because the reference does, and vmangos — unlike the + /// real 1.12 service, which never answered one — replies; the reference's own handler drops + /// that reply at the typemask-8 lookup and so does ours (decision 1872, + /// [`crate::net::apply`]'s `quest_giver_status`). Without this, the *whole* GameObject half of + /// the feature is invisible from inside the client: a correct drop and a query that was never + /// sent both read as "no status", which is exactly the pair a live probe has to tell apart. + refused: Option<(u64, u32)>, + refused_count: u32, } impl QuestGiver { @@ -141,6 +153,17 @@ impl QuestGiver { self.reask } + /// Record a dialog status the typemask gate refused — see [`Self::refused`]. + pub(crate) fn refuse_status(&mut self, npc: u64, status: u32) { + self.refused = Some((npc, status)); + self.refused_count = self.refused_count.saturating_add(1); + } + + /// The last refused `(guid, status)` and the session's refusal count — see the field. + pub(crate) fn refused(&self) -> (Option<(u64, u32)>, u32) { + (self.refused, self.refused_count) + } + /// The stored dialog status for `npc`, if any. The store-now half of DIALOG_STATUS /// (decision 0088): no consumer yet — the `!`/`?` world marker is a later nameplate slice — so /// this accessor is deliberately unused for now. diff --git a/crates/benilla-app/src/ui_script/action_bar_tests.rs b/crates/benilla-app/src/ui_script/action_bar_tests.rs index bde05ff0..7b3e9a97 100644 --- a/crates/benilla-app/src/ui_script/action_bar_tests.rs +++ b/crates/benilla-app/src/ui_script/action_bar_tests.rs @@ -30,13 +30,25 @@ fn shipped_action_bar_drives_end_to_end() { ); if file == "ActionBar.xml" { assert_eq!( - report.frames, 62, + report.frames, 59, "bar + XP StatusBar (+ its numerals overlay) + exhaustion tick + max-level rail + art frame + 12 buttons (each with a Cooldown child) + 2 page buttons + the performance meter and its hover button, \ - + BonusActionBarFrame and its 12 buttons with their Cooldown children (25 — hidden, as the reference's is; decision 1223), + ReputationWatchBar with its status bar and its numerals overlay (3 — hidden, ref ReputationFrame.xml:869-994)" + + BonusActionBarFrame and its 12 buttons with their Cooldown children (25 — hidden, as the reference's is; decision 1223) — and NO ReputationWatchBar: its three frames went home to the reference's own ReputationFrame.xml with 1875" ); } } + // `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code, + // safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since + // 1875 that file is the reference's own, so a harness that drives the XP bar has to load it too + // or the tick raises on its first event. + // In manifest order: the fonts its check-box labels colour from, the panel templates those + // boxes inherit through, then the pane. + super::test_ui::load_ui(&s, "Fonts.xml"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\OptionsFrameTemplates.xml"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\ReputationFrame.xml"); + // A warrior in battle stance: offset 1 ⇒ the bar shows actions 73..84. s.set_bonus_bar_offset(1); s.set_action( @@ -164,6 +176,18 @@ fn load_action_bar(s: &UiScript) { report.errors ); } + + // `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code, + // safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since + // 1875 that file is the reference's own, so a harness that drives the XP bar has to load it too + // or the tick raises on its first event. + // In manifest order: the fonts its check-box labels colour from, the panel templates those + // boxes inherit through, then the pane. + super::test_ui::load_ui(s, "Fonts.xml"); + super::test_ui::load_ui(s, r"Interface\FrameXML\UIPanelTemplates.lua"); + super::test_ui::load_ui(s, r"Interface\FrameXML\UIPanelTemplates.xml"); + super::test_ui::load_ui(s, r"Interface\FrameXML\OptionsFrameTemplates.xml"); + super::test_ui::load_ui(s, r"Interface\FrameXML\ReputationFrame.xml"); } /// The state/feedback layer (decision 0137 phase 4) through the REAL shipped XML: a pushed @@ -1273,20 +1297,24 @@ fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() { let mut s = UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); for file in [ + // Fonts first: the pane's check-box labels colour from `RED_FONT_COLOR` in their own OnLoad. + "Fonts.xml", "UIParent.xml", "Cooldown.xml", "ActionBar.xml", + // The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and + // `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own + // coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the + // two template files its check boxes inherit through (1875). + r"Interface\FrameXML\UIPanelTemplates.lua", + r"Interface\FrameXML\UIPanelTemplates.xml", + r"Interface\FrameXML\OptionsFrameTemplates.xml", + r"Interface\FrameXML\ReputationFrame.xml", "MultiBars.xml", ] { - let text = std::fs::read_to_string( - std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("assets/ui") - .join(file), - ) - .unwrap(); - let doc = benilla_ui::framexml::parse(&text).unwrap(); - let report = benilla_ui::loader::load(&s, &doc, &|_| None); - assert!(report.errors.is_empty(), "{file}: {:?}", report.errors); + // `test_ui::load_ui`, not a disk read: this list names chain entries now (the reputation + // pane and the templates it inherits through), and `assets/ui` cannot answer for those. + super::test_ui::load_ui(&s, file); } assert_eq!(s.eval::("return CURRENT_ACTIONBAR_PAGE").unwrap(), 1); @@ -1413,6 +1441,18 @@ fn bonus_bar_slides_up_with_sound_and_down_without() { let report = benilla_ui::loader::load(&s, &doc, &|_| None); assert!(report.errors.is_empty(), "{file}: {:?}", report.errors); } + + // `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code, + // safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since + // 1875 that file is the reference's own, so a harness that drives the XP bar has to load it too + // or the tick raises on its first event. + // In manifest order: the fonts its check-box labels colour from, the panel templates those + // boxes inherit through, then the pane. + super::test_ui::load_ui(&s, "Fonts.xml"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\OptionsFrameTemplates.xml"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\ReputationFrame.xml"); s.fire_event("PLAYER_ENTERING_WORLD", vec![]); s.tick(10.0); // a nonzero clock epoch let _ = s.take_sounds(); @@ -1592,6 +1632,18 @@ fn bonus_bar_turnaround_continues_from_position() { let report = benilla_ui::loader::load(&s, &doc, &|_| None); assert!(report.errors.is_empty(), "{file}: {:?}", report.errors); } + + // `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code, + // safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since + // 1875 that file is the reference's own, so a harness that drives the XP bar has to load it too + // or the tick raises on its first event. + // In manifest order: the fonts its check-box labels colour from, the panel templates those + // boxes inherit through, then the pane. + super::test_ui::load_ui(&s, "Fonts.xml"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\OptionsFrameTemplates.xml"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\ReputationFrame.xml"); s.tick(10.0); let _ = s.take_sounds(); @@ -1647,6 +1699,18 @@ fn the_page_arrows_do_not_steal_each_other_s_clicks() { let report = benilla_ui::loader::load(&s, &doc, &|_| None); assert!(report.errors.is_empty(), "{file}: {:?}", report.errors); } + + // `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code, + // safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since + // 1875 that file is the reference's own, so a harness that drives the XP bar has to load it too + // or the tick raises on its first event. + // In manifest order: the fonts its check-box labels colour from, the panel templates those + // boxes inherit through, then the pane. + super::test_ui::load_ui(&s, "Fonts.xml"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\OptionsFrameTemplates.xml"); + super::test_ui::load_ui(&s, r"Interface\FrameXML\ReputationFrame.xml"); // The post-login state, which is what a player clicks into. Without it `ExhaustionTick_Update` // never runs, and the rested marker — DIALOG strata, declared CENTER on the XP strip, which is // exactly where the arrows are — sits unhidden over both and eats every click. That is the diff --git a/crates/benilla-app/src/ui_script/basic_controls_tests.rs b/crates/benilla-app/src/ui_script/basic_controls_tests.rs index 525f909d..f17253b9 100644 --- a/crates/benilla-app/src/ui_script/basic_controls_tests.rs +++ b/crates/benilla-app/src/ui_script/basic_controls_tests.rs @@ -13,7 +13,7 @@ use super::test_ui::load_ui as load_xml; fn basic_controls() -> UiScript { let mut s = UiScript::new().unwrap(); load_xml(&s, "Fonts.xml"); - load_xml(&s, "BasicControls.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); s.set_screen_size(1024.0, 768.0); s.resolve(); s diff --git a/crates/benilla-app/src/ui_script/exp_bar_tests.rs b/crates/benilla-app/src/ui_script/exp_bar_tests.rs index 971c08a5..b7b1a54e 100644 --- a/crates/benilla-app/src/ui_script/exp_bar_tests.rs +++ b/crates/benilla-app/src/ui_script/exp_bar_tests.rs @@ -22,6 +22,14 @@ fn exp_bar_harness() -> UiScript { "Interface\\FrameXML\\TextStatusBar.xml", "Cooldown.xml", "ActionBar.xml", + // The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and + // `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own + // coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the + // two template files its check boxes inherit through (1875). + r"Interface\FrameXML\UIPanelTemplates.lua", + r"Interface\FrameXML\UIPanelTemplates.xml", + r"Interface\FrameXML\OptionsFrameTemplates.xml", + r"Interface\FrameXML\ReputationFrame.xml", ] { super::test_ui::load_ui(&s, file); } diff --git a/crates/benilla-app/src/ui_script/fade_tests.rs b/crates/benilla-app/src/ui_script/fade_tests.rs index 13bdf5b6..2776ef2f 100644 --- a/crates/benilla-app/src/ui_script/fade_tests.rs +++ b/crates/benilla-app/src/ui_script/fade_tests.rs @@ -106,3 +106,75 @@ fn a_started_fade_still_ramps_and_the_driver_reparks_after() { ); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } + +/// **The FLASH kit alternates and stops** — the fade kit's twin, transcribed from `UIParent.lua` +/// because two stock windows call it and nothing answered (1879). +/// +/// Driven rather than merely loaded: a kit that is present but never exercised is exactly how +/// twelve faux lists shipped unable to scroll (1868), so this runs a real flash to completion. +#[test] +fn a_flash_alternates_then_stops_and_the_driver_reparks() { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + load_xml(&s, "MoneyFrame.xml"); + load_xml(&s, "UiPanels.xml"); + load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); + load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); + s.resolve(); + + // Parked until something asks — the whole point of the driver over UIParent's own OnUpdate. + assert!( + !s.eval::("return BenillaFlashDriver:IsShown()") + .unwrap(), + "an empty FLASHFRAMES parks the driver" + ); + + s.run( + r#"Probe = CreateFrame("Frame", "BenillaFlashProbe", UIParent) + Probe:SetWidth(10) Probe:SetHeight(10) Probe:SetPoint("TOPLEFT") + UIFrameFlash(Probe, 0.05, 0.05, 0.3, nil, 0, 0)"#, + ) + .unwrap(); + assert!( + s.eval::("return UIFrameIsFlashing(BenillaFlashProbe) == 1") + .unwrap(), + "the frame is on the flash list" + ); + assert!( + s.eval::("return BenillaFlashDriver:IsShown()") + .unwrap(), + "UIFrameFlash is the single inserter and wakes the driver" + ); + + // Re-arming an already-flashing frame is a no-op, not a second entry (ref UIParent.lua:1234). + s.run("UIFrameFlash(BenillaFlashProbe, 1, 1, 1, nil, 0, 0)") + .unwrap(); + assert_eq!( + s.eval::("return table.getn(FLASHFRAMES)").unwrap(), + 1, + "already flashing: the reference returns rather than re-arming" + ); + + // Run past flashDuration: the frame leaves both lists, is restored to full alpha, and — with + // `showWhenDone` nil — ends hidden. + for _ in 0..40 { + s.tick(0.016); + s.resolve(); + } + assert!(s.errors().is_empty(), "flashing raised: {:?}", s.errors()); + assert_eq!( + s.eval::("return table.getn(FLASHFRAMES)").unwrap(), + 0, + "the flash finished and the frame left the list" + ); + assert!( + !s.eval::("return BenillaFlashProbe:IsShown()") + .unwrap(), + "showWhenDone was nil, so it ends hidden" + ); + assert!( + !s.eval::("return BenillaFlashDriver:IsShown()") + .unwrap(), + "and the driver re-parks on the first empty tick" + ); +} diff --git a/crates/benilla-app/src/ui_script/friends_tests.rs b/crates/benilla-app/src/ui_script/friends_tests.rs index f56713d9..7c649486 100644 --- a/crates/benilla-app/src/ui_script/friends_tests.rs +++ b/crates/benilla-app/src/ui_script/friends_tests.rs @@ -38,7 +38,26 @@ fn setup() -> UiScript { // guards, because there RaidFrame.xml is FrameXML and always loaded — so the guard belongs in // the harness's load order, not in shipped Lua defending against a state the client cannot be // in (decision 1549). - load_xml(&s, "RaidFrame.xml"); + // Stock `RaidFrame_OnLoad` reconciles the party frames the moment it loads + // (`RaidOptionsFrame_UpdatePartyFrames` -> `HidePartyFrame`/`ShowPartyFrame`), so this pane + // drags in the whole unit-frame cluster — and it must be the REAL frames: loading only + // `PartyFrame.lua` for the two names gets "attempt to index a nil value" the first time one + // iterates. The manifest already seats these far above RaidFrame (1874). + load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua"); + // The reference's own `BasicControls.xml`, which is manifest entry 3 — far above the pane + // (1874). It is here for the error/message pair and `TEXT`. NOTE: unlike `raid_tests`, this + // harness does not load `UIParent.xml`, so `UIParentLoadAddOn` — which 1881 moved there out + // of BasicControls — is absent. Nothing this file exercises reaches it; if a future + // assertion drives `RaidFrame_LoadUI`, that is the line to add. + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, r"Interface\FrameXML\TextStatusBar.lua"); + load_xml(&s, r"Interface\FrameXML\TextStatusBar.xml"); + load_xml(&s, r"Interface\FrameXML\BuffFrame.xml"); + load_xml(&s, r"Interface\FrameXML\UnitFrame.xml"); + load_xml(&s, r"Interface\FrameXML\CombatFeedback.xml"); + load_xml(&s, r"Interface\FrameXML\PartyFrame.xml"); + load_xml(&s, r"Interface\FrameXML\RaidFrame.xml"); + load_xml(&s, r"Interface\AddOns\Blizzard_RaidUI\Blizzard_RaidUI.xml"); s } diff --git a/crates/benilla-app/src/ui_script/guild_tests.rs b/crates/benilla-app/src/ui_script/guild_tests.rs index ea49a88b..495bc850 100644 --- a/crates/benilla-app/src/ui_script/guild_tests.rs +++ b/crates/benilla-app/src/ui_script/guild_tests.rs @@ -204,7 +204,7 @@ fn setup() -> UiScript { }), ); load_xml(&s, "Fonts.xml"); - load_xml(&s, "BasicControls.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); load_xml(&s, "MoneyFrame.xml"); load_xml(&s, "UiPanels.xml"); load_xml(&s, "GameTooltip.xml"); @@ -221,7 +221,20 @@ fn setup() -> UiScript { // guards, because there RaidFrame.xml is FrameXML and always loaded — so the guard belongs in // the harness's load order, not in shipped Lua defending against a state the client cannot be // in (decision 1549). - load_xml(&s, "RaidFrame.xml"); + // Stock `RaidFrame_OnLoad` reconciles the party frames the moment it loads + // (`RaidOptionsFrame_UpdatePartyFrames` -> `HidePartyFrame`/`ShowPartyFrame`), so this pane + // drags in the whole unit-frame cluster — and it must be the REAL frames: loading only + // `PartyFrame.lua` for the two names gets "attempt to index a nil value" the first time one + // iterates. The manifest already seats these far above RaidFrame (1874). + load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua"); + load_xml(&s, r"Interface\FrameXML\TextStatusBar.lua"); + load_xml(&s, r"Interface\FrameXML\TextStatusBar.xml"); + load_xml(&s, r"Interface\FrameXML\BuffFrame.xml"); + load_xml(&s, r"Interface\FrameXML\UnitFrame.xml"); + load_xml(&s, r"Interface\FrameXML\CombatFeedback.xml"); + load_xml(&s, r"Interface\FrameXML\PartyFrame.xml"); + load_xml(&s, r"Interface\FrameXML\RaidFrame.xml"); + load_xml(&s, r"Interface\AddOns\Blizzard_RaidUI\Blizzard_RaidUI.xml"); s } diff --git a/crates/benilla-app/src/ui_script/help_frame_tests.rs b/crates/benilla-app/src/ui_script/help_frame_tests.rs index 5f220f43..9460b186 100644 --- a/crates/benilla-app/src/ui_script/help_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/help_frame_tests.rs @@ -20,7 +20,7 @@ fn setup() -> UiScript { s.set_screen_size(1024.0, 768.0); for file in [ "Fonts.xml", - "BasicControls.xml", + "Interface\\FrameXML\\BasicControls.xml", r"Interface\FrameXML\UIPanelTemplates.lua", r"Interface\FrameXML\UIPanelTemplates.xml", "ScrollTemplates.xml", diff --git a/crates/benilla-app/src/ui_script/honor_frame_tests.rs b/crates/benilla-app/src/ui_script/honor_frame_tests.rs index 3f5ca03b..73eb93c9 100644 --- a/crates/benilla-app/src/ui_script/honor_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/honor_frame_tests.rs @@ -368,7 +368,7 @@ fn shown_inspect_honor_page() -> UiScript { for file in [ "Fonts.xml", // `TEXT`, the stock level line's formatter. - "BasicControls.xml", + "Interface\\FrameXML\\BasicControls.xml", "MoneyFrame.xml", "UiPanels.xml", r"Interface\FrameXML\UIPanelTemplates.lua", diff --git a/crates/benilla-app/src/ui_script/inspect_tests.rs b/crates/benilla-app/src/ui_script/inspect_tests.rs index 3ac7f5cf..cf2be346 100644 --- a/crates/benilla-app/src/ui_script/inspect_tests.rs +++ b/crates/benilla-app/src/ui_script/inspect_tests.rs @@ -116,7 +116,7 @@ fn armed() -> UiScript { load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "Fonts.xml"); // `TEXT`, which the stock `InspectPaperDollFrame_SetLevel` formats its level line through. - load_xml(&s, "BasicControls.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); load_xml(&s, "MoneyFrame.xml"); load_xml(&s, "UiPanels.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); @@ -163,7 +163,7 @@ fn shipped_inspect_frame_loads_clean() { load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "Fonts.xml"); // `TEXT`, which the stock `InspectPaperDollFrame_SetLevel` formats its level line through. - load_xml(&s, "BasicControls.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); load_xml(&s, "MoneyFrame.xml"); load_xml(&s, "UiPanels.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); diff --git a/crates/benilla-app/src/ui_script/instance_tests.rs b/crates/benilla-app/src/ui_script/instance_tests.rs index 852712a2..b8d4bacb 100644 --- a/crates/benilla-app/src/ui_script/instance_tests.rs +++ b/crates/benilla-app/src/ui_script/instance_tests.rs @@ -15,6 +15,7 @@ use super::test_ui::load_ui as load_xml; /// `ResetInstances` a queue. #[test] fn the_three_bindings_have_the_reference_shapes() { + let _data = benilla_formats::wow_data_or_skip!(); let mut s = UiScript::new().unwrap(); // Nothing pushed yet: the app has not said where we are. @@ -66,13 +67,17 @@ fn the_three_bindings_have_the_reference_shapes() { /// and Yes is the only thing that sends. #[test] fn the_self_menu_row_gates_on_the_binding_and_confirms_before_sending() { + let _data = benilla_formats::wow_data_or_skip!(); let mut s = UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); // The row labels a bare harness has no GlobalStrings.lua for. Verbatim 1.12 values - // (`RESET_INSTANCES` l.3342, `CONFIRM_RESET_INSTANCES` l.851, `YES` l.5463, `NO` l.2794) — - // production runs the player's own string table at boot. + // (`RESET_INSTANCES` l.3342, `CONFIRM_RESET_INSTANCES` l.851, `YES` l.5463, `NO` l.2794, + // `GROUP` l.2029) — production runs the player's own string table at boot. s.run( r#" + -- The stock raid pane concatenates this into each of its eight group headers inside + -- their own OnLoad, so it has to exist before the addon loads (1874). + GROUP = "Group" RESET_INSTANCES = "Reset all instances" CONFIRM_RESET_INSTANCES = "Do you really want to reset all of your instances?" YES = "Yes" @@ -100,7 +105,8 @@ fn the_self_menu_row_gates_on_the_binding_and_confirms_before_sending() { "Interface\\FrameXML\\PartyFrame.xml", "Interface\\FrameXML\\TargetFrame.xml", "Interface\\FrameXML\\PetFrame.xml", - "RaidFrame.xml", + r"Interface\FrameXML\RaidFrame.xml", + r"Interface\AddOns\Blizzard_RaidUI\Blizzard_RaidUI.xml", ] { load_xml(&s, file); } diff --git a/crates/benilla-app/src/ui_script/macro_tests.rs b/crates/benilla-app/src/ui_script/macro_tests.rs index 2dc6d7b0..dd965904 100644 --- a/crates/benilla-app/src/ui_script/macro_tests.rs +++ b/crates/benilla-app/src/ui_script/macro_tests.rs @@ -90,7 +90,7 @@ fn harness_with(player: &str) -> UiScript { for file in [ r"Interface\FrameXML\GlobalStrings.lua", "Fonts.xml", - "BasicControls.xml", // `TEXT` + "Interface\\FrameXML\\BasicControls.xml", // `TEXT` "MoneyFrame.xml", "UiPanels.xml", // The chain's `PanelTemplates_SelectTab` reaches for `GameTooltip` unguarded. diff --git a/crates/benilla-app/src/ui_script/merchant_tests.rs b/crates/benilla-app/src/ui_script/merchant_tests.rs index 01c2a90c..2c1ea576 100644 --- a/crates/benilla-app/src/ui_script/merchant_tests.rs +++ b/crates/benilla-app/src/ui_script/merchant_tests.rs @@ -1078,6 +1078,9 @@ fn trade_recipient_money_renders_the_digit_not_ellipsis() { load_xml(&s, f); } load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); // the BenillaMoney_* helpers + // TradeFrame's money entry is the chain's own since 1882 — its OnLoad calls MoneyInputFrame_*. + load_xml(&s, "Interface\\FrameXML\\MoneyInputFrame.lua"); + load_xml(&s, "Interface\\FrameXML\\MoneyInputFrame.xml"); load_xml(&s, "TradeFrame.xml"); s.set_text_measurer(Box::new(super::FixedWidthFont(6.0))); diff --git a/crates/benilla-app/src/ui_script/minimap_tests.rs b/crates/benilla-app/src/ui_script/minimap_tests.rs index a5831bb9..a4504a81 100644 --- a/crates/benilla-app/src/ui_script/minimap_tests.rs +++ b/crates/benilla-app/src/ui_script/minimap_tests.rs @@ -134,7 +134,7 @@ fn game_time_session(hour: u32, minute: u32) -> UiScript { s.run("LocalizeFrames()").unwrap(); load_xml(&s, "Fonts.xml"); // `TEXT()`, which the reference's own tooltip and label formatting passes every string through. - load_xml(&s, "BasicControls.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); load_xml(&s, "GameTooltip.xml"); load_xml(&s, "MinimapCluster.xml"); load_xml(&s, "Interface\\FrameXML\\GameTime.xml"); diff --git a/crates/benilla-app/src/ui_script/multibar_stance_tests.rs b/crates/benilla-app/src/ui_script/multibar_stance_tests.rs index c64e2762..8d46287f 100644 --- a/crates/benilla-app/src/ui_script/multibar_stance_tests.rs +++ b/crates/benilla-app/src/ui_script/multibar_stance_tests.rs @@ -23,6 +23,14 @@ fn load_action_bar(s: &UiScript) { "GameTooltip.xml", "Cooldown.xml", "ActionBar.xml", + // The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and + // `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own + // coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the + // two template files its check boxes inherit through (1875). + r"Interface\FrameXML\UIPanelTemplates.lua", + r"Interface\FrameXML\UIPanelTemplates.xml", + r"Interface\FrameXML\OptionsFrameTemplates.xml", + r"Interface\FrameXML\ReputationFrame.xml", ] { load_xml(s, file); } @@ -429,6 +437,14 @@ fn multibar_hover_renders_the_buttons_own_action() { "GameTooltip.xml", "Cooldown.xml", "ActionBar.xml", + // The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and + // `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own + // coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the + // two template files its check boxes inherit through (1875). + r"Interface\FrameXML\UIPanelTemplates.lua", + r"Interface\FrameXML\UIPanelTemplates.xml", + r"Interface\FrameXML\OptionsFrameTemplates.xml", + r"Interface\FrameXML\ReputationFrame.xml", "MultiBars.xml", ] { load_xml(&s, file); diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index c38de37e..9851e5ae 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -750,6 +750,14 @@ fn interface_harness() -> UiScript { "Interface\\FrameXML\\ColorPickerFrame.xml", "Cooldown.xml", "ActionBar.xml", + // The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and + // `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own + // coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the + // two template files its check boxes inherit through (1875). + r"Interface\FrameXML\UIPanelTemplates.lua", + r"Interface\FrameXML\UIPanelTemplates.xml", + r"Interface\FrameXML\OptionsFrameTemplates.xml", + r"Interface\FrameXML\ReputationFrame.xml", "ScrollTemplates.xml", "Interface\\FrameXML\\MerchantFrame.xml", "QuestFrame.xml", @@ -801,6 +809,14 @@ fn actionbars_harness() -> UiScript { "UIParent.xml", "Cooldown.xml", "ActionBar.xml", + // The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and + // `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own + // coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the + // two template files its check boxes inherit through (1875). + r"Interface\FrameXML\UIPanelTemplates.lua", + r"Interface\FrameXML\UIPanelTemplates.xml", + r"Interface\FrameXML\OptionsFrameTemplates.xml", + r"Interface\FrameXML\ReputationFrame.xml", "MultiBars.xml", ], ); diff --git a/crates/benilla-app/src/ui_script/pet_bar_tests.rs b/crates/benilla-app/src/ui_script/pet_bar_tests.rs index e709849f..ec4c68b5 100644 --- a/crates/benilla-app/src/ui_script/pet_bar_tests.rs +++ b/crates/benilla-app/src/ui_script/pet_bar_tests.rs @@ -13,11 +13,21 @@ fn load_pet_bar(s: &UiScript) { for file in [ "MoneyFrame.xml", "UiPanels.xml", + "Fonts.xml", r"Interface\FrameXML\UIPanelTemplates.lua", r"Interface\FrameXML\UIPanelTemplates.xml", "UIParent.xml", "Cooldown.xml", "ActionBar.xml", + // The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and + // `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own + // coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the + // two template files its check boxes inherit through (1875). + "Fonts.xml", + r"Interface\FrameXML\UIPanelTemplates.lua", + r"Interface\FrameXML\UIPanelTemplates.xml", + r"Interface\FrameXML\OptionsFrameTemplates.xml", + r"Interface\FrameXML\ReputationFrame.xml", "PetActionBar.xml", ] { load_xml(s, file); @@ -353,11 +363,21 @@ fn pet_bar_row(with_multibar: bool) -> (usize, f32) { for file in [ "MoneyFrame.xml", "UiPanels.xml", + "Fonts.xml", r"Interface\FrameXML\UIPanelTemplates.lua", r"Interface\FrameXML\UIPanelTemplates.xml", "UIParent.xml", "Cooldown.xml", "ActionBar.xml", + // The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and + // `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own + // coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the + // two template files its check boxes inherit through (1875). + "Fonts.xml", + r"Interface\FrameXML\UIPanelTemplates.lua", + r"Interface\FrameXML\UIPanelTemplates.xml", + r"Interface\FrameXML\OptionsFrameTemplates.xml", + r"Interface\FrameXML\ReputationFrame.xml", ] { load_xml(&s, file); } @@ -643,6 +663,14 @@ fn load_pet_bar_with_tooltip(s: &UiScript) { "GameTooltip.xml", "Cooldown.xml", "ActionBar.xml", + // The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and + // `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own + // coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the + // two template files its check boxes inherit through (1875). + r"Interface\FrameXML\UIPanelTemplates.lua", + r"Interface\FrameXML\UIPanelTemplates.xml", + r"Interface\FrameXML\OptionsFrameTemplates.xml", + r"Interface\FrameXML\ReputationFrame.xml", "PetActionBar.xml", ] { load_xml(s, file); diff --git a/crates/benilla-app/src/ui_script/petition_tests.rs b/crates/benilla-app/src/ui_script/petition_tests.rs index eaefc04c..775feced 100644 --- a/crates/benilla-app/src/ui_script/petition_tests.rs +++ b/crates/benilla-app/src/ui_script/petition_tests.rs @@ -91,7 +91,7 @@ fn setup() -> UiScript { // never paints. load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "Fonts.xml"); - load_xml(&s, "BasicControls.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); load_xml(&s, "MoneyFrame.xml"); load_xml(&s, "UiPanels.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); diff --git a/crates/benilla-app/src/ui_script/raid_tests.rs b/crates/benilla-app/src/ui_script/raid_tests.rs index 3dd04134..b0b1d329 100644 --- a/crates/benilla-app/src/ui_script/raid_tests.rs +++ b/crates/benilla-app/src/ui_script/raid_tests.rs @@ -35,7 +35,26 @@ fn setup() -> UiScript { load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); load_xml(&s, "FriendsFrame.xml"); - load_xml(&s, "RaidFrame.xml"); + // Stock `RaidFrame_OnLoad` reconciles the party frames the moment it loads + // (`RaidOptionsFrame_UpdatePartyFrames` -> `HidePartyFrame`/`ShowPartyFrame`), so this pane + // drags in the whole unit-frame cluster — and it must be the REAL frames: loading only + // `PartyFrame.lua` for the two names gets "attempt to index a nil value" the first time one + // iterates. The manifest already seats these far above RaidFrame (1874). + load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua"); + // The reference's own `BasicControls.xml`, which is manifest entry 3 — far above the pane + // (1874). `UIParentLoadAddOn` used to be what this line was here for; 1881 moved that + // function to `UIParent.xml`, its home in the reference, and this harness already loads + // `UIParent.xml` above, so the `RaidFrame_LoadUI` arm on `RAID_ROSTER_UPDATE` still + // resolves. What is left here is the error/message pair and `TEXT`. + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); + load_xml(&s, r"Interface\FrameXML\TextStatusBar.lua"); + load_xml(&s, r"Interface\FrameXML\TextStatusBar.xml"); + load_xml(&s, r"Interface\FrameXML\BuffFrame.xml"); + load_xml(&s, r"Interface\FrameXML\UnitFrame.xml"); + load_xml(&s, r"Interface\FrameXML\CombatFeedback.xml"); + load_xml(&s, r"Interface\FrameXML\PartyFrame.xml"); + load_xml(&s, r"Interface\FrameXML\RaidFrame.xml"); + load_xml(&s, r"Interface\AddOns\Blizzard_RaidUI\Blizzard_RaidUI.xml"); s } @@ -738,7 +757,18 @@ fn the_raid_info_panel_lists_the_saved_lockouts() { .unwrap(), 0 ); - assert!(!visible(&s, "RaidInfoInstance1")); + // **The stale row STAYS, and that is the reference's own defect.** `RaidInfoFrame_Update` + // wraps its whole row loop in `if ( savedInstances > 0 )` (RaidFrame.lua:93-106), so the + // count dropping to zero never reaches the `Hide()` in its else-arm — the last lockout + // remains painted under an already-open panel. Our retired file hid them; the migration + // reverts that, and it is the director's call, not ours to re-author (1874). + // + // What DOES protect the player is the line above: the button is disabled, so the flyout + // cannot be reopened onto the stale row. + assert!( + visible(&s, "RaidInfoInstance1"), + "the reference leaves the last row standing when the count hits zero" + ); // The button toggles the flyout. assert!(visible(&s, "RaidInfoFrame")); @@ -779,12 +809,21 @@ fn a_player_with_no_lockouts_loses_the_raid_info_button_on_the_second_answer() { "an empty lockout list is a dead button" ); - // And the panel behind it is emptied, not left in the state the XML loaded: every row away, - // and no scroll bar standing over a list of nothing. + // **The panel IS left in the state the XML loaded, and that is the reference's own defect.** + // `RaidInfoFrame_Update` wraps its entire row loop in `if ( savedInstances > 0 )` + // (RaidFrame.lua:93-106), so with a lockout list that has never had anything in it the loop + // never runs at all and no row is ever hidden — they keep the visibility stock + // `Blizzard_RaidUI.xml` declared. Our retired file cleared them; the migration reverts that, + // and a look the migration reverts is the director's call, not ours to re-author (1874). + // + // What protects the player is the line above: the button is disabled, so this panel cannot be + // reached by a click at all. The rows below are only visible because the test showed the frame + // by hand. s.run("RaidInfoFrame:Show()").unwrap(); - assert!(!visible(&s, "RaidInfoInstance1")); - assert!(!visible(&s, "RaidInfoInstance10")); - assert!(!visible(&s, "RaidInfoScrollFrameScrollBar")); + assert!( + visible(&s, "RaidInfoInstance1") && visible(&s, "RaidInfoInstance10"), + "with no lockouts the reference's update never runs, so the XML's own rows stand" + ); s.run("RaidInfoFrame:Hide()").unwrap(); // The button is dead to a real click, not merely drawn grey — the half the director asked for @@ -909,51 +948,10 @@ fn the_ready_check_popup_opens_and_answers() { ); } -// ───────────────────────────────────────────────────────────────────────────────────────────── -// The reference-geometry diff (decision 0675's discipline) -// ───────────────────────────────────────────────────────────────────────────────────────────── - -/// Every element this file shares with the reference carries the reference's numbers. -/// -/// The pane's specification is TWO reference files — `RaidFrame.xml` from FrameXML and -/// `Blizzard_RaidUI.xml` from the load-on-demand addon — so this runs twice. The addon half is -/// read straight out of the patch chain rather than from an extraction on disk: `_extracted_framexml/` -/// holds FrameXML only, and requiring someone to have extracted the addon first would leave half -/// the window's geometry guarded by nothing. -#[test] -fn the_geometry_matches_both_reference_files() { - let _data = benilla_formats::wow_data_or_skip!(); - // The FrameXML half: the pane, its two buttons, the info flyout and the lockout row template. - if let Some(reference) = super::framexml_diff::reference("RaidFrame.xml") { - super::framexml_diff::assert_geometry_matches( - "RaidFrame.xml", - &reference, - &[ - // The reference's close button inherits `UIPanelCloseButton`, which carries the - // 32x32; no such template ships in this house (FriendsFrame's own close button - // says the same), so ours states the size the template would have given it. The - // ANCHOR offsets match; only the extra size pair differs. - "RaidInfoCloseButton", - ], - 10, - ); - } - - // The addon half: the row, the slot, the group frame and the ready-check popup. - let Some(data) = benilla_formats::wow_data() else { - return; - }; - let chain = benilla_formats::open_chain(&data).expect("open chain"); - let bytes = chain - .read("Interface\\AddOns\\Blizzard_RaidUI\\Blizzard_RaidUI.xml") - .expect("Blizzard_RaidUI.xml is in the patch chain"); - super::framexml_diff::assert_geometry_matches_text( - "RaidFrame.xml", - &String::from_utf8_lossy(&bytes), - &[], - 10, - ); -} +// The reference-geometry diff that stood here is GONE with the transcription it policed (1874). +// It compared our `RaidFrame.xml`'s numbers against the reference's; the pane is now the +// reference's own file off the player's chain, so there is nothing left to diff — the geometry is +// the reference's by construction rather than by assertion. /// **A drag the cursor carries off the window edge ENDS — it does not glue the row to the mouse /// for the rest of the session** (B310). diff --git a/crates/benilla-app/src/ui_script/reputation_frame_tests.rs b/crates/benilla-app/src/ui_script/reputation_frame_tests.rs index 867e857d..71f95396 100644 --- a/crates/benilla-app/src/ui_script/reputation_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/reputation_frame_tests.rs @@ -218,8 +218,9 @@ fn the_reputation_page_opens_on_the_windows_third_tab() { "slot 9 has nothing to hold" ); assert!(!shown(&mut s, "ReputationHeader9"), "neither twin shows"); + // No trough line here: it was ours, and the stock pane declares none (1875). Asserting a frame + // that cannot exist would pass for the wrong reason. assert!(!visible(&mut s, "ReputationListScrollFrameScrollBar")); - assert!(!visible(&mut s, "ReputationListScrollFrameScrollBarTrough")); // Switching away hides it again through the same one-page-at-a-time switch. s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap(); @@ -383,28 +384,11 @@ fn the_scroll_offset_rebinds_the_fixed_row_slots() { visible(&mut s, "ReputationListScrollFrameScrollBar"), "21 rows in 15 slots raises the scroll bar" ); - assert!( - visible(&mut s, "ReputationListScrollFrameScrollBarTrough"), - "and the trough it rides in comes up with it (the kit shows the pair together)" - ); - - // **The trough fits this window to the pixel, and that is not a coincidence.** - // `BenillaScrollTrough_Seat`'s 21-above / 20-below / 8-left hang was DERIVED from - // ReputationFrame's own reference anchors (ScrollTemplates.xml cites ref l.573-599 against - // ref-UIPanelTemplates.xml l.166-181), so the seated trough has to land back on the reference's - // own numbers here: TOPRIGHT + (-2, +5), BOTTOMRIGHT + (+29, -4), 31 wide. Miss it by 4 and the - // arrow buttons ride out of their sockets onto the caps, which is what B224 reported. - let d = |s: &mut UiScript, expr: &str| s.eval::(&format!("return {expr}")).unwrap(); - let sf_right = d(&mut s, "ReputationListScrollFrame:GetRight()"); - let sf_top = d(&mut s, "ReputationListScrollFrame:GetTop()"); - let sf_bottom = d(&mut s, "ReputationListScrollFrame:GetBottom()"); - let t = "ReputationListScrollFrameScrollBarTrough"; - assert_eq!(d(&mut s, &format!("{t}:GetLeft()")), sf_right - 2.0); - assert_eq!(d(&mut s, &format!("{t}:GetRight()")), sf_right + 29.0); - assert_eq!(d(&mut s, &format!("{t}:GetTop()")), sf_top + 5.0); - assert_eq!(d(&mut s, &format!("{t}:GetBottom()")), sf_bottom - 4.0); - assert_eq!(d(&mut s, &format!("{t}:GetWidth()")), 31.0); - + // **The trough is GONE with our pane.** `ReputationListScrollFrameScrollBarTrough` was the one + // name in this window that was never the reference's (1844) — it belongs to our own scroll + // templates, and stock `ReputationFrame.xml` declares no such thing. B224's law (the 21/20/8 + // hang that drops each arrow into its socket) is unchanged and still asserted, by the + // keybindings page's own trough, which is a window we still own (1875). // Scroll three rows down: every slot re-binds, and slot 1 stops being a header. s.run("FauxScrollFrame_SetOffset(ReputationListScrollFrame, 3) ReputationFrame_Update()") .unwrap(); diff --git a/crates/benilla-app/src/ui_script/spellbook_tests.rs b/crates/benilla-app/src/ui_script/spellbook_tests.rs index 12227315..68f6d7aa 100644 --- a/crates/benilla-app/src/ui_script/spellbook_tests.rs +++ b/crates/benilla-app/src/ui_script/spellbook_tests.rs @@ -127,6 +127,14 @@ fn shipped_spellbook_drives_end_to_end() { load_xml(&s, "GameTooltip.xml"); load_xml(&s, "Cooldown.xml"); load_xml(&s, "ActionBar.xml"); + // The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and + // `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own + // coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the + // two template files its check boxes inherit through (1875). + load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); + load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); + load_xml(&s, r"Interface\FrameXML\OptionsFrameTemplates.xml"); + load_xml(&s, r"Interface\FrameXML\ReputationFrame.xml"); load_xml(&s, "SpellBookFrame.xml"); s.fire_event("PLAYER_ENTERING_WORLD", vec![]); @@ -657,7 +665,7 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() { for file in [ r"Interface\FrameXML\GlobalStrings.lua", "Fonts.xml", - "BasicControls.xml", // `TEXT` + "Interface\\FrameXML\\BasicControls.xml", // `TEXT` "MoneyFrame.xml", "UiPanels.xml", // `ShowMacroFrame` lives here since 1848. @@ -675,6 +683,14 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() { r"Interface\FrameXML\ClassTrainerFrameTemplates.xml", "MicroMenu.xml", "ActionBar.xml", + // The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and + // `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own + // coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the + // two template files its check boxes inherit through (1875). + r"Interface\FrameXML\UIPanelTemplates.lua", + r"Interface\FrameXML\UIPanelTemplates.xml", + r"Interface\FrameXML\OptionsFrameTemplates.xml", + r"Interface\FrameXML\ReputationFrame.xml", r"Interface\AddOns\Blizzard_MacroUI\Blizzard_MacroUI.xml", "SpellBookFrame.xml", ] { diff --git a/crates/benilla-app/src/ui_script/test_ui.rs b/crates/benilla-app/src/ui_script/test_ui.rs index 03412c29..ef17d6aa 100644 --- a/crates/benilla-app/src/ui_script/test_ui.rs +++ b/crates/benilla-app/src/ui_script/test_ui.rs @@ -143,7 +143,7 @@ fn read(req: &str) -> Option> { pub(super) const MERCHANT_UI: &[&str] = &[ "Interface\\FrameXML\\GlobalStrings.lua", "Fonts.xml", - "BasicControls.xml", // TEXT() + "Interface\\FrameXML\\BasicControls.xml", // TEXT() "Interface\\FrameXML\\ItemButtonTemplate.xml", "MoneyFrame.xml", "UiPanels.xml", @@ -220,7 +220,11 @@ pub(super) const CHARACTER_UI: &[&str] = &[ // concatenates `SPELL_STAT0_NAME`..`4` on every repaint. "Interface\\FrameXML\\GlobalStrings.lua", "Fonts.xml", - "BasicControls.xml", // TEXT(), which every one of those label sets goes through + // `GetText` — the reference's gendered-string helper, which stock + // `ReputationFrame.lua:65` calls for every row's standing label. Manifest line 94 + // (1875). + r"Interface\FrameXML\LocaleProperties.lua", + "Interface\\FrameXML\\BasicControls.xml", // TEXT(), which every one of those label sets goes through "Interface\\FrameXML\\ItemButtonTemplate.xml", // PaperDollItemSlotButtonTemplate's base "MoneyFrame.xml", "UIParent.xml", // Model_OnLoad/_Rotate*/_OnUpdate — the model panes' turntable @@ -267,7 +271,11 @@ pub(super) const CHARACTER_UI: &[&str] = &[ "Interface\\FrameXML\\CharacterFrame.xml", "Interface\\FrameXML\\PaperDollFrame.xml", "Interface\\FrameXML\\PetPaperDollFrame.xml", - "ReputationFrame.xml", + // `updateContainerFrameAnchors` — `ReputationWatchBar_Update` hard-calls it when the bar + // moves (ReputationFrame.lua:248), because in the reference the bar's presence reflows the + // bag row. It comes with `ContainerFrame.xml`, manifest 585 against the pane's 693 (1875). + "Interface\\FrameXML\\ContainerFrame.xml", + r"Interface\FrameXML\ReputationFrame.xml", "SkillFrame.xml", "Interface\\FrameXML\\HonorFrame.xml", ]; @@ -286,7 +294,7 @@ pub(super) const BAG_UI: &[&str] = &[ // `MainMenuBarBackpackButton`'s OnEnter calls (`GameTooltip:SetText(TEXT(BACKPACK_TOOLTIP)…)`) // and `BagSlotButton_OnEnter` calls for `EQUIP_CONTAINER`. Manifest entry 3, and not optional // for the bag bar since 1751's third window made that bar the reference's own. - "BasicControls.xml", + "Interface\\FrameXML\\BasicControls.xml", // `UIParent` itself: the twelve `ContainerFrame`s declare `parent="UIParent"`, and // `updateContainerFrameAnchors` anchors each open bag to `frame:GetParent()` while // `OpenAllBags` opens with `if not UIParent:IsVisible() then return end`. Without it the diff --git a/crates/benilla-app/src/ui_script/zone_text_tests.rs b/crates/benilla-app/src/ui_script/zone_text_tests.rs index 6aa0daaa..33ceb8d9 100644 --- a/crates/benilla-app/src/ui_script/zone_text_tests.rs +++ b/crates/benilla-app/src/ui_script/zone_text_tests.rs @@ -42,7 +42,7 @@ fn harness() -> UiScript { load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_xml(&s, "Fonts.xml"); // `TEXT()`, which AutoFollowStatus_OnEvent puts its message through. - load_xml(&s, "BasicControls.xml"); + load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); load_xml(&s, "MoneyFrame.xml"); load_xml(&s, "UiPanels.xml"); load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); diff --git a/crates/benilla-app/tests/auction_frame.rs b/crates/benilla-app/tests/auction_frame.rs index c8588c3a..816471b6 100644 --- a/crates/benilla-app/tests/auction_frame.rs +++ b/crates/benilla-app/tests/auction_frame.rs @@ -17,12 +17,16 @@ mod common; /// Every one of these is a real dependency: UiPanels for the panel manager + tab kit + StaticPopup /// engine, ScrollTemplates for the faux lists, UIPanelTemplates for the button/input/checkbox /// templates, UIDropDownMenu for the rarity capsule, MoneyFrame for `SmallMoneyFrameTemplate` + -/// the `MoneyTypeInfo` table this window registers `AUCTION_DEPOSIT` into, The `BenillaMoneyInput_*` money-entry +/// the `MoneyTypeInfo` table this window registers `AUCTION_DEPOSIT` into, The `MoneyInputFrame_*` money-entry /// helpers used to mean loading MerchantFrame.xml as well; 1751 moved that kit to MoneyFrame.xml /// on its way to the chain, so the dependency is gone. -const FILES: [&str; 9] = [ +const FILES: [&str; 11] = [ "Fonts.xml", "MoneyFrame.xml", + // The four money-entry frames come off the chain since 1882 — `MoneyInputFrameTemplate` and + // the `MoneyInputFrame_*` verbs, replacing our own verbatim copy of both. + r"Interface\FrameXML\MoneyInputFrame.lua", + r"Interface\FrameXML\MoneyInputFrame.xml", "UiPanels.xml", "GameTooltip.xml", "Interface\\FrameXML\\UIDropDownMenu.xml", @@ -368,7 +372,7 @@ fn the_bid_and_buyout_gates_read_the_purse() { "50s is affordable on 2g" ); assert_eq!( - s.eval::("return BenillaMoneyInput_GetCopper('BrowseBidPrice')") + s.eval::("return MoneyInputFrame_GetCopper(BrowseBidPrice)") .unwrap(), 1000, "with no bids the required bid IS the minimum bid" @@ -529,9 +533,9 @@ fn the_create_gate_and_the_deposit() { ); // With no item, the form does not even reach the price checks — the buyout error stays hidden. - s.run("BenillaMoneyInput_SetCopper('StartPrice', 10000)") + s.run("MoneyInputFrame_SetCopper(StartPrice, 10000)") .unwrap(); - s.run("BenillaMoneyInput_SetCopper('BuyoutPrice', 5000)") + s.run("MoneyInputFrame_SetCopper(BuyoutPrice, 5000)") .unwrap(); s.run("AuctionsFrameAuctions_ValidateAuction()").unwrap(); assert!(!s @@ -604,8 +608,7 @@ fn the_create_gate_and_the_deposit() { .unwrap()); // Clear the buyout and the form opens; pressing Create sends exactly what is on screen. - s.run("BenillaMoneyInput_SetCopper('BuyoutPrice', 0)") - .unwrap(); + s.run("MoneyInputFrame_SetCopper(BuyoutPrice, 0)").unwrap(); s.run("AuctionsFrameAuctions_ValidateAuction()").unwrap(); assert!( s.eval::("return AuctionsCreateAuctionButton:IsEnabled() ~= 0") diff --git a/crates/benilla-app/tests/character_stats.rs b/crates/benilla-app/tests/character_stats.rs index 456f7fb9..20c5ff80 100644 --- a/crates/benilla-app/tests/character_stats.rs +++ b/crates/benilla-app/tests/character_stats.rs @@ -36,7 +36,7 @@ use benilla_ui::script::{UiScript, UnitCombatStats, UnitState}; const FILES: [&str; 12] = [ "Interface\\FrameXML\\GlobalStrings.lua", "Fonts.xml", - "BasicControls.xml", + "Interface\\FrameXML\\BasicControls.xml", // The reference's own since 1751 window 24 — `common::load_ui` speaks both stores. "Interface\\FrameXML\\ItemButtonTemplate.xml", "MoneyFrame.xml", diff --git a/crates/benilla-app/tests/mail_frame.rs b/crates/benilla-app/tests/mail_frame.rs index 8d082f80..41a39fac 100644 --- a/crates/benilla-app/tests/mail_frame.rs +++ b/crates/benilla-app/tests/mail_frame.rs @@ -12,9 +12,13 @@ const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui"); /// The mail window's load prefix — the app's own order (`ui_script/mod.rs`), members only. /// MerchantFrame.xml rides along because MailFrame.xml reuses its global `BenillaMoney_*` coin /// helpers (postage display), so a load error in either fails here. -const FILES: [&str; 7] = [ +const FILES: [&str; 9] = [ "Fonts.xml", "MoneyFrame.xml", + // The send tab's money entry comes off the chain since 1882 — `MoneyInputFrameTemplate` and + // the `MoneyInputFrame_*` verbs. Seated straight after MoneyFrame.xml, benilla.toc's order. + r"Interface\FrameXML\MoneyInputFrame.lua", + r"Interface\FrameXML\MoneyInputFrame.xml", "UiPanels.xml", r"Interface\FrameXML\UIPanelTemplates.lua", r"Interface\FrameXML\UIPanelTemplates.xml", diff --git a/crates/benilla-app/tests/talent_frame.rs b/crates/benilla-app/tests/talent_frame.rs index b48e2f5e..a0faf258 100644 --- a/crates/benilla-app/tests/talent_frame.rs +++ b/crates/benilla-app/tests/talent_frame.rs @@ -25,7 +25,7 @@ const FILES: [&str; 13] = [ "Interface\\FrameXML\\GlobalStrings.lua", "Fonts.xml", // `TEXT`. - "BasicControls.xml", + "Interface\\FrameXML\\BasicControls.xml", "Interface\\FrameXML\\ItemButtonTemplate.xml", "MoneyFrame.xml", "UiPanels.xml", diff --git a/crates/benilla-app/tests/trade_frame.rs b/crates/benilla-app/tests/trade_frame.rs index 7a49d3f4..d88e14e8 100644 --- a/crates/benilla-app/tests/trade_frame.rs +++ b/crates/benilla-app/tests/trade_frame.rs @@ -13,9 +13,14 @@ const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui"); /// The trade window's load prefix — the app's own order (`ui_script/mod.rs`), members only. /// MerchantFrame.xml rides along because TradeFrame.xml reuses its global `BenillaMoney_*` coin /// helpers (the two gold displays), so a load error in either fails here. -const FILES: [&str; 7] = [ +const FILES: [&str; 9] = [ "Fonts.xml", "MoneyFrame.xml", + // The money entry comes off the chain since 1882 — `MoneyInputFrameTemplate` and the + // `MoneyInputFrame_*` verbs the window's OnLoad calls. Seated straight after MoneyFrame.xml, + // which is benilla.toc's own order. + r"Interface\FrameXML\MoneyInputFrame.lua", + r"Interface\FrameXML\MoneyInputFrame.xml", "UiPanels.xml", r"Interface\FrameXML\UIPanelTemplates.lua", r"Interface\FrameXML\UIPanelTemplates.xml", diff --git a/crates/benilla-ui/src/framexml.rs b/crates/benilla-ui/src/framexml.rs index 829d54f3..e8e7c57e 100644 --- a/crates/benilla-ui/src/framexml.rs +++ b/crates/benilla-ui/src/framexml.rs @@ -323,14 +323,32 @@ pub fn expand( element: &Element, templates: &HashMap<&str, &Element>, warnings: &mut Vec, +) -> Element { + expand_known(element, templates, &HashSet::new(), warnings) +} + +/// [`expand`], plus the names that are known to be **font objects** rather than element templates. +/// +/// A `` names a font, not a template, and the font is +/// applied later by its own path (`apply_fontstring_font`) — so the name must be skipped here +/// silently rather than warned as unknown. `Loader::expand_region` already made that distinction +/// for an INSTANCE; it could not make it one level down, and stock `BuffFrame.xml` is exactly that +/// case: `BuffButtonDurationTemplate` is a virtual `` that inherits a font object, so +/// every instance of it warned twenty-six times over (decision 1874). +pub fn expand_known( + element: &Element, + templates: &HashMap<&str, &Element>, + fonts: &HashSet<&str>, + warnings: &mut Vec, ) -> Element { let mut active = HashSet::new(); - expand_inner(element, templates, warnings, &mut active) + expand_inner(element, templates, fonts, warnings, &mut active) } fn expand_inner( element: &Element, templates: &HashMap<&str, &Element>, + fonts: &HashSet<&str>, warnings: &mut Vec, active: &mut HashSet, ) -> Element { @@ -372,13 +390,17 @@ fn expand_inner( .map(|(_, v)| *v) }); let Some(template) = hit else { - warnings.push(format!( - "unknown template '{name}' referenced by inherits; skipping" - )); + // A FONT OBJECT is not an unknown template: it is a different namespace, applied by + // `apply_fontstring_font` after this. Skip it without a word (1874). + if !fonts.contains(name) { + warnings.push(format!( + "unknown template '{name}' referenced by inherits; skipping" + )); + } active.remove(name); continue; }; - let expanded_template = expand_inner(template, templates, warnings, active); + let expanded_template = expand_inner(template, templates, fonts, warnings, active); active.remove(name); base = Some(match base { None => expanded_template, diff --git a/crates/benilla-ui/src/loader/mod.rs b/crates/benilla-ui/src/loader/mod.rs index 53f2cffd..e97c3d01 100644 --- a/crates/benilla-ui/src/loader/mod.rs +++ b/crates/benilla-ui/src/loader/mod.rs @@ -593,8 +593,15 @@ impl Loader<'_> { let templates = model.framexml_templates.borrow(); let view: HashMap<&str, &Element> = templates.iter().map(|(k, v)| (k.as_str(), v)).collect(); + // The font namespace, so a font object inside an inherit chain is skipped rather than + // warned as an unknown template (1874). + let fonts = model.framexml_fonts.borrow(); + let font_names: std::collections::HashSet<&str> = + fonts.keys().map(|k| k.as_str()).collect(); let mut warns = Vec::new(); - let out = framexml::expand(el, &view, &mut warns); + let out = framexml::expand_known(el, &view, &font_names, &mut warns); + drop(font_names); + drop(fonts); drop(view); drop(templates); drop(model); diff --git a/crates/benilla-ui/src/script/client.rs b/crates/benilla-ui/src/script/client.rs index af806090..69cf39ef 100644 --- a/crates/benilla-ui/src/script/client.rs +++ b/crates/benilla-ui/src/script/client.rs @@ -42,6 +42,25 @@ const BUILD_DATE: &str = "Sep 19 2006"; pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let g = lua.globals(); + // `GetLocale()` -> one string, and `IsMacClient()` -> nil, both ARITY-EXACT in the reference's + // own shape table (`re/audit/binding-shapes.tsv`: `GetLocale 0x46ce40`/`0x48d8b0` is + // `0 -> 1 (string?)`, `IsMacClient 0x48c980` is `0 -> 1 (nil)`, both `exact`/`agree`). + // + // **`IsMacClient` answering nil is the whole verb, not a placeholder.** The table records the + // return KIND as `(nil)` — this binding pushes nil on the PC build the reference was carved + // from, and there is nothing else for it to say. FrameXML branches on it for Mac-only key + // labels; nil takes the PC arm, which is the arm this client wants. + // + // `GetLocale` is the two-registration case the shape gate warns about (1843): a glue copy and + // an in-game copy, same shape. Ours answers the one locale this client ships against — the + // enUS `GlobalStrings.lua` the manifest loads and 1804's reference defaults are read from. + // Decision 1880. + g.set( + "GetLocale", + lua.create_function(|lua, ()| lua.create_string("enUS"))?, + )?; + g.set("IsMacClient", lua.create_function(|_, ()| Ok(Value::Nil))?)?; + // version, build, date — three, and no fourth (decision 1842) g.set( "GetBuildInfo", @@ -286,4 +305,26 @@ mod tests { "a malformed script must raise, not vanish" ); } + + /// `GetLocale` answers one string and `IsMacClient` answers nil — the reference's own shapes + /// (`binding-shapes.tsv`, both `exact`/`agree`), and the arity is the half a caller branches on. + #[test] + fn the_client_identity_pair_answers_its_reference_arity() { + let s = UiScript::new().unwrap(); + assert_eq!( + s.eval::("return select('#', GetLocale())").unwrap(), + 1, + "GetLocale pushes exactly one value" + ); + assert_eq!(s.eval::("return GetLocale()").unwrap(), "enUS"); + assert_eq!( + s.eval::("return select('#', IsMacClient())").unwrap(), + 1, + "IsMacClient pushes one value, and it is nil" + ); + assert!( + s.eval::("return IsMacClient() == nil").unwrap(), + "nil is the PC arm, which is the arm this client wants" + ); + } } diff --git a/crates/benilla-ui/src/script/reputation.rs b/crates/benilla-ui/src/script/reputation.rs index acfd01cc..8dfc4275 100644 --- a/crates/benilla-ui/src/script/reputation.rs +++ b/crates/benilla-ui/src/script/reputation.rs @@ -445,19 +445,33 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // CollapseFactionHeader(index) / ExpandFactionHeader(index) — a VISIBLE header index; `0` or a // non-header row acts on EVERY header (the client's own fall-through). + // **Both fire `UPDATE_FACTION`, and that is the engine's own doing, not a convenience.** + // `CollapseFactionHeader 0x4d6a50` and `ExpandFactionHeader 0x4d6aa0` both reach `0x4d6400`, + // which tail-jumps to `0x4d5c40` — and `0x4d5dab`, inside that function, is one of the FOUR + // whole-image fire sites of `UPDATE_FACTION` (wow-re `reputation-panel-law.md` §7.5 and §8, + // both VERIFIED). The stock pane has no other repaint path after a fold: its header `` + // calls the binding and nothing else (ReputationFrame.xml:9-15), and `ReputationFrame_OnEvent` + // repaints on `UPDATE_FACTION` alone. Our own retired file repainted itself, which is why this + // was invisible until the pane went stock (1875). g.set( "CollapseFactionHeader", lua.create_function(|lua, index: usize| { - let mut model = lua.app_data_mut::().expect("model app_data"); - set_collapsed(&mut model, index, true); + { + let mut model = lua.app_data_mut::().expect("model app_data"); + set_collapsed(&mut model, index, true); + } + super::event::fire_global(lua, "UPDATE_FACTION", &[]); Ok(()) })?, )?; g.set( "ExpandFactionHeader", lua.create_function(|lua, index: usize| { - let mut model = lua.app_data_mut::().expect("model app_data"); - set_collapsed(&mut model, index, false); + { + let mut model = lua.app_data_mut::().expect("model app_data"); + set_collapsed(&mut model, index, false); + } + super::event::fire_global(lua, "UPDATE_FACTION", &[]); Ok(()) })?, )?;