the camera follows again, /errors opens the log, cmangos lets you in

- the camera auto-follow returns, as a setting: Camera Following Style on the Controls options page — Smart (the default), Always, Never — so after an orbit drag the camera swings back behind you while you run; far sight still forces the chase off
- broken addons announce themselves: world entry prints "3 addon load failures — type /errors to see them", and the `/errors` window lists every script error this session with its traceback — 256 deduplicated rows, repeats collapsing onto a count — while the five load-failure classes that only ever hit the terminal (missing .toc, unparseable FrameXML, dependency cycles…) finally get a channel
- B opens the backpack alone, SHIFT-B opens all bags, F8–F11 toggle the individual slots — the reference's six bag commands instead of one command wearing every chord; and the Skills page gets its CLOSE button, with the ALL fold in the row font
- cmangos-classic logons work: `CMSG_AUTH_SESSION` now carries the real client's twelve secure-addon records (342 bytes, compressing to 130) — an empty addon block is a shape no client can send, and cmangos kicks for it where vmangos shrugs; Warden-enabled anticheat remains a separate wall

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-21 18:10:06 +07:00
co-authored by Claude Fable 5
parent b3dbd5d6ab
commit c9fe3d7a48
28 changed files with 2382 additions and 227 deletions
+107 -41
View File
@@ -497,13 +497,24 @@
BenillaBagWindow_Repaint(frame)
end
-- OpenAllBags / CloseAllBags / ToggleBackpack (ref ContainerFrame.lua l.152-217): the 'B'
-- binding (OPENALLBAGS) and the backpack button BOTH open or close EVERY bag at once, not
-- just the backpack — the real client's own behavior (MainMenuBarBackpackButton's OnClick and
-- the OPENALLBAGS binding both route to ToggleBackpack). "All bags" = the backpack (bag 0,
-- which always exists) plus each equipped-bag slot 1..4 that actually holds a bag
-- (GetContainerNumSlots > 0); an empty slot has no window to show. The five windows, indexed
-- by bag id (BENILLA_BAG_WINDOWS[bagId+1]) — one code path over all of them.
-- ToggleBag / ToggleBackpack / OpenAllBags (ref ContainerFrame.lua l.46-82, l.662-700) —
-- TWO DISTINCT KNOBS, and benilla ran them as one until 1494. The reference's split, which
-- is now ours:
--
-- B / F12 TOGGLEBACKPACK -> ToggleBackpack() — the BACKPACK's toggle. Closed: opens
-- bag 0 ALONE. Already open: hides every container window there is. The
-- asymmetry is the reference's own — one bag out, all bags in.
-- SHIFT-B OPENALLBAGS -> OpenAllBags() — the ALL-bags toggle: everything open
-- already closes the lot, anything else opens the lot.
-- F8..F11 TOGGLEBAG1..4 -> ToggleBag(4..1) — one equipped bag each (note the
-- reference's REVERSED numbering: TOGGLEBAG1 is bag 4).
--
-- "All bags" = the backpack (bag 0, which always exists) plus each equipped-bag slot 1..4
-- that actually holds a bag (GetContainerNumSlots > 0); an empty slot has no window to
-- show. The five windows, indexed by bag id (BENILLA_BAG_WINDOWS[bagId+1]) — one code path
-- over all of them. The keyring is a seventh window with its own ToggleKeyRing: swept by
-- the two close arms below (the ref's sweeps are over ALL ContainerFrames, which is what
-- its keyring lives in), never counted as an open bag and never opened by them.
BENILLA_BAG_WINDOWS = { "BenillaBagFrame", "BenillaBagFrame1", "BenillaBagFrame2", "BenillaBagFrame3", "BenillaBagFrame4" }
-- THE REFERENCE'S NAMES ARE THE REAL ENTRY POINTS, and our own UI calls THEM.
@@ -512,7 +523,7 @@
-- client to call its replacement. `Bagnon_Core/core/Overrides.lua` is the canonical shape —
-- local bToggleBackpack = ToggleBackpack
-- ToggleBackpack = function() ... end
-- and until now that override was INERT here, because the 'B' binding and the bag button
-- and until now that override was INERT here, because the bag bindings and the bag button
-- called `BenillaBagToggle_OnClick` directly and nothing ever looked up `ToggleBackpack`.
-- Bagnon loaded, appeared in the AddOns list, replaced the globals, and the player still
-- got the stock bags. The director saw that; no instrument here could, because the corpus
@@ -522,23 +533,50 @@
-- silently inert wherever our UI calls a benilla-named equivalent instead of the
-- reference's name. `CloseAllBags`/`OpenBackpack`/`CloseBackpack` already lived under the
-- ref's names in UiPanels.xml for exactly this reason; these are the three that did not.
function OpenAllBags()
for bagId = 0, 4 do
-- The backpack always exists; an equipped-bag slot only when a bag is in it.
if bagId == 0 or GetContainerNumSlots(bagId) > 0 then
local frame = getglobal(BENILLA_BAG_WINDOWS[bagId + 1])
if frame and not frame:IsShown() then
BenillaBagWindow_Repaint(frame) -- bag 0 routes through its observable alias
frame:Show()
end
--
-- `OpenAllBags(forceOpen)` is the ref's SHIFT-B body (ContainerFrame.lua l.662-700) and it
-- is a TOGGLE, not a plain open: one pass hides every open window while counting them
-- against how many CAN be open, and if everything already was, that pass IS the close and
-- it returns. `forceOpen` (the ref's own argument, used by callers that mean "open") skips
-- the close arm. The open arm calls the GLOBALS `ToggleBackpack()`/`ToggleBag(1..4)` on a
-- now-closed set — the ref's exact tail, so a bag addon holding either name gets both.
function OpenAllBags(forceOpen)
if UIParent and not UIParent:IsVisible() then return end
local totalBags = 1 -- the backpack always counts
for bagId = 1, 4 do
if GetContainerNumSlots(bagId) > 0 then totalBags = totalBags + 1 end
end
local bagsOpen = 0
for _, name in ipairs(BENILLA_BAG_WINDOWS) do
local frame = getglobal(name)
if frame and frame:IsShown() then
frame:Hide()
bagsOpen = bagsOpen + 1
end
end
-- Swept with the rest, but never counted (ref: `if GetID() ~= KEYRING_CONTAINER`).
local keyring = getglobal("BenillaKeyRingFrame")
if keyring and keyring:IsShown() then keyring:Hide() end
if bagsOpen >= totalBags and not forceOpen then return end
ToggleBackpack()
ToggleBag(1)
ToggleBag(2)
ToggleBag(3)
ToggleBag(4)
end
-- `ToggleBag(id)` — the ref's per-bag toggle (ContainerFrame.lua l.152-170), and the other
-- `ToggleBag(id)` — the ref's per-bag toggle (ContainerFrame.lua l.46-65), and the other
-- global Bagnon replaces. Ours drives the same window the all-bags path does.
--
-- The ref's `if size > 0` gate is load-bearing here and was missing until 1494: it is what
-- keeps `OpenAllBags`' blind `ToggleBag(1..4)` tail from showing an empty slot's window.
-- Bag 0 is exempt for the same reason `OpenBag` exempts it (it always exists even when the
-- feed has not told us its size yet) — the ref gets that for free from its own always-16
-- backpack.
function ToggleBag(id)
local frame = getglobal(BENILLA_BAG_WINDOWS[(id or 0) + 1])
id = id or 0
if id ~= 0 and GetContainerNumSlots(id) <= 0 then return end
local frame = getglobal(BENILLA_BAG_WINDOWS[id + 1])
if not frame then return end
if frame:IsShown() then
frame:Hide()
@@ -584,16 +622,28 @@
return nil
end
-- `ToggleBackpack()` — the ref's all-bags toggle ("if numOpen > 0 then CloseAllBags() else
-- OpenAllBags()"). It calls the two GLOBALS by name rather than local bodies, so hooking
-- either of those works too.
-- `ToggleBackpack()` — ref ContainerFrame.lua l.67-82, and NOT the all-bags toggle it was
-- here until 1494:
--
-- if ( IsBagOpen(0) ) then <hide every ContainerFrame> else ToggleBag(0) end
--
-- Open arm: `ToggleBag(0)`, the backpack ALONE (through the global, so a bag addon holding
-- `ToggleBag` gets it). Close arm: the ref's own frame sweep — every window, keyring
-- included — and NOT a `CloseAllBags()` call, because the reference does not make one here
-- and an addon holding that name would fire on a path the real client never runs it on.
-- The condition reads bag 0 SPECIFICALLY: with only bag 2 open, B opens the backpack
-- beside it rather than closing anything.
function ToggleBackpack()
local anyOpen = false
for _, name in ipairs(BENILLA_BAG_WINDOWS) do
local frame = getglobal(name)
if frame and frame:IsShown() then anyOpen = true end
if IsBagOpen(0) then
for _, name in ipairs(BENILLA_BAG_WINDOWS) do
local frame = getglobal(name)
if frame and frame:IsShown() then frame:Hide() end
end
local keyring = getglobal("BenillaKeyRingFrame")
if keyring and keyring:IsShown() then keyring:Hide() end
else
ToggleBag(0)
end
if anyOpen then CloseAllBags() else OpenAllBags() end
end
-- The backpack button (MainMenuBarBackpackButton): if any bag window is open, close them
@@ -643,7 +693,7 @@
-- (backpack included), INCLUDING the real fn's keyring fork: the keyring has its own
-- jingling KeyRingOpen/KeyRingClose pair (both real SoundEntries kits), every other window
-- the plain igBackPackOpen/igBackPackClose. Fired on every visibility transition (a Show/Hide
-- from a toggle button, the 'B' binding, a close button, or ESC), never at load (every window
-- from a toggle button, a bag binding, a close button, or ESC), never at load (every window
-- is authored hidden="true", so none ever transitions on startup).
--
-- The `this` legacy global (RF-0025, script/event.rs) names the window that just
@@ -844,14 +894,15 @@
--
-- ref MainMenuBarBackpackButton OnEnter (ref-MainMenuBarBagButtons.xml l.91-99). The ref
-- appends the live binding key with GameTooltip:AppendText; ours concatenates the same
-- suffix from a LIVE GetBindingKey read since 0997 (our one bag knob is OPENALLBAGS —
-- the toggle this very button runs), rendered through the Key Bindings window's display
-- law when it is loaded. Unbound: the bare label, the ref's own read.
-- suffix from a LIVE GetBindingKey read since 0997, rendered through the Key Bindings
-- window's display law when it is loaded. Unbound: the bare label, the ref's own read.
-- The command is TOGGLEBACKPACK — the ref's own read here, and since 1494 ours too: it is
-- the key that runs what a plain click on this button runs.
function BenillaBagToggle_OnEnter(button)
if not GameTooltip then return end -- engine tests load file subsets
GameTooltip:SetOwner(button, "ANCHOR_LEFT")
local text = BACKPACK_TOOLTIP
local key = GetBindingKey("OPENALLBAGS")
local key = GetBindingKey("TOGGLEBACKPACK")
if key then
if KeyBindings_KeyText then key = KeyBindings_KeyText(key) end
text = text .. " " .. NORMAL_FONT_COLOR_CODE .. "(" .. key .. ")" .. FONT_COLOR_CODE_CLOSE
@@ -860,9 +911,14 @@
end
-- ref BagSlotButton_OnEnter (ref-MainMenuBarBagButtons.lua l.86-96): the equipped bag's own
-- item tooltip, or "Equip Container" for an empty slot. The ref's "(F1)".."(F4)" suffix
-- needs GetBindingKey AND the TOGGLEBAG1..4 bindings — benilla ships neither, so no slot
-- carries one; the day those bind, this is where the suffix goes.
-- item tooltip, or "Equip Container" for an empty slot. The ref's "(F8)".."(F11)" suffix
-- needed GetBindingKey AND the TOGGLEBAG1..4 bindings; those bind as of 1494, so the ONE
-- thing still missing is `GameTooltip:AppendText` — our tooltip has SetText/AddLine and no
-- append verb, and the ref's suffix lands on a plate SetInventoryItem already wrote. That
-- verb's byte semantics (which line does it extend?) are unverified, so the suffix waits
-- rather than guessing; this is where it goes, over `GetBindingKey("TOGGLEBAG"..(5 -
-- button.bagId))` — the ref's index is REVERSED against the bar, so the slot nearest the
-- backpack (bagId 1) is TOGGLEBAG4/F11 and the farthest (bagId 4) is TOGGLEBAG1/F8.
function BenillaBagBarSlot_OnEnter(button)
if not GameTooltip then return end
GameTooltip:SetOwner(button, "ANCHOR_LEFT")
@@ -1676,10 +1732,13 @@
exactly as ContainerFrame.lua does it. Its 20 template buttons cover the keyring's 16-slot
ceiling; the level-gated remainder hides through the ordinary physIndex-past-size branch.
It is deliberately NOT in BENILLA_BAG_WINDOWS: the keyring is not one of "your bags", so 'B'
/ the backpack toggle / a vendor's auto-open never touch it, which is the reference's own
arrangement (OpenAllBags counts it out, and CloseAllBags is id-driven over 1..4). ESC still
closes it — UiPanels.xml's CloseAllWindows hides every open container, the ref's own loop.
It is deliberately NOT in BENILLA_BAG_WINDOWS: the keyring is not one of "your bags", so no
bag toggle ever OPENS it and a vendor's auto-open never touches it — the reference's own
arrangement (OpenAllBags' open arm is ToggleBackpack + ToggleBag(1..4), and its count
explicitly excludes KEYRING_CONTAINER). It is still SWEPT by the two close arms, which the
ref runs over every ContainerFrame — the keyring lives in one there, so 1494 sweeps ours by
name alongside the list. ESC closes it too — UiPanels.xml's CloseAllWindows hides every open
container, the ref's own loop.
-->
<Frame name="BenillaKeyRingFrame" id="-2" inherits="BenillaBagWindowTemplate">
<Scripts>
@@ -1750,10 +1809,17 @@
</OnLoad>
<!-- The undo repairs the CheckButton widget's own pre-handler flip; from there the
checked state belongs to the windows' OnShow/OnHide writes (ours and any
addon's) — the stated divergence at BenillaBagToggle_OnClick's comment. -->
addon's) — the stated divergence at BenillaBagToggle_OnClick's comment.
The shift fork is the ref's own (MainMenuBarBagButtons.xml l.81-87): the button
carries the SAME pair of knobs the B / SHIFT-B keys do (1494), so a shift-click
opens all bags and a plain one toggles the backpack. -->
<OnClick>
self:SetChecked(not self:GetChecked())
BenillaBagToggle_OnClick()
if IsShiftKeyDown() then
OpenAllBags()
else
BenillaBagToggle_OnClick()
end
</OnClick>
<OnEnter>BenillaBagToggle_OnEnter(self)</OnEnter>
<OnLeave>BenillaBagBarButton_OnLeave()</OnLeave>
+31 -2
View File
@@ -612,7 +612,7 @@
OPTIONS_PAGE_ROWS = {
Controls = {
"RowStickyTarget", "RowAutoLoot", "RowInvertMouse", "RowMouseSpeed",
"RowMaxCameraDistance",
"RowCameraFollowStyle", "RowMaxCameraDistance",
},
Audio = {
"RowEnableAll", "RowMaster", "RowSound", "RowMusic", "RowAmbience",
@@ -867,6 +867,12 @@
if match then
dd.selectedValue = match.value
UIDropDownMenu_SetText(match.text, dd)
-- A dropdown whose ENTRIES each carry their own description repaints the row's
-- plate as the selection moves — the reference's own shape for exactly this row
-- (UIOptionsFrameCameraDropDown_OnClick: `tooltip = getglobal("OPTION_TOOLTIP_
-- CAMERA"..GetSelectedID())`). Rows whose entries carry no `tip` keep the single
-- key OptionsRow_OnLoad gave them.
if match.tip then row.tip = match.tip end
else
dd.selectedValue = v
UIDropDownMenu_SetText(v, dd)
@@ -1983,11 +1989,34 @@
"22" means the wheel stops at 22 yards, which is the
thing being chosen. Our default is the factor fully
raised (30 yd), where 1.12 ships 1.0. -->
<Frame name="$parentRowMaxCameraDistance" inherits="OptionsSliderRowTemplate">
<!-- Camera Following Style (decision 1493): 1.12's own
dropdown (UIOptionsFrameCameraDropDown), values and
order and all — Smart "1", Always "2", Never "3", with
each entry carrying the reference's own description key
so the row's plate changes with the selection, the way
that dropdown's does. Seated at the head of the camera
block because it decides whether the camera follows at
all; the distance slider under it decides how far. -->
<Frame name="$parentRowCameraFollowStyle" inherits="OptionsDropdownRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowMouseSpeed" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowMouseSpeed" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "cameraSmoothStyle",
"Camera Following Style", "OPTION_TOOLTIP_CAMERA1")
OptionsDropdownRow_Setup(self, {
{ text = "Smart", value = "1", tip = "OPTION_TOOLTIP_CAMERA1" },
{ text = "Always", value = "2", tip = "OPTION_TOOLTIP_CAMERA2" },
{ text = "Never", value = "3", tip = "OPTION_TOOLTIP_CAMERA3" },
})
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowMaxCameraDistance" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowCameraFollowStyle" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowCameraFollowStyle" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "cameraDistanceMaxFactor",
"Max Camera Distance", "OPTION_TOOLTIP_MAX_FOLLOW_DIST")
@@ -0,0 +1,366 @@
<Ui>
<!--
benilla's **script error log** (decision 1495) — the window B293 asked for, and it is
entirely OURS: 1.12 has no counterpart, so every name here wears the `Benilla` prefix (1254's
rule, `tests::reference_surface`'s gate).
## Why it exists beside `ScriptErrors`
`BasicControls.xml`'s red `ScriptErrors` dialog is the reference's whole answer to a Lua
fault, transcribed and installed by 1305 — and it stays exactly as it is. It shows **one**
message, because `_ERRORMESSAGE` guards on `ScriptErrors:IsVisible()`, and it remembers
nothing. Two reporters said so within days of it shipping: Goudy, *minutes after
screenshotting that dialog working*, *"some kind of lua error frame probably needs to be
implemented, as there are a lot of addons that still doesn't work"*; nazriel_0, *"probably we
should start using something like Bug Grabber to report those stack traces better"*.
So: the dialog is the interrupt, this is the log. Additive, no divergence — the reference
simply never had this instrument.
## Shape
`CraftFrame.xml`'s shape, which is the house idiom for "windowed panel, faux-scrolled row
list, detail pane that repaints on selection": 13 fixed 16px rows over a
`FauxScrollFrameTemplate`, and a **real** `<ScrollFrame>` detail pane below (the real kit,
not the faux one — a message is a `mlua` error string with its `stack traceback:` tail
attached, so the detail is genuinely variable-height content, which is what the real kit is
for; `GossipFrame.xml`'s greeting pane is the same wiring).
The window is registered in `UIPanelWindows` (`UiPanels.xml`), which is what seats it in the
left slot and gets it closed by `CloseAllWindows()` — i.e. by ESC — with no code here. Our
own toplevels deliberately stay OUT of `UISpecialFrames`: that table is the addons' (0544's
note, 1206).
## Reading the model
Three engine reads, all from the retained log on the model
(`benilla_ui::script::diagnostics`): `BenillaGetNumScriptErrors()` →
`retained, totalEverRecorded`; `BenillaGetScriptErrorInfo(i)` → `seq, kind, message, count`
(1-based, oldest first, nil past the end); `BenillaClearScriptErrors()`. There is no per-frame
feed and nothing to keep in step — the window polls the log it is a view of.
-->
<Script><![CDATA[
BENILLA_SCRIPTLOG_ROWS = 13
BENILLA_SCRIPTLOG_ROW_HEIGHT = 16
-- How much of a message a ROW shows. The full text is the detail pane's job; a row is an
-- index, and a row that wraps would break the fixed-height list the faux kit requires.
BENILLA_SCRIPTLOG_ROW_CHARS = 52
-- Which row is selected (a `seq`, not an index — indices shift when the cap evicts, and a
-- selection that silently slides onto a different error is worse than one that goes away).
BenillaScriptLogSelected = nil
-- The first line of a message, trimmed to a row's width. A traceback's later lines are
-- indented continuations; on one row they read as noise.
function BenillaScriptLog_RowText(message)
local cut = strfind(message, "\n", 1, 1)
local head = message
if cut then head = strsub(message, 1, cut - 1) end
if strlen(head) > BENILLA_SCRIPTLOG_ROW_CHARS then
head = strsub(head, 1, BENILLA_SCRIPTLOG_ROW_CHARS - 3) .. "..."
end
return head
end
-- `load` rows are red and `error` rows amber, and the split is by CONSEQUENCE: a `load` row
-- means an addon is not running at all, which is the thing the player can act on. Colours
-- come from the shipped tables so this window matches the rest of the UI.
function BenillaScriptLog_RowColor(kind)
if kind == "load" then return 1.0, 0.3, 0.3 end
return 1.0, 0.82, 0.0
end
function BenillaScriptLog_Update()
local shown, total = BenillaGetNumScriptErrors()
-- The summary line carries the one number a bug report needs and the window cannot
-- otherwise show: how many were DROPPED. `total` counts distinct failures ever
-- recorded; `shown` is what survived the cap.
local summary
if total == 0 then
summary = "No errors this session."
elseif total > shown then
summary = format("%d errors (%d older not kept)", total, total - shown)
elseif total == 1 then
summary = "1 error"
else
summary = format("%d errors", total)
end
getglobal("BenillaScriptLogSummary"):SetText(summary)
local scrollFrame = getglobal("BenillaScriptLogListScrollFrame")
FauxScrollFrame_Update(scrollFrame, shown, BENILLA_SCRIPTLOG_ROWS, BENILLA_SCRIPTLOG_ROW_HEIGHT)
local offset = FauxScrollFrame_GetOffset(scrollFrame)
local selectedRow = nil
for i = 1, BENILLA_SCRIPTLOG_ROWS do
local button = getglobal("BenillaScriptLogRow" .. i)
local index = offset + i
local seq, kind, message, count = BenillaGetScriptErrorInfo(index)
if seq then
button.seq = seq
button.message = message
-- The count rides the index, not the text: `#12 x1113` says "this is one
-- failure that happened 1,113 times", which is the whole reason the log
-- deduplicates instead of listing 1,113 rows.
local label = "#" .. seq
if count > 1 then label = label .. " x" .. count end
button.index:SetText(label)
button.label:SetText(BenillaScriptLog_RowText(message))
button.label:SetTextColor(BenillaScriptLog_RowColor(kind))
button:Show()
if seq == BenillaScriptLogSelected then selectedRow = button end
else
button.seq = nil
button.message = nil
button:Hide()
end
end
-- The moving highlight, seated onto the selected row only when that row is on screen —
-- scrolling the selection away hides the glow rather than stranding it (CraftFrame's
-- own behaviour).
local highlight = getglobal("BenillaScriptLogHighlightFrame")
if selectedRow then
highlight:SetPoint("TOPLEFT", selectedRow:GetName(), "TOPLEFT", 0, 0)
highlight:Show()
else
highlight:Hide()
end
BenillaScriptLog_UpdateDetail()
end
-- The detail pane: the FULL message of the selected row, traceback and all. Selection is by
-- `seq`, so this walks the log rather than indexing it.
function BenillaScriptLog_UpdateDetail()
local text = ""
if BenillaScriptLogSelected then
local shown = BenillaGetNumScriptErrors()
for i = 1, shown do
local seq, _, message = BenillaGetScriptErrorInfo(i)
if seq == BenillaScriptLogSelected then
text = message
break
end
end
end
if text == "" then
text = "Select an error above to read it in full."
end
getglobal("BenillaScriptLogDetailText"):SetText(text)
end
-- Frame-late by nature (the shared kit's own note): text rects resolve after Lua runs, so
-- the child is re-measured from OnUpdate until it stops changing, after which this is a
-- no-op compare per frame. OnUpdate only fires on visible frames, so a closed window costs
-- nothing.
function BenillaScriptLogFrame_OnUpdate()
BenillaScroll_ResizeChild("BenillaScriptLogDetailScroll", { "BenillaScriptLogDetailText" })
end
function BenillaScriptLogRow_OnClick(button)
if not button.seq then return end
-- Clicking the selected row deselects: the detail pane is a drawer, and a list with no
-- way back to "nothing selected" is a small trap.
if BenillaScriptLogSelected == button.seq then
BenillaScriptLogSelected = nil
else
BenillaScriptLogSelected = button.seq
end
BenillaScriptLog_Update()
end
function BenillaScriptLogFrame_OnLoad(self)
getglobal("BenillaScriptLogListScrollFrame").updateFunc = BenillaScriptLog_Update
end
-- The shared helper steps the faux frame's bar, whose OnValueChanged runs the kit's
-- offset handler and thence `updateFunc` — so the repaint is wired once, at OnLoad, and
-- not repeated here.
function BenillaScriptLogFrame_OnMouseWheel(delta)
BenillaFauxScrollFrame_OnMouseWheel(getglobal("BenillaScriptLogListScrollFrame"), delta)
end
-- ── The three entry points the client and the player reach this by ────────────────────
function BenillaScriptLog_Toggle()
local frame = getglobal("BenillaScriptLogFrame")
if frame:IsVisible() then
HideUIPanel(frame)
else
ShowUIPanel(frame)
end
end
-- `/errors clear`. Forgets the rows, keeps the numbering (the engine's own rule — a number
-- must never name two different failures across a screenshot). Prints a confirmation
-- because a command whose whole effect is "a window you may not have open is now empty"
-- is otherwise indistinguishable from a typo.
function BenillaScriptLog_Clear()
BenillaClearScriptErrors()
BenillaScriptLogSelected = nil
if getglobal("BenillaScriptLogFrame"):IsVisible() then
BenillaScriptLog_Update()
end
DEFAULT_CHAT_FRAME:AddMessage("Script error log cleared.", 1.0, 1.0, 0.0)
end
]]></Script>
<!-- One list row: the `#N xCount` index column, then the message head. Same shape as
BenillaCraftSkillButtonTemplate (fixed columns — this engine has no FontString-width-based
dynamic anchor). -->
<Button name="BenillaScriptLogRowTemplate" virtual="true" hidden="true">
<Size><AbsDimension x="293" y="16"/></Size>
<Layers>
<Layer level="ARTWORK">
<FontString name="$parentIndex" inherits="GameFontNormalSmall" justifyH="LEFT">
<Size><AbsDimension x="56" y="16"/></Size>
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="$parentLabel" inherits="GameFontNormalSmall" justifyH="LEFT">
<Size><AbsDimension x="229" y="16"/></Size>
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="62" y="0"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<HighlightTexture file="Interface\Buttons\UI-Listbox-Highlight2" alphaMode="ADD"/>
<Scripts>
<OnLoad>
self.index = getglobal(self:GetName() .. "Index")
self.label = getglobal(self:GetName() .. "Label")
</OnLoad>
<OnClick>BenillaScriptLogRow_OnClick(self)</OnClick>
</Scripts>
</Button>
<Frame name="BenillaScriptLogFrame" hidden="true">
<!-- 384x512, the standard left-slot panel size. No <Anchors>: ShowUIPanel seats it. -->
<Size><AbsDimension x="384" y="512"/></Size>
<Layers>
<Layer level="BACKGROUND">
<Texture name="BenillaScriptLogFramePortrait" file="Interface\Icons\INV_Misc_Note_01">
<Size><AbsDimension x="60" y="60"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="7" y="-6"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
<!-- The four quadrant slabs. The ClassTrainer family, all four, because it is the one
UNIFORM set in the 1.12 art (CraftFrame's own note) and this window belongs to no
profession — borrowing a themed half would make it look like a window it isn't. -->
<Layer level="BORDER">
<Texture file="Interface\ClassTrainerFrame\UI-ClassTrainer-TopLeft"><Size><AbsDimension x="256" y="256"/></Size><Anchors><Anchor point="TOPLEFT"/></Anchors></Texture>
<Texture file="Interface\ClassTrainerFrame\UI-ClassTrainer-TopRight"><Size><AbsDimension x="128" y="256"/></Size><Anchors><Anchor point="TOPRIGHT"/></Anchors></Texture>
<Texture file="Interface\ClassTrainerFrame\UI-ClassTrainer-BotLeft"><Size><AbsDimension x="256" y="256"/></Size><Anchors><Anchor point="BOTTOMLEFT"/></Anchors></Texture>
<Texture file="Interface\ClassTrainerFrame\UI-ClassTrainer-BotRight"><Size><AbsDimension x="128" y="256"/></Size><Anchors><Anchor point="BOTTOMRIGHT"/></Anchors></Texture>
<FontString name="BenillaScriptLogTitle" inherits="GameFontNormal" justifyH="CENTER" text="Script Errors">
<Anchors><Anchor point="TOP" relativePoint="TOP"><Offset><AbsDimension x="0" y="-17"/></Offset></Anchor></Anchors>
</FontString>
<!-- The summary line, and the ONLY place the dropped-row count can be told. -->
<FontString name="BenillaScriptLogSummary" inherits="GameFontHighlightSmall" justifyH="LEFT" text="">
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="26" y="-76"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- The top-right X. This engine ships no UIPanelCloseButton; the minimize-button art
stands in, MacroFrame/TrainerFrame's precedent. -->
<Button name="BenillaScriptLogCloseButton">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors><Anchor point="TOPRIGHT"><Offset><AbsDimension x="-29" y="-8"/></Offset></Anchor></Anchors>
<NormalTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Up"/>
<PushedTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Down"/>
<HighlightTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Highlight" alphaMode="ADD"/>
<Scripts><OnClick>HideUIPanel(getglobal("BenillaScriptLogFrame"))</OnClick></Scripts>
</Button>
<!-- Declared BEFORE the rows so row text paints over the glow (the house z-tiebreak). -->
<Frame name="BenillaScriptLogHighlightFrame" hidden="true">
<Size><AbsDimension x="293" y="16"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<Layers><Layer level="ARTWORK">
<Texture file="Interface\Buttons\UI-Listbox-Highlight2">
<Anchors><Anchor point="TOPLEFT"/><Anchor point="BOTTOMRIGHT"/></Anchors>
</Texture>
</Layer></Layers>
</Frame>
<Button name="BenillaScriptLogRow1" inherits="BenillaScriptLogRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-94"/></Offset></Anchor></Anchors>
</Button>
<Button name="BenillaScriptLogRow2" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow1" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaScriptLogRow3" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow2" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaScriptLogRow4" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow3" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaScriptLogRow5" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow4" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaScriptLogRow6" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow5" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaScriptLogRow7" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow6" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaScriptLogRow8" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow7" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaScriptLogRow9" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow8" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaScriptLogRow10" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow9" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaScriptLogRow11" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow10" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaScriptLogRow12" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow11" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="BenillaScriptLogRow13" inherits="BenillaScriptLogRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaScriptLogRow12" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<!-- The faux kit over the 13-row span; only its right-hand Slider renders. -->
<Frame name="BenillaScriptLogListScrollFrame" inherits="FauxScrollFrameTemplate">
<Size><AbsDimension x="293" y="208"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-94"/></Offset></Anchor></Anchors>
</Frame>
<!-- The detail pane: the REAL scroll kit, because a message carries its `stack
traceback:` tail and is genuinely variable-height. GossipFrame's greeting pane is
the same wiring. -->
<ScrollFrame name="BenillaScriptLogDetailScroll">
<Size><AbsDimension x="293" y="128"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-314"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>self:SetScrollChild(getglobal(self:GetName() .. "Child"))</OnLoad>
<OnMouseWheel>BenillaScroll_OnMouseWheel(self, arg1)</OnMouseWheel>
<OnVerticalScroll>BenillaScroll_SyncBar(self)</OnVerticalScroll>
<OnScrollRangeChanged>BenillaScroll_SyncBar(self)</OnScrollRangeChanged>
</Scripts>
<Frames>
<Slider name="$parentBar" inherits="BenillaScrollBarTemplate" hidden="true">
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="6" y="-16"/></Offset></Anchor>
<Anchor point="BOTTOMLEFT" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="6" y="16"/></Offset></Anchor>
</Anchors>
<Scripts><OnValueChanged>BenillaScrollBar_OnValueChanged(self, arg1)</OnValueChanged></Scripts>
</Slider>
<Frame name="$parentChild">
<Size><AbsDimension x="293" y="128"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<Layers>
<Layer level="ARTWORK">
<!-- 270 wide so the engine wraps the traceback rather than clipping
it; the pane pans over whatever height that comes to. -->
<FontString name="BenillaScriptLogDetailText" inherits="GameFontHighlightSmall" justifyH="LEFT" justifyV="TOP" text="">
<Size><AbsDimension x="270" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="6" y="-6"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
</Frame>
</Frames>
</ScrollFrame>
<Button name="BenillaScriptLogClearButton" inherits="UIPanelButtonTemplate" text="Clear">
<Size><AbsDimension x="96" y="22"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-455"/></Offset></Anchor></Anchors>
<Scripts><OnClick>BenillaScriptLog_Clear()</OnClick></Scripts>
</Button>
<Button name="BenillaScriptLogCloseBottomButton" inherits="UIPanelButtonTemplate" text="Close">
<Size><AbsDimension x="96" y="22"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="219" y="-455"/></Offset></Anchor></Anchors>
<Scripts><OnClick>HideUIPanel(getglobal("BenillaScriptLogFrame"))</OnClick></Scripts>
</Button>
</Frames>
<Scripts>
<OnLoad>BenillaScriptLogFrame_OnLoad(self)</OnLoad>
<!-- The list wheel lives on the WINDOW: the engine bubbles a spin up the parent chain
(CraftFrame's own note). The detail pane has its own, which wins over its own rect. -->
<OnMouseWheel>BenillaScriptLogFrame_OnMouseWheel(arg1)</OnMouseWheel>
<OnShow>BenillaScriptLog_Update()</OnShow>
<OnUpdate>BenillaScriptLogFrame_OnUpdate()</OnUpdate>
</Scripts>
</Frame>
</Ui>
+93 -13
View File
@@ -56,11 +56,14 @@
2. NO SORT CHECKBOX (ref l.215-241's own XML-commented-out "Replace once code is ready..."
`SkillSortButton`): the ref itself ships this dead/commented, so there is nothing to
transcribe (TradeSkillFrame.xml's own "ref's own dead code is dropped" precedent).
3. NO ACCEPT/CANCEL BUTTONS (ref l.318-357's `SkillFrameAcceptButton`/`SkillFrameCancelButton`,
the latter ALSO commented dead in the ref): both back the training-point economy (Accept/
CancelSkillUps) this pane doesn't ship, AND this page has no window of its own to close —
`CharacterFrame`'s single shared close X (CharacterFrame.xml) already covers every
tab, matching `PaperDollFrame`'s own lack of a per-page close button.
3. NO ACCEPT BUTTON (ref l.318-337's `SkillFrameAcceptButton`) — it is XML-commented dead
in the ref, and it backs the training-point economy this pane doesn't ship (deviation 1),
so there is nothing to transcribe. Its neighbour `SkillFrameCancelButton` (l.339-357) is
NOT dead and IS built: the ref's comment closes at l.337, one line above it, and the real
client draws a CLOSE button in the page's bottom-right seat. This list used to claim both
were commented out, and that misread is exactly why that seat rendered empty — decision
1496. Only its `CancelSkillUps()` call drops (deviation 1's dead economy again); the two
hides are transcribed.
4. THE RANK TEXT FORMAT IS THE REF'S BYTE-EXACT ONE, not the task brief's own paraphrase:
`ref-ui/SkillFrame.lua` l.178-186 builds `rank .. " (" .. color .. modifier .. close ..
")/" .. max` (e.g. "57 (|cff20ff20+3|r)/75") — transcribed verbatim (the
@@ -333,6 +336,7 @@
end
BenillaSkillFrame_UpdateDetail()
BenillaSkillExpandButtonFrame_Fit()
end
-- A rank-bar click selects it (ref SkillBar_OnClick, l.450-455).
@@ -381,6 +385,38 @@
BenillaSkillFrame_Update()
end
-- The tab is as wide as its own label (ref SkillFrameExpandButtonFrame's OnLoad, l.312-316:
-- SetWidth(SkillFrameCollapseAllButton:GetTextWidth()+45)) — the 45 is the two 8px caps plus
-- the label's 25px seat plus the margin the ref leaves before the right cap, and the middle
-- slab stretches into whatever that leaves off its own two anchors.
--
-- Run from BOTH the ref's own OnLoad and every Update, because a measure here is not the
-- ref's synchronous one: benilla seats the VM's font engine at the frame boundary
-- (`ui_script::extract`, ahead of the tick), and XML load happens BEFORE any frame — so the
-- OnLoad call reads 0 and the tab would keep a 45px stump forever. The zero guard is what
-- makes the pair safe: an unmeasured call leaves the width alone rather than squashing the
-- tab, and the first Update after the atlas exists fits it. (The house's own preference is
-- two opposing anchors and no Lua width — UiPanels.xml's tab-highlight note — but a span
-- can only be resolved from two anchors when both edges are frames; a text width has to be
-- asked for.)
function BenillaSkillExpandButtonFrame_Fit()
local label = getglobal("SkillCollapseAllButton")
local w = label:GetTextWidth()
if w and w > 0 then
getglobal("SkillExpandButtonFrame"):SetWidth(w + 45)
end
end
-- The page's own CLOSE (ref SkillFrameCancelButton's OnClick, l.350-356). The ref's
-- CancelSkillUps() drops out with the training-point economy this pane doesn't ship (this
-- file's header comment deviation 1); the two hides are the ref's own, in its own order —
-- the page first, then the window (CharacterFrame_OnHide's subframe loop would reach this
-- page anyway, but the ref hides it explicitly and so do we).
function BenillaSkillFrameCancelButton_OnClick()
getglobal("SkillFrame"):Hide()
HideUIPanel(getglobal("CharacterFrame"))
end
-- The window's wheel handler (bubbled up the parent chain from a row) → one row via the faux
-- kit (TrainerFrame.xml's own note: the engine bubbles a spin up the parent chain).
function BenillaSkillFrame_OnMouseWheel(delta)
@@ -552,28 +588,72 @@
</Button>
<!-- The expand/collapse-all tab (ref SkillFrameExpandButtonFrame, l.242-317 — the SAME
QuestLogSortTab art TrainerFrame.xml's own BenillaTrainerExpandButtonFrame uses). -->
QuestLogSortTab art TrainerFrame.xml's own BenillaTrainerExpandButtonFrame uses).
The tab is SIZED BY ITS LABEL, the ref's own OnLoad law (l.312-316:
SetWidth(GetTextWidth()+45)) — the middle slab stretches between the two 8px caps
off its OWN two anchors (ref l.279-287), so the 45 is exactly the two caps plus the
label's seat. Was a fixed 54 with a fixed 38 middle, copied off
TrainerFrame.xml rather than read off THIS ref; the label's own font fix below
(12px, not 10) would have run under the right cap at that width. -->
<Frame name="SkillExpandButtonFrame">
<Size><AbsDimension x="54" y="32"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="70" y="-49"/></Offset></Anchor></Anchors>
<Layers><Layer level="BACKGROUND">
<Texture name="SkillExpandTabLeft" file="Interface\QuestFrame\UI-QuestLogSortTab-Left"><Size><AbsDimension x="8" y="32"/></Size><Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="0" y="6"/></Offset></Anchor></Anchors></Texture>
<Texture name="SkillExpandTabMiddle" file="Interface\QuestFrame\UI-QuestLogSortTab-Middle"><Size><AbsDimension x="38" y="32"/></Size><Anchors><Anchor point="LEFT" relativeTo="SkillExpandTabLeft" relativePoint="RIGHT"/></Anchors></Texture>
<Texture file="Interface\QuestFrame\UI-QuestLogSortTab-Right"><Size><AbsDimension x="8" y="32"/></Size><Anchors><Anchor point="LEFT" relativeTo="SkillExpandTabMiddle" relativePoint="RIGHT"/></Anchors></Texture>
<Texture name="SkillExpandTabRight" file="Interface\QuestFrame\UI-QuestLogSortTab-Right"><Size><AbsDimension x="8" y="32"/></Size><Anchors><Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="0" y="6"/></Offset></Anchor></Anchors></Texture>
<Texture name="SkillExpandTabMiddle" file="Interface\QuestFrame\UI-QuestLogSortTab-Middle"><Size><AbsDimension x="0" y="32"/></Size><Anchors><Anchor point="LEFT" relativeTo="SkillExpandTabLeft" relativePoint="RIGHT"/><Anchor point="RIGHT" relativeTo="SkillExpandTabRight" relativePoint="LEFT"/></Anchors></Texture>
</Layer></Layers>
<Frames>
<!-- The ALL fold (ref SkillFrameCollapseAllButton, l.292-310). Its face is the
ref's SkillLabelTemplate one — the SAME face every SkillTypeLabel row wears
(BenillaSkillHeaderTemplate above, l.83-128 of the ref): the +/- glyph at
LEFT+3, the label at LEFT+25, and GameFontHighlight — WHITE at 12px, not
GameFontNormalSmall's yellow 10. Spelled out here rather than inherited
because this button keeps its own OnClick (this engine's
CollapseSkillHeader(0)/ExpandSkillHeader(0) fold everything at once, so it
needs no skillIndex/isExpanded bookkeeping) and this loader CONCATENATES an
instance's children onto its template's rather than replacing them, so an
inherited <Scripts> would stand alongside ours. Its seat is the ref's own
(-3,-3) off the left cap: the tab art's visible lip sits BELOW the
texture's geometric middle, so the label rides 3px low, not 3px high — the
+3 copied from TrainerFrame.xml put the whole "- All" 6px above where the
reference seats it. -->
<Button name="SkillCollapseAllButton" text="ALL">
<Size><AbsDimension x="40" y="22"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="SkillExpandTabLeft" relativePoint="RIGHT"><Offset><AbsDimension x="0" y="3"/></Offset></Anchor></Anchors>
<NormalTexture file="Interface\Buttons\UI-MinusButton-Up"><Size><AbsDimension x="16" y="16"/></Size><Anchors><Anchor point="LEFT" relativePoint="LEFT"/></Anchors></NormalTexture>
<HighlightTexture file="Interface\Buttons\UI-PlusButton-Hilight" alphaMode="ADD"><Size><AbsDimension x="16" y="16"/></Size><Anchors><Anchor point="LEFT" relativePoint="LEFT"/></Anchors></HighlightTexture>
<ButtonText><Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="18" y="0"/></Offset></Anchor></Anchors></ButtonText>
<NormalFont inherits="GameFontNormalSmall" justifyH="LEFT"/>
<Anchors><Anchor point="LEFT" relativeTo="SkillExpandTabLeft" relativePoint="RIGHT"><Offset><AbsDimension x="-3" y="-3"/></Offset></Anchor></Anchors>
<NormalTexture file="Interface\Buttons\UI-MinusButton-Up"><Size><AbsDimension x="16" y="16"/></Size><Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="3" y="0"/></Offset></Anchor></Anchors></NormalTexture>
<HighlightTexture file="Interface\Buttons\UI-PlusButton-Hilight" alphaMode="ADD"><Size><AbsDimension x="16" y="16"/></Size><Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="3" y="0"/></Offset></Anchor></Anchors></HighlightTexture>
<ButtonText><Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="25" y="0"/></Offset></Anchor></Anchors></ButtonText>
<NormalFont inherits="GameFontHighlight" justifyH="LEFT"/>
<Scripts><OnClick>BenillaSkillCollapseAllButton_OnClick()</OnClick></Scripts>
</Button>
</Frames>
<Scripts><OnLoad>BenillaSkillExpandButtonFrame_Fit()</OnLoad></Scripts>
</Frame>
<!-- The page's own CLOSE button (ref SkillFrameCancelButton, l.339-357: 80x22,
UIPanelButtonTemplate, CENTER of TOPLEFT (305,-422), text CLOSE). LIVE in the
reference, not dead: the ref's XML comment that swallows SkillFrameAcceptButton
CLOSES at l.337, one line ABOVE this button — this file's own header comment
deviation 3 read that boundary wrong and dropped a button the real client draws,
which is why the page's bottom-right seat sat empty (decision 1496). The
UIPanelButtonTemplate face is reconstructed locally, the house convention every
window here follows (CraftFrame.xml's BenillaCraftPanelButtonTemplate note) — one
site, so inline rather than a virtual template (GroupLootFrame.xml's own precedent
for a single ref-template button). -->
<Button name="SkillFrameCancelButton" text="CLOSE">
<Size><AbsDimension x="80" y="22"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="TOPLEFT"><Offset><AbsDimension x="305" y="-422"/></Offset></Anchor></Anchors>
<NormalFont inherits="GameFontNormal"/>
<HighlightFont inherits="GameFontHighlight"/>
<DisabledFont inherits="GameFontDisable"/>
<NormalTexture file="Interface\Buttons\UI-Panel-Button-Up"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Panel-Button-Down"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></PushedTexture>
<DisabledTexture file="Interface\Buttons\UI-Panel-Button-Disabled"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></DisabledTexture>
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></HighlightTexture>
<Scripts><OnClick>BenillaSkillFrameCancelButton_OnClick()</OnClick></Scripts>
</Button>
<!-- The persistent selection highlight (Trainer/TradeSkillFrame's own precedent, this
file's header comment deviation 7): 281×15, ARTWORK-layer UI-Listbox-Highlight2.
Declared BEFORE the rows so the row text paints OVER the glow (same z-tiebreak). -->
+9 -1
View File
@@ -34,6 +34,13 @@
-- UISpecialFrames — the table exists now (1206) but it is the ADDONS' list, and mixing our
-- windows into it would make an addon's `tremove` able to break the client's own ESC.
UIPanelWindows["MailFrame"] = { area = "left", pushable = 0 }
-- The script error log (decision 1495) — OURS, with no reference row to transcribe. It is
-- registered here for exactly one reason: an unregistered frame takes ShowUIPanel's bare
-- `frame:Show()` branch (no left-slot placement, and `CloseAllWindows` never sees it), so
-- without this row the window would flip IsShown() without landing on screen and ESC would
-- not close it. `pushable = 9` puts it behind everything already registered: a diagnostic
-- must never shove a player's real window out of the slot it wanted.
UIPanelWindows["BenillaScriptLogFrame"] = { area = "left", pushable = 9 }
-- The trade window (decision 0592 P1, TradeFrame.xml): the ref's own UIParent.lua l.27
-- registration (`UIPanelWindows["TradeFrame"] = { area = "left", pushable = 1 }`). Without
-- this row ShowUIPanel takes its unregistered branch — a bare frame:Show() with no left-slot
@@ -502,7 +509,8 @@
-- tracks `backpackWasOpen` so a bag you already had open is LEFT open on the window's close;
-- benilla stores that on each frame (`wasOpen`). benilla DIVERGES from the ref here on
-- purpose: the real OpenBackpack touches only the backpack, but ours opens EVERY equipped
-- bag with the window (director's call — decision 0561 — matching the 'B' all-bags toggle),
-- bag with the window (director's call — decision 0561; its stated reason was matching the
-- 'B' all-bags toggle, which 1494 split apart — the call itself stands on its own),
-- so the names keep the ref's caller contract while the body loops the five windows. Every
-- step is guarded, so a surface without the bag file — a headless panel test — no-ops
-- rather than erroring.
+8
View File
@@ -358,6 +358,14 @@ ChatFrame.xml
# show/hide), and it must come AFTER SpellBookFrame, whose shift-click reaches
# `BenillaMacroFrame_AddMacroLine`. Before GameMenuFrame, whose Macros button opens it.
MacroFrame.xml
# The script error log (decision 1495): the window `/errors` opens — the list B293's two
# reporters asked for, which the reference's one-message-at-a-time ScriptErrors modal cannot
# be. Ours entirely (1.12 has no counterpart), reading the retained log on the model rather
# than any per-frame feed. Needs ScrollTemplates (BOTH kits — the faux one for the fixed row
# list, the real one for the traceback pane), UIPanelTemplates (the Clear/Close buttons) and
# UiPanels (ShowUIPanel/HideUIPanel + the UIPanelWindows row that seats it and gets it closed
# by ESC). Before GameMenuFrame, which is deliberately last.
ScriptLogFrame.xml
# The Keybindings page MODULE (decision 1008, superseding 0997's standalone window):
# the templates + script of the Options window's Keybindings category, over the
# engine's binding table (benilla_ui::script::keybind + crate::bindings — the 1.12
+98
View File
@@ -1429,4 +1429,102 @@ mod tests {
"a UI wheel is the frame's"
);
}
/// **B opens the backpack, SHIFT-B opens all of them** — the director's report, pinned end to
/// end (1494): a real keyboard event, through the chord table and the registry's shipped
/// defaults, into the binding body, into the Lua that shows the windows.
///
/// This is the seam the bug lived in and nothing else covers it. `bag_tests` drive the Lua
/// globals directly, so they could not see that BOTH chords were registered on one command;
/// the registry tests read the table, so they could not see what the bodies do. Only pressing
/// the key joins the two.
#[test]
fn b_opens_the_backpack_and_shift_b_opens_every_bag() {
let mut script = UiScript::new().expect("VM");
script.register_bindings(&registry_commands());
script.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"UiPanels.xml",
"MerchantFrame.xml",
"Cooldown.xml",
"BagFrame.xml",
] {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.expect("shipped asset");
let doc = benilla_ui::framexml::parse(&text).expect("parse");
let report = benilla_ui::loader::load(&script, &doc, &|_| None);
assert!(report.errors.is_empty(), "{file}: {:?}", report.errors);
}
script.set_money(0);
// The backpack, plus one equipped bag in slot 2 — so "all bags" is two windows and
// "the backpack alone" is visibly different from it.
script.set_container(
0,
Some(benilla_ui::script::ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots: std::collections::HashMap::new(),
}),
);
script.set_container(
2,
Some(benilla_ui::script::ContainerState {
name: Some("Small Pouch".into()),
num_slots: 6,
slots: std::collections::HashMap::new(),
}),
);
let mut app = vm_harness(script);
let shown = |app: &App, name: &str| {
app.world()
.non_send_resource::<UiScript>()
.eval::<bool>(&format!("return {name}:IsShown()"))
.expect("eval")
};
// B — TOGGLEBACKPACK's shipped default. The backpack, and nothing else.
press_key(&mut app, KeyCode::KeyB);
app.update();
release_key(&mut app, KeyCode::KeyB);
app.update();
assert!(shown(&app, "BenillaBagFrame"), "B opens the backpack");
assert!(
!shown(&app, "BenillaBagFrame2"),
"B does NOT open the equipped bag — this is the bug 1494 fixes"
);
// B again: bag 0 is open, so this is the close arm.
press_key(&mut app, KeyCode::KeyB);
app.update();
release_key(&mut app, KeyCode::KeyB);
app.update();
assert!(!shown(&app, "BenillaBagFrame"), "B again shuts it");
// SHIFT-B — OPENALLBAGS' shipped default, a DIFFERENT command with a different body.
press_key(&mut app, KeyCode::ShiftLeft);
press_key(&mut app, KeyCode::KeyB);
app.update();
release_key(&mut app, KeyCode::KeyB);
app.update();
assert!(
shown(&app, "BenillaBagFrame") && shown(&app, "BenillaBagFrame2"),
"SHIFT-B opens every bag"
);
// …and closes them again, the count now reading all-open.
press_key(&mut app, KeyCode::KeyB);
app.update();
release_key(&mut app, KeyCode::KeyB);
release_key(&mut app, KeyCode::ShiftLeft);
app.update();
assert!(
!shown(&app, "BenillaBagFrame") && !shown(&app, "BenillaBagFrame2"),
"SHIFT-B again shuts them all"
);
}
}
+123 -85
View File
@@ -19,8 +19,9 @@
//!
//! Recorded default divergences (0997): `TOGGLEUI` ships `ALT-Z` (0870: the cache's `CTRL-Z` is
//! a player rebind all three accounts inherited, not the shipped default — the one row that does
//! not trust the cache); `OPENALLBAGS` ships `B` + `SHIFT-B` (1.12 splits B/TOGGLEBACKPACK from
//! SHIFT-B/OPENALLBAGS; benilla's one bag knob is the open-all toggle that already lived on B).
//! not trust the cache). That is now the ONLY one — the bag row's divergence (`OPENALLBAGS` wearing
//! both `B` and `SHIFT-B`, because benilla had a single all-bags knob) is gone as of 1494: the
//! reference's split ships whole, keys and bodies alike.
/// A command's index into [`SPECS`] — the engine-side handle (`cmd::JUMP`).
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
@@ -72,39 +73,77 @@ macro_rules! spec {
/// Engine-side handles for the host-dispatched commands (indexes into [`SPECS`], asserted by
/// test). Lua-bodied commands need no handle — nothing in Rust names them.
pub(crate) mod cmd {
use super::Cmd;
pub(crate) const MOVE_AND_STEER: Cmd = Cmd(0);
pub(crate) const MOVE_FORWARD: Cmd = Cmd(1);
pub(crate) const MOVE_BACKWARD: Cmd = Cmd(2);
pub(crate) const TURN_LEFT: Cmd = Cmd(3);
pub(crate) const TURN_RIGHT: Cmd = Cmd(4);
pub(crate) const STRAFE_LEFT: Cmd = Cmd(5);
pub(crate) const STRAFE_RIGHT: Cmd = Cmd(6);
pub(crate) const JUMP: Cmd = Cmd(7);
pub(crate) const SIT_OR_STAND: Cmd = Cmd(8);
pub(crate) const TOGGLE_SHEATH: Cmd = Cmd(9);
pub(crate) const TOGGLE_AUTORUN: Cmd = Cmd(10);
pub(crate) const OPEN_CHAT: Cmd = Cmd(12);
pub(crate) const OPEN_CHAT_SLASH: Cmd = Cmd(13);
pub(crate) const REPLY: Cmd = Cmd(17);
pub(crate) const TARGET_NEAREST_ENEMY: Cmd = Cmd(51);
pub(crate) const TARGET_PREVIOUS_ENEMY: Cmd = Cmd(52);
pub(crate) const NAMEPLATES: Cmd = Cmd(63);
pub(crate) const FRIEND_NAMEPLATES: Cmd = Cmd(64);
pub(crate) const ALL_NAMEPLATES: Cmd = Cmd(65);
pub(crate) const ATTACK_TARGET: Cmd = Cmd(66);
// These three shift whenever a row lands ahead of them in SPECS (1057's `TOGGLECHARACTER3`
// moved them 86/87/88 → 87/88/89; 1136's `TOGGLEACTIONBARLOCK` → 88/89/90).
// `the_cmd_handles_index_their_rows` is what makes that loud and cheap instead of silent: it
// names the handle and the row it actually points at.
pub(crate) const TOGGLE_UI: Cmd = Cmd(88);
pub(crate) const CAMERA_ZOOM_IN: Cmd = Cmd(90);
pub(crate) const CAMERA_ZOOM_OUT: Cmd = Cmd(91);
use super::{Cmd, TABLE};
/// Resolve a command NAME to its [`Cmd`] handle at compile time — a linear scan of [`TABLE`]
/// in a `const fn`, so a name that is not in the registry is a BUILD error, not a runtime
/// surprise.
///
/// These handles used to be hand-written row numbers, and every row that landed ahead of one
/// silently re-pointed it at its neighbour: 1057's `TOGGLECHARACTER3` moved three of them
/// 86/87/88 → 87/88/89, 1136's `TOGGLEACTIONBARLOCK` moved the same three again, and 1494's
/// bag family would have moved them a third time (`TOGGLE_UI` landing on `MINIMAPZOOMOUT`). A
/// guard test named the drift but the numbers still had to be re-typed by hand each time; the
/// name is the thing we actually mean, so the name is what the table is asked for now.
const fn by_name(name: &str) -> Cmd {
let mut i = 0;
while i < TABLE.len() {
if const_str_eq(TABLE[i].name, name) {
return Cmd(i as u16);
}
i += 1;
}
panic!("no such binding command")
}
const fn const_str_eq(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
if a.len() != b.len() {
return false;
}
let mut i = 0;
while i < a.len() {
if a[i] != b[i] {
return false;
}
i += 1;
}
true
}
pub(crate) const MOVE_AND_STEER: Cmd = by_name("MOVEANDSTEER");
pub(crate) const MOVE_FORWARD: Cmd = by_name("MOVEFORWARD");
pub(crate) const MOVE_BACKWARD: Cmd = by_name("MOVEBACKWARD");
pub(crate) const TURN_LEFT: Cmd = by_name("TURNLEFT");
pub(crate) const TURN_RIGHT: Cmd = by_name("TURNRIGHT");
pub(crate) const STRAFE_LEFT: Cmd = by_name("STRAFELEFT");
pub(crate) const STRAFE_RIGHT: Cmd = by_name("STRAFERIGHT");
pub(crate) const JUMP: Cmd = by_name("JUMP");
pub(crate) const SIT_OR_STAND: Cmd = by_name("SITORSTAND");
pub(crate) const TOGGLE_SHEATH: Cmd = by_name("TOGGLESHEATH");
pub(crate) const TOGGLE_AUTORUN: Cmd = by_name("TOGGLEAUTORUN");
pub(crate) const OPEN_CHAT: Cmd = by_name("OPENCHAT");
pub(crate) const OPEN_CHAT_SLASH: Cmd = by_name("OPENCHATSLASH");
pub(crate) const REPLY: Cmd = by_name("REPLY");
pub(crate) const TARGET_NEAREST_ENEMY: Cmd = by_name("TARGETNEARESTENEMY");
pub(crate) const TARGET_PREVIOUS_ENEMY: Cmd = by_name("TARGETPREVIOUSENEMY");
pub(crate) const NAMEPLATES: Cmd = by_name("NAMEPLATES");
pub(crate) const FRIEND_NAMEPLATES: Cmd = by_name("FRIENDNAMEPLATES");
pub(crate) const ALL_NAMEPLATES: Cmd = by_name("ALLNAMEPLATES");
pub(crate) const ATTACK_TARGET: Cmd = by_name("ATTACKTARGET");
pub(crate) const TOGGLE_UI: Cmd = by_name("TOGGLEUI");
pub(crate) const CAMERA_ZOOM_IN: Cmd = by_name("CAMERAZOOMIN");
pub(crate) const CAMERA_ZOOM_OUT: Cmd = by_name("CAMERAZOOMOUT");
}
/// The registry, 1.12 `Bindings.xml` order. Sub-tables (action buttons, shapeshift, raid
/// targets) are written out because each row carries its own Lua body string.
pub(crate) static SPECS: &[Spec] = &[
///
/// `TABLE` is the `const` view [`cmd::by_name`] scans at compile time (a `static` cannot be read
/// during const evaluation); `SPECS` is the one everything else uses.
pub(crate) static SPECS: &[Spec] = TABLE;
const TABLE: &[Spec] = &[
// ── Movement (BINDING_HEADER_MOVEMENT) ──────────────────────────────────────────────
spec!("MOVEANDSTEER", MOVEMENT, Kind::Held, Some("BUTTON3"), None),
spec!("MOVEFORWARD", MOVEMENT, Kind::Held, Some("W"), Some("UP")),
@@ -556,21 +595,64 @@ pub(crate) static SPECS: &[Spec] = &[
Some("C"),
None
),
// 1.12 splits TOGGLEBACKPACK (B, F12) from OPENALLBAGS (SHIFT-B); benilla's one bag knob is
// the open-all toggle that already lived on B — so OPENALLBAGS ships with both defaults
// (recorded divergence, 0997).
// The bag family, in the reference's own `Bindings.xml` order and with its own bodies and
// defaults (1494). 0997 collapsed all of this onto ONE command — `OPENALLBAGS` wearing both
// `B` and `SHIFT-B` over a `ToggleBackpack()` that opened every bag — because benilla had a
// single all-bags knob to hang keys off. The reference has three knobs and the director
// reported the difference: `B` opens the backpack ALONE, `SHIFT-B` opens the lot.
//
// The body is the bare GLOBAL, exactly the ref's own Bindings.xml (`ToggleBackpack();`) —
// NOT the backpack button's OnClick handler it used to run: the handler carries the button's
// checked bookkeeping, and routing the key through it dragged that onto a path the reference
// keeps clean (with a bag addon holding `ToggleBackpack`, the key press is the path that
// lights the backpack button on the real client — the addon's OnShow write is the last word).
// Every default here is byte-real from the client's own `bindings-cache.wtf`, identical across
// all three independent accounts (ONE, TWO, WINUSER) — which is what rules out a player rebind
// (the `TOGGLEUI` trap, 0870).
//
// Each body is the bare GLOBAL, exactly the ref's own `Bindings.xml` — NOT a button's OnClick
// handler: a handler carries the button's checked bookkeeping, and routing a key through it
// drags that onto a path the reference keeps clean (with a bag addon holding `ToggleBackpack`,
// the key press is the path that lights the backpack button on the real client — the addon's
// OnShow write is the last word).
spec!(
"OPENALLBAGS",
"TOGGLEBACKPACK",
INTERFACE,
Kind::Edge("ToggleBackpack()"),
Some("B"),
Some("SHIFT-B")
Some("F12")
),
// TOGGLEBAG**N** is bag **5-N**: the reference's numbering runs the bar right-to-left, so F8
// opens the slot FARTHEST from the backpack. Quoted from ref Bindings.xml l.564-575.
spec!(
"TOGGLEBAG1",
INTERFACE,
Kind::Edge("ToggleBag(4)"),
Some("F8"),
None
),
spec!(
"TOGGLEBAG2",
INTERFACE,
Kind::Edge("ToggleBag(3)"),
Some("F9"),
None
),
spec!(
"TOGGLEBAG3",
INTERFACE,
Kind::Edge("ToggleBag(2)"),
Some("F10"),
None
),
spec!(
"TOGGLEBAG4",
INTERFACE,
Kind::Edge("ToggleBag(1)"),
Some("F11"),
None
),
spec!(
"OPENALLBAGS",
INTERFACE,
Kind::Edge("OpenAllBags()"),
Some("SHIFT-B"),
None
),
spec!(
"TOGGLECHARACTER1",
@@ -1074,43 +1156,6 @@ pub(crate) static SPECS: &[Spec] = &[
mod tests {
use super::*;
/// The named handles in [`cmd`] must index the rows they claim — the table is the truth,
/// the consts are the readable view of it.
#[test]
fn the_cmd_handles_index_their_rows() {
for (handle, name) in [
(cmd::MOVE_AND_STEER, "MOVEANDSTEER"),
(cmd::MOVE_FORWARD, "MOVEFORWARD"),
(cmd::MOVE_BACKWARD, "MOVEBACKWARD"),
(cmd::TURN_LEFT, "TURNLEFT"),
(cmd::TURN_RIGHT, "TURNRIGHT"),
(cmd::STRAFE_LEFT, "STRAFELEFT"),
(cmd::STRAFE_RIGHT, "STRAFERIGHT"),
(cmd::JUMP, "JUMP"),
(cmd::SIT_OR_STAND, "SITORSTAND"),
(cmd::TOGGLE_SHEATH, "TOGGLESHEATH"),
(cmd::TOGGLE_AUTORUN, "TOGGLEAUTORUN"),
(cmd::OPEN_CHAT, "OPENCHAT"),
(cmd::OPEN_CHAT_SLASH, "OPENCHATSLASH"),
(cmd::REPLY, "REPLY"),
(cmd::TARGET_NEAREST_ENEMY, "TARGETNEARESTENEMY"),
(cmd::TARGET_PREVIOUS_ENEMY, "TARGETPREVIOUSENEMY"),
(cmd::NAMEPLATES, "NAMEPLATES"),
(cmd::FRIEND_NAMEPLATES, "FRIENDNAMEPLATES"),
(cmd::ALL_NAMEPLATES, "ALLNAMEPLATES"),
(cmd::ATTACK_TARGET, "ATTACKTARGET"),
(cmd::TOGGLE_UI, "TOGGLEUI"),
(cmd::CAMERA_ZOOM_IN, "CAMERAZOOMIN"),
(cmd::CAMERA_ZOOM_OUT, "CAMERAZOOMOUT"),
] {
assert_eq!(
SPECS[handle.0 as usize].name, name,
"cmd handle {name} points at SPECS[{}] = {}",
handle.0, SPECS[handle.0 as usize].name
);
}
}
/// Every default chord in the table parses — a typo'd token would otherwise silently ship
/// an unpressable default.
#[test]
@@ -1214,13 +1259,6 @@ mod tests {
s.name
);
let ours: Vec<&str> = [s.d1, s.d2].into_iter().flatten().collect();
if s.name == "OPENALLBAGS" {
// The one recorded divergence (0997): 1.12 splits `B`/TOGGLEBACKPACK from
// `SHIFT-B`/OPENALLBAGS, and benilla's single bag knob is the open-all toggle
// that already lived on B — so it ships wearing both.
assert_eq!(ours, ["B", "SHIFT-B"]);
continue;
}
assert_eq!(
ours,
defaults.get(s.name).cloned().unwrap_or_default(),
+44 -4
View File
@@ -31,7 +31,7 @@ use bevy::prelude::*;
use crate::chat_bubble::BubbleConfig;
use crate::minimap::MinimapZoom;
use crate::nameplates::NameConfig;
use crate::player::camera::{LookConfig, ZoomLimit, MOUSE_SPEED_RANGE};
use crate::player::camera::{FollowStyle, LookConfig, ZoomLimit, MOUSE_SPEED_RANGE};
use crate::portrait::PaneRate;
use crate::sound::SoundConfig;
use crate::target::ClickConfig;
@@ -107,6 +107,13 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
// raised — because that IS benilla's shipped 30 yd ceiling, a knowing divergence from the
// reference's registrar "1" that camera.rs has carried in prose since it was written.
("cameraDistanceMaxFactor", "2"),
// Camera Following Style (1493): 1.12's `cameraSmoothStyle` — the auto-follow that swings the
// camera back behind the character. Registered "1" = Smart, which is BOTH the reference's
// registrar default and the director's call; benilla behaved as "3" (Never) unconditionally
// until this row, so the knob is what un-freezes a camera that was frozen by design. The three
// values are the reference dropdown's own (UIOptionsFrameCameraDropDown: 1 Smart, 2 Always,
// 3 Never) — see `FollowStyle`.
("cameraSmoothStyle", "1"),
// Status Text (1140): 1.12's `statusBarText`, the "always show value / max on a status bar"
// switch. **No host knob** — its consumer is Lua (TextStatusBar.xml, decision 1082, which was
// written waiting for this key and reads it on every repaint). Default "0": the reference's
@@ -234,6 +241,7 @@ struct Knobs<'a> {
minimap: &'a mut MinimapZoom,
bubbles: &'a mut BubbleConfig,
zoom: &'a mut ZoomLimit,
follow: &'a mut FollowStyle,
video: &'a mut VideoConfig,
pane_rate: &'a mut PaneRate,
}
@@ -261,6 +269,9 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
"deselectonclick" => knobs.click.deselect_on_click = v != 0.0,
"mouseinvertpitch" => knobs.look.invert_pitch = v != 0.0,
"cameradistancemaxfactor" => knobs.zoom.set_factor(v),
// The three stops are 1 Smart / 2 Always / 3 Never; anything else reads as the registrar
// default rather than as a dead camera (`FollowStyle::from_cvar`).
"camerasmoothstyle" => *knobs.follow = FollowStyle::from_cvar(v),
// The 1.12 slider's own range; an off-grid hand-edit rides between stops, like the others.
"mousespeed" => {
knobs.look.sensitivity = v.clamp(*MOUSE_SPEED_RANGE.start(), *MOUSE_SPEED_RANGE.end());
@@ -321,6 +332,7 @@ fn load_config(
mut minimap: ResMut<MinimapZoom>,
mut bubbles: ResMut<BubbleConfig>,
mut zoom: ResMut<ZoomLimit>,
mut follow: ResMut<FollowStyle>,
mut video: ResMut<VideoConfig>,
mut pane_rate: ResMut<PaneRate>,
) {
@@ -336,6 +348,7 @@ fn load_config(
minimap: &mut minimap,
bubbles: &mut bubbles,
zoom: &mut zoom,
follow: &mut follow,
video: &mut video,
pane_rate: &mut pane_rate,
};
@@ -414,6 +427,7 @@ fn sync_cvars(
mut minimap: ResMut<MinimapZoom>,
mut bubbles: ResMut<BubbleConfig>,
mut zoom: ResMut<ZoomLimit>,
mut follow: ResMut<FollowStyle>,
mut video: ResMut<VideoConfig>,
mut pane_rate: ResMut<PaneRate>,
) {
@@ -435,7 +449,7 @@ fn sync_cvars(
);
script.register_cvars(REGISTERED.iter().copied());
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
let session: [(&str, String); 25] = [
let session: [(&str, String); 26] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -450,6 +464,7 @@ fn sync_cvars(
("mouseInvertPitch", flag(look.invert_pitch)),
("mousespeed", look.sensitivity.to_string()),
("cameraDistanceMaxFactor", zoom.factor().to_string()),
("cameraSmoothStyle", follow.cvar().to_string()),
("autoLootDefault", flag(loot.auto_loot)),
("UnitNamePlayer", flag(names.player)),
("UnitNameNPC", flag(names.npc)),
@@ -488,6 +503,7 @@ fn sync_cvars(
minimap: &mut minimap,
bubbles: &mut bubbles,
zoom: &mut zoom,
follow: &mut follow,
video: &mut video,
pane_rate: &mut pane_rate,
};
@@ -532,6 +548,7 @@ pub(crate) fn fold_dying_vm_cvars(world: &mut World) {
Option<ResMut<MinimapZoom>>,
Option<ResMut<BubbleConfig>>,
Option<ResMut<ZoomLimit>>,
Option<ResMut<FollowStyle>>,
Option<ResMut<VideoConfig>>,
Option<ResMut<PaneRate>>,
)> = bevy::ecs::system::SystemState::new(world);
@@ -549,6 +566,7 @@ pub(crate) fn fold_dying_vm_cvars(world: &mut World) {
minimap,
bubbles,
zoom,
follow,
video,
pane_rate,
) = state.get_mut(world);
@@ -572,11 +590,12 @@ pub(crate) fn fold_dying_vm_cvars(world: &mut World) {
Some(mut minimap),
Some(mut bubbles),
Some(mut zoom),
Some(mut follow),
Some(mut video),
Some(mut pane_rate),
) = (
sound, scale, view, look, click, loot, names, clutter, minimap, bubbles, zoom, video,
pane_rate,
sound, scale, view, look, click, loot, names, clutter, minimap, bubbles, zoom, follow,
video, pane_rate,
) {
let mut knobs = Knobs {
sound: &mut sound,
@@ -590,6 +609,7 @@ pub(crate) fn fold_dying_vm_cvars(world: &mut World) {
minimap: &mut minimap,
bubbles: &mut bubbles,
zoom: &mut zoom,
follow: &mut follow,
video: &mut video,
pane_rate: &mut pane_rate,
};
@@ -736,6 +756,14 @@ mod tests {
);
assert_eq!(d["mousespeed"], LookConfig::default().sensitivity);
assert_eq!(d["cameraDistanceMaxFactor"], ZoomLimit::default().factor());
// The following style (1493) welds to the enum's own Default — and BOTH sides are the
// reference's registrar "1" = Smart, the one row in this arc that agrees with the binary
// outright.
assert_eq!(
d["cameraSmoothStyle"],
FollowStyle::default().cvar().parse::<f32>().unwrap()
);
assert_eq!(FollowStyle::default(), FollowStyle::Smart);
assert_eq!(d["autoLootDefault"] != 0.0, LootConfig::default().auto_loot);
// The name trio (0992) welds to NameConfig's defaults the same way.
let names = NameConfig::default();
@@ -782,6 +810,7 @@ mod tests {
let mut minimap = MinimapZoom::default();
let mut bubbles = BubbleConfig::default();
let mut zoom = ZoomLimit::default();
let mut follow = FollowStyle::default();
let mut video = VideoConfig::default();
let mut pane_rate = PaneRate::default();
let mut knobs = Knobs {
@@ -796,6 +825,7 @@ mod tests {
minimap: &mut minimap,
bubbles: &mut bubbles,
zoom: &mut zoom,
follow: &mut follow,
video: &mut video,
pane_rate: &mut pane_rate,
};
@@ -821,6 +851,14 @@ mod tests {
assert_eq!(knobs.look.sensitivity, 1.4);
assert!(apply_to_knobs("mousespeed", "9", &mut knobs));
assert_eq!(knobs.look.sensitivity, 1.5);
// The following style lands as the enum, on the reference's own numbering — and an
// off-grid value reads as the registrar default rather than as a dead camera.
assert!(apply_to_knobs("cameraSmoothStyle", "3", &mut knobs));
assert_eq!(*knobs.follow, FollowStyle::Never);
assert!(apply_to_knobs("camerasmoothstyle", "2", &mut knobs));
assert_eq!(*knobs.follow, FollowStyle::Always);
assert!(apply_to_knobs("cameraSmoothStyle", "0", &mut knobs));
assert_eq!(*knobs.follow, FollowStyle::Smart);
// The max-orbit factor lands as YARDS on the knob (base 15 x factor), clamped to 1..2.
assert!(apply_to_knobs("cameraDistanceMaxFactor", "1", &mut knobs));
assert_eq!(knobs.zoom.max, 15.0);
@@ -915,6 +953,7 @@ mod tests {
.init_resource::<MinimapZoom>()
.init_resource::<BubbleConfig>()
.init_resource::<ZoomLimit>()
.init_resource::<FollowStyle>()
.init_resource::<VideoConfig>()
.init_resource::<PaneRate>()
.add_plugins(CvarPlugin);
@@ -985,6 +1024,7 @@ mod tests {
.init_resource::<MinimapZoom>()
.init_resource::<BubbleConfig>()
.init_resource::<ZoomLimit>()
.init_resource::<FollowStyle>()
.init_resource::<VideoConfig>()
.init_resource::<PaneRate>()
.add_plugins(CvarPlugin);
+40 -5
View File
@@ -10,8 +10,10 @@
//! forward** (vanilla's "both-button move"), steering with the mouse like a right-drag. The **scroll
//! wheel** zooms the third-person
//! distance (clamped to the vanilla `cameraDistanceMax` range; the camera *glides* to the new
//! distance). A left-drag orbit offset *persists* — the vanilla `cameraSmoothStyle` auto-follow that
//! swung the camera back behind the character while moving is deliberately removed (director's call).
//! distance). A left-drag orbit offset is reeled back in by the **auto-follow** — vanilla's
//! `cameraSmoothStyle`, a real player setting since decision 1493 ([`camera::FollowStyle`]):
//! Smart (the shipped default) chases while the character moves, Always chases always, Never
//! leaves the camera exactly where the hand put it.
//! The **dev chord + `F`** toggles free-fly (1043); the **dev chord + `G`** lands the avatar where
//! the camera is ([`land`]).
//!
@@ -187,6 +189,7 @@ impl Plugin for PlayerPlugin {
);
app.init_resource::<camera::LookConfig>();
app.init_resource::<camera::ZoomLimit>();
app.init_resource::<camera::FollowStyle>();
// Far sight: resolve `PLAYER_FARSIGHT` into a pose before `control` reads it to seat the
// camera. A separate system rather than another query on `control` for a hard reason —
// `control` already holds the self entity's `Transform` mutably, so it cannot also read an
@@ -340,13 +343,15 @@ fn control(
buttons: Res<ButtonInput<MouseButton>>,
// Nested into one param to stay within Bevy's 16-element system-param tuple limit. (The
// scroll wheel left this tuple with 0997: zoom reads the CAMERAZOOM bindings now.)
// The pointer's motion this frame + the two knobs that scale what it does with it. Bundled
// because a Bevy system takes at most 16 parameters and this one is at the ceiling — the
// grouping is the existing `mouse` tuple, widened rather than a seventeenth argument.
// The pointer's motion this frame + the camera knobs that scale what it does with it — the
// two look/zoom knobs and the auto-follow style (decision 1493). Bundled because a Bevy system
// takes at most 16 parameters and this one is at the ceiling — the grouping is the existing
// `mouse` tuple, widened rather than a seventeenth argument.
pointer: (
Res<AccumulatedMouseMotion>,
Res<camera::LookConfig>,
Res<camera::ZoomLimit>,
Res<camera::FollowStyle>,
),
// The net bridge, bundled into one param (16-param limit): the outbound command channel + the
// inbound teleport/worldport messages `apply_net_updates` wrote earlier this frame
@@ -488,6 +493,15 @@ fn control(
let view_subject = &speed_capsule.8;
let self_guid = speed_capsule.9 .0;
let scoped = &speed_capsule.10;
// The auto-follow style (decision 1493), with far sight's one exception folded in here so both
// camera seats below agree: while the rig orbits somebody ELSE's body (Mind Vision, Sentry
// Totem), our own facing is not what "behind" means, so the chase is forced off rather than
// reeling that camera toward a heading with nothing to do with the view.
let follow_style = if view_subject.remote.is_some() {
camera::FollowStyle::Never
} else {
*pointer.3
};
let dt = time.delta_secs();
// While a focused UI EditBox (the chat input, a mail field) owns the keyboard, keyboard reads see
// "no keys held" — so the avatar isn't also driven while typing (a `.tele` command). Mouse still
@@ -718,9 +732,17 @@ fn control(
// which reads as the view detaching into free flight (director, 2026-08-13). A
// `reseat` window is excluded — there the resource still describes the body we are
// letting go of, and `apply_server_moves` owns the adoption.
//
// The same sync answers the auto-follow's Smart gate (1493): is the body we are NOT
// driving nevertheless moving? A taxi, a Charge, a knockback or a fear all translate
// the avatar while the controller stands down, and a camera that ignored them would
// sit at the angle you left it while the body ran out from under it. A spline ride is
// translation by definition; a driven body is measured against the pose we held.
let mut driven_moving = player.server_riding;
if player.control_lost && !player.reseat {
if let Ok((_, t, ..)) = body.single() {
let yaw = server_ride::yaw_of(t.rotation);
driven_moving |= t.translation.distance_squared(player.pos) > 1.0e-6;
player.pos = t.translation;
player.face_yaw = yaw;
player.model_yaw = yaw;
@@ -750,6 +772,11 @@ fn control(
&mut cam_t,
&collide,
cam_probe,
&camera::FollowInput {
style: follow_style,
face_yaw: player.face_yaw,
moving: driven_moving,
},
);
// Flush a stale run once, so observers stop extrapolating it — but never under a ride,
// whose FORWARD report is deliberate and would be cancelled every frame.
@@ -1679,6 +1706,13 @@ fn control(
// — the deck's yaw delta was already applied to `cam.yaw` at the ride block (frame motion
// carries the camera unconditionally; only input turns respect `seat_camera`'s
// look-session gate).
// The auto-follow's own three facts (decision 1493): the player's style, where "behind"
// is, and whether the character is translating — Smart's gate.
let follow = camera::FollowInput {
style: follow_style,
face_yaw: player.face_yaw,
moving,
};
seat_camera(
dt,
turn_delta,
@@ -1690,6 +1724,7 @@ fn control(
&mut cam_t,
&collide,
cam_probe,
&follow,
);
// The cast bar's local self-cancel trigger (`ui_cast::local_self_cancel`): a fresh
+142 -5
View File
@@ -12,6 +12,7 @@ use bevy::window::{CursorGrabMode, CursorOptions};
use avian3d::prelude::*;
use crate::creature_anim::wrap_pi;
use crate::net::Embodied;
use benilla_assets::materials::WowModelMaterial;
use benilla_world::interact::{WorldClick, WorldRightClick, WorldRightPress};
@@ -194,6 +195,95 @@ impl LookConfig {
LOOK_SENSITIVITY * self.sensitivity
}
}
/// The auto-follow's angular rate — 1.12's `cameraYawSmoothSpeed`, whose registrar default is
/// **180 °/s** (`WoW.exe` `[0xbe1070]`, the yaw transition channel's own CVar float — wow-re
/// `ui/scratch/camera-control.md`). The reference exposes it as the AUTO_FOLLOW_SPEED slider
/// (`UIOptionsFrameSliders`, 90 … 270 °/s by 10) and *disables that slider* when the style is
/// Never; benilla ships the registrar default as a constant, since it has no row for it yet.
const FOLLOW_RATE: f32 = std::f32::consts::PI;
/// **Camera Following Style** — 1.12's `cameraSmoothStyle` (decision 1493): does the camera swing
/// back behind the character on its own?
///
/// benilla shipped the reference behaviour *removed* from the day the camera was written (the
/// orbit offset simply persisted, a director's call) — this is the setting that gives it back, and
/// the director's call now is the reference's own default: **Smart**.
///
/// The three values are the reference's, dropdown order and all (`UIOptionsFrameCameraDropDown`,
/// which writes this CVar): `"1"` Smart, `"2"` Always, `"3"` Never — note that Never is **3**, not
/// 0. The registrar default is `"1"`.
#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug, Default)]
pub(crate) enum FollowStyle {
/// Stay where placed, *except while the character is moving* — the reference's own
/// "(Recommended Mode)" and its registrar default.
#[default]
Smart,
/// Always prefer being behind the character.
Always,
/// Never auto-adjust: the orbit offset stays exactly where the hand left it. This is what
/// benilla did unconditionally before 1493.
Never,
}
impl FollowStyle {
/// From the CVar's number. Anything off the three stops reads as the registrar default —
/// a hand-edited config or an addon's stray write gets the shipped behaviour, not a dead
/// camera.
pub(crate) fn from_cvar(v: f32) -> Self {
match v as i32 {
2 => Self::Always,
3 => Self::Never,
_ => Self::Smart,
}
}
/// The CVar string this style is — the value the table and `config.toml` carry.
pub(crate) fn cvar(self) -> &'static str {
match self {
Self::Smart => "1",
Self::Always => "2",
Self::Never => "3",
}
}
/// Should the camera chase the character's facing this frame? `moving` is the character's own
/// translation intent (Smart's gate — the reference's "except when your character is moving").
fn chases(self, moving: bool) -> bool {
match self {
Self::Always => true,
Self::Smart => moving,
Self::Never => false,
}
}
}
/// What [`seat_camera`] needs to run the auto-follow this frame — the style, where "behind" is,
/// and whether the character is moving. Bundled because the three are one question, and
/// `seat_camera` is already at its argument ceiling.
pub(super) struct FollowInput {
pub(super) style: FollowStyle,
/// The character's own facing (same yaw convention as [`FlyCam::yaw`] — a right-drag couples
/// the two directly).
pub(super) face_yaw: f32,
/// Is the character translating this frame? (Turn-in-place is not movement here: a keyboard
/// turn already carries the camera rigidly, so there is no offset left for the chase.)
pub(super) moving: bool,
}
impl FollowInput {
/// The yaw this frame's chase adds — the shortest arc to the character's facing, capped at
/// [`FOLLOW_RATE`]·dt so the camera *slews* rather than snapping, and exactly zero on a style
/// (or a stillness) that isn't chasing. The cap stops **at** the target: the clamp can never
/// overshoot, so a settled camera writes nothing and the no-op gate downstream stays quiet.
fn step(&self, cam_yaw: f32, dt: f32) -> f32 {
if !self.style.chases(self.moving) {
return 0.0;
}
let step = FOLLOW_RATE * dt;
wrap_pi(self.face_yaw - cam_yaw).clamp(-step, step)
}
}
/// Camera pitch clamp (radians) — **VERIFIED ±89.00°** (`WoW.exe` `0x8089d8`/`0x8089dc` =
/// 1.5533430576 rad; the pitch integrate `FUN_00510120`, wow-re `follow-camera`). A single uniform
/// clamp at every zoom level — the reference has **no** distinct first-person look-down limit.
@@ -530,7 +620,8 @@ pub(super) fn apply_zoom_scroll(scroll: f32, dt: f32, rig: &mut CameraControl, m
/// deck turning under the rider is frame motion and is applied to `cam.yaw` at the ride block in
/// [`super::control`], bypassing this function's look-session gate (routing it here was the
/// right-drag drift bug — the gate ate the deck's share while a drag was held). A left-drag orbit
/// offset otherwise **persists** (no auto-follow — director's call, see the body).
/// offset is then reeled back in by the **auto-follow**, on the player's `cameraSmoothStyle`
/// setting ([`FollowStyle`], decision 1493) — or kept forever, on Never.
/// `head`/`player_pos` are precomputed by [`super::control`] (which owns the avatar capsule
/// constants); `cam_pivot_height` is the world pivot height it derived from [`CameraPivot`] this
/// frame.
@@ -546,6 +637,7 @@ pub(super) fn seat_camera(
cam_t: &mut Mut<Transform>,
collide: &benilla_world::collision::WorldCollision<'_, '_>,
cam_probe: &Collider,
follow: &FollowInput,
) {
// A keyboard turn carries the camera RIGIDLY (char and camera rotate as one — the reference
// look, director's call closing 0050's open "camera follow on turn"): an eased chase of a
@@ -554,12 +646,14 @@ pub(super) fn seat_camera(
// — no INPUT-turn carry against the user's hand. (A transport deck's turn is not an input and
// never arrives here — the ride block applies it to `cam.yaw` directly, drag or no drag.)
//
// A left-drag orbit offset now **persists** once released: the vanilla `cameraSmoothStyle`
// auto-follow that eased the camera back behind the character while moving/turning is removed
// (director's call — we don't want it even though it's faithful). The camera stays where you
// put it; only a fresh drag, or a right-drag/movement that resyncs `face_yaw`, moves it.
// **The auto-follow** (decision 1493, 1.12's `cameraSmoothStyle`) rides the same gate for the
// same reason — a held drag owns the camera, hand on it: swing the camera back behind the
// character, along the shortest arc, at [`FOLLOW_RATE`]. Which frames it runs on is the
// player's setting — Always every frame, Smart only while the character is translating, Never
// not at all (the behaviour benilla shipped unconditionally until now).
if rig.look.is_none() {
cam.yaw += turn_delta;
cam.yaw += follow.step(cam.yaw, dt);
}
// Orient the camera, then orbit it behind the avatar's torso. The framing **pivot** is
// `feet + cam_pivot_height` (model-derived, ~neck height — [`CameraPivot`]); the camera looks at
@@ -1016,6 +1110,49 @@ mod tests {
assert_eq!(slow.rate(), LOOK_SENSITIVITY * 0.5);
}
/// **The auto-follow** (decision 1493) — 1.12's `cameraSmoothStyle`, the setting benilla spent
/// its whole life behaving as "Never". Four properties, and the last two are the ones a
/// re-derivation gets wrong: the style gate (Smart is *movement*-gated, Always is not, Never is
/// never), the rate cap, the fact that the chase takes the SHORT way round the ±π seam, and
/// that it stops exactly ON the facing rather than oscillating across it.
#[test]
fn the_auto_follow_chases_on_the_styles_that_chase_and_never_overshoots() {
const DT: f32 = 1.0 / 60.0;
let step = |style, moving, face_yaw, cam_yaw| {
FollowInput {
style,
face_yaw,
moving,
}
.step(cam_yaw, DT)
};
// Never is inert — the camera stays exactly where the hand left it, moving or not.
assert_eq!(step(FollowStyle::Never, true, 1.0, 0.0), 0.0);
// Smart is gated on the character actually translating ("except when your character is
// moving"); Always is not.
assert_eq!(step(FollowStyle::Smart, false, 1.0, 0.0), 0.0);
assert!(step(FollowStyle::Smart, true, 1.0, 0.0) > 0.0);
assert!(step(FollowStyle::Always, false, 1.0, 0.0) > 0.0);
// A big gap slews at the rate, not in one frame.
let capped = step(FollowStyle::Always, true, 3.0, 0.0);
assert!((capped - FOLLOW_RATE * DT).abs() < 1.0e-6, "{capped}");
// A gap smaller than one frame's slew lands ON the facing — no overshoot, and the next
// frame writes exactly nothing (which is what keeps the no-op transform gate quiet).
let small = 0.5 * FOLLOW_RATE * DT;
let landed = step(FollowStyle::Always, true, small, 0.0);
assert!((landed - small).abs() < 1.0e-6, "{landed} vs {small}");
assert_eq!(step(FollowStyle::Always, true, 0.0, 0.0), 0.0);
// Across the ±π seam the chase takes the short way: a facing at −3.1 from a camera at 3.1
// is 0.08 rad AHEAD, not 6.2 rad behind.
assert!(
step(FollowStyle::Always, true, -3.1, 3.1) > 0.0,
"the shortest arc, not the long way round"
);
}
/// The second arm: between 200 and 800 world the travel gate applies, per axis and independently
/// (`0x514ae0`'s `both accums < 8.0` arm). The thresholds are the reference's 2.25° of yaw and
/// 2.0° of pitch — see [`CLICK_HOLD_CEILING`] for why we hold the angle, not the raw literal.
+19 -1
View File
@@ -93,6 +93,13 @@ pub(crate) enum SlashIndex {
/// `GlobalStrings.lua` has no `SLASH_RELOADUI`), so this alias is a literal in [`Self::build`]
/// rather than data read off the chain, exactly like the ESC-menu AddOns window is ours (1197).
ReloadUi,
/// `/errors` `/err` — **benilla's own addition** (decision 1495): opens the script error log,
/// which is ours because 1.12 has no such window to alias. The reference's whole answer to a
/// Lua fault is the `ScriptErrors` modal, which shows a burst's first message and remembers
/// nothing; B293 is two reporters asking for the list that modal cannot be. **Player-facing,
/// not a `DevCmd`**: the people who need it are the people running addons, and gating it on a
/// dev build would leave exactly the reporters who asked unable to type it.
ScriptErrors,
}
impl SlashIndex {
@@ -137,13 +144,16 @@ impl SlashIndex {
// No shipped alias string exists under this key (the walk finds nothing); the `/reload`
// alias is inserted as a literal in `build` — see the variant's doc.
Self::ReloadUi => "RELOADUI",
// No shipped `SLASH_BENILLASCRIPTERRORS1` exists — the aliases are literals in
// `build`. The key is still ours to name so the variant round-trips like any other.
Self::ScriptErrors => "BENILLASCRIPTERRORS",
}
}
/// Every registered index — the registry proper. A reference command NOT in this list resolves
/// nowhere and answers `HELP_TEXT_SIMPLE`, exactly as an unknown command does in the reference
/// (better than a registered handler that silently does nothing).
const ALL: [Self; 36] = [
const ALL: [Self; 37] = [
Self::Reply,
Self::Join,
Self::Leave,
@@ -180,6 +190,7 @@ impl SlashIndex {
Self::MacroHelp,
Self::Console,
Self::ReloadUi,
Self::ScriptErrors,
];
}
@@ -297,6 +308,13 @@ impl SlashCommands {
"reload",
Command::Slash(SlashIndex::ReloadUi),
);
for alias in ["errors", "err"] {
insert(
&mut by_alias,
alias,
Command::Slash(SlashIndex::ScriptErrors),
);
}
let added_aliases = by_alias.len() - slash_aliases - emote_aliases;
// 4 · benilla's instruments, last so they can never shadow a shipped command.
@@ -400,6 +400,18 @@ fn slash_command(index: SlashIndex, args: &str) -> ParsedChat {
// The console command match is case-insensitive like the engine's console; `0x4035f0`
// reads no arguments, so anything after `reloadUI` is ignored rather than an error.
S::ReloadUi => ParsedChat::ReloadUi,
// `/errors` (ours, 1495) → the script error log. Routed as Lua rather than a new
// `ParsedChat` variant for the same reason `/macro` is: the window is FrameXML, the
// toggle is a FrameXML function, and a Rust arm would only forward to it.
// `/errors clear` empties the log — the reporter's workflow is clear, reproduce,
// screenshot, and without it the shot carries a session's worth of unrelated rows.
S::ScriptErrors => ParsedChat::Lua {
body: if args.trim().eq_ignore_ascii_case("clear") {
"BenillaScriptLog_Clear()".into()
} else {
"BenillaScriptLog_Toggle()".into()
},
},
S::Console => match args.split_whitespace().next() {
Some(cmd) if cmd.eq_ignore_ascii_case("reloadui") => ParsedChat::ReloadUi,
_ => ParsedChat::ConsoleUnknown {
+5 -2
View File
@@ -1368,8 +1368,11 @@ fn real_alias_table_resolves_the_shipped_commands() {
// 1291 added CONSOLE's `/console` — one distinct alias, SLASH_CONSOLE1 and 2 are both the
// same string).
//
// The third number is benilla's own player-facing additions (1291): `/reload` — present in
// The third number is benilla's own player-facing additions: `/reload` (1291) and
// `/errors` `/err` (1495, the script error log) — 3 aliases over 2 commands. Present in
// every build, deliberately counted apart from the shipped surface so the seam stays visible.
// The error log is player-facing on purpose and NOT an instrument: gating it on
// `dev_affordances()` would leave exactly the reporters who asked for it unable to type it.
//
// The fourth is the instrument **seam** (decision 1179): benilla's own instrument commands
// (`/castvis` `/chattest` `/partytest` `/shot` `/liquid` `/reaction` `/react` — 7 aliases over 6
@@ -1383,7 +1386,7 @@ fn real_alias_table_resolves_the_shipped_commands() {
};
assert_eq!(
table.counts(),
(68, 225, 1, instruments),
(68, 225, 3, instruments),
"(slash, emote, benilla addition, instrument) aliases"
);
}
@@ -196,6 +196,10 @@ impl Addon {
Source::Builtin => error!("ui_script: {e}"),
Source::Dir(_) => warn!("ui_script: {e}"),
}
// Retained where the player can read it (1495). This is the single commonest way
// an addon "doesn't work" with nothing on screen — the director's own AtlasLoot
// copy lists `Bossnames\BossNames.xml` and ships no such folder.
script.report_load_failure(&e);
failures.push(e);
continue;
};
@@ -225,6 +229,10 @@ impl Addon {
Err(e) => {
let e = format!("{}/{file}: {e}", self.name);
error!("ui_script: parsing {e}");
// Still log-only as far as the *dialog* goes — the reference answers an
// unparseable document with a FrameXML.log line and silence, and 1495 does not
// change that. What it changes is that the silence is no longer total.
script.report_load_failure(&e);
failures.push(e);
continue;
}
@@ -808,6 +816,7 @@ impl Walk {
let chain = self.loading.join(" → ");
let e = format!("{key}: dependency cycle ({chain} → {key})");
error!("ui_script: {e}");
script.report_load_failure(&e);
self.failures.push(e);
self.failed.insert(addon.name.clone());
return Err(());
@@ -835,6 +844,9 @@ impl Walk {
addon.name
);
error!("ui_script: {e}");
// The one failure a player can usually FIX themselves — install the dependency — and
// until 1495 the only place it was said was the terminal.
script.report_load_failure(&e);
self.failures.push(e);
self.failed.insert(addon.name.clone());
return Err(());
@@ -852,6 +864,7 @@ impl Walk {
Err(e) => {
let e = format!("{}/Bindings.xml: {e}", addon.name);
error!("ui_script: {e}");
script.report_load_failure(&e);
self.failures.push(e);
}
}
+160 -40
View File
@@ -115,12 +115,19 @@ fn backpack_toggle_plays_open_and_close_kits() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The 'B' binding / backpack button opens or closes EVERY bag at once (ref ToggleBackpack), not
/// just the backpack: an equipped bag (here bag 2) opens alongside it, while empty bag slots (1,3,4
/// — no container, GetContainerNumSlots == 0) stay shut. A second toggle closes them all. And ESC's
/// CloseAllWindows must sweep all of them, not only the backpack.
/// **B / the backpack button open the BACKPACK ALONE** (ref ToggleBackpack, ContainerFrame.lua
/// l.67-82) — the director's report, and the fix in 1494. Until then both ran an all-bags toggle
/// and an equipped bag came up beside the backpack every time.
///
/// The reference's shape is deliberately asymmetric, and all three arms are pinned here:
/// * shut → `ToggleBag(0)`: bag 0 and nothing else, however many bags are equipped;
/// * bag 0 OPEN → hide every container window there is (the close arm is the all-bags one);
/// * bag 0 shut but ANOTHER bag open → the condition reads bag 0 specifically, so this is still
/// the open arm: the backpack joins the open bag rather than closing it.
///
/// ESC's CloseAllWindows must still sweep all of them.
#[test]
fn b_toggles_all_bags_open_and_closed() {
fn b_opens_the_backpack_alone_and_closes_every_bag() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
@@ -151,40 +158,141 @@ fn b_toggles_all_bags_open_and_closed() {
let shown =
|s: &mut UiScript, name: &str| s.eval::<bool>(&format!("return {name}:IsShown()")).unwrap();
// Toggle open: backpack + bag 2 open; the empty slots do not.
// Toggle open: the BACKPACK, and only the backpack — bag 2 is equipped and stays shut.
s.run("BenillaBagToggle_OnClick()").unwrap();
let _ = s.take_sounds();
assert!(shown(&mut s, "BenillaBagFrame"), "backpack opens");
assert!(
shown(&mut s, "BenillaBagFrame2"),
"the equipped bag opens too"
!shown(&mut s, "BenillaBagFrame2"),
"the equipped bag stays shut — this is the backpack's own toggle, not open-all"
);
assert!(
!shown(&mut s, "BenillaBagFrame1")
&& !shown(&mut s, "BenillaBagFrame3")
&& !shown(&mut s, "BenillaBagFrame4"),
"and the empty slots have no window to show either way"
);
// The close arm IS the all-bags one: with bag 2 also open by some other path, one toggle
// takes the lot down.
s.run("ToggleBag(2)").unwrap();
assert!(shown(&mut s, "BenillaBagFrame2"));
s.run("BenillaBagToggle_OnClick()").unwrap();
let _ = s.take_sounds();
assert!(
!shown(&mut s, "BenillaBagFrame") && !shown(&mut s, "BenillaBagFrame2"),
"bag 0 open ⇒ the toggle hides every container window"
);
// Bag 0 shut, bag 2 open: the ref's condition reads bag 0, so this is the OPEN arm — the
// backpack joins the bag already up instead of closing it.
s.run("ToggleBag(2)").unwrap();
s.run("BenillaBagToggle_OnClick()").unwrap();
let _ = s.take_sounds();
assert!(
shown(&mut s, "BenillaBagFrame") && shown(&mut s, "BenillaBagFrame2"),
"with only another bag open, B opens the backpack beside it"
);
// ESC's CloseAllWindows sweeps every open bag, not just the backpack.
s.run("CloseAllWindows()").unwrap();
assert!(
!shown(&mut s, "BenillaBagFrame") && !shown(&mut s, "BenillaBagFrame2"),
"CloseAllWindows hides every bag window"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **SHIFT-B / a shift-click on the backpack button open ALL of them** (ref OpenAllBags,
/// ContainerFrame.lua l.662-700) — the other half of the split 1494 restored. It is a TOGGLE, and
/// its arms are decided by a COUNT, not by "is anything open":
/// * anything less than everything open → open the lot (the backpack plus each equipped bag);
/// * everything already open → the counting pass IS the close;
/// * `forceOpen` → always the open arm (what a vendor window would pass).
///
/// The keyring is swept by the counting pass but never counted and never opened — the reference's
/// own `GetID() ~= KEYRING_CONTAINER` exclusion.
#[test]
fn shift_b_toggles_every_bag_at_once() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
s.set_money(0);
s.set_container(
0,
Some(ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots: std::collections::HashMap::new(),
}),
);
s.set_container(
2,
Some(ContainerState {
name: Some("Small Pouch".into()),
num_slots: 6,
slots: std::collections::HashMap::new(),
}),
);
let shown =
|s: &mut UiScript, name: &str| s.eval::<bool>(&format!("return {name}:IsShown()")).unwrap();
// From nothing open: the backpack AND the equipped bag; the empty slots have no window.
s.run("OpenAllBags()").unwrap();
let _ = s.take_sounds();
assert!(shown(&mut s, "BenillaBagFrame") && shown(&mut s, "BenillaBagFrame2"));
assert!(
!shown(&mut s, "BenillaBagFrame1")
&& !shown(&mut s, "BenillaBagFrame3")
&& !shown(&mut s, "BenillaBagFrame4"),
"empty bag slots (no container) have no window to show"
);
assert!(
!shown(&mut s, "BenillaKeyRingFrame"),
"the keyring is not one of your bags — open-all never opens it"
);
// Toggle again: every open bag closes.
s.run("BenillaBagToggle_OnClick()").unwrap();
// Everything open ⇒ the next one closes the lot.
s.run("OpenAllBags()").unwrap();
let _ = s.take_sounds();
assert!(!shown(&mut s, "BenillaBagFrame") && !shown(&mut s, "BenillaBagFrame2"));
// PARTIAL is the open arm, not the close arm — the count is what decides. Backpack alone up
// (1 of 2) ⇒ open-all still opens rather than closing what is there.
s.run("ToggleBag(0)").unwrap();
s.run("OpenAllBags()").unwrap();
let _ = s.take_sounds();
assert!(
shown(&mut s, "BenillaBagFrame") && shown(&mut s, "BenillaBagFrame2"),
"1 of 2 open is not 'all open': the lot opens"
);
// An open keyring rides the sweep down without ever counting toward "all open" — so this
// still reads 2-of-2 and closes.
s.run("ToggleKeyRing()").unwrap();
assert!(shown(&mut s, "BenillaKeyRingFrame"));
s.run("OpenAllBags()").unwrap();
let _ = s.take_sounds();
assert!(
!shown(&mut s, "BenillaBagFrame") && !shown(&mut s, "BenillaBagFrame2"),
"the second toggle closes them all"
"the keyring does not count as an open bag"
);
assert!(
!shown(&mut s, "BenillaKeyRingFrame"),
"…but it IS swept by the same pass"
);
// ESC's CloseAllWindows sweeps every open bag, not just the backpack.
s.run("BenillaBagToggle_OnClick()").unwrap();
// forceOpen skips the close arm: from all-open, everything stays open.
s.run("OpenAllBags(1)").unwrap();
s.run("OpenAllBags(1)").unwrap();
let _ = s.take_sounds();
assert!(
shown(&mut s, "BenillaBagFrame2"),
"reopened for the ESC check"
);
s.run("CloseAllWindows()").unwrap();
assert!(
!shown(&mut s, "BenillaBagFrame") && !shown(&mut s, "BenillaBagFrame2"),
"CloseAllWindows hides every bag window"
shown(&mut s, "BenillaBagFrame") && shown(&mut s, "BenillaBagFrame2"),
"forceOpen means open, never toggle"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -223,8 +331,9 @@ fn bag_bar_buttons_light_while_their_bag_is_open() {
.unwrap()
};
// Open-all: the backpack button and the equipped bag's slot light; the empty slots don't.
s.run("BenillaBagToggle_OnClick()").unwrap();
// Open-all (SHIFT-B's knob since 1494 — the backpack button alone would light only its own
// ring now): the backpack button and the equipped bag's slot light; the empty slots don't.
s.run("OpenAllBags()").unwrap();
assert!(
checked(&mut s, "MainMenuBarBackpackButton"),
"backpack ring lights"
@@ -1944,7 +2053,7 @@ fn an_addon_that_hooks_toggle_backpack_receives_the_click() {
)
.unwrap();
// The button's own OnClick path — what the 'B' binding and the backpack button both run.
// The button's own OnClick path — what the B binding and a plain backpack-button click run.
s.run("BenillaBagToggle_OnClick()").unwrap();
assert_eq!(
@@ -2022,13 +2131,15 @@ fn the_bag_slots_carry_the_references_names_and_icon_names() {
/// The backpack button's checked law after the stated divergence (BenillaBagToggle_OnClick's
/// comment): checked belongs to the WINDOWS' OnShow/OnHide writes, and the button's XML click
/// wrapper only undoes the CheckButton widget's pre-handler flip. The killer case is the click
/// that never touches the backpack window — backpack shut, another bag's window open →
/// ToggleBackpack sees an open window and CLOSES ALL; the backpack never transitions, nothing
/// writes its button, and only the wrapper's undo keeps the widget flip from leaving it
/// lit-while-shut (the job the ref's native-window scan tail used to do).
/// wrapper only undoes the CheckButton widget's pre-handler flip.
///
/// Driven through REAL input (the widget flip fires only on real clicks) over the case 1494
/// changed: backpack shut with another bag's window open. That used to be the close-all arm —
/// nothing transitioned the backpack and only the undo kept the button from ending lit-while-shut.
/// It is now the OPEN arm, so the pin runs the whole cycle: the click opens bag 0 beside bag 1 and
/// its OnShow lights the button, the next click closes everything and the OnHide clears it.
#[test]
fn a_close_others_click_leaves_the_backpack_button_dark() {
fn the_backpack_buttons_ring_follows_its_own_window_through_real_clicks() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
@@ -2070,22 +2181,31 @@ fn a_close_others_click_leaves_the_backpack_button_dark() {
s.mouse_move(r[0], r[1]);
s.mouse_button(r[0], r[1], "LeftButton", true);
s.mouse_button(r[0], r[1], "LeftButton", false);
assert!(
s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
"the click opens the backpack (ToggleBackpack's open arm reads bag 0, not 'anything open')"
);
assert!(
s.eval::<bool>("return BenillaBagFrame1:IsShown()").unwrap(),
"…and leaves the bag that was already up alone"
);
assert!(
checked(&mut s),
"open ⇒ lit, written by the window's OnShow over the widget flip"
);
// The second real click: bag 0 is open now, so this is the close-all arm — both windows go,
// and the OnHide write clears the ring.
s.mouse_button(r[0], r[1], "LeftButton", true);
s.mouse_button(r[0], r[1], "LeftButton", false);
assert!(!s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap());
assert!(
!s.eval::<bool>("return BenillaBagFrame1:IsShown()").unwrap(),
"the click closes the other bag's window (ToggleBackpack's close-all arm)"
"the close arm takes every container down"
);
assert!(
!checked(&mut s),
"no backpack transition happened, so the button must stay dark — the widget flip undone"
);
// The second real click opens the backpack (plus the equipped bag): its OnShow writes the light.
s.mouse_button(r[0], r[1], "LeftButton", true);
s.mouse_button(r[0], r[1], "LeftButton", false);
assert!(s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap());
assert!(
checked(&mut s),
"open ⇒ lit, written by the window's OnShow"
"shut ⇒ dark, written by the window's OnHide"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -273,6 +273,35 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
// (`AddOn_Load 0x51f240` steps 2 → 4 → 6, decision 1128); reversing it means the defaults
// always win and nothing can ever be remembered.
finish_ui_load(&mut script);
// **Say it out loud when an addon didn't load** (decision 1495). Every failure the walk found
// is retained now, but a log nobody knows to open does not fix silence — and silence is the
// actual defect B293 reports: *"there are a lot of addons that still doesn't work"*, with
// nothing on screen to say which or why. Counted off the retained log rather than the walk's
// `failures` vec so the number matches what `/errors` will show: the log deduplicates, and one
// broken addon that fails four files should not read as four broken addons. The VM is fresh
// per world entry, so every `Load` row here is this load's.
// Placed AFTER `finish_ui_load` so the count covers the WHOLE load edge — the saved-variables
// chunks and `ADDON_LOADED`/`VARIABLES_LOADED` handlers it runs are part of loading, and an
// addon that dies in one of them is as absent as one whose file was missing.
let failed = script
.diagnostics()
.iter()
.filter(|d| d.kind == benilla_ui::script::diagnostics::DiagnosticKind::Load)
.count();
if failed > 0 {
if let Some(mut chat) = world.get_resource_mut::<crate::ui_chat::ChatLog>() {
// Queued, not drawn: `ChatLog` is a pending buffer the chat feed drains once the UI is
// up, which is what makes it safe to push from inside the world-entry load edge.
chat.push_event(crate::ui_chat::ChatEvent::text_only(
crate::ui_chat::ChatEventKind::System,
format!(
"{failed} addon load {} — type /errors to see {}.",
if failed == 1 { "failure" } else { "failures" },
if failed == 1 { "it" } else { "them" }
),
));
}
}
// The load edge is over: disarm the instruction bound `load_ingame_ui` installed (decision
// 1306). From here every OnUpdate and event handler runs unhooked — a session must not kill
// a player's addon for being slow; only a load that never returns is fair game.
+123 -12
View File
@@ -1769,13 +1769,15 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
);
checked += 1;
}
// 20 CVar rows (Social's two bubble switches are 1139's; Status Bar Text, Mouse Sensitivity
// and Max Camera Distance 1140's; Graphics' Vertical Sync is 1394's) + the Combat page's 14
// saved-variable rows (1134) + the Interface page's 4 (3 from 1136, Buff Durations 1139) and
// the Action Bars page's 1 (1136) + the Interface page's 2 API rows (Show Cloak / Show Helm,
// 1472) — which is the point of counting here rather than per page: the third store's rows are
// held to the same "the key is 1.12's own and it resolves" bar as the other two.
assert_eq!(checked, 41, "every row but one carries a live 1.12 key");
// 21 CVar rows (Social's two bubble switches are 1139's; Status Bar Text, Mouse Sensitivity
// and Max Camera Distance 1140's; Graphics' Vertical Sync is 1394's; Camera Following Style
// 1493's) + the Combat page's 14 saved-variable rows (1134) + the Interface page's 4 (3 from
// 1136, Buff Durations 1139) and the Action Bars page's 1 (1136) + the Interface page's 2 API
// rows (Show Cloak / Show Helm, 1472) — which is the point of counting here rather than per
// page: the third store's rows are held to the same "the key is 1.12's own and it resolves"
// bar as the other two. Camera Following Style is counted on the key it wears at rest (Smart's
// OPTION_TOOLTIP_CAMERA1); the other two ride the same census as the selection moves.
assert_eq!(checked, 42, "every row but one carries a live 1.12 key");
assert_eq!(
untipped,
vec!["ControlsRowAutoLoot".to_string()],
@@ -1864,11 +1866,12 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
s.errors()
);
}
// 20 of the 21 CVar rows (Social's two are 1139's; Status Bar Text, Mouse Sensitivity and
// Max Camera Distance 1140's; Vertical Sync 1394's), plus the Combat page's 14 saved-variable
// rows (1134), the Interface page's 4 (1136, + Buff Durations 1139), Action Bars' 1 (1136) and
// the Interface page's 2 API rows (Show Cloak / Show Helm, 1472).
assert_eq!(raised, 41, "every row but Auto Loot has a 1.12 description");
// 21 of the 22 CVar rows (Social's two are 1139's; Status Bar Text, Mouse Sensitivity and
// Max Camera Distance 1140's; Vertical Sync 1394's; Camera Following Style 1493's), plus the
// Combat page's 14 saved-variable rows (1134), the Interface page's 4 (1136, + Buff Durations
// 1139), Action Bars' 1 (1136) and the Interface page's 2 API rows (Show Cloak / Show Helm,
// 1472).
assert_eq!(raised, 42, "every row but Auto Loot has a 1.12 description");
}
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
@@ -2799,3 +2802,111 @@ fn the_max_camera_distance_slider_stores_a_factor_and_reads_out_yards() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Camera Following Style** (decision 1493) — 1.12's `cameraSmoothStyle`, worn as a Controls-page
/// dropdown, and the setting that decides whether the camera swings back behind the character at
/// all. Three things pinned: the reference's own values *and* their order (Smart `"1"`, Always
/// `"2"`, Never `"3"` — Never is **3**, not 0, which is exactly what a tidy-up would get wrong);
/// the registered default landing on Smart, where benilla behaved as Never for its whole life
/// before this row; and the plate following the SELECTION rather than the row — the reference's own
/// dropdown repaints its tooltip per entry (`OPTION_TOOLTIP_CAMERA1/2/3`) and this is the first row
/// here whose description is not a property of the row alone.
#[test]
fn the_camera_following_style_dropdown_carries_the_reference_values_and_plate() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowControls:Click()")
.unwrap();
// The registrar default is the reference's, and it is what the director asked to ship.
assert_eq!(
s.eval::<String>(
"return OptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownText:GetText()"
)
.unwrap(),
"Smart"
);
assert_eq!(
s.eval::<String>(
"return OptionsFrameContainerBodyControlsRowCameraFollowStyleLabel:GetText()"
)
.unwrap(),
"Camera Following Style"
);
assert_eq!(
s.eval::<String>("return OptionsFrameContainerBodyControlsRowCameraFollowStyle.tip")
.unwrap(),
"OPTION_TOOLTIP_CAMERA1"
);
assert!(
s.take_cvar_changes().is_empty(),
"reading the table on select must not write it back"
);
// The list is the reference dropdown's, entry for entry: Smart, Always, Never — with the
// stored value checked.
s.run("OptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownButton:Click()")
.unwrap();
assert_eq!(
s.eval::<f64>("return DropDownList1.numButtons").unwrap(),
3.0
);
assert_eq!(
s.eval::<String>(
"return DropDownList1Button1:GetText() .. \",\" .. DropDownList1Button2:GetText() \
.. \",\" .. DropDownList1Button3:GetText()"
)
.unwrap(),
"Smart,Always,Never"
);
assert!(s
.eval::<bool>("return DropDownList1Button1Check:IsVisible()")
.unwrap());
// Never stores "3" — the value the reference writes, not the 0 a fresh enum would invent —
// and the row's plate becomes Never's own description.
s.run("DropDownList1Button3:Click()").unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("cameraSmoothStyle".to_string(), "3".to_string())]
);
assert_eq!(
s.eval::<String>(
"return OptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownText:GetText()"
)
.unwrap(),
"Never"
);
assert_eq!(
s.eval::<String>("return OptionsFrameContainerBodyControlsRowCameraFollowStyle.tip")
.unwrap(),
"OPTION_TOOLTIP_CAMERA3",
"the plate follows the selection, like the reference dropdown's own"
);
// Always is the middle entry and stores "2" (the pairing that goes wrong if the order is read
// off the alphabet instead of off the reference).
s.run("OptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownButton:Click()")
.unwrap();
s.run("DropDownList1Button2:Click()").unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("cameraSmoothStyle".to_string(), "2".to_string())]
);
// Defaults walks it back to Smart.
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
assert_eq!(
s.eval::<String>("return GetCVar(\"cameraSmoothStyle\")")
.unwrap(),
"1"
);
assert_eq!(
s.eval::<String>(
"return OptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownText:GetText()"
)
.unwrap(),
"Smart"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -297,3 +297,169 @@ fn a_real_hunters_block_lists_exactly_what_the_reference_client_lists() {
"Beast Mastery's skillMaxRank"
);
}
/// The page's own **CLOSE** button (decision 1496). `SkillFrameCancelButton` is live in the
/// reference — the XML comment that swallows its `SkillFrameAcceptButton` neighbour closes one
/// line above it (ref `SkillFrame.xml` l.337/339) — and the director's screenshot of an empty
/// bottom-right seat is what that misread cost. Pins the button's existence, the ref's own seat
/// (80x22 centred on the page's TOPLEFT + (305,-422)), and that it closes the window.
#[test]
fn the_pages_close_button_sits_where_the_reference_seats_it_and_closes_the_window() {
let mut s = shown_skills_page();
assert!(
s.eval::<bool>("return SkillFrameCancelButton ~= nil")
.unwrap(),
"the ref's SkillFrameCancelButton is built"
);
assert!(
s.eval::<bool>("return SkillFrameCancelButton:IsVisible()")
.unwrap(),
"and shown — nothing in the ref's SkillFrame.lua ever hides it"
);
// The ref's geometry, read page-relative so the assertion is the ref's own numbers.
let (page_top, page_left) = s
.eval::<(f64, f64)>("return SkillFrame:GetTop(), SkillFrame:GetLeft()")
.unwrap();
let (top, bottom, left, right) = s
.eval::<(f64, f64, f64, f64)>(
"local b = SkillFrameCancelButton \
return b:GetTop(), b:GetBottom(), b:GetLeft(), b:GetRight()",
)
.unwrap();
assert_eq!(
(
(left + right) / 2.0 - page_left,
(top + bottom) / 2.0 - page_top,
right - left,
top - bottom,
),
(305.0, -422.0, 80.0, 22.0),
"CENTER of the page's TOPLEFT at (305,-422), 80x22 (ref l.339-348)"
);
// Its label is the CLOSE global string's seat, in the panel-button gold.
let label = s
.extract()
.iter()
.find_map(|q| match &q.content {
QuadContent::Text {
text: Some(t),
color,
..
} if t == "CLOSE" => Some(*color),
_ => None,
})
.expect("the CLOSE label draws");
assert_eq!(
label,
Some([1.0, 0.82, 0.0, 1.0]),
"GameFontNormal — the UIPanelButtonTemplate face's own normal font"
);
// And it does what the ref's OnClick does: page down, window down.
s.run("SkillFrameCancelButton:Click()").unwrap();
s.resolve();
assert!(
!s.eval::<bool>("return SkillFrame:IsVisible()").unwrap(),
"the page hides"
);
assert!(
!s.eval::<bool>("return CharacterFrame:IsVisible()").unwrap(),
"and the window with it (ref l.352-355)"
);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// The **ALL** fold's face and seat (decision 1496). Both halves of the director's report: the
/// label is the row font — `GameFontHighlight`, WHITE at 12 — not `GameFontNormalSmall`'s yellow
/// 10, and the button rides 3px BELOW the tab cap's centre (the ref's own `(-3,-3)` off the left
/// cap), not 3px above it, which is where the offset copied from `TrainerFrame.xml` put it.
#[test]
fn the_collapse_all_fold_wears_the_row_font_and_the_references_seat() {
let s = shown_skills_page();
let label = s
.extract()
.iter()
.find_map(|q| match &q.content {
QuadContent::Text {
text: Some(t),
color,
font_height,
..
} if t == "ALL" => Some((*color, *font_height)),
_ => None,
})
.expect("the ALL label draws");
assert_eq!(
label,
(Some([1.0, 1.0, 1.0, 1.0]), Some(12.0)),
"GameFontHighlight — the SAME face every SkillTypeLabel row wears"
);
let (tab_top, tab_bottom) = s
.eval::<(f64, f64)>("return SkillExpandTabLeft:GetTop(), SkillExpandTabLeft:GetBottom()")
.unwrap();
let (btn_top, btn_bottom, btn_left) = s
.eval::<(f64, f64, f64)>(
"local b = SkillCollapseAllButton \
return b:GetTop(), b:GetBottom(), b:GetLeft()",
)
.unwrap();
assert_eq!(
(tab_top + tab_bottom) / 2.0 - (btn_top + btn_bottom) / 2.0,
3.0,
"the fold sits 3px BELOW the cap's centre (ref's -3); +3 above was the bug"
);
let cap_right = s
.eval::<f64>("return SkillExpandTabLeft:GetRight()")
.unwrap();
assert_eq!(
btn_left - cap_right,
-3.0,
"and 3px back over the cap (ref's -3 on x)"
);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// The tab's **fit law** (ref `SkillFrameExpandButtonFrame`'s OnLoad, l.312-316:
/// `SetWidth(GetTextWidth()+45)`). benilla seats the VM's font engine at the frame boundary, so
/// the XML-load call reads 0 — the guard leaves the declared width standing, and the first Update
/// with a measurer fits the tab. Both states are pinned here because only the second one is the
/// reference's, and only the first is what a cold load sees.
#[test]
fn the_expand_tab_fits_its_label_once_a_measure_answers() {
let mut s = shown_skills_page();
assert_eq!(
s.eval::<f64>("return SkillExpandButtonFrame:GetWidth()")
.unwrap(),
54.0,
"unmeasured, the declared width stands — a 0 measure must not squash the tab to 45"
);
s.set_text_measurer(Box::new(super::FixedWidthFont(7.0)));
s.run("BenillaSkillFrame_Update()").unwrap();
s.resolve();
assert_eq!(
s.eval::<f64>("return SkillExpandButtonFrame:GetWidth()")
.unwrap(),
7.0 * 3.0 + 45.0,
"then the ref's own law: the label's width + 45"
);
// The middle slab is the span between the two caps, so the fit reaches the art for free.
let (mid_l, mid_r, cap_r_l) = s
.eval::<(f64, f64, f64)>(
"return SkillExpandTabMiddle:GetLeft(), SkillExpandTabMiddle:GetRight(), \
SkillExpandTabRight:GetLeft()",
)
.unwrap();
assert_eq!(mid_r, cap_r_l, "the middle stretches to the right cap");
assert_eq!(
mid_r - mid_l,
66.0 - 16.0,
"and carries the whole span minus the two caps"
);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
@@ -772,3 +772,181 @@ fn the_login_one_shots_wait_for_the_in_game_ui() {
drop(app);
let _ = std::fs::remove_dir_all(&tmp);
}
/// **B293's headline, at the edge that produces it** (decision 1495). An addon that fails to load
/// *without raising* — the commonest shape by far, a `.toc` naming a file the package does not
/// ship — used to `warn!` to the terminal and vanish. Nothing raised, so 1305's dialog could not
/// fire; the walk's failure list was dropped on the floor at `load_ingame_ui_on_world_entry`; and
/// the per-frame drain kept no history. From the player's chair the addon simply was not there and
/// the client said nothing, which is the literal content of *"there are a lot of addons that still
/// doesn't work"*.
///
/// Three claims, and the third is the one that makes the first two reachable: the failure is
/// **retained**, it is **readable from Lua** (so the window is a view of it, not a second copy),
/// and the player is **told to look**.
#[test]
fn an_addon_that_fails_to_load_without_raising_is_readable_in_the_error_log() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_probe("addon-missing-file");
// Alphabetically first, so the probe behind it also proves this class drops only itself.
let dir = tmp.join("benilla-config/AddOns/AaMissing");
std::fs::create_dir_all(&dir).expect("addon dir");
std::fs::write(
dir.join("AaMissing.toc"),
"## Interface: 11200\nBossnames\\BossNames.xml\n",
)
.expect("toc");
// …and no such file is written. This is the director's own AtlasLoot copy, reduced.
let mut world = booted_world();
world.init_resource::<crate::ui_chat::ChatLog>();
log_in_as(&mut world, "Onehunter", 1);
assert_eq!(
probe_saw(&world).as_deref(),
Some("Onehunter"),
"the addon after the broken one still loads"
);
let script = world
.get_non_send_resource::<benilla_ui::script::UiScript>()
.expect("VM");
// 1 · retained, and tagged as a LOAD failure — the kind that means "this addon is not running".
let rows = script.diagnostics();
let row = rows
.iter()
.find(|d| d.message.contains("AaMissing"))
.unwrap_or_else(|| panic!("the missing file is in the log; got {rows:#?}"));
assert_eq!(
row.kind,
benilla_ui::script::diagnostics::DiagnosticKind::Load
);
assert!(
row.message.contains("not found"),
"the row says what went wrong, verbatim as the terminal line: {:?}",
row.message
);
// 2 · readable from Lua by the same three reads the window uses.
let count: i64 = script
.eval("local shown = BenillaGetNumScriptErrors() return shown")
.expect("BenillaGetNumScriptErrors is installed");
assert!(count >= 1, "the window's own read sees it");
let seen: String = script
.eval(
"local text = '' \
for i = 1, BenillaGetNumScriptErrors() do \
local seq, kind, message = BenillaGetScriptErrorInfo(i) \
if kind == 'load' then text = message end \
end \
return text",
)
.expect("BenillaGetScriptErrorInfo is installed");
assert!(
seen.contains("AaMissing"),
"the window walks the log and finds it: {seen:?}"
);
// …and the window itself materialized, so `/errors` has something to toggle.
assert!(
script
.eval::<bool>("return BenillaScriptLogFrame ~= nil")
.expect("eval"),
"ScriptLogFrame.xml loaded and built the window"
);
// **The repaint actually runs, over a log that has a row in it.** Asserting the globals exist
// would prove nothing: this drives the real path — `FauxScrollFrame_Update`, the row rebind,
// `strfind`/`strsub`/`strlen`/`format`, `SetTextColor`, the highlight seat and the detail
// pane — and a `nil` global anywhere in it raises here instead of on the player's first
// `/errors` (which is exactly the shape of failure this whole record exists to stop shipping).
script
.eval::<()>("BenillaScriptLog_Update() return nil")
.expect("the window repaints over a real log without raising");
let row_label: String = script
.eval::<Option<String>>("return BenillaScriptLogRow1Label:GetText()")
.expect("eval")
.unwrap_or_default();
assert!(
row_label.contains("AaMissing"),
"row 1 shows the failure, trimmed to the row's width: {row_label:?}"
);
let summary: String = script
.eval::<Option<String>>("return BenillaScriptLogSummary:GetText()")
.expect("eval")
.unwrap_or_default();
assert!(
summary.contains("error"),
"the summary line counted them: {summary:?}"
);
// 3 · **this class deliberately does NOT seize the screen.** Nothing raised; the reference's
// answer to an unparseable/absent document is a log line and silence, and 1495 keeps that.
// What it changes is that the silence is no longer total — hence the chat notice below.
assert!(
!script
.eval::<bool>("return ScriptErrors:IsVisible()")
.expect("ScriptErrors exists"),
"a non-raising load failure must not pop the red dialog — that would put non-errors \
through `_ERRORMESSAGE` and through every addon handler that replaces it"
);
// 4 · the player is told to look. Without this the log is a room nobody knows about, and
// silence — not the missing list — is the actual defect B293 reports.
assert_eq!(
world.resource::<crate::ui_chat::ChatLog>().pending_len(),
1,
"world entry queued the 'N addon load failures — type /errors' line"
);
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
/// **The burst 1305 measured, as the log sees it.** An `OnUpdate` that raises every frame produced
/// 470–1113 collected errors in 1305's own runs, of which `_ERRORMESSAGE` shows the **first** and
/// the per-frame drain keeps none. Deduplication is what makes a log survive that: one row with a
/// count, not 1,113 rows and not a truncated window of the last few.
#[test]
fn a_repeating_error_is_one_row_with_a_count_not_a_flood() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_probe("addon-repeat");
let mut world = booted_world();
log_in_as(&mut world, "Onehunter", 1);
let mut script = world
.remove_non_send_resource::<benilla_ui::script::UiScript>()
.expect("VM");
let before = script.diagnostics().len();
// The same failure, over and over, through the engine's own catch path — a slash command whose
// body raises is the cheapest real one to drive from a test.
script
.run("SlashCmdList = SlashCmdList or {} SLASH_B293BOOM1 = '/b293boom' SlashCmdList['B293BOOM'] = function() error('every frame') end")
.expect("register");
for _ in 0..500 {
script.run_slash_command("b293boom", "");
}
let rows = script.diagnostics();
assert_eq!(
rows.len(),
before + 1,
"500 identical raises are ONE new row: {rows:#?}"
);
let row = rows.last().expect("a row");
assert_eq!(row.count, 500, "the count is where the 500 went");
assert_eq!(
row.kind,
benilla_ui::script::diagnostics::DiagnosticKind::Error,
"code ran and raised — the addon is loaded, unlike a Load row"
);
drop(script);
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
@@ -0,0 +1,210 @@
//! The **addon-info block** at the tail of `CMSG_AUTH_SESSION` — what the client tells the world
//! server about its *secure* addons (decision 1497).
//!
//! Every 1.12.1 logon carries one of these. It is not optional decoration: it is the last field of
//! the auth packet, and a server reads it before the session exists. benilla used to send a
//! `decompressed_size = 0` + zlib-of-nothing stub, which **no real client can produce** — and
//! cmangos-classic kicks the session for it (B277). See [`addon_block`] for the shape and
//! [`STOCK_SECURE_ADDONS`] for what a stock install actually sends.
//!
//! ## Where the shape comes from
//!
//! VERIFIED in `WoW.exe` (5875) — wow-5875-re `system/net/scratch/cmsg-auth-session-addon-block.md`.
//! The writer is `0x51d910`, called once, from `HandleAuthChallenge` (`0x5b4143`) straight after the
//! 20-byte SHA-1 proof. It appends:
//!
//! ```text
//! u32 uncompressed_size ; byte length of the buffer below
//! u8[] zlib stream ; RFC1950 framing (deflateInit_ windowBits +15), runs to end of packet
//! ```
//!
//! and the uncompressed buffer is a **bare concatenation** — no leading count, no trailer —
//! of one record per secure addon:
//!
//! ```text
//! CString name ; NUL-terminated
//! u8 flags ; byte 0 of the addon's own .pub signature file
//! u32 modulus_crc ; CRC-32 of the 256 modulus bytes (.pub bytes 1..=256)
//! u32 url_crc ; CRC-32 of the addon's .url string; 0 when it has none
//! ```
//!
//! **Which** addons: exactly those whose `.toc` declares `## Secure:` non-zero — enable/disable
//! state is not consulted. On a stock install that is precisely the twelve `Blizzard_*` built-ins
//! and nothing else, because no third-party 1.12 addon declares `## Secure`. A client with *zero*
//! secure addons appends **nothing at all** (not a zero dword) — the writer returns before the
//! local store exists.
//!
//! ## Why the emulators disagree about it, and why it matters
//!
//! Both vmangos and cmangos reject `size == 0` with the same comment ("empty addon packet … can't
//! be received from real client") — that judgement is *correct*, and matched the binary all along.
//! What differs is the consequence:
//!
//! - vmangos `WorldSocket.cpp:447` — `if (BuildAddonPacket(...)) SendPacket(addonPacket);`. A
//! rejection just means no `SMSG_ADDON_INFO` goes back; the session lives. That is the only
//! reason benilla's stub ever worked.
//! - cmangos-classic `WorldSocket.cpp:562` — `if (!ReadAddonInfo(...)) { … "sent bad addon info.
//! Kicking."; return false; }`. Same rejection, session killed. Not config-gated: the check is in
//! the anticheat module *and* in `NullSessionAnticheat`, so it runs with the anticheat off.
//!
//! cmangos's two readers also disagree with each other on field order — its anticheat module reads
//! `name, u8, u32, u32` (right, matching the binary) and its `NullSessionAnticheat` reads
//! `name, u32, u32, u8` (wrong, but the same nine bytes, so it parses without throwing).
/// CRC-32 of the stock Blizzard public-key modulus — what a stock, unmodified addon's `.pub`
/// hashes to, and the value every emulator calls the "standard addon CRC". A server that sees it
/// knows it need not push the 256-byte modulus back in `SMSG_ADDON_INFO`.
///
/// VERIFIED: `zlib.crc32` of the 256 modulus bytes in this install's `Blizzard_*.pub` files, which
/// are byte-identical to the `modulus`/`tdata` array carried in vmangos and cmangos alike.
pub const STANDARD_MODULUS_CRC: u32 = 0x4C1C_776D;
/// One record in the addon block — a secure addon as the client describes it to the server.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SecureAddon<'a> {
/// The addon's folder name (`Blizzard_AuctionUI`), sent as a `CString`.
pub name: &'a str,
/// Byte 0 of the addon's `.pub` file — `1` for a stock signature, `0` when the client found no
/// usable `.pub`. A server may write a different byte back (`SMSG_ADDON_INFO`), which the real
/// client persists into the `.pub` and echoes next logon; cmangos's anticheat uses exactly that
/// channel to stamp a per-install fingerprint across four of the twelve.
pub flags: u8,
/// CRC-32 of the `.pub`'s 256 modulus bytes — [`STANDARD_MODULUS_CRC`] for a stock signature,
/// `0` when `flags` is `0`.
pub modulus_crc: u32,
/// CRC-32 of the addon's `.url` string. `0` for every stock addon (none ship one).
pub url_crc: u32,
}
impl SecureAddon<'_> {
/// A stock, signed Blizzard built-in: enabled signature, standard modulus, no URL. The shape
/// all twelve take on an unmodified install.
const fn stock(name: &str) -> SecureAddon<'_> {
SecureAddon {
name,
flags: 1,
modulus_crc: STANDARD_MODULUS_CRC,
url_crc: 0,
}
}
}
/// The twelve `Blizzard_*` built-ins a stock 1.12.1 install reports, in the order the client sends
/// them (ascending ASCII of the folder name).
///
/// VERIFIED against a real 1.12.1.5875 client's `CMSG_AUTH_SESSION` captured against a live
/// Blizzard server in 2006 (wow-5875-re `system/net/scratch/cmsg-auth-session-addon-block.md`):
/// 342 uncompressed bytes, twelve records, every one `flags = 1`, `modulus_crc =`
/// [`STANDARD_MODULUS_CRC`], `url_crc = 0`, and nothing left over — the arithmetic proof that the
/// buffer carries no count and no trailer.
///
/// benilla sends this list rather than one derived from the install's own `Interface/AddOns`
/// folder, and the two agree on every stock install: the client's rule is "`## Secure:` non-zero",
/// which no third-party 1.12 addon sets, and benilla's own addons (loaded from
/// `benilla-config/AddOns`) are third-party by construction. Reading each `.pub` off the player's
/// chain would only differ on an install whose signature files have been altered — see 1497.
pub const STOCK_SECURE_ADDONS: [SecureAddon<'static>; 12] = [
SecureAddon::stock("Blizzard_AuctionUI"),
SecureAddon::stock("Blizzard_BattlefieldMinimap"),
SecureAddon::stock("Blizzard_BindingUI"),
SecureAddon::stock("Blizzard_CombatText"),
SecureAddon::stock("Blizzard_CraftUI"),
SecureAddon::stock("Blizzard_GMSurveyUI"),
SecureAddon::stock("Blizzard_InspectUI"),
SecureAddon::stock("Blizzard_MacroUI"),
SecureAddon::stock("Blizzard_RaidUI"),
SecureAddon::stock("Blizzard_TalentUI"),
SecureAddon::stock("Blizzard_TradeSkillUI"),
SecureAddon::stock("Blizzard_TrainerUI"),
];
/// The **uncompressed** addon buffer: one record per addon, concatenated, nothing else.
pub fn addon_block(addons: &[SecureAddon]) -> Vec<u8> {
let mut out = Vec::with_capacity(addons.iter().map(|a| a.name.len() + 10).sum());
for addon in addons {
out.extend_from_slice(addon.name.as_bytes());
out.push(0);
out.push(addon.flags);
out.extend_from_slice(&addon.modulus_crc.to_le_bytes());
out.extend_from_slice(&addon.url_crc.to_le_bytes());
}
out
}
/// The block as it rides the wire: `u32` uncompressed size + the zlib stream, or **nothing at all**
/// when there are no secure addons (what the real client does — it never emits a zero size).
pub fn addon_tail(addons: &[SecureAddon]) -> Vec<u8> {
if addons.is_empty() {
return Vec::new();
}
let plain = addon_block(addons);
let mut out = Vec::with_capacity(plain.len() / 2 + 8);
out.extend_from_slice(&(plain.len() as u32).to_le_bytes());
let mut encoder =
flate2::write::ZlibEncoder::new(out, flate2::Compression::default() /* level 6 */);
std::io::Write::write_all(&mut encoder, &plain).expect("zlib write to a Vec cannot fail");
encoder.finish().expect("zlib finish on a Vec cannot fail")
}
#[cfg(test)]
mod tests {
use super::*;
/// The retail ground truth: 342 uncompressed bytes, consumed exactly by twelve records.
#[test]
fn stock_block_matches_the_retail_capture() {
let plain = addon_block(&STOCK_SECURE_ADDONS);
assert_eq!(plain.len(), 342, "retail sent 342 uncompressed bytes");
// Walk it back the way a server does — name, flags, modulus crc, url crc — and require it
// to land exactly on the end. That is what proves there is no count and no trailer.
let mut rest = &plain[..];
let mut seen = Vec::new();
while !rest.is_empty() {
let nul = rest
.iter()
.position(|&b| b == 0)
.expect("record has a name");
let name = std::str::from_utf8(&rest[..nul]).expect("name is utf-8");
rest = &rest[nul + 1..];
let (flags, tail) = rest.split_first().expect("record has flags");
let modulus_crc = u32::from_le_bytes(tail[0..4].try_into().unwrap());
let url_crc = u32::from_le_bytes(tail[4..8].try_into().unwrap());
assert_eq!(*flags, 1, "{name} is a stock signed addon");
assert_eq!(modulus_crc, STANDARD_MODULUS_CRC, "{name} modulus crc");
assert_eq!(url_crc, 0, "{name} ships no .url");
seen.push(name.to_string());
rest = &tail[8..];
}
assert_eq!(seen.len(), 12);
assert!(seen.iter().all(|n| n.starts_with("Blizzard_")));
let mut sorted = seen.clone();
sorted.sort();
assert_eq!(
seen, sorted,
"the client sends them in ascending ASCII order"
);
}
/// The wire tail: size prefix, zlib framing, and the retail compressed length. `flate2` at its
/// default level reproduces the 2006 client's 130 bytes byte-for-byte — the real client used
/// `Z_DEFAULT_COMPRESSION` through the same zlib deflate.
#[test]
fn stock_tail_is_size_plus_zlib() {
let tail = addon_tail(&STOCK_SECURE_ADDONS);
assert_eq!(
u32::from_le_bytes(tail[0..4].try_into().unwrap()),
342,
"size prefix is the uncompressed length"
);
assert_eq!(tail[4], 0x78, "RFC1950 zlib framing, not raw deflate");
assert_eq!(tail.len(), 4 + 130, "retail compressed to 130 bytes");
}
/// Zero secure addons appends *nothing* — never a zero size, which no real client can emit and
/// which cmangos-classic kicks for.
#[test]
fn no_secure_addons_appends_nothing() {
assert!(addon_tail(&[]).is_empty());
}
}
@@ -2,28 +2,29 @@
//! the `ServerPacket` vocabulary stay in [`super`]; spell/attack builders live with their family
//! in [`super::spells`]). Framing/encryption is `world.rs`'s job; these produce plain body bytes.
use super::addons::{addon_tail, SecureAddon};
use super::{CharCreateReq, MovementInfo};
/// `CMSG_AUTH_SESSION` body: build, server id, account name, client seed, proof, and an empty
/// zlib-wrapped addon block (`decompressed_size = 0` + zlib of no bytes — what the client sends and
/// vmangos accepts).
/// `CMSG_AUTH_SESSION` body: build, server id, account name, client seed, proof, and the
/// **addon-info block** — `addons`' uncompressed size plus its zlib stream, exactly as the writer
/// at `0x51d910` appends it (see [`super::addons`], decision 1497). Pass
/// [`super::STOCK_SECURE_ADDONS`] for what a stock install sends.
pub fn auth_session(
build: u32,
username: &str,
client_seed: u32,
client_proof: &[u8; 20],
addons: &[SecureAddon],
) -> Vec<u8> {
let mut body = Vec::with_capacity(40 + username.len());
let tail = addon_tail(addons);
let mut body = Vec::with_capacity(34 + username.len() + tail.len());
body.extend_from_slice(&build.to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes()); // server_id
body.extend_from_slice(username.as_bytes());
body.push(0);
body.extend_from_slice(&client_seed.to_le_bytes());
body.extend_from_slice(client_proof);
body.extend_from_slice(&0u32.to_le_bytes()); // addon decompressed_size
let encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
let empty_addon_zlib = encoder.finish().expect("zlib finish on empty input");
body.extend_from_slice(&empty_addon_zlib);
body.extend_from_slice(&tail);
body
}
@@ -16,6 +16,7 @@
//! `tests/common`).
mod action_bar;
pub mod addons;
mod area_trigger;
mod attack;
mod bank;
@@ -60,6 +61,7 @@ mod world_state;
pub use action_bar::{
set_action_button, ActionButton, ACTION_KIND_ITEM, ACTION_KIND_MACRO, ACTION_KIND_SPELL,
};
pub use addons::{SecureAddon, STANDARD_MODULUS_CRC, STOCK_SECURE_ADDONS};
pub use area_trigger::area_trigger;
pub use attack::{attack_swing, AttackerState};
pub use bank::{
@@ -98,11 +98,15 @@ impl WorldSession {
seed.into_client_header_crypto(&username_n, session_key, server_seed);
// 3. CMSG_AUTH_SESSION goes out unencrypted; obfuscation begins immediately after.
// The addon block is the tail of this packet and is NOT optional: cmangos-classic kicks a
// session whose addon size is zero, which is what benilla used to send (B277, decision
// 1497). `STOCK_SECURE_ADDONS` is what a stock 1.12.1 install reports.
let body = messages::auth_session(
u32::from(crate::CLIENT_BUILD),
&username.to_uppercase(),
client_seed,
&client_proof,
&messages::STOCK_SECURE_ADDONS,
);
send_packet(&mut stream, None, opcode::CMSG_AUTH_SESSION, &body)
.context("sending CMSG_AUTH_SESSION")?;
+24 -2
View File
@@ -36,11 +36,33 @@ fn faction_language_per_race() {
#[test]
fn client_bodies_golden() {
let proof: [u8; 20] = std::array::from_fn(|i| (i as u8).wrapping_mul(3).wrapping_add(7));
// The tail is the addon-info block: `342` uncompressed + the zlib stream of the stock twelve
// (decision 1497). Byte-identical to a real 1.12.1.5875 client's — the same 130 compressed
// bytes as the 2006 retail capture wow-5875-re verified this against.
assert_eq!(
messages::auth_session(5875, "TESTUSER", 0x1122_3344, &proof),
hx("f31600000000000054455354555345520044332211070a0d101316191c1f2225282b2e3134373a3d4000000000789c030000000001"),
messages::auth_session(
5875,
"TESTUSER",
0x1122_3344,
&proof,
&messages::STOCK_SECURE_ADDONS
),
hx(concat!(
"f31600000000000054455354555345520044332211070a0d101316191c1f2225282b2e3134373a3d40",
"56010000",
"789c75ccbd0ec2300c04e0f21ebc0c614095c842c38c4ce2220bc7a98ccb4f9f1e16240673eb777781",
"695940cb693367a326c7be5bd5c77adf7d12be16c08c7124e41249a8c2e495480ac9c53dd8b67a064b",
"f8340f15467367bb38cc7ac7978bbddc26ccfe3042d6e6ca01a8b8908051fcb7a45070b812f33f2641",
"fdb5379019668f",
)),
"CMSG_AUTH_SESSION body"
);
// No secure addons: the tail is *absent*, never a zero size (which no real client emits).
assert_eq!(
messages::auth_session(5875, "TESTUSER", 0x1122_3344, &proof, &[]),
hx("f31600000000000054455354555345520044332211070a0d101316191c1f2225282b2e3134373a3d40"),
"CMSG_AUTH_SESSION body with no secure addons"
);
// Zero-appearance body (the create-if-empty starter): name + [race,class,gender] + 5 zeros + 0.
assert_eq!(
messages::char_create(&messages::CharCreateReq {
+339
View File
@@ -0,0 +1,339 @@
//! The **script error log** — what went wrong, kept where the player can read it (decision 1495).
//!
//! ## Why this exists
//!
//! 1305 gave script errors a *screen*: the engine dispatches every caught error to
//! `geterrorhandler()`, and FrameXML's `_ERRORMESSAGE` puts the red `ScriptErrors` dialog up. That
//! is the reference's behaviour and it stays exactly as it is. It is also, on its own, not enough,
//! and the reporters said so within a day of it shipping — Goudy, *minutes after screenshotting
//! that very dialog working*: *"some kind of lua error frame probably needs to be implemented, as
//! there are a lot of addons that still doesn't work"*; nazriel_0, two days later: *"probably we
//! should start using something like Bug Grabber to report those stack traces better"* (ledger
//! B293, split from B271).
//!
//! Both asks are the same three gaps, and all three are about *memory*:
//!
//! 1. **`_ERRORMESSAGE` shows a burst's FIRST message only** — its own `ScriptErrors:IsVisible()`
//! guard, faithfully transcribed. A world entry that raises 1,113 times (1305 measured exactly
//! that) shows one string. This is precisely why BugGrabber exists in the real world.
//! 2. **An addon can fail to load without ever raising**, and those failures had no channel at all
//! — a manifest entry the package does not ship, a document that will not parse, a dependency
//! cycle, a missing hard dependency, a broken `Bindings.xml`. They logged to the terminal and
//! stopped; the addon simply was not there and the client said nothing. That is the literal
//! content of *"a lot of addons that still doesn't work"*.
//! 3. **Nothing retained anything.** [`super::UiScript::take_errors`] drains to the host log every
//! frame, so once a frame passed, the terminal held the only copy. A player cannot read a
//! terminal, and a bug report written from one is what B271 was debugged off.
//!
//! ## What this is, and what it deliberately is not
//!
//! A bounded, deduplicating, ordered log of everything that went wrong this session, written at
//! the two choke points every failure already passes ([`super::model::Model::record_script_error`]
//! and [`super::UiScript::report_load_failure`]) and read by whatever wants to show it.
//!
//! **"Session" means one world session, not one process.** The log lives on the `Model`, and the
//! in-game UI is rebuilt onto a fresh VM at every world entry (1290) — so a logout or a `ReloadUI`
//! starts an empty log. That is the right boundary and it self-heals: the load walk runs again on
//! the way back in and re-records whatever is still broken. What it is *not* is a place to look for
//! what happened before the reload you just did.
//!
//! It is **purely additive**: no dispatch changes, no `_ERRORMESSAGE` change, no divergence from
//! the reference's own behaviour. The reference simply never had this instrument, and *"a modern,
//! idiomatic client"* covers building one.
//!
//! It is **not** a transcription of BugGrabber, which is somebody else's addon (and not Blizzard's
//! either). It is our own equivalent, and an addon that installs its own `seterrorhandler` still
//! wins outright for the dispatch half exactly as 1195/1305 leave it.
//!
//! ## Dedupe, order, and the bound
//!
//! **Repeats collapse onto the first occurrence and bump a count.** An `OnUpdate` that raises
//! every frame is one row reading `×1113`, not 1,113 rows — without that the log is useless within
//! seconds, and unbounded besides.
//!
//! **Order is first-occurrence, oldest first.** The load-time failures are the causes and the
//! later runtime errors are usually their consequences, so a chronological read is the one that
//! explains itself. A repeat does *not* move its row to the end: a row that jumps around while you
//! read it is worse than a stale position.
//!
//! **[`DIAGNOSTIC_LOG_CAP`] distinct rows**, evicting oldest-first. `seq` is monotonic and never
//! reused, so an evicted prefix is visible as a gap rather than silently rewriting history — a
//! surface can say "showing #251-#500" honestly. The cap binds *distinct* messages, which after
//! dedupe is a much higher ceiling than it looks: 1305's 1,113-raise run produces **one** row.
use std::collections::VecDeque;
use mlua::{IntoLua, Lua, MultiValue, Value};
use super::Model;
/// How many **distinct** messages the log retains before evicting the oldest.
///
/// Sized against the worst real load we have measured rather than a round number: the 218-addon
/// corpus behind 1306, loaded at once, is the widest set of distinct failures this client has ever
/// produced. Dedupe means repeats cost nothing, so this bounds *kinds* of problem, not events.
pub const DIAGNOSTIC_LOG_CAP: usize = 256;
/// What a retained row *is* — and the split is by **consequence**, not by where it was caught,
/// because that is the only distinction the player can act on.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnosticKind {
/// **Code ran and raised.** The addon is loaded and working apart from this; the failure may
/// be harmless, may be one frame's bad luck, may be constant. This is the class that already
/// reaches the red dialog.
Error,
/// **An addon did not load.** Definitely broken, definitely worth telling somebody about, and
/// before 1495 invisible unless you were reading the terminal. Both the raising kind (a file
/// scope that blew up) and the silent kind (a missing file, an unparseable document, a
/// dependency that isn't there) land here — from the player's side they are one thing: *the
/// addon isn't running*.
Load,
}
impl DiagnosticKind {
/// The one-word tag a surface prints. Stable — a surface may key colour off it.
pub fn tag(self) -> &'static str {
match self {
Self::Error => "error",
Self::Load => "load",
}
}
}
/// One retained row.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Diagnostic {
/// Monotonic, 1-based, **never reused** — the "#N" a surface shows. Survives eviction, so a
/// gap in the numbers is an honest report that older rows were dropped.
pub seq: u64,
pub kind: DiagnosticKind,
/// The message exactly as the host logged it — same string, same spelling, so a player's
/// screenshot and our terminal line are greppable against each other.
pub message: String,
/// How many times this exact `(kind, message)` has occurred. Starts at 1.
pub count: u32,
}
/// The log itself. Lives on the model; see the module header for the shape and the why.
#[derive(Debug, Default)]
pub(crate) struct DiagnosticLog {
rows: VecDeque<Diagnostic>,
/// The last `seq` handed out. Kept separately from `rows.len()` precisely so eviction cannot
/// renumber anything.
seq: u64,
}
impl DiagnosticLog {
/// Record one failure, collapsing it onto an existing identical row if there is one.
///
/// The dedupe scan is linear over at most [`DIAGNOSTIC_LOG_CAP`] rows and runs on the failure
/// path only. The pathological caller is an `OnUpdate` raising at frame rate, where the scan
/// hits its row and returns — cheaper by far than the `String` allocation it saves, and
/// arithmetic beside the `mlua` error formatting that produced the message in the first place.
pub(crate) fn record(&mut self, kind: DiagnosticKind, message: &str) {
if let Some(row) = self
.rows
.iter_mut()
.find(|r| r.kind == kind && r.message == message)
{
// Saturating rather than wrapping: a count that rolls over to 0 reads as "this never
// happened", which is the one answer that is never true here.
row.count = row.count.saturating_add(1);
return;
}
self.seq += 1;
if self.rows.len() == DIAGNOSTIC_LOG_CAP {
self.rows.pop_front();
}
self.rows.push_back(Diagnostic {
seq: self.seq,
kind,
message: message.to_string(),
count: 1,
});
}
/// Every retained row, oldest first.
pub(crate) fn rows(&self) -> impl Iterator<Item = &Diagnostic> {
self.rows.iter()
}
/// Retained rows.
pub(crate) fn len(&self) -> usize {
self.rows.len()
}
/// Distinct failures **ever** recorded this session, including rows since evicted. The
/// difference from [`Self::len`] is how many the cap ate.
pub(crate) fn total(&self) -> u64 {
self.seq
}
/// Forget everything — the player's own "I've read these" act. Deliberately does **not** reset
/// `seq`: after a clear, the next row is still #N+1, so a screenshot taken before the clear and
/// one taken after cannot claim the same number for different failures.
pub(crate) fn clear(&mut self) {
self.rows.clear();
}
}
impl super::UiScript {
/// Every retained row, oldest first — a clone, because the caller is the app's UI and the
/// model is behind `app_data`. The log is capped at [`DIAGNOSTIC_LOG_CAP`] rows, so this is a
/// bounded copy of a list nothing reads at frame rate.
pub fn diagnostics(&self) -> Vec<Diagnostic> {
self.model_ref().diagnostics.rows().cloned().collect()
}
/// `(retained rows, distinct failures ever recorded)`. The two differ once the cap has evicted.
pub fn diagnostic_counts(&self) -> (usize, u64) {
let m = self.model_ref();
(m.diagnostics.len(), m.diagnostics.total())
}
/// Forget the retained rows (the player's "I've read these"). See [`DiagnosticLog::clear`] for
/// why the numbering is deliberately *not* reset.
pub fn clear_diagnostics(&self) {
self.model_mut().diagnostics.clear();
}
/// Record an addon that **did not load** for a reason that never raised — a manifest entry the
/// package does not ship, a document that will not parse, a dependency cycle, a hard
/// dependency that is missing, a broken `Bindings.xml`.
///
/// **Deliberately does not dispatch.** These are not Lua errors: nothing raised, there is no
/// traceback, and handing them to `geterrorhandler()` would put non-errors through
/// `_ERRORMESSAGE` and through every addon handler that ever replaces it — a divergence from
/// the reference for no gain, since the reference's own answer to an unparseable document is a
/// line in `FrameXML.log` and silence on screen. What 1495 changes is that the silence is no
/// longer *total*: the failure is retained and readable, it just does not seize the screen.
///
/// The caller still logs its own line; this is the retention half, not a replacement for it.
pub fn report_load_failure(&self, msg: &str) {
self.model_mut()
.diagnostics
.record(DiagnosticKind::Load, msg);
}
}
/// Register the error-log reads the `BenillaScriptLogFrame` polls.
///
/// **`Benilla`-prefixed because they are ours**, not 1.12's — the prefix rule as
/// `tests::reference_surface` enforces it (1254: a name we invent must not collide, a name the
/// reference owns must not be hidden). 1.12 has no error-log API to shadow, so there is nothing
/// here an addon could have meant by a bare name.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// BenillaGetNumScriptErrors() -> retained, totalEverRecorded
lua.globals().set(
"BenillaGetNumScriptErrors",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok((model.diagnostics.len(), model.diagnostics.total()))
})?,
)?;
// BenillaGetScriptErrorInfo(index) -> seq, kind, message, count (1-based, oldest first;
// nil for an out-of-range index, so a stale row index during a repaint reads as "gone"
// rather than erroring inside the very frame that lists errors.)
lua.globals().set(
"BenillaGetScriptErrorInfo",
lua.create_function(|lua, index: i64| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(row) = usize::try_from(index)
.ok()
.and_then(|i| i.checked_sub(1))
.and_then(|i| model.diagnostics.rows().nth(i))
else {
return Ok(MultiValue::new());
};
Ok(MultiValue::from_vec(vec![
Value::Integer(row.seq as i64),
lua.create_string(row.kind.tag())?.into_lua(lua)?,
lua.create_string(&row.message)?.into_lua(lua)?,
Value::Integer(i64::from(row.count)),
]))
})?,
)?;
// BenillaClearScriptErrors()
lua.globals().set(
"BenillaClearScriptErrors",
lua.create_function(|lua, ()| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.diagnostics
.clear();
Ok(())
})?,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repeats_collapse_onto_the_first_row_and_count() {
let mut log = DiagnosticLog::default();
for _ in 0..1113 {
log.record(DiagnosticKind::Error, "OnUpdate: boom");
}
assert_eq!(log.len(), 1, "1,113 raises are ONE row — the whole point");
let row = log.rows().next().unwrap();
assert_eq!(row.count, 1113);
assert_eq!(row.seq, 1);
assert_eq!(log.total(), 1);
}
#[test]
fn kind_is_part_of_identity() {
let mut log = DiagnosticLog::default();
log.record(DiagnosticKind::Error, "same text");
log.record(DiagnosticKind::Load, "same text");
assert_eq!(
log.len(),
2,
"the same string means different things per kind"
);
}
#[test]
fn order_is_first_occurrence_and_a_repeat_does_not_move_its_row() {
let mut log = DiagnosticLog::default();
log.record(DiagnosticKind::Load, "first");
log.record(DiagnosticKind::Error, "second");
log.record(DiagnosticKind::Load, "first");
let seen: Vec<_> = log.rows().map(|r| r.message.as_str()).collect();
assert_eq!(seen, ["first", "second"]);
assert_eq!(log.rows().next().unwrap().count, 2);
}
#[test]
fn eviction_is_oldest_first_and_seq_never_rewinds() {
let mut log = DiagnosticLog::default();
for i in 0..DIAGNOSTIC_LOG_CAP + 10 {
log.record(DiagnosticKind::Error, &format!("e{i}"));
}
assert_eq!(log.len(), DIAGNOSTIC_LOG_CAP);
let first = log.rows().next().unwrap();
assert_eq!(first.message, "e10", "the oldest ten were evicted");
assert_eq!(
first.seq, 11,
"seq is monotonic: the gap IS the honest report that rows were dropped"
);
assert_eq!(log.total() as usize, DIAGNOSTIC_LOG_CAP + 10);
}
#[test]
fn clear_forgets_rows_but_not_the_numbering() {
let mut log = DiagnosticLog::default();
log.record(DiagnosticKind::Error, "a");
log.record(DiagnosticKind::Error, "b");
log.clear();
assert_eq!(log.len(), 0);
log.record(DiagnosticKind::Error, "c");
assert_eq!(
log.rows().next().unwrap().seq,
3,
"a number never names two different failures"
);
}
}
+16 -6
View File
@@ -55,6 +55,7 @@ mod cooldown;
mod craft;
mod cursor;
mod death;
pub mod diagnostics;
mod editbox;
pub(crate) use editbox::adopt_text_region;
pub(crate) mod addon;
@@ -569,6 +570,7 @@ impl UiScript {
tooltip::install(&lua)?;
worldmap::install(&lua)?;
net_stats::install(&lua)?;
diagnostics::install(&lua)?;
let s = UiScript {
lua,
@@ -1238,13 +1240,21 @@ impl UiScript {
/// Report a script error the host caught **outside** the VM's own dispatch — an addon file
/// that failed to compile or raised at file scope during the load walk. It joins the
/// handler-dispatch queue only: the caller has already logged it (the load walk's per-file
/// `error!` + `failures` contract), so putting it in `errors` too would double-log at the
/// app's per-frame drain.
/// handler-dispatch queue and the retained log, but **not** `errors`: the caller has already
/// logged it (the load walk's per-file `error!` + `failures` contract), so putting it there too
/// would double-log at the app's per-frame drain.
///
/// Its silent sibling is [`Self::report_load_failure`] (decision 1495) — same retention, no
/// dispatch, for the load failures that never raise at all.
pub fn report_script_error(&self, msg: &str) {
self.model_mut()
.pending_error_dispatch
.push(msg.to_string());
let mut model = self.model_mut();
// Retained as a **Load** row, not an Error one (decision 1495): every caller of this is
// the load walk, and from the player's side "the addon's file scope raised" and "the
// addon's file was missing" are the same fact — the addon is not running.
model
.diagnostics
.record(diagnostics::DiagnosticKind::Load, msg);
model.pending_error_dispatch.push(msg.to_string());
}
/// Hand every queued script error to the Lua-side error handler — the reference's own shape:
+12
View File
@@ -328,6 +328,12 @@ pub(crate) struct Model {
/// inside the failed call). A message produced *by* the handler itself goes to `errors` only —
/// that asymmetry is the recursion guard.
pub(crate) pending_error_dispatch: Vec<String>,
/// The **retained** script-error log — what [`errors`](Self::errors) and the load walk both
/// throw away once logged (decision 1495). `errors` is a per-frame drain for the host's
/// terminal; this is the session's memory, deduplicated and bounded, and the only thing a
/// player can actually read. See [`super::diagnostics`] for why the reference's own dialog is
/// not enough on its own.
pub(crate) diagnostics: super::diagnostics::DiagnosticLog,
/// Non-fatal warnings surfaced to the host (e.g. `CreateFrame`'s ignored `inherits=` template).
pub(crate) warnings: Vec<String>,
/// The screen-root rect (`[bottom, left, top, right]`), the anchor base for top-level frames.
@@ -1194,6 +1200,11 @@ impl Model {
/// a failure raised by the error handler *itself* is pushed to `errors` directly instead,
/// which is what keeps the dispatch from recursing.
pub(crate) fn record_script_error(&mut self, msg: String) {
// Three channels now, and the third is the one with a memory (decision 1495): the dialog
// shows a burst's first message and the host drain empties every frame, so without this
// the 1,112 raises after the first exist nowhere a player can reach.
self.diagnostics
.record(super::diagnostics::DiagnosticKind::Error, &msg);
self.pending_error_dispatch.push(msg.clone());
self.errors.push(msg);
}
@@ -1259,6 +1270,7 @@ impl Model {
pending_size_changed: Vec::new(),
errors: Vec::new(),
pending_error_dispatch: Vec::new(),
diagnostics: Default::default(),
warnings: Vec::new(),
// Classic Era's UIParent virtual space is 1024×768-ish; a sensible default the host can
// override with `set_screen_size`. y-up: [bottom, left, top, right].