dialogs come to the front, and the red line names what it needs

- dialogs carry the reference's toplevel bit, so the stack-split window opens above Bagnon instead of behind it
- the failure line fills its blank — "Requires Blacksmith Hammer", not "Requires" — and Opening casts stop printing a label the spell itself marks "no text"
- the pet bar's Attack/Follow/Stay tooltips name their keybinding, and typed text reaches the focused frame through the client's own delivery walk
- AtlasLoot parses again: our Lua keeps 5.0's compatibility semicolon

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-14 19:38:18 +07:00
co-authored by Claude Fable 5
parent dd1f4351a0
commit b427860b35
57 changed files with 2163 additions and 275 deletions
+18 -6
View File
@@ -363,15 +363,27 @@
-- tooltip with its rank column, an ITEM slot the shared item renderer; ref
-- ActionButton_SetTooltip). The re-enter keeps it live across page flips.
-- Anchor: ref ActionButton_SetTooltip (ActionButton.lua l.366-372) branches on the
-- UberTooltips CVar — "1" → the default corner, else RIGHT/LEFT by bar. UberTooltips
-- registers with default "1" (byte-read: WoW.exe 0x48fdd9, default string 0x82e748;
-- sibling rows BlockTrades→"0", UnitNameRenderMode→"2" confirm the layout), and benilla
-- ships no CVar state, so the branch collapses to the stock-client one: action-bar
-- tooltips live at the screen's bottom-right, above the bags.
-- UberTooltips CVar — "1" → the default corner, else beside the button, LEFT for the bars
-- that sit at the screen edge and RIGHT for the rest. This branch was collapsed to the "1"
-- leg until B230, on the reason that benilla shipped no CVar state for anything to move
-- it; 0954 ended that and cvars.rs now registers the key (default "1", byte-read — the
-- evidence lives on that row), so the fork is live and the stock look is unchanged.
--
-- The ref's LEFT set is {MultiBarBottomRight, MultiBarRight, MultiBarLeft}. Only the first
-- exists here — the two VERTICAL bars are deliberately not shipped (see the header of
-- MultiBars.xml) — so it is the whole test, and the day they land they join it.
function BenillaActionButton_OnEnter(button)
BenillaActionButton_Adopt(button)
if not GameTooltip then return end -- engine tests load file subsets
GameTooltip_SetDefaultAnchor(GameTooltip, button)
if GetCVar("UberTooltips") == "0" then
if button:GetParent() == MultiBarBottomRight then
GameTooltip:SetOwner(button, "ANCHOR_LEFT")
else
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
end
else
GameTooltip_SetDefaultAnchor(GameTooltip, button)
end
GameTooltip:SetAction(BenillaActionButton_Action(button))
end
@@ -207,7 +207,10 @@
MainMenuFrameButtonTemplate (Classic/Frame/MainMenuFrameTemplates.xml l.11-16) is that same
button — same art, same trio, same size — so the header's ART BYTES note is unchanged. -->
<Frame name="GameMenuFrame" frameStrata="DIALOG" enableMouse="true" hidden="true">
<!-- `toplevel` is the ref's own (GameMenuFrame.xml l.3) and was dropped here: without the bit
a DIALOG-strata window opened over another one stays behind it (the B180/Bagnon class,
decision 1318). Restored alone; the ref's `movable`/`parent` stay off deliberately. -->
<Frame name="GameMenuFrame" frameStrata="DIALOG" toplevel="true" enableMouse="true" hidden="true">
<!-- The era layout engine's own numbers (header SCOPE note): 28+144+28 wide, 32-top /
28-bottom padding around our seven 21-unit rungs with three 20-unit section gaps. -->
<Size><AbsDimension x="200" y="267"/></Size>
+3 -1
View File
@@ -91,7 +91,9 @@
end
]]></Script>
<GameTooltip name="ItemRefTooltip" inherits="GameTooltipTemplate" frameStrata="HIGH" hidden="true">
<!-- `toplevel` is the ref's (ItemRef.xml l.3) — a linked-item tooltip is a window that must
land above the HIGH-strata frames it is opened over (decision 1318). -->
<GameTooltip name="ItemRefTooltip" inherits="GameTooltipTemplate" frameStrata="HIGH" toplevel="true" hidden="true">
<Size><AbsDimension x="128" y="64"/></Size>
<Anchors>
<Anchor point="BOTTOM"><Offset><AbsDimension x="0" y="80"/></Offset></Anchor>
@@ -1475,7 +1475,10 @@
</Scripts>
</Frame>
<Frame name="OptionsFrame" frameStrata="HIGH" enableMouse="true" hidden="true">
<!-- `toplevel` restored (ref OptionsFrame.xml l.3) — same class as the B180/Bagnon dialog
(decision 1318). NOTE our strata is HIGH where the ref is DIALOG; left as-is, flagged in
the record rather than silently changed. -->
<Frame name="OptionsFrame" frameStrata="HIGH" toplevel="true" enableMouse="true" hidden="true">
<Size><AbsDimension x="920" y="724"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<!-- The 1.14 System-window dialog, verbatim (BACKDROP_DIALOG_32_32 — wow-ui-source
+147 -50
View File
@@ -98,13 +98,18 @@
--
-- 4 bones, pivoting at the corners of a 0.02 x 0.02 unit square, each keyed with the
-- SAME 5-key 2.000 s translation loop walking the square edge by edge (0.5 s per edge) —
-- the four phase-shifted by one corner each, so they chase one another round it.
-- the four phase-shifted by one corner each, so they chase one another round it. The
-- tracks are `interp == 1`, LINEAR (read at the bone record's M2Track word, not assumed):
-- constant speed along each edge, no easing into the corners, which is what the walk
-- below reproduces.
-- 4 particle emitters, one per bone: 300/s, life 1.000 s, Add blend, unlit, texture
-- Interface\Buttons\GlowStar.blp, size half-extent 0.005 -> 0.002 -> 0.001 over life,
-- colour (0.976,0.875,0.192,1) -> (0.996,0.945,0.745,1) -> (1,1,1,0). Its spline has 4
-- control points ALL AT THE ORIGIN, so a particle never travels: it is born where the
-- bone is and shrinks away there. The effect is therefore four comet trails running
-- round the button's rim, not a rotating ring.
-- Interface\Buttons\GlowStar.blp, size half-extent 0.005 -> 0.0015 -> 0.001 over life,
-- colour (0.976,0.875,0.192,1) -> (0.996,0.945,0.745,1) -> (1,1,1,0). Its spline is one
-- cubic Bezier segment whose 4 control points are ALL AT THE ORIGIN — checked, all four,
-- because the two INTERIOR handles are what would bend the path and reading only the
-- endpoints proves nothing — so a particle never travels: it is born where the bone is
-- and shrinks away there. The effect is therefore four comet trails running round the
-- button's rim, not a rotating ring.
--
-- Model units -> pixels is the same 1200 px/unit calibration BagFrame.xml derived from the
-- Cooldown model (its own header carries the derivation), and it CROSS-CHECKS here: the
@@ -114,22 +119,44 @@
-- number rather than eyeball a size.
--
-- OUR ONE DEPARTURE is the particle COUNT: 300 live per emitter is 12 000 quads across a
-- full bar, for a trail that reads identically at a fraction of that. We draw
-- BENILLA_PET_AUTOCAST_TRAIL per emitter, evenly spaced in AGE, which samples the same
-- position/size/colour ramps at the same times — the trail's shape, speed and colour are
-- the file's; only its density is ours.
-- full bar. We draw a SAMPLE of the same trail — each sample sits where the bone was `age`
-- ago and reads the same position/size/colour ramps at that age — so the trail's shape,
-- speed and colour are the file's; only its density is ours.
--
-- **How many samples is DERIVED, not chosen** (B228). It was 8, evenly spaced in age, on
-- the claim that the trail "reads identically at a fraction" of 300. Nobody had checked
-- that claim, and the arithmetic says it is false. The rim is 4 x 28.8 = 115.2 px round and
-- the lap is 2.0 s, so the bone travels 57.6 px/s and a particle's 1.0 s life spans 57.6 px
-- of rim — two whole edges. Eight even samples therefore sit 7.2 px apart, while the stars
-- behind the head are 4.3 px and then 2.9 px across: the back half of every trail was a row
-- of SEPARATED DOTS where the file draws one continuous streak. Marching dots are legible
-- motion in a way a smooth streak is not, which is the shape of thing a reporter reads as
-- "too fast" (nazriel, B228) even at the right period — and the period IS right, measured
-- through this file's own OnUpdate in `pet_bar_tests`.
--
-- So the samples are spaced by the file's own SIZE ramp rather than evenly: each step
-- advances one star-width / OVERLAP of arc. That spends samples in the tail where the
-- stars are small and saves them at the head where a 14.4 px star covers everything —
-- continuous everywhere, and cheaper than a uniform spacing fine enough for the tail.
BENILLA_PET_AUTOCAST_PX_PER_UNIT = 1200
BENILLA_PET_AUTOCAST_SCALE = 1.2 -- the ref Model's
BENILLA_PET_AUTOCAST_PERIOD = 2.0 -- seq0 loop, seconds
BENILLA_PET_AUTOCAST_LIFE = 1.0 -- particle lifespan
BENILLA_PET_AUTOCAST_TRAIL = 8 -- ours (see above)
BENILLA_PET_AUTOCAST_EMITTERS = 4
-- Star-widths of cover on every point of the trail. 1 would put neighbours exactly edge to
-- edge, and a GlowStar fades out toward its own edge, so the seam would show; 2 (centres
-- half a width apart) is the cheapest value with no seam.
BENILLA_PET_AUTOCAST_OVERLAP = 2
-- 0.02 units square, in pixels.
BENILLA_PET_AUTOCAST_SIZE = 0.02 * BENILLA_PET_AUTOCAST_PX_PER_UNIT * BENILLA_PET_AUTOCAST_SCALE
-- Half-extent -> full width, in pixels: {0.005, 0.002, 0.001} * 2 * K * scale.
-- Half-extent -> full width, in pixels: {0.005, 0.0015, 0.001} * 2 * K * scale.
-- The MID key is 0.0015, not the 0.002 this table carried until B228: `m2part` printed the
-- ramp at 3 decimals and rounded it up, and the transcription took the printout at its
-- word. (Fixed at the instrument too — that column prints 4 decimals now.) 33% of the
-- mid-life star, so the trail was reading thicker than the file authors it.
BENILLA_PET_AUTOCAST_STAR = {
0.010 * BENILLA_PET_AUTOCAST_PX_PER_UNIT * BENILLA_PET_AUTOCAST_SCALE,
0.004 * BENILLA_PET_AUTOCAST_PX_PER_UNIT * BENILLA_PET_AUTOCAST_SCALE,
0.003 * BENILLA_PET_AUTOCAST_PX_PER_UNIT * BENILLA_PET_AUTOCAST_SCALE,
0.002 * BENILLA_PET_AUTOCAST_PX_PER_UNIT * BENILLA_PET_AUTOCAST_SCALE,
}
-- The over-life colour ramp's three keys (the file's), as {r, g, b, a}.
@@ -167,15 +194,60 @@
return out
end
-- The trail's sample AGES, walked once off the size ramp above (the header has the why).
-- Each step advances by one local star-width / OVERLAP of rim, converted to seconds at the
-- bone's own rim speed — so the spacing tightens as the stars shrink and the streak stays
-- unbroken end to end. The count falls out of the file's numbers; nothing here picks it.
function BenillaPetAutocast_TrailAges()
local speed = 4 * BENILLA_PET_AUTOCAST_SIZE / BENILLA_PET_AUTOCAST_PERIOD -- px/s of rim
local ages, age = {}, 0
while age < BENILLA_PET_AUTOCAST_LIFE do
table.insert(ages, age)
local w = BenillaPetAutocast_Ramp(BENILLA_PET_AUTOCAST_STAR, age / BENILLA_PET_AUTOCAST_LIFE)
-- The step is only positive while the ramp is. This runs at FILE LOAD, so a future
-- edit that put a zero in the size table would hang the whole UI before a frame
-- ever drew — the failure mode decision 1306 exists for. Bail instead.
if w <= 0 then break end
age = age + w / BENILLA_PET_AUTOCAST_OVERLAP / speed
end
return ages
end
BENILLA_PET_AUTOCAST_AGES = BenillaPetAutocast_TrailAges()
BENILLA_PET_AUTOCAST_TRAIL = table.getn(BENILLA_PET_AUTOCAST_AGES)
-- Build one button's sparkle pool: EMITTERS x TRAIL textures, hidden until autocast is on.
--
-- Everything about a sample EXCEPT where it is right now is fixed by its age — size,
-- colour and alpha are reads of the over-life ramps at an age that never changes for a
-- given slot — so all of it is set HERE, once. The per-frame pass moves the texture and
-- touches nothing else, which is why tripling the sample count still costs less per frame
-- than the eight-sample version did (it re-derived all five values for every spark, every
-- frame). Each spark also carries its fixed place in the loop: its emitter's quarter-lap
-- offset, less how far the bone has moved on since this star was born.
--
-- Built LAZILY, on the first frame the button actually autocasts. Most never do, and a
-- pool that is only ever hidden is pure cost — 22 buttons' worth of it across the bar and
-- the spell book.
function BenillaPetAutocast_Create(button)
button.sparks = {}
for i = 1, BENILLA_PET_AUTOCAST_EMITTERS * BENILLA_PET_AUTOCAST_TRAIL do
local t = button:CreateTexture(nil, "OVERLAY")
t:SetTexture("Interface\\Buttons\\GlowStar")
t:SetBlendMode("ADD")
t:Hide()
table.insert(button.sparks, t)
for e = 0, BENILLA_PET_AUTOCAST_EMITTERS - 1 do
for k = 1, BENILLA_PET_AUTOCAST_TRAIL do
local age = BENILLA_PET_AUTOCAST_AGES[k]
local life = age / BENILLA_PET_AUTOCAST_LIFE
local size = BenillaPetAutocast_Ramp(BENILLA_PET_AUTOCAST_STAR, life)
local c = BenillaPetAutocast_Ramp(BENILLA_PET_AUTOCAST_COLOR, life)
local spark = button:CreateTexture(nil, "OVERLAY")
spark:SetTexture("Interface\\Buttons\\GlowStar")
spark:SetBlendMode("ADD")
spark:SetWidth(size)
spark:SetHeight(size)
spark:SetVertexColor(c[1], c[2], c[3])
spark:SetAlpha(c[4])
spark:Hide()
spark.phase = e / BENILLA_PET_AUTOCAST_EMITTERS
- age / BENILLA_PET_AUTOCAST_PERIOD
table.insert(button.sparks, spark)
end
end
end
@@ -183,34 +255,30 @@
-- emitters are one model, and every autocasting button plays the same loop in step, which
-- is what the reference's per-button Models do too (each starts at sequence time 0 and
-- they never drift, the loop being exact).
--
-- Show and hide are EDGE work, not per-frame work: this runs for all ten buttons every
-- frame and all but one or two of them are dark, so the old unconditional Hide loop was
-- spending most of the bar's frame budget re-hiding what was already hidden.
function BenillaPetAutocast_Update(button, clock)
if not button.sparks then return end
if not button.autocasting then
for i = 1, table.getn(button.sparks) do button.sparks[i]:Hide() end
if button.sparksLit then
for i = 1, table.getn(button.sparks) do button.sparks[i]:Hide() end
button.sparksLit = nil
end
return
end
local n = 1
for e = 0, BENILLA_PET_AUTOCAST_EMITTERS - 1 do
-- The four bones are the same lap, a quarter apart.
local phase = clock / BENILLA_PET_AUTOCAST_PERIOD + e / BENILLA_PET_AUTOCAST_EMITTERS
for k = 0, BENILLA_PET_AUTOCAST_TRAIL - 1 do
-- Evenly spaced in AGE: this star was emitted `age` seconds ago, so it sits
-- where the bone was then and has aged that far along the ramps.
local age = (k / BENILLA_PET_AUTOCAST_TRAIL) * BENILLA_PET_AUTOCAST_LIFE
local x, y = BenillaPetAutocast_Point(phase - age / BENILLA_PET_AUTOCAST_PERIOD)
local life = age / BENILLA_PET_AUTOCAST_LIFE
local size = BenillaPetAutocast_Ramp(BENILLA_PET_AUTOCAST_STAR, life)
local c = BenillaPetAutocast_Ramp(BENILLA_PET_AUTOCAST_COLOR, life)
local spark = button.sparks[n]
n = n + 1
spark:SetWidth(size)
spark:SetHeight(size)
spark:ClearAllPoints()
spark:SetPoint("CENTER", button, "CENTER", x, y)
spark:SetVertexColor(c[1], c[2], c[3])
spark:SetAlpha(c[4])
spark:Show()
end
if not button.sparks then BenillaPetAutocast_Create(button) end
local sparks = button.sparks
local lap = clock / BENILLA_PET_AUTOCAST_PERIOD
for i = 1, table.getn(sparks) do
local spark = sparks[i]
-- SetPoint on the SAME point replaces it, so no ClearAllPoints is needed.
local x, y = BenillaPetAutocast_Point(lap + spark.phase)
spark:SetPoint("CENTER", button, "CENTER", x, y)
end
if not button.sparksLit then
for i = 1, table.getn(sparks) do sparks[i]:Show() end
button.sparksLit = 1
end
end
@@ -233,7 +301,8 @@
button.icon = getglobal(button:GetName() .. "Icon")
button.cooldown = getglobal(button:GetName() .. "Cooldown")
button.autocastable = getglobal(button:GetName() .. "AutoCastable")
BenillaPetAutocast_Create(button)
-- No sparkle pool here: BenillaPetAutocast_Update builds it on the first frame this
-- button actually autocasts, and most buttons never do.
end
-- Ref PetActionButton_OnClick. The CheckButton widget toggles itself BEFORE OnClick fires,
@@ -311,21 +380,49 @@
PetActionBar_Update()
end
-- Ref PetActionButton_OnEnter. A token slot has no spell to look up, so its tooltip is
-- built from the two strings the info call already returned (the ref's own `isToken` fork);
-- a spell slot goes through the engine's tooltip channel.
-- Ref PetActionButton_OnEnter (PetActionBarFrame.lua l.285-305). ONE predicate,
-- `isToken or UberTooltips == "0"`, decides two things at once:
--
-- * WHICH TOOLTIP. A token slot has no spell to look up, so its tooltip is built here out
-- of the two strings the info call already returned — and the BINDING (B230). Anything
-- else is a real pet SPELL and goes through the engine's tooltip channel, which appends
-- nothing. So the parenthesised key belongs to Attack/Follow/Stay and the reactions, and
-- NOT to the pet's own spells: putting it on every slot would be the wrong fix.
-- * WHERE IT SITS. Only the UberTooltips-off leg moves the tooltip beside the button
-- (ANCHOR_RIGHT); a token with the CVar on, and every spell, take the default corner.
-- The CVar registers "1" (cvars.rs carries the byte-read), so the off leg is the
-- player's to reach and not what ships.
--
-- The suffix is the ref's own expression and it is UNCONDITIONAL — no `if the key exists`
-- guard, unlike MicroButtonTooltipText's version of the same idea (MicroMenu.xml). That is
-- visible, not academic: GetBindingText answers "" for a nil key (UIParent.xml, transcribed
-- from the ref's own l.1819-1821), so 1.12 really does render "Attack ()" on a token slot
-- whose row is unbound. Stock, none are: BONUSACTIONBUTTON1-10 default to CTRL-1..CTRL-0
-- (bindings/commands.rs, byte-real from the install's bindings-cache.wtf).
--
-- Those same ten rows are the binding B218 found the CORNER hotkey glyph can never show
-- (the header's ref-divergence list has the `bindingSuffix == this:GetID()` string-vs-number
-- reason). Corner empty, tooltip parenthesised — both faithful, at once.
function PetActionButton_OnEnter()
local button = this
if not GameTooltip then return end -- engine tests load file subsets
if not button.tooltipName then return end
GameTooltip_SetDefaultAnchor(GameTooltip, button)
if button.isToken then
GameTooltip:SetText(button.tooltipName, 1.0, 1.0, 1.0)
local uber = GetCVar("UberTooltips")
if button.isToken or uber == "0" then
if uber == "0" then
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
else
GameTooltip_SetDefaultAnchor(GameTooltip, button)
end
local key = GetBindingText(GetBindingKey("BONUSACTIONBUTTON" .. button.index), "KEY_")
GameTooltip:SetText(button.tooltipName .. NORMAL_FONT_COLOR_CODE .. " (" .. key .. ")"
.. FONT_COLOR_CODE_CLOSE, 1.0, 1.0, 1.0)
if button.tooltipSubtext then
GameTooltip:AddLine(button.tooltipSubtext, "", 0.5, 0.5, 0.5)
end
GameTooltip:Show()
else
GameTooltip_SetDefaultAnchor(GameTooltip, button)
GameTooltip:SetPetAction(button.index)
end
end
@@ -292,8 +292,8 @@
-- The ref's `$parentAutoCast` is a <Model> playing UI-AutoCastButton.mdx; we have no
-- Model widget, so the pet bar's byte-derived sparkle emitter stands in for it
-- (decision 0982 — PetActionBar.xml owns the numbers and both surfaces share them).
-- Guarded because several engine test harnesses load this file without the pet bar.
if BenillaPetAutocast_Create then BenillaPetAutocast_Create(button) end
-- Nothing to build here: that emitter pools itself lazily, on the first frame a
-- button autocasts, which on this page is a pet-book spell and never a player one.
BenillaSpellButton_UpdateButton(button)
end
+103 -22
View File
@@ -19,11 +19,15 @@
for GossipFrame/QuestFrame/QuestLogFrame ("our engine ships no UIPanelButtonTemplate, so we
draw it") — this is one more independent draw of the same real art.
NAMED DEFERRALS (both cited on the functions below, not just here):
- Typed-digit entry (`StackSplitFrame_OnChar`/`_OnKeyDown` in the ref): this engine has no
plain-frame OnChar/OnKeyDown keyboard capture, only EditBox focus (grepped; no such hook
exists anywhere in benilla-ui). The Left/Right spinner buttons are the only way to change
the split count.
TYPED-DIGIT ENTRY IS LIVE (decision 1319). It used to be this file's headline deferral
("this engine has no plain-frame OnChar/OnKeyDown capture, only EditBox focus"), and the
engine now has the delivery walk that unblocks it (`script::keyboard`, off wow-re's
byte-verified `frame-key-script-delivery.md`). The handlers below are OUR OWN spinner
logic, written from the widget's behaviour, not a transcription of the reference's Lua
(their text is never copied into this repo). `enableKeyboard` on the frame is
what puts it in the walk's bucket at all.
NAMED DEFERRAL:
- The ref's shift-click "insert the item link into the chat editbox" (StackSplitFrame isn't
even opened in that branch — ContainerFrame.lua:568-569 diverts to
`ChatFrameEditBox:Insert` instead of `OpenStackSplitFrame`) never triggers here: the
@@ -51,6 +55,10 @@
frame.owner = owner
owner.hasStackSplit = true
frame.split = 1
-- `typed` distinguishes "1 because nothing has been typed yet" from "1 because the
-- player typed it": the first digit REPLACES the seed, later digits append. Cleared on
-- every open, so a dialog reopened after typing starts fresh.
frame.typed = nil
StackSplitText:SetText(frame.split)
StackSplitLeftButton:Disable()
StackSplitRightButton:Enable()
@@ -92,26 +100,15 @@
end
end
-- The arrows go through the shared setter (below) rather than nudging the count and the
-- two buttons themselves: with typed entry live there are now three writers of
-- `frame.split`, and three copies of the enable/disable rule is how they drift apart.
function BenillaStackSplitLeft_Click()
local frame = StackSplitFrame
if frame.split == 1 then return end
frame.split = frame.split - 1
StackSplitText:SetText(frame.split)
if frame.split == 1 then
StackSplitLeftButton:Disable()
end
StackSplitRightButton:Enable()
BenillaStackSplitSet(StackSplitFrame.split - 1)
end
function BenillaStackSplitRight_Click()
local frame = StackSplitFrame
if frame.split == frame.maxStack then return end
frame.split = frame.split + 1
StackSplitText:SetText(frame.split)
if frame.split == frame.maxStack then
StackSplitRightButton:Disable()
end
StackSplitLeftButton:Enable()
BenillaStackSplitSet(StackSplitFrame.split + 1)
end
function BenillaStackSplitOkay_Click()
@@ -126,6 +123,81 @@
StackSplitFrame:Hide()
end
-- The one place the count is written, so the buttons' enable state and the label can
-- never disagree with `frame.split` (the arrow handlers below call it too). Clamped to
-- [1, maxStack] because every caller can overshoot: typing 9 into a 5-stack, or the
-- arrows at their ends.
function BenillaStackSplitSet(n)
local frame = StackSplitFrame
if n < 1 then n = 1 end
if n > frame.maxStack then n = frame.maxStack end
frame.split = n
StackSplitText:SetText(n)
if n <= 1 then
StackSplitLeftButton:Disable()
else
StackSplitLeftButton:Enable()
end
if n >= frame.maxStack then
StackSplitRightButton:Disable()
else
StackSplitRightButton:Enable()
end
end
-- Typed digits (decision 1319). The engine hands OnChar the literal character, so this is
-- a digit test and an append: the first keystroke replaces the seeded 1, each later one
-- shifts a decimal place in. A digit that would overshoot the stack is CLAMPED rather than
-- rejected -- typing 9 against a 5-stack leaves 5, which is what the player meant -- and
-- clamping keeps `typed` set, so a following digit still appends against the clamp.
-- Anything that is not a digit is ignored here; the frame consumes the key regardless
-- (the engine's existence gate decides that, not this handler's return).
function StackSplitFrame_OnChar()
local c = arg1
if c < "0" or c > "9" then return end
local frame = StackSplitFrame
local digit = tonumber(c)
local n
if frame.typed then
n = frame.split * 10 + digit
else
n = digit
end
-- A leading 0 types nothing: the count can never be 0, so it stays 1 and unlatched,
-- and the next digit still reads as the first one.
if n < 1 then return end
frame.typed = true
BenillaStackSplitSet(n)
end
-- The editing/commit keys for typed entry. BACKSPACE drops the last digit (and unlatches
-- at the end, so the next digit starts over rather than appending to a stale 1); ENTER
-- commits exactly as Okay does and ESCAPE cancels, which is what a player expects of a
-- dialog they are typing into. The arrow keys drive the spinner. Every other key is
-- swallowed by the frame -- the reference's own consequence of having a key script bound
-- at all, and the reason this dialog must not stay open invisibly.
function StackSplitFrame_OnKeyDown()
local key = arg1
local frame = StackSplitFrame
if key == "BACKSPACE" then
local n = floor(frame.split / 10)
if n < 1 then
frame.typed = nil
BenillaStackSplitSet(1)
else
BenillaStackSplitSet(n)
end
elseif key == "ENTER" then
BenillaStackSplitOkay_Click()
elseif key == "ESCAPE" then
BenillaStackSplitCancel_Click()
elseif key == "LEFT" then
BenillaStackSplitLeft_Click()
elseif key == "RIGHT" then
BenillaStackSplitRight_Click()
end
end
-- OnHide (ref l.158-162): ANY hide — Okay, Cancel, or ToggleGameMenu's chain hook below —
-- clears the owner's `hasStackSplit` flag (ref ItemButtonTemplate's OnHide, ContainerFrame.xml
-- l.35-39, checks `this.hasStackSplit` to auto-close the spinner if the SLOT itself goes away
@@ -152,7 +224,14 @@
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></HighlightTexture>
</Button>
<Frame name="StackSplitFrame" frameStrata="HIGH" hidden="true">
<!-- The frame attrs are the ref's own (l.4), and `toplevel` is load-bearing: Bagnon's bag
windows sit in the SAME HIGH stratum, and without the bit the dialog's Show raises
nothing — it loses the level tie to the later-shown bag window and the plate draws
UNDERNEATH the slots while the child buttons (level+1) poke through (the director's
08-14 Bagnon report; decision 1318). With the bit, Show runs the verified raise (compact,
then top-occupied+1). `enableKeyboard` alone is left off: its only consumer is the
typed-digit deferral in the file header. -->
<Frame name="StackSplitFrame" frameStrata="HIGH" parent="UIParent" toplevel="true" enableMouse="true" enableKeyboard="true" hidden="true">
<!-- ref StackSplitFrame.xml l.4-7: 172×96. The CENTER anchor here is only the pre-first-open
placeholder — OpenStackSplitFrame always repositions before the first Show. -->
<Size><AbsDimension x="172" y="96"/></Size>
@@ -228,6 +307,8 @@
</Frames>
<Scripts>
<OnHide>StackSplitFrame_OnHide()</OnHide>
<OnChar>StackSplitFrame_OnChar()</OnChar>
<OnKeyDown>StackSplitFrame_OnKeyDown()</OnKeyDown>
</Scripts>
</Frame>
</Ui>
+8 -3
View File
@@ -62,11 +62,16 @@
-- Hover: the form's spell tooltip through the engine channel (ref ShapeshiftBar's
-- GameTooltip:SetShapeshift; decision 0274 P2). Anchor: the ref's ShapeshiftButton
-- OnEnter (BonusActionBarFrame.xml l.40-45) takes the default corner when UberTooltips
-- is "1" — its byte-verified stock default (ActionBar.xml has the read) — so the branch
-- collapses to the default corner here too.
-- is "1" — its byte-verified stock default — and sits beside the button otherwise. Like
-- the action bar's, that fork was collapsed to the "1" leg until B230 registered the CVar
-- (cvars.rs carries the byte-read); the stock look is unchanged, the off leg is reachable.
function BenillaShapeshiftButton_OnEnter(button)
if not GameTooltip then return end -- engine tests load file subsets
GameTooltip_SetDefaultAnchor(GameTooltip, button)
if GetCVar("UberTooltips") == "0" then
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
else
GameTooltip_SetDefaultAnchor(GameTooltip, button)
end
GameTooltip:SetShapeshift(button.index)
end
@@ -1119,7 +1119,8 @@
it is ONE library file replicated, and only that one of its two sites is unguarded (the
other is `info.textHeight or UIDROPDOWNMENU_DEFAULT_TEXT_HEIGHT or 10`). Writing a
literal here would have been a silent fidelity divergence, not a fix. -->
<Button name="DropDownList1" inherits="UIDropDownListTemplate" id="1" parent="UIParent">
<!-- `toplevel` is authored on the ref's INSTANCES, not its template (decision 1318). -->
<Button name="DropDownList1" inherits="UIDropDownListTemplate" id="1" toplevel="true" parent="UIParent">
<Scripts>
<OnLoad>
local fontName, fontHeight, fontFlags = getglobal("DropDownList1Button1NormalText"):GetFont();
@@ -1131,7 +1132,7 @@
</OnHide>
</Scripts>
</Button>
<Button name="DropDownList2" inherits="UIDropDownListTemplate" id="2" parent="UIParent">
<Button name="DropDownList2" inherits="UIDropDownListTemplate" id="2" toplevel="true" parent="UIParent">
<Scripts>
<OnHide>
CloseDropDownMenus(3);
+3 -1
View File
@@ -1712,7 +1712,9 @@
the click's hide so a second instance must be free; the ref's own StaticPopup2 case).
Extend toward 4 the day a third coexists. StaticPopup2 seats below 1 (the ref's
StaticPopup_SetUpPosition stack, simplified to a static anchor). -->
<Frame name="StaticPopupTemplate" virtual="true" enableMouse="true" frameStrata="DIALOG" hidden="true">
<!-- `toplevel` is the ref's (StaticPopup.xml l.3): a confirm popup must come to the front over
whatever DIALOG-strata window raised it (decision 1318). -->
<Frame name="StaticPopupTemplate" virtual="true" toplevel="true" enableMouse="true" frameStrata="DIALOG" hidden="true">
<Size><AbsDimension x="320" y="72"/></Size>
<Backdrop bgFile="Interface\DialogFrame\UI-DialogBox-Background" edgeFile="Interface\DialogFrame\UI-DialogBox-Border" tile="true">
<BackgroundInsets><AbsInset left="11" right="12" top="12" bottom="11"/></BackgroundInsets>
+4 -2
View File
@@ -106,8 +106,10 @@ StanceBar.xml
# i.e. until the server sends `SMSG_PET_SPELLS` for a live pet or charm — so a class that
# never has one never sees it. Right after the stance bar, whose row it shares (either
# one showing raises UIParent.xml's managed "pet" delta), and after Cooldown.xml
# (CooldownFrame_SetTimer), ActionBar.xml (the `MainMenuBar` anchor target) and
# GameTooltip.xml (its hover). It is also the pet ARC's file, so decision 1066's three
# (CooldownFrame_SetTimer), ActionBar.xml (the `MainMenuBar` anchor target),
# GameTooltip.xml (its hover), Fonts.xml (NORMAL_FONT_COLOR_CODE, which wraps the binding
# a token slot's tooltip appends) and UIParent.xml (GetBindingText, which renders it).
# It is also the pet ARC's file, so decision 1066's three
# right-click-menu dialogs (ABANDON_PET / RENAME_PET / PETRENAMECONFIRM) register here —
# which adds UiPanels.xml to its dependency set, already far above.
PetActionBar.xml
+13 -3
View File
@@ -111,6 +111,16 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
// out-of-box look is hover-only numerals. That default is BEHAVIOUR-derived, not byte-read —
// 1.12's registrar value for this var is not pinned in wow-re yet.
("statusBarText", "0"),
// Enhanced Tooltips (B230): 1.12's `UberTooltips`, the *Enhanced Tooltips* checkbox
// (`UIOptionsFrame.lua:15`, `USE_UBERTOOLTIPS`). **No host knob** — its consumers are Lua, and
// there are three: PetActionBar.xml forks the whole tooltip on it (a token's own text with the
// binding appended, vs the engine's pet-spell channel), ActionBar.xml and StanceBar.xml fork
// their anchor. Registered "1" — byte-read, not behaviour-derived: WoW.exe `0x48fdd9`, default
// string `0x82e748`, with the sibling rows `BlockTrades`→"0" and `UnitNameRenderMode`→"2"
// confirming the layout. Those three Lua sites each carried the reference's fork in prose and
// then collapsed it to this default, on the stated premise that benilla shipped no CVar state
// for anything to move. That premise expired with 0954, and this row is what un-collapses them.
("UberTooltips", "1"),
// The two chat-bubble switches (1139): 1.12's own registrar CVars over the bubble gate,
// which held them as `const bool` from 0598 until this window had a page for them.
// `ChatBubbles` is the reference's registered "1"; `ChatBubblesParty` is ON where the binary
@@ -236,9 +246,9 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
"unitnameplayer" => knobs.names.player = v != 0.0,
"unitnamenpc" => knobs.names.npc = v != 0.0,
"unitnameown" => knobs.names.own = v != 0.0,
// A CVar with no HOST knob, because its consumer is Lua (1140). Known — so the caller
// dirties the config and the value persists — with nothing to apply on this side.
"statusbartext" => {}
// Two CVars with no HOST knob, because their consumers are Lua (1140, B230). Known — so
// the caller dirties the config and the value persists — with nothing to apply this side.
"statusbartext" | "ubertooltips" => {}
// The two bubble switches (1139) — flags, like every other pair here.
"chatbubbles" => knobs.bubbles.all = v != 0.0,
"chatbubblesparty" => knobs.bubbles.party = v != 0.0,
+2
View File
@@ -714,10 +714,12 @@ pub(super) fn apply_net_updates(
spell_id,
success,
reason,
arg,
} => cast_result(
spell_id,
success,
reason,
arg,
&mut commands,
&self_guid,
&index,
+1 -1
View File
@@ -160,7 +160,7 @@ fn pet_feedback_key(reason: u8) -> Option<&'static str> {
pub(super) fn pet_cast_failed(spell_id: u32, reason: Option<u8>, errors: &mut CastErrors) {
debug!("net: pet cast failed — spell {spell_id} reason {reason:?}");
if let Some(reason) = reason {
errors.0.push((spell_id, reason));
errors.push_local(spell_id, reason);
}
}
+7 -1
View File
@@ -77,6 +77,7 @@ pub(super) fn cast_result(
spell_id: u32,
success: bool,
reason: Option<u8>,
arg: Option<u32>,
commands: &mut Commands,
self_guid: &SelfGuid,
index: &GuidIndex,
@@ -153,7 +154,11 @@ pub(super) fn cast_result(
// still shows "out of range"/"line of sight" even though no bar is (or should be) up.
if let Some(reason) = reason {
if !dont_report {
cast_errors.0.push((spell_id, reason));
cast_errors.0.push(crate::ui_action::CastFail {
spell_id,
reason,
arg,
});
}
}
// The cast bar's red "Failed" — only the showing cast's own failure turns it red.
@@ -1336,6 +1341,7 @@ mod tests {
75,
false,
Some(reason),
None,
&mut commands,
&self_guid,
&index,
+9 -2
View File
@@ -487,7 +487,7 @@ enum KeyFact {
fn resolve_go_action(
guid: u64,
inputs: &mut GoLockInputs,
known: &std::collections::HashSet<u32>,
known: &std::collections::BTreeSet<u32>,
go: Option<(&ObjectStore, u32)>,
me_store: Option<&ObjectStore>,
net: &NetCommands,
@@ -516,7 +516,14 @@ fn resolve_go_action(
);
let key_entry = match outcome {
super::lock::LockOutcome::Unlocked => return GoAction::Use,
super::lock::LockOutcome::OpenBySpell(spell_id) => return GoAction::OpenLock(spell_id),
super::lock::LockOutcome::OpenBySpell(spell_id) => {
// The retest instrument for B247 (decision 1312): which of the player's openers this
// lock resolved to, by id. The spell's Spell.dbc name is what the cast bar prints, so
// reading the id off a probe run is how "the bar says Opening - No Text" becomes a
// machine-checkable fact instead of a screenshot.
debug!("target: lock {} → open by spell {spell_id}", tmpl.lock_id);
return GoAction::OpenLock(spell_id);
}
super::lock::LockOutcome::OpenByKey(entry) => entry,
super::lock::LockOutcome::Unmet => {
// Unopenable — §8.8's routing, which keys off Lock.dbc **slot 0** regardless of which
+97 -13
View File
@@ -27,7 +27,7 @@
//! the skill block. That is not an approximation on the client's part: the DBC encodes the same
//! number (Pick Lock at 60 → 300, a rogue's cap; Mining at 60 → 300).
use std::collections::HashSet;
use std::collections::BTreeSet;
use benilla_formats::{LockSlot, LOCK_KEY_ITEM, LOCK_KEY_SKILL};
use bevy::prelude::*;
@@ -129,13 +129,20 @@ pub(crate) const GO_FLAG_LOCKED: u32 = 0x2;
/// `matched_spell` is the out-param the toast routing needs; it is written for a LockType match
/// even when the value test then fails.
///
/// One deliberate difference: the reference walks its known-spell **array** in index order, while
/// `known` is a set. That only decides *which* of several equally-matching spells lands in
/// `matched_spell` (a miner knows four Mining ranks, all `LockType 3`), never the outcome — the
/// scan keeps going past an insufficient one, so the best-valued opener still wins.
/// **The scan order is part of the answer, not an implementation detail** (decision 1312). The
/// reference walks its known-spell **array** in index order and *returns on the first sufficient
/// match*, so when two known spells both match the LockType and both clear the requirement, the
/// order decides which one is cast — and that spell's name is what the cast bar reads. This was
/// documented here for a long time as a harmless difference ("it only decides which spell lands in
/// `matched_spell`, never the outcome"), which is false: every character knows both 6478 "Opening"
/// and 22810 **"Opening - No Text"** — both `SPELL_EFFECT_OPEN_LOCK` on `LockType 13` (Open
/// Kneeling), both trivially sufficient against the `Skill == 0` slots the ground containers carry
/// — so iterating a `HashSet` put Blizzard's placeholder name on the cast bar at the hash's whim
/// (B247). `known` is a [`BTreeSet`] for exactly that reason: ascending spell id is the reference
/// array's own order after login, the server building `SMSG_INITIAL_SPELLS` out of a `std::map`.
pub(crate) fn resolve_lock(
slots: &[LockSlot],
known: &HashSet<u32>,
known: &BTreeSet<u32>,
spells: Option<&crate::ui_action::Spells>,
me: Option<&ObjectStore>,
items: &crate::items::Items,
@@ -375,7 +382,7 @@ mod tests {
let mut matched = None;
let out = resolve_lock(
&slots,
&HashSet::new(),
&BTreeSet::new(),
None,
None,
&items,
@@ -461,7 +468,7 @@ mod tests {
assert_eq!(
resolve_lock(
&scholomance,
&HashSet::from([6247]),
&BTreeSet::from([6247]),
Some(&spells),
None,
&items,
@@ -480,7 +487,7 @@ mod tests {
assert_eq!(
resolve_lock(
&scholomance,
&HashSet::from([6247]),
&BTreeSet::from([6247]),
Some(&spells),
None,
&items,
@@ -500,7 +507,7 @@ mod tests {
assert_eq!(
resolve_lock(
&scholomance,
&HashSet::from([1804]),
&BTreeSet::from([1804]),
Some(&spells),
None,
&items,
@@ -528,7 +535,7 @@ mod tests {
assert_eq!(
resolve_lock(
&searing_gorge,
&HashSet::from([6247]),
&BTreeSet::from([6247]),
Some(&spells),
None,
&items,
@@ -559,7 +566,7 @@ mod tests {
assert_eq!(
resolve_lock(
&vein,
&HashSet::from([2575]),
&BTreeSet::from([2575]),
Some(&with_mining),
None,
&items,
@@ -574,6 +581,83 @@ mod tests {
);
}
/// **B247** (decision 1312): two known openers on one LockType, both sufficient — the LOWEST
/// spell id wins, deterministically, because that is the reference array's order and the scan
/// returns on its first sufficient match.
///
/// The real values: lock 43 (Hyacinth Mushroom and every other ground container) carries one
/// SKILL slot, `LockType 13` "Open Kneeling", `Skill 0`, `Action 0`. Three shipped spells carry
/// `SPELL_EFFECT_OPEN_LOCK` on type 13 — 6478 "Opening", 17667 "Light On Fire", and 22810
/// **"Opening - No Text"** — and `playercreateinfo_spell` grants 6478 *and* 22810 to every
/// race/class. Whichever this scan returns is the spell the click casts, so its Spell.dbc name
/// is what the cast bar prints: with a `HashSet` here the bar read Blizzard's placeholder about
/// half the time.
#[test]
fn two_sufficient_openers_pick_the_lower_spell_id() {
use benilla_formats::{OpenLock, SpellCatalog, SpellDisplay};
let mut mushroom = [LockSlot::default(); 8];
mushroom[0] = skill_slot(13, 0, 0);
// Both "Opening" spells: OPEN_LOCK on type 13, flat value 100 (base 99 + dice 1), which
// clears the slot's `Skill 0`. Identical in every input the resolver reads — the pick is
// decided by visit order and nothing else, which is the whole point.
let kneeling_opener = || SpellDisplay {
open_lock: Some(OpenLock {
lock_type: 13,
effect: 0,
}),
effect_base_points: [99, 0, 0],
effect_base_dice: [1, 0, 0],
..Default::default()
};
let spells = crate::ui_action::Spells {
catalog: SpellCatalog::from_displays(
[(6478, kneeling_opener()), (22810, kneeling_opener())]
.into_iter()
.collect(),
),
..crate::ui_action::Spells::empty_for_tests()
};
let items = crate::items::Items::default();
let unlocked = GoFacts {
state: GO_STATE_READY,
flag_locked: false,
level: 0,
};
let mut matched = None;
assert_eq!(
resolve_lock(
&mushroom,
&BTreeSet::from([6478, 22810]),
Some(&spells),
None,
&items,
unlocked,
&mut matched,
),
LockOutcome::OpenBySpell(6478),
"6478 \"Opening\", never 22810 \"Opening - No Text\""
);
assert_eq!(matched, Some(6478), "the toast's out-param agrees");
// Insertion order cannot change it — the set is ordered, so the scan is too.
let mut matched = None;
assert_eq!(
resolve_lock(
&mushroom,
&BTreeSet::from([22810, 6478]),
Some(&spells),
None,
&items,
unlocked,
&mut matched,
),
LockOutcome::OpenBySpell(6478),
);
}
/// An all-empty row is not a lock at all — `CMSG_GAMEOBJ_USE`.
#[test]
fn an_empty_row_is_unlocked() {
@@ -583,7 +667,7 @@ mod tests {
assert_eq!(
resolve_lock(
&slots,
&HashSet::new(),
&BTreeSet::new(),
None,
None,
&items,
+176 -31
View File
@@ -10,18 +10,33 @@
//! screen shows "Spell is not ready yet." while `SPELL_FAILED_NOT_READY` reads
//! "Not yet recovered", and "Not enough rage" for a rage spell's NO_POWER.
//!
//! 3. A per-reason **argument arm** (the second dispatch `0x6e1d8e`, 13 targets) fills that
//! message's own `%s`/`%d` from a DBC or item name before it is displayed. The two arms whose
//! tables benilla already loads live in [`FailArgs::fill`]; the drain fills three more before
//! calling here (below), and the rest strip.
//!
//! Strings are never hardcoded here: every message resolves from the VM's loaded
//! `GlobalStrings.lua` by key, so localization rides for free. Suppression is faithful on
//! both mechanisms: reason `0x17` (DONT_REPORT) never reaches display (control-flow), and a
//! key absent from GlobalStrings displays as nothing (data — 0x08/0x21/0x75, happiness
//! NO_POWER). Known approximations, each marked below: the DBC-name `%s` fills of the
//! argument-formatted reasons are stripped instead of filled — except `0x78` TOTEMS and
//! `0x5c` REAGENTS, whose item-name fills (and query-then-redisplay cache-miss behavior) the
//! drain models before calling here ("Requires Mining Pick", decisions 0545 + 0552) — and
//! `0x0a`'s item-spell leg (`ERR_INVALID_ITEM_TARGET`) is unmodeled — the drain does not
//! know item-ness.
//! NO_POWER).
//!
//! **The argument arms, and what is still approximate.** Filled: `0x5e` REQUIRES_SPELL_FOCUS and
//! `0x5d` REQUIRES_AREA here (decision 1313 — `0x6e1f62`/`0x6e1fad`), and `0x78` TOTEMS / `0x5c`
//! REAGENTS / `0x19`–`0x1b` EQUIPPED_ITEM_CLASS\* in the drain, which owns them because their
//! fills need the item caches and the query-then-redisplay cache-miss behavior ("Requires Mining
//! Pick", decisions 0545 + 0552). Still stripped rather than filled — each needs a DBC we do not
//! load yet: `0x56` ONLY_SHAPESHIFT (form-name list), `0x8d` PREVENTED_BY_MECHANIC
//! (`SpellMechanic.dbc`), `0x90` MIN_SKILL (`SkillLine.dbc`), `0x31` NEED_EXOTIC_AMMO and `0x84`
//! PROSPECT_NEED_MORE. Stripping is a **deliberate divergence**, now byte-confirmed as one: on a
//! bad id or an absent word the reference jumps to the default arm with the pointer still on the
//! *unfilled* template, so it displays a literal `Requires %s` (wow-re §WIRE-ARGS C3 — the fill
//! path's own buffer swap sits after the printf and is skipped). We show the bare stem instead:
//! "Requires" reads as terse, "Requires %s" reads as broken software (§7 — judge by the result).
//! `0x0a`'s item-spell leg (`ERR_INVALID_ITEM_TARGET`) is unmodeled — the drain does not know
//! item-ness.
use benilla_formats::SpellDisplay;
use benilla_formats::{AreaTableCatalog, SpellDisplay, SpellFocusCatalog};
/// Wire reason → its `SPELL_FAILED_*` GlobalStrings key (the `0x6e23e0` table, byte-exact).
pub(super) const CAST_FAIL_KEYS: [&str; 146] = [
@@ -194,12 +209,64 @@ fn is_food(spell: Option<&SpellDisplay>) -> bool {
spell.is_some_and(|d| matches!(d.category, 0xA | 0xB))
}
/// The argument arms' inputs: the wire's reason-specific word ([`super::CastFail::arg`]) and the
/// DBC name tables the arms read. Both catalogs are `Option` because a client without game data
/// has neither — the arm then declines and the template strips, exactly as an unmodeled arm does.
#[derive(Default, Clone, Copy)]
pub(super) struct FailArgs<'a> {
pub(super) arg: Option<u32>,
/// `SpellFocusObject.dbc` (`0xc0d800`) — the `0x5e` arm's names ("Anvil", "Forge", and the
/// Teldrassil moonwells the Crown of the Earth phials name).
pub(super) focus: Option<&'a SpellFocusCatalog>,
/// `AreaTable.dbc` (`0xc0e048`) — the `0x5d` arm's `AreaName`.
pub(super) areas: Option<&'a AreaTableCatalog>,
}
impl FailArgs<'_> {
/// The `%s` fill for the argument-formatted reasons this module owns, or `None` to leave the
/// template to the strip fallback.
///
/// Both arms are byte-verified and §5 cross-checked (wow-re `cast-fail-strings.md`
/// §WIRE-ARGS): each reads the **wire's** first argument word — `[ebx+8]`, the handler's own
/// stack slot, never a re-read of `Spell.dbc` — indexes its DBC store, and `SStrPrintf`s the
/// reason's `SPELL_FAILED_*` template. The errorId stays the default `0x2c` (`"%s"`) for both,
/// so what the player reads IS the filled template. A single-`%s` template is what makes a
/// plain `replace` faithful here; the reference runs a real two-pass printf, which would
/// matter for a multi-specifier format.
///
/// **`0x5e` REQUIRES_SPELL_FOCUS** (`0x6e1f62`) → `SpellFocusObject.dbc` (`0xc0d800`)
/// `Name_Lang` at `row + 0x4 + locale*4`, so `"Requires %s"` reads "Requires Starbreeze
/// Village Moonwell". The failing spell's own `RequiresSpellFocus` column holds the same
/// number vmangos copied onto the wire, so we fall back to it when the word is absent — a
/// benilla-side robustness margin, not a transcription: the client itself never reads
/// `SpellRec+0x3c` on this path.
///
/// **`0x5d` REQUIRES_AREA** (`0x6e1fad`) → `AreaTable.dbc` (`0xc0e048`) `AreaName_Lang` at
/// `row + 0x2c + locale*4`, so `"You need to be in %s"` names the zone. This one has **no**
/// client-side stand-in: the server derives the id from its own `spell_area` rows and nothing
/// in `Spell.dbc` holds it, so an absent word means an unfilled message.
fn fill(&self, reason: u8, spell: Option<&SpellDisplay>) -> Option<String> {
match reason {
0x5D => self.areas?.name(self.arg?).map(str::to_string),
0x5E => {
let id = self
.arg
.filter(|&id| id != 0)
.or_else(|| Some(spell?.requires_spell_focus).filter(|&id| id != 0))?;
self.focus?.name(id).map(str::to_string)
}
_ => None,
}
}
}
/// The displayed text for a failed cast — `None` = the reference shows nothing. `get` is the
/// VM's GlobalStrings lookup (an absent or empty key resolves to `None`, the data-suppression
/// face). Reasons beyond the table print their code — our debug affordance, not a ref string.
pub(super) fn cast_fail_text(
reason: u8,
spell: Option<&SpellDisplay>,
args: FailArgs<'_>,
get: &dyn Fn(&str) -> Option<String>,
) -> Option<String> {
let get_display = |key: &str| get(key).filter(|s| !s.is_empty());
@@ -248,9 +315,12 @@ pub(super) fn cast_fail_text(
return Some(format!("Spell failed ({reason:#04x})"));
};
let text = get_display(key)?;
// The argument-formatted reasons fill %s/%d from DBC names (0x6e1d8e's arm table) —
// unmodeled: strip the tokens so the stem reads clean ("Missing reagent: %s" →
// "Missing reagent"), never a raw % on screen.
// The argument arms (`0x6e1d8e`): fill the template's `%s` from the reason's own DBC.
if let Some(name) = args.fill(reason, spell) {
return Some(text.replace("%s", &name));
}
// An arm we don't model (or one whose lookup missed) — strip the tokens so the stem reads
// clean ("Missing reagent: %s" → "Missing reagent"), never a raw % on screen.
if text.contains('%') {
let stripped = text
.replace("%s", "")
@@ -321,27 +391,36 @@ mod tests {
fn overrides_replace_and_passthrough_reads() {
let m = gs();
let g = getter(&m);
assert_eq!(cast_fail_text(0x43, None, &g).unwrap(), "Out of ammo");
assert_eq!(cast_fail_text(0x59, None, &g).unwrap(), "Out of range.");
assert_eq!(cast_fail_text(0x76, None, &g).unwrap(), "Target too close");
assert_eq!(
cast_fail_text(0x09, None, &g).unwrap(),
cast_fail_text(0x43, None, FailArgs::default(), &g).unwrap(),
"Out of ammo"
);
assert_eq!(
cast_fail_text(0x59, None, FailArgs::default(), &g).unwrap(),
"Out of range."
);
assert_eq!(
cast_fail_text(0x76, None, FailArgs::default(), &g).unwrap(),
"Target too close"
);
assert_eq!(
cast_fail_text(0x09, None, FailArgs::default(), &g).unwrap(),
"You have no target."
);
// 0x3c: plain spell → spell cooldown; Attr&0x10 → ability; potion category → potion.
let plain = spell(0, 0, 0);
assert_eq!(
cast_fail_text(0x3C, Some(&plain), &g).unwrap(),
cast_fail_text(0x3C, Some(&plain), FailArgs::default(), &g).unwrap(),
"Spell is not ready yet."
);
let ability = spell(1, 0, 0x10);
assert_eq!(
cast_fail_text(0x3C, Some(&ability), &g).unwrap(),
cast_fail_text(0x3C, Some(&ability), FailArgs::default(), &g).unwrap(),
"Ability is not ready yet."
);
let potion = spell(0, 4, 0);
assert_eq!(
cast_fail_text(0x3C, Some(&potion), &g).unwrap(),
cast_fail_text(0x3C, Some(&potion), FailArgs::default(), &g).unwrap(),
"Item is not ready yet."
);
}
@@ -354,12 +433,12 @@ mod tests {
let g = getter(&m);
let rage = spell(1, 0, 0);
assert_eq!(
cast_fail_text(0x4D, Some(&rage), &g).unwrap(),
cast_fail_text(0x4D, Some(&rage), FailArgs::default(), &g).unwrap(),
"Not enough rage"
);
let mana = spell(0, 0, 0);
assert_eq!(
cast_fail_text(0x4D, Some(&mana), &g).unwrap(),
cast_fail_text(0x4D, Some(&mana), FailArgs::default(), &g).unwrap(),
"Not enough mana"
);
}
@@ -371,13 +450,16 @@ mod tests {
fn suppression_hex_fallback_and_template_strip() {
let m = gs();
let g = getter(&m);
assert_eq!(cast_fail_text(0x17, None, &g), None);
assert_eq!(cast_fail_text(0x08, None, &g), None);
assert_eq!(cast_fail_text(0x17, None, FailArgs::default(), &g), None);
assert_eq!(cast_fail_text(0x08, None, FailArgs::default(), &g), None);
assert_eq!(
cast_fail_text(0x92, None, &g).unwrap(),
cast_fail_text(0x92, None, FailArgs::default(), &g).unwrap(),
"Spell failed (0x92)"
);
assert_eq!(cast_fail_text(0x5C, None, &g).unwrap(), "Missing reagent");
assert_eq!(
cast_fail_text(0x5C, None, FailArgs::default(), &g).unwrap(),
"Missing reagent"
);
}
/// The RUNTIME leg, end to end on the real data: the shipped `GlobalStrings.lua` executed
@@ -396,30 +478,93 @@ mod tests {
s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
let g = |key: &str| s.lua().globals().get::<String>(key).ok();
assert_eq!(cast_fail_text(0x43, None, &g).unwrap(), "Out of ammo");
assert_eq!(cast_fail_text(0x59, None, &g).unwrap(), "Out of range.");
assert_eq!(
cast_fail_text(0x3C, None, &g).unwrap(),
cast_fail_text(0x43, None, FailArgs::default(), &g).unwrap(),
"Out of ammo"
);
assert_eq!(
cast_fail_text(0x59, None, FailArgs::default(), &g).unwrap(),
"Out of range."
);
assert_eq!(
cast_fail_text(0x3C, None, FailArgs::default(), &g).unwrap(),
"Spell is not ready yet."
);
let rage = spell(1, 0, 0);
assert_eq!(
cast_fail_text(0x4D, Some(&rage), &g).unwrap(),
cast_fail_text(0x4D, Some(&rage), FailArgs::default(), &g).unwrap(),
"Not enough rage"
);
// The environment gate's pair (decision 1056) — both are plain passthroughs, so what the
// player reads IS the GlobalStrings value. A typo'd key here would degrade a real refusal
// to a dead-looking button, which is what this test exists to catch.
assert_eq!(
cast_fail_text(0x50, None, &g).unwrap(),
cast_fail_text(0x50, None, FailArgs::default(), &g).unwrap(),
"Cannot use while swimming"
);
assert_eq!(
cast_fail_text(0x58, None, &g).unwrap(),
cast_fail_text(0x58, None, FailArgs::default(), &g).unwrap(),
"Can only use while swimming"
);
// The data-suppression face on the real file: the absent keys show nothing.
assert_eq!(cast_fail_text(0x08, None, &g), None);
assert_eq!(cast_fail_text(0x21, None, &g), None);
assert_eq!(cast_fail_text(0x08, None, FailArgs::default(), &g), None);
assert_eq!(cast_fail_text(0x21, None, FailArgs::default(), &g), None);
// B255, end to end on the real data: the argument arms against the real DBCs and the real
// GlobalStrings templates. Without the fill these read as the bare stems "Requires" and
// "You need to be in" — which is exactly what shipped.
let focus =
benilla_formats::load_spell_focus_catalog(&mut chain).expect("SpellFocusObject");
let areas = benilla_formats::load_area_table_catalog(&mut chain).expect("AreaTable");
let args = |arg: u32| FailArgs {
arg: Some(arg),
focus: Some(&focus),
areas: Some(&areas),
};
// 0x5e REQUIRES_SPELL_FOCUS: the Crown of the Earth phials' own refusal. Focus 12 is the
// Starbreeze Village moonwell — using the Jade Phial at any *other* pool is the report.
assert_eq!(
cast_fail_text(0x5E, None, args(12), &g).unwrap(),
"Requires Starbreeze Village Moonwell"
);
assert_eq!(
cast_fail_text(0x5E, None, args(1), &g).unwrap(),
"Requires Anvil"
);
// 0x5d REQUIRES_AREA: area 1657 is Darnassus.
assert_eq!(
cast_fail_text(0x5D, None, args(1657), &g).unwrap(),
"You need to be in Darnassus"
);
// The fallbacks. An id the DBC doesn't name, and a wire word the server never sent, both
// decline the arm and fall through to the strip — never a raw `%s` on screen.
assert_eq!(
cast_fail_text(0x5E, None, args(999_999), &g).unwrap(),
"Requires"
);
assert_eq!(
cast_fail_text(0x5D, None, FailArgs::default(), &g).unwrap(),
"You need to be in"
);
// 0x5e alone has a client-side stand-in: the failing spell's own `RequiresSpellFocus`
// column is the very number the server copied onto the wire, so an absent word still
// fills. (Spell 4976 "Filling" — the Crystal Phial's — carries focus 11.)
let filling = SpellDisplay {
requires_spell_focus: 11,
..Default::default()
};
assert_eq!(
cast_fail_text(
0x5E,
Some(&filling),
FailArgs {
focus: Some(&focus),
..FailArgs::default()
},
&g
)
.unwrap(),
"Requires Shadowglen Moonwell"
);
}
}
+20 -13
View File
@@ -290,7 +290,7 @@ fn send_spell_cast(
// `HandleCastFailed`) — the inflight rec here is always an ordinary cast, so even an
// on-next-swing press is refused while it holds.
if pending.current(now) != Some(spell_id) {
cast_errors.0.push((spell_id, 0x61));
cast_errors.push_local(spell_id, 0x61);
}
debug!("ui_action: cast {spell_id} suppressed — a cast is already in flight");
return;
@@ -343,7 +343,7 @@ fn send_spell_cast(
debug!(
"ui_action: cast {spell_id} refused locally — unbindable target ({reason:#x})"
);
cast_errors.0.push((spell_id, reason));
cast_errors.push_local(spell_id, reason);
return;
}
},
@@ -371,7 +371,7 @@ fn send_spell_cast(
dist_sq,
) {
debug!("ui_action: cast {spell_id} refused locally — range ({reason:#x})");
cast_errors.0.push((spell_id, reason));
cast_errors.push_local(spell_id, reason);
return;
}
}
@@ -417,7 +417,7 @@ fn send_spell_cast(
}
}
}
cast_errors.0.push((spell_id, commit.not_ready_reason()));
cast_errors.push_local(spell_id, commit.not_ready_reason());
return;
}
// Rung 2 — the power gate (`0x60962c`, SPELL presses only: the item fork's clear-query jump
@@ -430,7 +430,7 @@ fn send_spell_cast(
if let (Some(d), Some(store)) = (def, ctx.rel.self_store) {
if !super::usable::can_afford(d, store) {
debug!("ui_action: cast {spell_id} refused locally — not enough power (0x4d)");
cast_errors.0.push((spell_id, 0x4d));
cast_errors.push_local(spell_id, 0x4d);
return;
}
}
@@ -451,7 +451,7 @@ fn send_spell_cast(
def,
) {
debug!("ui_action: cast {spell_id} refused locally — mounted (0x39)");
cast_errors.0.push((spell_id, 0x39));
cast_errors.push_local(spell_id, 0x39);
return;
}
// The ENVIRONMENT leg of the SAME requirement validator (`0x609d39–0x609d7b`, between the
@@ -463,7 +463,7 @@ fn send_spell_cast(
// (ledger B176) and lets you eat mid-swim (B155's eating half). The condition is [`state`]'s.
if let Some(reason) = state::cast_water_refusal(ctx.self_move_flags, def) {
debug!("ui_action: cast {spell_id} refused locally — water side ({reason:#x})");
cast_errors.0.push((spell_id, reason));
cast_errors.push_local(spell_id, reason);
return;
}
// The moving leg of the SAME requirement validator (`0x609de3`, after the mounted/posture/
@@ -482,7 +482,7 @@ fn send_spell_cast(
let cast_time_ms = spells.map_or(0, |s| s.cast_time_ms(d, caster_level));
if state::cast_moving_refusal(ctx.self_move_flags, cast_time_ms, def) {
debug!("ui_action: cast {spell_id} refused locally — moving (0x2e)");
cast_errors.0.push((spell_id, 0x2e));
cast_errors.push_local(spell_id, 0x2e);
return;
}
}
@@ -507,7 +507,7 @@ fn send_spell_cast(
if let Some(refusal) = d.form_refusal(form, form_is_stance) {
let reason = refusal.reason();
debug!("ui_action: cast {spell_id} refused locally — the form gate ({reason:#x})");
cast_errors.0.push((spell_id, reason));
cast_errors.push_local(spell_id, reason);
return;
}
}
@@ -651,6 +651,7 @@ fn send_spell_cast(
mod tests {
use super::*;
use crate::net::Reputations;
use crate::ui_action::CastFail;
use bevy::ecs::system::RunSystemOnce;
use crossbeam_channel::Receiver;
@@ -934,7 +935,7 @@ mod tests {
assert!(rx.try_recv().is_err());
assert_eq!(
world.resource::<CastErrors>().0,
vec![(MOUNT, 0x61)],
vec![CastFail::local(MOUNT, 0x61)],
"a different spell mid-cast is 6e4d97's \"Another action is in progress\""
);
}
@@ -959,7 +960,10 @@ mod tests {
send(&mut world, HEARTHSTONE, HEARTH_COMMIT);
assert!(rx.try_recv().is_err(), "an item on cooldown never sends");
assert_eq!(world.resource::<CastErrors>().0, vec![(HEARTHSTONE, 0x28)]);
assert_eq!(
world.resource::<CastErrors>().0,
vec![CastFail::local(HEARTHSTONE, 0x28)]
);
// The byte law's other half (0948, correcting this test's pre-§5 shape): the record is
// keyed (use-spell, item ENTRY), and a bare SPELL press queries (spell, 0) — the item
@@ -1075,7 +1079,10 @@ mod tests {
})
.expect("one-shot");
assert!(rx.try_recv().is_err(), "an unaffordable press never sends");
assert_eq!(world.resource::<CastErrors>().0, vec![(HEARTHSTONE, 0x4d)]);
assert_eq!(
world.resource::<CastErrors>().0,
vec![CastFail::local(HEARTHSTONE, 0x4d)]
);
world.resource_mut::<CastErrors>().0.clear();
let base = ctx();
@@ -1122,7 +1129,7 @@ mod tests {
assert!(rx.try_recv().is_err());
assert_eq!(
world.resource::<CastErrors>().0,
vec![(MOUNT, 0x61)],
vec![CastFail::local(MOUNT, 0x61)],
"mid-cast outranks the cooldown rung (the ref's IsCasting precedes the validator)"
);
}
+44 -11
View File
@@ -1,8 +1,8 @@
//! The red error line's queues and resolvers — every route into `UIErrorsFrame`'s top line
//! except the cast-fail two-layer display (its own module, [`super::cast_fail`]):
//!
//! - [`CastErrors`] — the wire `(spell_id, reason)` pairs (`SMSG_CAST_RESULT` + local cast
//! refusals), resolved by [`super::cast_fail`] at the drain.
//! - [`CastErrors`] — the wire [`CastFail`]s (`SMSG_CAST_RESULT` + local cast refusals), resolved
//! by [`super::cast_fail`] at the drain.
//! - [`MountErrors`] — the `SMSG_MOUNTRESULT`/`SMSG_DISMOUNTRESULT` code pairs, resolved by
//! [`mount_result_key`] (decision 0441 P2).
//! - [`UiErrorKeys`] — client-LOCAL refusals straight by GlobalStrings key, the
@@ -21,12 +21,45 @@ use bevy::prelude::*;
use crate::net::ObjectStore;
/// Cast failures queued for the UI error line, as `(spell_id, reason)` — the wire pair from
/// `SMSG_CAST_RESULT` and the local refusals alike. The spell id rides along because the
/// display layer keys several messages on the failing spell's record ([`super::cast_fail`]:
/// NO_POWER's power family, the 0x28/0x3c cooldown families).
/// Cast failures queued for the UI error line — the wire triple from `SMSG_CAST_RESULT` and the
/// local refusals alike. The spell id rides along because the display layer keys several messages
/// on the failing spell's record ([`super::cast_fail`]: NO_POWER's power family, the 0x28/0x3c
/// cooldown families).
#[derive(Resource, Default)]
pub(crate) struct CastErrors(pub Vec<(u32, u8)>);
pub(crate) struct CastErrors(pub Vec<CastFail>);
/// One queued cast failure: the failing spell, the wire reason, and the reason-specific argument
/// word that fills the message's `%s` (the arm table at `0x6e1d8e` — a `SpellFocusObject.dbc` id
/// for 0x5e, an `AreaTable.dbc` id for 0x5d).
///
/// `arg` is `None` for every **client-local** refusal, and that is faithful rather than a gap: the
/// local refusals the reference raises itself go through `DisplayError` with no argText, and the
/// two client-generated argument messages it *does* build (the lock toasts, MIN_SKILL) are
/// [`UiError`]'s tenants, not this queue's.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct CastFail {
pub spell_id: u32,
pub reason: u8,
pub arg: Option<u32>,
}
impl CastFail {
/// A **client-local** refusal — no wire argument (see [`Self::arg`]).
pub(crate) const fn local(spell_id: u32, reason: u8) -> Self {
Self {
spell_id,
reason,
arg: None,
}
}
}
impl CastErrors {
/// Queue a [`CastFail::local`] refusal.
pub(crate) fn push_local(&mut self, spell_id: u32, reason: u8) {
self.0.push(CastFail::local(spell_id, reason));
}
}
/// (Dis)mount refusals queued for the UI error line, as the wire pair `(mount, code)` from
/// `SMSG_MOUNTRESULT`/`SMSG_DISMOUNTRESULT` (decision 0441 P2) — resolved to text through the
@@ -200,7 +233,7 @@ pub(crate) fn reagent_totem_refusal(
return false;
};
debug!("cast {spell_id} refused locally — missing totem/reagent ({reason:#04x})");
errors.0.push((spell_id, reason));
errors.push_local(spell_id, reason);
true
}
@@ -567,7 +600,7 @@ mod totem_reagent_tests {
&items,
&mut errors
));
assert_eq!(errors.0.as_slice(), &[(2575, 0x78)]);
assert_eq!(errors.0.as_slice(), &[CastFail::local(2575, 0x78)]);
let mut reagents = [(0, 0); 8];
reagents[0] = (17056, 1); // Slow Fall's Light Feather
@@ -580,7 +613,7 @@ mod totem_reagent_tests {
&items,
&mut errors
));
assert_eq!(errors.0.as_slice(), &[(130, 0x5c)]);
assert_eq!(errors.0.as_slice(), &[CastFail::local(130, 0x5c)]);
let both = spell([2901, 0], reagents);
let mut errors = CastErrors::default();
@@ -591,7 +624,7 @@ mod totem_reagent_tests {
&items,
&mut errors
));
assert_eq!(errors.0.as_slice(), &[(1, 0x78)]);
assert_eq!(errors.0.as_slice(), &[CastFail::local(1, 0x78)]);
let plain = spell([0, 0], [(0, 0); 8]);
let mut errors = CastErrors::default();
+36 -4
View File
@@ -84,6 +84,10 @@ pub(super) fn feed_actions(
mut items: ResMut<Items>,
icons: Option<Res<ItemDisplays>>,
sub_classes: Option<Res<crate::ui_items::ItemSubClasses>>,
// The two DBC name tables the cast-fail argument arms read (`FailArgs`): the crafting book's
// SpellFocusObject catalog and the map arc's AreaTable one, both already loaded.
spell_focus: Option<Res<crate::ui_tradeskill::SpellFocus>>,
areas: Option<Res<crate::area::AreaTableRes>>,
commands: Res<NetCommands>,
mut memory: Local<crate::ui_script::VmMemo<FeedMemory>>,
) {
@@ -102,12 +106,22 @@ pub(super) fn feed_actions(
// item-cache miss the ref queries and shows nothing that frame, then its DBCACHECALLBACK
// `0x6e29b0` REDISPLAYS when the answer lands — modeled by keeping the entry queued: the
// ask-once query is away, and the frame the template answers, the fill succeeds and fires.
// The DBC-only arms (0x5d REQUIRES_AREA, 0x5e REQUIRES_SPELL_FOCUS) need no round trip and
// are filled inside [`cast_fail`] itself, off the wire's argument word.
let self_store = self_q.iter().next();
let mut await_template: Vec<(u32, u8)> = Vec::new();
let mut await_template: Vec<crate::ui_action::CastFail> = Vec::new();
let fail_args = cast_fail::FailArgs {
arg: None,
focus: spell_focus.as_deref().map(|f| &f.catalog),
areas: areas.as_deref().map(|a| &a.0),
};
let texts: Vec<String> = cast_errors
.0
.drain(..)
.filter_map(|(spell_id, reason)| {
.filter_map(|fail| {
let crate::ui_action::CastFail {
spell_id, reason, ..
} = fail;
let d = spells.as_ref().and_then(|s| s.catalog.get(spell_id));
let get = |key: &str| script.lua().globals().get::<String>(key).ok();
// 0x19/0x1a/0x1b EQUIPPED_ITEM_CLASS* — the other argument-formatted family whose
@@ -154,7 +168,7 @@ pub(super) fn feed_actions(
// still pending → keep the entry queued for the redisplay.
None if items.template_answered_unknown(failing) => "UNKNOWN".to_string(),
None => {
await_template.push((spell_id, reason));
await_template.push(fail);
return None;
}
};
@@ -167,7 +181,25 @@ pub(super) fn feed_actions(
.filter(|s| !s.is_empty())
.map(|t| t.replace("%s", &name));
}
cast_fail::cast_fail_text(reason, d, &get)
let text = cast_fail::cast_fail_text(
reason,
d,
cast_fail::FailArgs {
arg: fail.arg,
..fail_args
},
&get,
);
// The retest instrument for this whole bug class (decision 1313). A red-line defect is
// reported as *seen* — B255 arrived as a screenshot of the word "Requires" — and until
// this line the only way to read what the client resolved was to look at the screen.
// Logging the reason, its wire argument and the resolved text makes an argument arm
// that silently declined (a missing word, an unnamed id) legible from a probe run.
debug!(
"ui_action: cast fail — spell {spell_id} reason {reason:#04x} arg {:?} → {:?}",
fail.arg, text
);
text
})
.collect();
cast_errors.0.extend(await_template);
+24 -10
View File
@@ -22,7 +22,7 @@
//! [`errors`] (the red error line's two layers), [`usable`] (the castability walk the stance bar
//! shares), [`weapon_icon`] (the auto-attack's borrowed weapon icon).
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeSet, HashMap};
use bevy::prelude::*;
@@ -65,8 +65,8 @@ pub(crate) struct CooldownEvents;
// `cast_send`, so a second send path cannot be written by accident (decision 0914).
pub(crate) use cast_send::{CastCommit, CastLadder};
pub(crate) use errors::{
attack_actor_refusal, reagent_totem_refusal, ui_error_text, CastErrors, MountErrors, UiError,
UiErrorKeys,
attack_actor_refusal, reagent_totem_refusal, ui_error_text, CastErrors, CastFail, MountErrors,
UiError, UiErrorKeys,
};
// `pub(crate)`: the target chain registers the cursor pre-empt + the click commit, and the
// spellbook/stance/craft drains thread the mode through the one cast-send path (decision 0792).
@@ -96,8 +96,17 @@ pub(crate) const SPELL_ATTACK: u32 = 6603;
pub(crate) struct PlayerActions {
/// Wire slot (0-based) → the slot's packed action. Lua action id = slot + 1.
pub buttons: HashMap<u8, ActionButton>,
/// The spell book (`SMSG_INITIAL_SPELLS`), for gating/consumers to come.
pub spells: HashSet<u32>,
/// The spell book (`SMSG_INITIAL_SPELLS`).
///
/// **Ordered, and that is load-bearing** (decision 1312). The reference keeps its known spells
/// in an ARRAY and the scans that hunt it — the GameObject lock resolver `0x5f83d0` above all —
/// stop at the first hit, so the visit order picks *which* of several equally-qualified spells
/// wins. A `HashSet` made that pick nondeterministic: every character knows both 6478
/// "Opening" and 22810 "Opening - No Text" (both `LockType 13`, both trivially sufficient), and
/// whichever the hash happened to reach first went on the cast bar (B247). Ascending spell id
/// is the array's own order after login — the server sends the initial batch out of a
/// `std::map`, so the wire arrives sorted.
pub spells: BTreeSet<u32>,
/// Set on every book/bar arrival AND every local `action_sets` drain; cleared by the feed
/// after re-resolving each slot's identity (icon/kind/action) and pushing. It is only ONE of
/// the identity resolve's two triggers — a landed item template is the other (decision 0660,
@@ -226,8 +235,13 @@ fn track_learned_abilities(
if !actions.is_changed() && !spells.is_changed() {
return;
}
let first_with = |effect: u32| {
actions.spells.iter().copied().find(|&id| {
// The **last** matching spell in ascending-id order, not the first: the reference latches at
// learn time and each new rank overwrites the global, so what it holds is the newest rank
// learned — and a rank chain is ascending by id (Skinning 8613 → 8617 → 8618 → 10768). Which
// end we take is only visible when a chain's ranks are known together, but "arbitrary" was
// never an answer: the set was a `HashSet` until 1312 and this picked at the hash's whim.
let last_with = |effect: u32| {
actions.spells.iter().copied().rfind(|&id| {
spells
.catalog
.get(id)
@@ -235,9 +249,9 @@ fn track_learned_abilities(
})
};
let (skinning, skin_player_corpse, feed_pet) = (
first_with(benilla_formats::SPELL_EFFECT_SKINNING),
first_with(SPELL_EFFECT_SKIN_PLAYER_CORPSE),
first_with(SPELL_EFFECT_FEED_PET),
last_with(benilla_formats::SPELL_EFFECT_SKINNING),
last_with(SPELL_EFFECT_SKIN_PLAYER_CORPSE),
last_with(SPELL_EFFECT_FEED_PET),
);
if (skinning, skin_player_corpse, feed_pet)
!= (
@@ -260,7 +260,7 @@ pub(crate) fn commit_item_cast_on_pick(
"ui_action: cast {spell_id} refused at the item bind ({reason:#x}) — \
the cursor stays up"
);
ladder.cast_errors.0.push((spell_id, reason));
ladder.cast_errors.push_local(spell_id, reason);
}
ItemBind::ConfirmBind => {
debug!("ui_action: item bind confirm for {spell_id} on item {item_guid:#x}");
+58 -7
View File
@@ -518,6 +518,30 @@ fn local_self_cancel(
}
}
/// The casting bar's **label** for a spell — the `name` argument of `SPELLCAST_START` /
/// `SPELLCAST_CHANNEL_START`, which the reference `CastingBarFrame` puts straight into
/// `CastingBarText:SetText(...)`.
///
/// Almost always the spell's `Spell.dbc` name, with two ways to come back empty:
///
/// - **`AttributesEx3 & 0x4`** (`SPELL_ATTR_EX3_NO_CASTING_BAR_TEXT`,
/// [`SpellDisplay::no_casting_bar_text`]) — the attribute that exists precisely so an internal
/// spell with no player-facing name never puts one on the bar. Three shipped rows carry it, and
/// one of them is *named after the bit*: **22810 "Opening - No Text"**, the opener the client
/// casts at `LockType 13` ground containers. Printing the name unconditionally is what put that
/// placeholder over a gathered Hyacinth Mushroom (B247, decision 1312).
/// - **no catalog row** (an unknown id, or no client data) — the empty string this always
/// returned in that case.
///
/// The bar still shows, fills and sweeps either way; only the text is suppressed.
fn cast_bar_label(spells: Option<&crate::ui_action::Spells>, id: u32) -> String {
spells
.and_then(|s| s.catalog.get(id))
.filter(|d| !d.no_casting_bar_text())
.map(|d| d.name.clone())
.unwrap_or_default()
}
/// Drain the queue into FrameScript events — the reference `CastingBarFrame` contract
/// (extracted 1.12 `CastingBarFrame.lua`): `SPELLCAST_START(name, ms)`,
/// `SPELLCAST_CHANNEL_START(ms, name)`, `SPELLCAST_CHANNEL_UPDATE(remaining_ms)`, and the
@@ -561,13 +585,7 @@ fn feed_cast_bar(
script.set_casting(
auto_repeat.0.is_some() || inflight(&pending, &queued_melee, started, now).is_some(),
);
let name = |id: u32| -> String {
spells
.as_ref()
.and_then(|s| s.catalog.get(id))
.map(|d| d.name.clone())
.unwrap_or_default()
};
let name = |id: u32| cast_bar_label(spells.as_deref(), id);
for edge in feed.0.drain(..) {
let (event, args): (&str, Vec<ScriptValue>) = match edge {
CastBarEdge::Start {
@@ -636,6 +654,39 @@ mod tests {
const FIREBALL: u32 = 133;
/// **B247** (decision 1312): the bar's label honours `SPELL_ATTR_EX3_NO_CASTING_BAR_TEXT`.
/// Spell 22810's Spell.dbc name — "Opening - No Text" — is Blizzard's own annotation of the
/// attribute, and it reached a player's screen because we printed the name regardless.
#[test]
fn the_no_casting_bar_text_attribute_blanks_the_label() {
use benilla_formats::{SpellCatalog, SpellDisplay};
let named = |name: &str, attributes_ex3: u32| SpellDisplay {
name: name.to_string(),
attributes_ex3,
..Default::default()
};
let spells = crate::ui_action::Spells {
catalog: SpellCatalog::from_displays(
[
(6478, named("Opening", 0)),
(22810, named("Opening - No Text", 0x4)),
]
.into_iter()
.collect(),
),
..crate::ui_action::Spells::empty_for_tests()
};
assert_eq!(cast_bar_label(Some(&spells), 6478), "Opening");
assert_eq!(
cast_bar_label(Some(&spells), 22810),
"",
"the bar fills and sweeps, but shows no name"
);
// The pre-existing empty cases are untouched: an unknown id, and no client data at all.
assert_eq!(cast_bar_label(Some(&spells), 133), "");
assert_eq!(cast_bar_label(None, 6478), "");
}
#[test]
fn a_fresh_guard_is_open() {
let g = PendingCast::default();
+129 -4
View File
@@ -1006,10 +1006,9 @@ fn shift_click_on_a_stack_opens_the_split_frame() {
/// B180 — the split dialog's parchment plate fills the whole 172×96 frame. The reference authors
/// the plate 256×32 with NO anchors, a vestigial size the real client never renders (the TexCoords
/// crop exactly 172×96 out of the 256×128 art — the frame's own size, a complete panel drawn 1:1
/// over it). Our sized-anchor-less fallback draws CENTERED at the authored size instead, which
/// squashed the dialog to a 32-tall bar: the count and arrows floated above it and Okay/Cancel
/// straddled its bottom edge. The plate's authored size is dropped (StackSplit.xml says why); this
/// pins the render so the vestigial size cannot come back.
/// over it). 1308 first dodged this by dropping the size; 1310 then landed the byte-verified law
/// (an anchor-less region gets an implicit SetAllPoints at creation, size unread under the two
/// corners) and restored the ref's own text — this pins the render through the real mechanism.
#[test]
fn the_split_frame_plate_fills_the_dialog() {
let mut s = UiScript::new().unwrap();
@@ -1057,6 +1056,132 @@ fn the_split_frame_plate_fills_the_dialog() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// B180's Bagnon follow-on (director, 08-14): the dialog opened UNDER an addon bag window —
/// Bagnon's windows are `frameStrata="HIGH"`, the dialog's own stratum, and our re-expression had
/// dropped the reference's `toplevel="true"`, so the dialog's Show raised nothing and lost the
/// level tie to the later-shown window (its child buttons at level+1 poked through; the plate did
/// not). With the ref's attrs restored, Show runs the verified raise (toplevel.rs: compact, then
/// top-occupied-plus-one) and the whole dialog lands above. The synthetic window stands in for
/// Bagnon: same stratum, shown after load, overlapping the dialog.
#[test]
fn the_split_frame_raises_over_a_same_stratum_window() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let (x, y) = open_backpack_with_a_five_stack(&mut s);
s.run(
r#"
local w = CreateFrame("Frame", "FakeBagnon", UIParent)
w:SetFrameStrata("HIGH")
w:SetPoint("BOTTOMLEFT", 0, 0)
w:SetSize(1024, 768)
local bg = w:CreateTexture(nil, "BACKGROUND")
bg:SetTexture("Interface\\FakeBagnonBG")
bg:SetAllPoints()
w:Show()
"#,
)
.unwrap();
s.resolve();
s.set_modifiers(true, false, false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.set_modifiers(false, false, false);
s.resolve();
assert!(
s.eval::<bool>("return StackSplitFrame:IsShown()").unwrap(),
"the spinner opened"
);
let (dialog, bagnon) = s
.eval::<(i64, i64)>("return StackSplitFrame:GetFrameLevel(), FakeBagnon:GetFrameLevel()")
.unwrap();
assert!(
dialog > bagnon,
"Show must raise the toplevel dialog over the same-stratum window \
(dialog level {dialog}, window level {bagnon})"
);
// The symptom itself: the plate paints AFTER the window's background in draw order.
let order: Vec<String> = s
.extract()
.iter()
.filter_map(|q| match &q.content {
QuadContent::Texture { path: Some(p), .. }
if p.contains("FakeBagnonBG") || p.contains("UI-MoneyFrame") =>
{
Some(p.clone())
}
_ => None,
})
.collect();
assert_eq!(order.len(), 2, "both textures on screen: {order:?}");
assert!(
order[0].contains("FakeBagnonBG"),
"the plate must draw over the window, not under it: {order:?}"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Typed-digit entry in the split spinner (decision 1319) — the director's ask, and the deferral
/// this file's header carried since 0216. Pins the whole chain in one go: the dialog is in the
/// keyboard walk (`enableKeyboard`), a digit reaches its `OnChar`, the first digit REPLACES the
/// seeded 1 while later digits append, an over-max entry clamps instead of being rejected,
/// BACKSPACE drops a digit, and ENTER commits exactly as Okay does.
#[test]
fn typing_a_number_into_the_split_spinner_sets_the_count() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let (x, y) = open_backpack_with_a_five_stack(&mut s);
s.set_modifiers(true, false, false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.set_modifiers(false, false, false);
s.resolve();
assert_eq!(
s.eval::<i64>("return StackSplitFrame.split").unwrap(),
1,
"opens seeded at 1"
);
// A digit is consumed by the dialog — which is also what stops it firing action button 3.
assert!(s.char_input("3"), "the spinner consumed the digit");
assert_eq!(
s.eval::<i64>("return StackSplitFrame.split").unwrap(),
3,
"the first digit REPLACES the seed rather than appending to it"
);
assert_eq!(
s.eval::<String>("return StackSplitText:GetText()").unwrap(),
"3",
"and the label follows"
);
// A second digit appends, then clamps to the 5-stack rather than being thrown away.
assert!(s.char_input("7"));
assert_eq!(
s.eval::<i64>("return StackSplitFrame.split").unwrap(),
5,
"37 against a 5-stack clamps to 5"
);
// BACKSPACE drops a digit off the clamped value.
assert!(s.frame_key_input("BACKSPACE"), "the dialog took BACKSPACE");
assert_eq!(s.eval::<i64>("return StackSplitFrame.split").unwrap(), 1);
// Type a real value and commit with ENTER — the Okay path, so the carry is picked up.
assert!(s.char_input("4"));
assert_eq!(s.eval::<i64>("return StackSplitFrame.split").unwrap(), 4);
assert!(s.key_input("ENTER"), "ENTER is consumed by the dialog");
assert!(
!s.eval::<bool>("return StackSplitFrame:IsShown()").unwrap(),
"ENTER commits and closes, like Okay"
);
let held = s.cursor_item().expect("ENTER committed the split");
assert_eq!(held.count, Some(4), "the typed count is what got picked up");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Okay in the split spinner only picks the split carry up (ref/cursor.rs `SplitContainerItem` —
/// a pickup, not a self-contained move); a SUBSEQUENT placement is what actually queues the
/// `ContainerMove` with `count: Some(n)`, drained the same way any other container move is.
+36 -2
View File
@@ -205,6 +205,30 @@ pub(super) fn feed_ui_input(
// route law decides; unfocused they return unconsumed, and the camera/turn keys that
// read arrows after UiInput see them exactly as before).
let chord = keymap::chord(ev.key_code, mods, mac);
// A KEYBOARD FRAME gets these by NAME before their chord runs (decision 1319). They are
// the keys a focused EditBox receives as a semantic `EditAction` instead of a name, so
// they never reached `key_input` at all — and a dialog you type into needs its BACKSPACE.
// `frame_key_input` walks in the engine's own order and declines at a focused box, so the
// box's editing keys can never be stolen by a frame below it; a `true` here means a frame
// consumed the key, which suppresses BOTH the chord below and the key's binding (the
// reference's existence gate, wow-re `frame-key-script-delivery.md` §3).
let frame_named = match ev.key_code {
KeyCode::Backspace => Some("BACKSPACE"),
KeyCode::Delete => Some("DELETE"),
KeyCode::ArrowLeft => Some("LEFT"),
KeyCode::ArrowRight => Some("RIGHT"),
KeyCode::ArrowUp => Some("UP"),
KeyCode::ArrowDown => Some("DOWN"),
KeyCode::Home => Some("HOME"),
KeyCode::End => Some("END"),
_ => None,
};
if let Some(name) = frame_named {
if script.frame_key_input(name) {
capture.0 = true; // consumed by a frame: its binding must not also fire
continue;
}
}
if let Some(name) = named {
// The three box-event keys. An unconsumed press is not acted on here: every GAME
// action of a key — ESCAPE's close/cancel ladder (TOGGLEGAMEMENU), TAB's targeting,
@@ -212,7 +236,12 @@ pub(super) fn feed_ui_input(
// dispatch (decision 0997, `crate::bindings`), which runs right after this pass and
// reads the capture gate written above, so a key a focused box consumed this frame
// never also fires its binding — the real client's ESC precedence, table-wide.
script.key_input(name);
// Consumption suppresses the binding — a keyboard FRAME that took this key counts
// exactly as a focused box does (decision 1319; `capture.0` above already covers the
// box case, and this adds the frame one).
if script.key_input(name) {
capture.0 = true;
}
} else if let Some(chord) = chord {
match chord {
keymap::Chord::Edit(action) => {
@@ -244,7 +273,12 @@ pub(super) fn feed_ui_input(
// plane European layouts type real text with (and macOS Option+letter comes through
// with only `alt`, composing its special characters).
if let Some(text) = &ev.text {
script.char_input(text);
// Same suppression as the named keys, and this is the case that bites: the number
// row IS a binding (the action buttons), so a digit typed into a keyboard frame —
// the stack-split spinner — must not also fire action button 3 (decision 1319).
if script.char_input(text) {
capture.0 = true;
}
}
}
}
@@ -184,10 +184,23 @@ fn the_shipped_pet_bar_drives_end_to_end() {
1,
"only Claw can autocast"
);
// 4 emitters x the DERIVED trail length, on the one autocasting slot — and on that slot only,
// which is the lazy pool's own pin: the other nine buttons have no sparks at all, where they
// used to carry a full hidden set each.
let trail: usize = s.eval("return BENILLA_PET_AUTOCAST_TRAIL").unwrap();
assert_eq!(
trail, 23,
"the size ramp's own spacing (see PetActionBar.xml's header) comes out at 23 samples"
);
assert_eq!(
textures(&quads, "Interface\\Buttons\\GlowStar"),
32,
"4 emitters x 8 trail stars, on the one autocasting slot"
4 * trail,
"4 emitters x the trail, on the one autocasting slot"
);
assert!(
s.eval::<bool>("return PetActionButton1.sparks == nil")
.unwrap(),
"a button that never autocasts never pools a spark"
);
// Geometry, quoted from the ref: the bar's TOPLEFT is MainMenuBar's BOTTOMLEFT +(36,97),
@@ -631,3 +644,282 @@ fn the_keybind_pair_pushes_and_casts_without_the_clicks_forks() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **B228 — the autocast trail's motion, MEASURED off the shipped handler.**
///
/// nazriel reads the shine as "a bit too fast". The health pass answered that from the constants —
/// the .m2 authors a 2.000 s loop and `BENILLA_PET_AUTOCAST_PERIOD` is 2.0 — which is a read, not a
/// measurement: it says nothing about whether the OnUpdate wiring turns wall-clock seconds into
/// that period, and an `elapsed` in the wrong unit would be invisible to it. So this drives the
/// real `<OnUpdate>` with real seconds and reads the head spark's own SetPoint back.
///
/// What is pinned, all of it off the file (`m2bones` on `Interface\Buttons\UI-AutoCastButton.m2`):
/// a 28.8 px square (0.02 units × 1200 px/unit × the ref Model's 1.2), one lap per 2.000 s, a
/// corner every 0.500 s, LINEAR between them (`interp == 1` at the bone record's translation
/// M2Track — so the half-edge sample sits at the half-time, no easing), and the four emitters one
/// quarter-lap apart. If the reported overspeed is in the period, it fails here; if this holds and
/// the report stands, the period is exonerated and the gap is in the trail's SHAPE.
#[test]
fn the_autocast_trail_laps_the_rim_once_every_two_seconds() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_pet_bar(&s);
declare_token_strings(&s);
s.set_pet_actions(true, true, true, hunter_slots());
s.fire_event("PET_BAR_UPDATE", vec![]);
s.resolve();
// Slot 4 is Claw, the one slot in the fixture with autocast RUNNING. `sparks[n]` is the pool
// `BenillaPetAutocast_Create` built: emitter-major, so emitter e's HEAD (age 0, the bone's own
// position) is index e·TRAIL + 1.
let head = |s: &UiScript, emitter: usize| -> (f32, f32) {
let expr = |axis: usize| {
format!(
"local n = {emitter} * BENILLA_PET_AUTOCAST_TRAIL + 1 \
local a, b, c, x, y = PetActionButton4.sparks[n]:GetPoint() \
return ({axis} == 0) and x or y"
)
};
(
s.eval::<f32>(&expr(0)).unwrap(),
s.eval::<f32>(&expr(1)).unwrap(),
)
};
let near = |got: (f32, f32), want: (f32, f32), what: &str| {
assert!(
(got.0 - want.0).abs() < 0.01 && (got.1 - want.1).abs() < 0.01,
"{what}: at {got:?}, expected {want:?}"
);
};
// 0.02 units × 1200 px/unit × 1.2 = 28.8 px across, so the corners sit ±14.4 from centre.
const H: f32 = 14.4;
let (bl, tl, tr, br) = ((-H, -H), (-H, H), (H, H), (H, -H));
// ── The clock WIRING: four half-second ticks of the shipped OnUpdate, one corner each. This
// is the half a constants read cannot reach — `arg1` really is seconds, and the bar's clock
// advances 1:1 with it.
let corner = |s: &mut UiScript, dt: f32, want: (f32, f32), what: &str| {
s.tick(dt);
near(head(s, 0), want, what);
};
corner(
&mut s,
0.5,
tl,
"0.5 s in: one edge, at the top-left corner",
);
corner(&mut s, 0.5, tr, "1.0 s: half a lap");
corner(&mut s, 0.5, br, "1.5 s: three quarters");
corner(
&mut s,
0.5,
bl,
"2.0 s: exactly one lap, back where it started",
);
// ── The position LAW, driven at exact clocks so no tick accumulation can blur it. The
// mid-edge sample is the `interp == 1` pin: linear means the half-time is the half-edge, and
// a spline-eased track would sit short of it.
let at = |s: &UiScript, clock: f32| -> (f32, f32) {
s.run(&format!(
"BenillaPetAutocast_Update(PetActionButton4, {clock})"
))
.unwrap();
head(s, 0)
};
near(at(&s, 0.0), bl, "clock 0");
near(
at(&s, 0.25),
(-H, 0.0),
"quarter edge in: the LEFT edge's midpoint",
);
near(at(&s, 0.5), tl, "clock 0.5");
near(at(&s, 1.0), tr, "clock 1.0");
near(at(&s, 1.5), br, "clock 1.5");
near(at(&s, 2.0), bl, "clock 2.0 wraps onto clock 0");
// ── The trail is CONTINUOUS, which is what deriving the sample count buys (B228). No two
// neighbours in a trail sit further apart than the smaller of their two stars is wide, so the
// streak has no seam at any age. The 8-even-samples version failed this from mid-life back —
// 7.2 px apart with 4.3 px stars — and a row of separated dots is what reads as marching.
let worst_gap: f32 = s
.eval(
"local worst = 0 \
for k = 1, BENILLA_PET_AUTOCAST_TRAIL - 1 do \
local a, b = PetActionButton4.sparks[k], PetActionButton4.sparks[k + 1] \
local _, _, _, ax, ay = a:GetPoint() \
local _, _, _, bx, by = b:GetPoint() \
local d = math.sqrt((ax - bx) * (ax - bx) + (ay - by) * (ay - by)) \
local w = math.min(a:GetWidth(), b:GetWidth()) \
if d / w > worst then worst = d / w end \
end \
return worst",
)
.unwrap();
assert!(
worst_gap <= 1.0,
"the widest neighbour gap is {worst_gap:.2} star-widths — the streak has a seam"
);
// ── The four emitters are ONE LAP, a quarter apart — the file's four bones at the four
// corners, chasing each other (m2bones: pivots at (-0.001,0), (0.019,0), (0.019,0.02),
// (-0.001,0.02), same track, one corner of phase between neighbours).
s.run("BenillaPetAutocast_Update(PetActionButton4, 0.0)")
.unwrap();
for (e, want, name) in [
(0, bl, "emitter 0"),
(1, tl, "emitter 1"),
(2, tr, "emitter 2"),
(3, br, "emitter 3"),
] {
near(head(&s, e), want, name);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The pet bar plus the three files its TOOLTIP needs, in the TOC's own order: Fonts.xml (the
/// colour codes that wrap the binding), GameTooltip.xml (the plate), and UIParent.xml — already a
/// prerequisite for the managed stack — for `GetBindingText`, which renders the key.
fn load_pet_bar_with_tooltip(s: &UiScript) {
for file in [
"Fonts.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"Cooldown.xml",
"ActionBar.xml",
"PetActionBar.xml",
] {
load_xml(s, file);
}
}
/// A hunter bar hovered through the REAL gesture, with the real binding registry and the real CVar
/// table behind it — `mouse_move` runs the shipped `<OnEnter>` with `this` bound, which is the only
/// way this fork gets exercised the way nazriel exercised it.
///
/// Buttons are 30 px chained +8 from the bar's own origin: button 1 spans x[72,102] y[56,86] ⇒
/// centre (87,71), button 4's left = 72 + 3·38 ⇒ centre (201,71) — the geometry
/// `clicks_route_through_the_attack_toggle_fork` already leans on.
const ATTACK_BUTTON: (f32, f32) = (87.0, 71.0);
const CLAW_BUTTON: (f32, f32) = (201.0, 71.0);
fn hovered_pet_bar() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The real command set and the real registered defaults, the way the app's own seed installs
// them — so `GetBindingKey("BONUSACTIONBUTTON1")` answers CTRL-1 (its byte-real default) and
// `GetCVar("UberTooltips")` answers "1" (its byte-read registrar value) rather than nil.
s.register_bindings(&crate::bindings::registry_commands());
s.register_cvars(crate::cvars::REGISTERED.iter().copied());
load_pet_bar_with_tooltip(&s);
declare_token_strings(&s);
s.set_pet_actions(true, true, true, hunter_slots());
s.fire_event("PET_BAR_UPDATE", vec![]);
s.resolve();
s
}
fn tooltip_line1(s: &UiScript) -> String {
s.eval::<String>("return GameTooltipTextLeft1:GetText() or \"\"")
.unwrap()
}
/// **B230 — Attack/Follow/Stay name their keybinding; the pet's own spells do not.**
///
/// nazriel: *"On 1.12 when you hover over Attack, Follow, Stay pet action buttons they show the
/// keybinding - which is not the case with Benilla"*. The reference agrees with him and is
/// narrower than "the pet bar": `PetActionButton_OnEnter` (PetActionBarFrame.lua l.285-305) forks
/// on `isToken or UberTooltips == "0"`, and only THAT branch concatenates
/// `NORMAL_FONT_COLOR_CODE.." ("..GetBindingText(GetBindingKey("BONUSACTIONBUTTON"..id), "KEY_")..")"`.
/// The other branch is a bare `SetPetAction` and appends nothing — so a fix that suffixed every
/// slot would be wrong, and the control below is what says ours doesn't.
///
/// The exact string is pinned, colour codes and all, because the ref's concatenation puts the
/// colour BEFORE the space (`Attack|cffffd200 (CTRL-1)|r`, not `Attack |cffffd200(CTRL-1)|r` —
/// which is what MicroMenu.xml's different-shaped version of the same idea produces).
#[test]
fn token_tooltips_name_their_binding_and_pet_spells_do_not() {
let mut s = hovered_pet_bar();
s.mouse_move(ATTACK_BUTTON.0, ATTACK_BUTTON.1);
s.resolve();
assert_eq!(
tooltip_line1(&s),
"Attack|cffffd200 (CTRL-1)|r",
"a command token carries BONUSACTIONBUTTON1's key in the normal-font colour"
);
// Uber on + a token ⇒ still the default corner; only the CVar-off leg owner-anchors.
assert!(
s.eval::<bool>("return GameTooltip.default ~= nil").unwrap(),
"a token's plate takes the default corner while UberTooltips is on"
);
// The CONTROL: slot 4 is Claw, a real pet spell, and it goes through the engine channel —
// its name, and nothing in parentheses.
s.mouse_move(CLAW_BUTTON.0, CLAW_BUTTON.1);
s.resolve();
let claw = tooltip_line1(&s);
assert_eq!(claw, "Claw", "a pet SPELL renders through SetPetAction");
assert!(
!claw.contains('('),
"…and appends no binding: {claw:?} (the ref's other branch)"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The append is UNCONDITIONAL in that branch, so an unbound row really does render empty
/// parentheses — `GetBindingText` answers `""` for a nil key (UIParent.xml, transcribed from the
/// ref's own l.1819-1821), and nothing guards the `"("`. That is the reference's behaviour, not a
/// slip of ours: MicroButtonTooltipText guards its own suffix and this one does not.
///
/// Stock it is unreachable — BONUSACTIONBUTTON1-10 default to CTRL-1..CTRL-0 — so it takes a
/// deliberate unbind to see, which is exactly what this does.
#[test]
fn an_unbound_token_row_renders_the_references_empty_parentheses() {
let mut s = hovered_pet_bar();
s.run(r#"SetBinding("CTRL-1")"#).unwrap();
assert!(
s.eval::<Option<String>>(r#"return GetBindingKey("BONUSACTIONBUTTON1")"#)
.unwrap()
.is_none(),
"the row really is unbound now"
);
s.mouse_move(ATTACK_BUTTON.0, ATTACK_BUTTON.1);
s.resolve();
assert_eq!(
tooltip_line1(&s),
"Attack|cffffd200 ()|r",
"unbound: the parentheses stay, empty — the ref's own unguarded concatenation"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The CVar is the fork's OTHER half, and it is not decoration: with `UberTooltips` off, a real pet
/// SPELL stops going through the engine channel and takes the token branch too — same built text,
/// same binding suffix — and the plate moves from the screen corner to beside the button
/// (`SetOwner(this, "ANCHOR_RIGHT")`). Both are the reference's, in the same five lines.
///
/// This is also the row's reason to exist in `cvars::REGISTERED`: benilla registers `UberTooltips`
/// at all because these Lua sites read it (the honest-tree rule's live-consumer half).
#[test]
fn ubertooltips_off_takes_the_spells_through_the_token_branch_and_moves_the_plate() {
let mut s = hovered_pet_bar();
s.set_cvar_engine("UberTooltips", "0");
s.mouse_move(CLAW_BUTTON.0, CLAW_BUTTON.1);
s.resolve();
assert_eq!(
tooltip_line1(&s),
"Claw|cffffd200 (CTRL-4)|r",
"with the CVar off a pet spell is built here too, binding and all"
);
assert!(
s.eval::<bool>("return GameTooltip:IsOwned(PetActionButton4)")
.unwrap(),
"…and the plate sits beside the button, not at the screen corner"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -192,11 +192,16 @@ fn your_own_portrait_explains_the_menu_instead_of_showing_your_health() {
/// Action-bar hovers take the default corner too — ref ActionButton_SetTooltip l.366-372
/// branches on the UberTooltips CVar, whose stock default is "1" (byte-read from WoW.exe
/// 0x48fdd9 / default string 0x82e748; see ActionBar.xml). An empty slot renders nothing,
/// 0x48fdd9 / default string 0x82e748; see `cvars::REGISTERED`). An empty slot renders nothing,
/// but the anchor must already be seated — asserted through GetPoint, resolved rect or not.
///
/// Registered, not merely absent: before B230 the CVar was not in the table at all, so `GetCVar`
/// answered nil and the "1" leg was reached by accident rather than by value. This seeds the real
/// table so the pass means what it says.
#[test]
fn action_button_hover_takes_the_default_corner() {
let s = harness(&["Cooldown.xml", "ActionBar.xml"]);
let mut s = harness(&["Cooldown.xml", "ActionBar.xml"]);
s.register_cvars(crate::cvars::REGISTERED.iter().copied());
s.run("BenillaActionButton_OnEnter(ActionButton3)").unwrap();
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
let ok: bool = s
@@ -212,6 +217,105 @@ fn action_button_hover_takes_the_default_corner() {
);
}
/// The other leg of that same branch, live since B230 registered the CVar: with `UberTooltips`
/// off, an action button's plate leaves the screen corner and seats BESIDE the button — LEFT for
/// the bars the reference lists (`MultiBarBottomRight` is the only one benilla ships; the two
/// vertical bars are not), RIGHT for everything else, including the main bar.
///
/// The two anchors are read off the resolved SetPoint pair, which is what `SetOwner` actually
/// writes: ANCHOR_RIGHT = the plate's BOTTOMLEFT on the button's TOPRIGHT, ANCHOR_LEFT its mirror
/// (`script/tooltip/verbs.rs`).
#[test]
fn ubertooltips_off_seats_action_bar_plates_beside_the_button() {
let mut s = harness(&["Cooldown.xml", "ActionBar.xml", "MultiBars.xml"]);
s.register_cvars(crate::cvars::REGISTERED.iter().copied());
s.set_cvar_engine("UberTooltips", "0");
let seat = |s: &UiScript| {
s.eval::<String>(
"local p, rel, rp = GameTooltip:GetPoint() \
return p .. \"/\" .. (rel and rel:GetName() or \"?\") .. \"/\" .. rp",
)
.unwrap()
};
s.run("BenillaActionButton_OnEnter(ActionButton3)").unwrap();
assert!(
s.eval::<bool>("return GameTooltip.default == nil").unwrap(),
"off: the main bar's plate is owner-anchored, not the default corner"
);
assert!(
s.eval::<bool>("return GameTooltip:IsOwned(ActionButton3)")
.unwrap(),
"…owned by the button it opened from"
);
assert_eq!(
seat(&s),
"BOTTOMLEFT/ActionButton3/TOPRIGHT",
"ANCHOR_RIGHT — the main bar is not in the ref's LEFT set"
);
// MultiBarBottomRight IS in that set — the one shipped member of it.
s.run("BenillaActionButton_OnEnter(MultiBarBottomRightButton1)")
.unwrap();
assert_eq!(
seat(&s),
"BOTTOMRIGHT/MultiBarBottomRightButton1/TOPLEFT",
"ANCHOR_LEFT — MultiBarBottomRight opens toward screen centre"
);
// And the CVar back on restores the corner — the fork is a fork, not a one-way door.
s.set_cvar_engine("UberTooltips", "1");
s.run("BenillaActionButton_OnEnter(MultiBarBottomRightButton1)")
.unwrap();
assert!(
s.eval::<bool>("return GameTooltip.default ~= nil").unwrap(),
"on: back to the screen corner"
);
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
}
/// The stance bar's own leg of the same branch (ref BonusActionBarFrame.xml l.40-45 — ANCHOR_RIGHT
/// with no bar fork of its own), pinned separately because it is a different file's handler and a
/// different tooltip verb; it was collapsed to the "1" leg alongside the action bar's and
/// un-collapsed with it.
#[test]
fn ubertooltips_off_seats_stance_plates_beside_the_button() {
let mut s = harness(&["Cooldown.xml", "ActionBar.xml", "StanceBar.xml"]);
s.register_cvars(crate::cvars::REGISTERED.iter().copied());
s.set_shapeshift_forms(vec![benilla_ui::script::ShapeshiftFormView {
spell_id: 5487,
name: "Bear Form".into(),
texture: Some("Interface\\Icons\\Ability_Racial_BearForm".into()),
active: false,
castable: true,
cooldown: None,
}]);
s.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
s.resolve();
s.run("BenillaShapeshiftButton_OnEnter(ShapeshiftButton1)")
.unwrap();
assert!(
s.eval::<bool>("return GameTooltip.default ~= nil").unwrap(),
"on (the stock default): the screen corner"
);
s.set_cvar_engine("UberTooltips", "0");
s.run("BenillaShapeshiftButton_OnEnter(ShapeshiftButton1)")
.unwrap();
assert_eq!(
s.eval::<String>(
"local p, rel, rp = GameTooltip:GetPoint() \
return p .. \"/\" .. (rel and rel:GetName() or \"?\") .. \"/\" .. rp",
)
.unwrap(),
"BOTTOMLEFT/ShapeshiftButton1/TOPRIGHT",
"off: ANCHOR_RIGHT, beside the button"
);
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
}
/// Buff hovers hang BELOW the button — ref BuffFrame.xml l.37 is ANCHOR_BOTTOMLEFT (the buff
/// row lives at the screen's top-right): the tooltip's TOPRIGHT seats on the button's
/// BOTTOMLEFT.
+5 -5
View File
@@ -31,7 +31,7 @@
//! and the rank tie-break falling to the rank *string* only when the parsed number is 0 (the
//! client's `SpellLevel` fallback for a digit-less rank is unmodeled — we have no `SpellLevel`).
use std::collections::{BTreeMap, HashSet};
use std::collections::{BTreeMap, BTreeSet};
use bevy::prelude::*;
@@ -231,7 +231,7 @@ fn feed_spellbook(
/// Build the whole book (module doc): the app's own resolve — the engine holds no spell
/// knowledge, only what's pushed here.
fn build_book(
known: &HashSet<u32>,
known: &BTreeSet<u32>,
catalog: &SpellCatalog,
skill_lines: Option<&SkillLineCatalog>,
race: u8,
@@ -458,7 +458,7 @@ mod tests {
map.insert(668, spell("Language: Common", None, 0x80)); // DO_NOT_DISPLAY → hidden
map.insert(818, spell("Cooking", None, 0x20)); // IS_TRADESKILL → hidden
let catalog = SpellCatalog::from_displays(map);
let known: HashSet<u32> = [133, 145, 2136, 668, 818].into_iter().collect();
let known: BTreeSet<u32> = [133, 145, 2136, 668, 818].into_iter().collect();
// No skill-line catalog → every shown spell lands in General (race/class irrelevant).
let book = build_book(&known, &catalog, None, 1, 1, None, None);
@@ -495,7 +495,7 @@ mod tests {
attack.icon = Some("Interface\\Icons\\Temp".into()); // the real DBC placeholder
attack.effects[0] = 78; // SPELL_EFFECT_ATTACK — makes it the melee auto-attack
let catalog = SpellCatalog::from_displays(HashMap::from([(ATTACK, attack)]));
let known: HashSet<u32> = [ATTACK].into_iter().collect();
let known: BTreeSet<u32> = [ATTACK].into_iter().collect();
// The feed's resolved icon (armed → the weapon, unarmed → Spell-Reset) wins over Temp.
for resolved in [
@@ -531,7 +531,7 @@ mod tests {
throw.icon = Some("Interface\\Icons\\Ability_Throw".into());
let catalog =
SpellCatalog::from_displays(HashMap::from([(AUTO_SHOT, auto_shot), (THROW, throw)]));
let known: HashSet<u32> = [AUTO_SHOT, THROW].into_iter().collect();
let known: BTreeSet<u32> = [AUTO_SHOT, THROW].into_iter().collect();
let bow = "Interface\\Icons\\INV_Weapon_Bow_02";
let book = build_book(&known, &catalog, None, 1, 1, None, Some(bow.into()));
+6 -6
View File
@@ -28,7 +28,7 @@
//! composed here on a cp2 rise: this feed owns the points diff, and the chat arc's own kinds
//! carry it as a SYSTEM line.
use std::collections::HashSet;
use std::collections::BTreeSet;
use bevy::prelude::*;
@@ -151,7 +151,7 @@ fn feed_talents(
}
/// One talent's current rank: the highest rank whose spell is known (module doc).
fn rank_of(t: &Talent, known: &HashSet<u32>) -> u32 {
fn rank_of(t: &Talent, known: &BTreeSet<u32>) -> u32 {
t.ranks
.iter()
.enumerate()
@@ -164,7 +164,7 @@ fn rank_of(t: &Talent, known: &HashSet<u32>) -> u32 {
/// Build the pushed snapshot — the app's whole resolve (module doc).
fn build_pages(
catalog: &TalentCatalog,
known: &HashSet<u32>,
known: &BTreeSet<u32>,
spells: &Spells,
race: u8,
class: u8,
@@ -341,11 +341,11 @@ mod tests {
#[test]
fn rank_is_the_highest_known_rank_spell() {
let t = talent(1, 81, 0, 0, &[100, 101, 102]);
let known: HashSet<u32> = [100, 101].into_iter().collect();
let known: BTreeSet<u32> = [100, 101].into_iter().collect();
assert_eq!(rank_of(&t, &known), 2);
assert_eq!(rank_of(&t, &HashSet::new()), 0);
assert_eq!(rank_of(&t, &BTreeSet::new()), 0);
// A gap never happens on the live wire (learn-up-to), but the max() read stays honest.
let holey: HashSet<u32> = [102].into_iter().collect();
let holey: BTreeSet<u32> = [102].into_iter().collect();
assert_eq!(rank_of(&t, &holey), 3);
}
}
+3 -3
View File
@@ -22,7 +22,7 @@
//! when the player walks out of the trainer's service range (or the trainer despawns) — the same
//! `CloseTrainer` clear.
use std::collections::HashSet;
use std::collections::BTreeSet;
use benilla_formats::{SkillLineCatalog, SpellCatalog};
use benilla_protocol::messages::TrainerSpell;
@@ -157,7 +157,7 @@ fn resolve_service(
trainer_type: u32,
spells: &SpellCatalog,
skill_lines: Option<&SkillLineCatalog>,
known: &HashSet<u32>,
known: &BTreeSet<u32>,
icons: Option<&ItemDisplays>,
items: &mut Items,
commands: &NetCommands,
@@ -269,7 +269,7 @@ fn snapshot(
open: &TrainerOpen,
spells: &SpellCatalog,
skill_lines: Option<&SkillLineCatalog>,
known: &HashSet<u32>,
known: &BTreeSet<u32>,
icons: Option<&ItemDisplays>,
items: &mut Items,
commands: &NetCommands,
+11 -11
View File
@@ -26,7 +26,7 @@ fn resolve_service(
trainer_type: u32,
spells: &SpellCatalog,
skill_lines: Option<&SkillLineCatalog>,
known: &HashSet<u32>,
known: &BTreeSet<u32>,
) -> TrainerService {
let mut deps = Deps::new();
super::resolve_service(
@@ -62,7 +62,7 @@ fn snap(open: &TrainerOpen, spells: &SpellCatalog) -> Option<TrainerState> {
open,
spells,
None,
&HashSet::new(),
&BTreeSet::new(),
None,
&mut deps.items,
&deps.commands,
@@ -306,7 +306,7 @@ fn resolve_hops_the_learn_wrapper_to_the_taught_ability() {
0,
&spells,
Some(&skills),
&HashSet::new(),
&BTreeSet::new(),
);
assert_eq!(svc.spell_id, 1605, "the buy id stays the wire wrapper");
assert_eq!(
@@ -340,7 +340,7 @@ fn display_name_is_the_wire_spell_not_the_taught_one() {
TRAINER_TYPE_TRADESKILL,
&spells,
Some(&skills),
&HashSet::new(),
&BTreeSet::new(),
);
assert_eq!(svc.name.as_deref(), Some("Apprentice Blacksmith"));
assert_eq!(svc.subtext.as_deref(), None);
@@ -359,7 +359,7 @@ fn display_name_is_the_wire_spell_not_the_taught_one() {
TRAINER_TYPE_TRADESKILL,
&spells,
Some(&skills),
&HashSet::new(),
&BTreeSet::new(),
);
assert_eq!(recipe.group_key, 2);
assert_eq!(recipe.group_name, "Recipes");
@@ -392,7 +392,7 @@ fn resolve_reads_cost_state_and_gates_with_no_catalog() {
let mut w = wire(2018, trainer_spell_state::RED, 1000, 20, 164);
w.req_spells = [78, 0, 0];
// Empty known set: the player knows nothing → the ability gate reads unmet on its own terms.
let svc = resolve_service(&w, TRAINER_TYPE_TRADESKILL, &spells, None, &HashSet::new());
let svc = resolve_service(&w, TRAINER_TYPE_TRADESKILL, &spells, None, &BTreeSet::new());
assert_eq!(svc.spell_id, 2018);
assert!(svc.name.is_none(), "no catalog → name in flight");
assert_eq!(svc.cost, 1000);
@@ -429,7 +429,7 @@ fn resolve_reads_cost_state_and_gates_with_no_catalog() {
0,
&spells,
None,
&HashSet::new(),
&BTreeSet::new(),
);
assert_eq!(plain.skill_req, None);
assert!(plain.ability_reqs.is_empty());
@@ -447,12 +447,12 @@ fn ability_req_met_tracks_known_spells_not_the_service_category() {
w.req_spells = [78, 0, 0]; // requires Heroic Strike (78)
// Player doesn't know 78 → the prerequisite is unmet (red).
let unknown = resolve_service(&w, 0, &spells, None, &HashSet::new());
let unknown = resolve_service(&w, 0, &spells, None, &BTreeSet::new());
assert_eq!(unknown.category, TrainerServiceCategory::Unavailable);
assert!(!unknown.ability_reqs[0].met, "prereq unknown → unmet");
// Player knows 78 → the prerequisite is met (white) EVEN THOUGH the service stays unavailable.
let known: HashSet<u32> = [78].into_iter().collect();
let known: BTreeSet<u32> = [78].into_iter().collect();
let learned = resolve_service(&w, 0, &spells, None, &known);
assert_eq!(learned.category, TrainerServiceCategory::Unavailable);
assert!(
@@ -473,7 +473,7 @@ fn ability_req_shows_the_required_rank_on_real_data() {
let mut w = wire(846, trainer_spell_state::RED, 100, 20, 0);
w.req_spells = [78, 0, 0]; // Heroic Strike Rank 1
let known: HashSet<u32> = [78].into_iter().collect();
let known: BTreeSet<u32> = [78].into_iter().collect();
let svc = resolve_service(&w, 0, &spells, None, &known);
assert_eq!(
svc.ability_reqs,
@@ -484,7 +484,7 @@ fn ability_req_shows_the_required_rank_on_real_data() {
"the prereq shows its rank and reads met because the player knows it"
);
// Not known → same name, but unmet (red).
let svc = resolve_service(&w, 0, &spells, None, &HashSet::new());
let svc = resolve_service(&w, 0, &spells, None, &BTreeSet::new());
assert!(!svc.ability_reqs[0].met);
assert_eq!(svc.ability_reqs[0].name, "Heroic Strike (Rank 1)");
}
@@ -1347,13 +1347,23 @@ pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> {
println!(" recursion model (child emitters): {r}");
}
if let Some(s) = &e.spline {
let pt = |p: &[f32; 3]| format!("({:.2},{:.2},{:.2})", p[0], p[1], p[2]);
// EVERY control point, and at full precision. This used to print `first .. last`,
// which is actively misleading on a cubic Bézier: the path is `3K+1` points and the
// two INTERIOR handles of each segment are what bend it, so an emitter whose ends
// both sit at the origin can still fling its particles a long way — or not move them
// at all. Reading "(0,0,0) .. (0,0,0)" as "the particles never travel" is a guess
// the truncated line invites and cannot support (B228 nearly rested on it).
// Wrapped at 4 per line so a long path stays readable.
let pt = |p: &[f32; 3]| format!("({:.4},{:.4},{:.4})", p[0], p[1], p[2]);
println!(
" spline: {} control points, {} .. {}",
" spline: {} control points ({} cubic segment(s)), model-local:",
s.points.len(),
s.points.first().map(pt).unwrap_or_default(),
s.points.last().map(pt).unwrap_or_default(),
s.points.len().saturating_sub(1) / 3,
);
for (i, chunk) in s.points.chunks(4).enumerate() {
let row: Vec<String> = chunk.iter().map(pt).collect();
println!(" [{:>2}] {}", i * 4, row.join(" "));
}
}
// Emission timing: the rate/gate pair, per file sequence slot.
@@ -1454,8 +1464,12 @@ pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> {
c[0], c[1], c[2], c[3]
);
}
// 4 decimals, not 3: UI models author their sizes in thousandths, where `{:.3}` turns the
// autocast shine's 0.0015 mid-key into a printed "0.002" — a 33% error that a transcriber
// reads as authored truth (B228). The colour ramp above stays at 3 because 0..1 channels
// do not have that problem.
println!(
" size {:.3} -> {:.3} -> {:.3} yd (half-extent)",
" size {:.4} -> {:.4} -> {:.4} yd (half-extent)",
ol.scale[0], ol.scale[1], ol.scale[2]
);
println!(
@@ -337,7 +337,7 @@ impl SkillLineCatalog {
pub fn highest_known_rank(
&self,
spell_id: u32,
known: &std::collections::HashSet<u32>,
known: &std::collections::BTreeSet<u32>,
) -> Option<u32> {
let mut cur = self.chain_head(spell_id);
let mut best = known.contains(&cur).then_some(cur);
@@ -361,12 +361,12 @@ fn real_rank_chains_resolve_the_highest_known_rank() {
// From ANY rank, knowing only rank 8, the answer is rank 8 — the walk starts at the
// chain head, so it works downward as well as upward.
let top: std::collections::HashSet<u32> = [11294].into_iter().collect();
let top: std::collections::BTreeSet<u32> = [11294].into_iter().collect();
for id in SS {
assert_eq!(cat.highest_known_rank(id, &top), Some(11294), "from {id}");
}
// Knowing an intermediate rank resolves to it, not to the top of the chain.
let mid: std::collections::HashSet<u32> = [1759].into_iter().collect();
let mid: std::collections::BTreeSet<u32> = [1759].into_iter().collect();
assert_eq!(cat.highest_known_rank(1752, &mid), Some(1759));
assert_eq!(cat.highest_known_rank(11294, &mid), Some(1759));
// No rank of the chain known → no answer (the caller leaves the slot alone).
@@ -377,11 +377,11 @@ fn real_rank_chains_resolve_the_highest_known_rank() {
// Frostbolt r1, Healing Touch r1, Renew r1, Immolate r1, Lightning Bolt r1.
for id in [133u32, 143, 116, 5185, 139, 348, 403] {
assert_eq!(cat.rank_successor(id), None, "spell {id} is not chained");
let known: std::collections::HashSet<u32> = [id].into_iter().collect();
let known: std::collections::BTreeSet<u32> = [id].into_iter().collect();
assert_eq!(cat.highest_known_rank(id, &known), Some(id));
}
// A spell with no `SkillLineAbility` row at all (the auto-attack) is its own chain.
let attack: std::collections::HashSet<u32> = [6603].into_iter().collect();
let attack: std::collections::BTreeSet<u32> = [6603].into_iter().collect();
assert_eq!(cat.rank_successor(6603), None);
assert_eq!(cat.highest_known_rank(6603, &attack), Some(6603));
}
@@ -247,6 +247,43 @@ fn real_spell_catalog_reads_open_lock_types() {
"Fireball opens no lock"
);
// **B247's data fact** (decision 1312): `LockType 13` "Open Kneeling" — what lock 43 carries,
// and with it every ground container from the Hyacinth Mushroom up — has TWO shipped openers
// that `playercreateinfo_spell` grants to every race/class, and one of them wears Blizzard's
// own placeholder name. The lock resolver returns the FIRST sufficient match in the
// known-spell array's order, so which of these it reaches is what the cast bar prints; this
// pins the ambiguity to the data instead of leaving it a surprise.
assert_eq!(cat.get(6478).unwrap().open_lock_type(), Some(13));
assert_eq!(cat.get(6478).unwrap().name, "Opening");
assert_eq!(cat.get(22810).unwrap().open_lock_type(), Some(13));
assert_eq!(
cat.get(22810).unwrap().name,
"Opening - No Text",
"the placeholder is Blizzard's own Spell.dbc string, not a formatting bug of ours"
);
// ...and the attribute that says never to show it. `AttributesEx3 & 0x4`
// (`SPELL_ATTR_EX3_NO_CASTING_BAR_TEXT`) is what the cast bar must honour — 22810 carries it,
// 6478 does not, and that ONE bit is the only column separating two otherwise byte-identical
// rows. Exactly three rows in the shipped file carry it, so the whole set is pinned here: a
// fourth appearing means the data is not what this law was derived from.
assert!(cat.get(22810).unwrap().no_casting_bar_text());
assert!(!cat.get(6478).unwrap().no_casting_bar_text());
let silent: Vec<u32> = {
let mut v: Vec<u32> = cat
.iter()
.filter(|(_, d)| d.no_casting_bar_text())
.map(|(id, _)| id)
.collect();
v.sort_unstable();
v
};
assert_eq!(
silent,
vec![6477, 22810, 26380],
"6477 Opening / 22810 Opening - No Text / 26380 zzOLDSummon Mouth Tentacle Visual"
);
// The totem (tool) and reagent columns the pre-send possession check reads (decision 0552;
// the ref's `0x6e4000` at SpellRec+0xA0/+0xA8 = cols 40-41 / 42-49+50-57). A column slip
// here silently breaks "Requires Mining Pick" / "Missing reagent: …".
@@ -407,6 +407,19 @@ impl SpellDisplay {
self.attributes_ex3 & ATTR_EX3_NORMAL_RANGED_ATTACK != 0
}
/// `AttributesEx3 & 0x4` — **the casting bar shows no name for this spell**
/// ([`ATTR_EX3_NO_CASTING_BAR_TEXT`], `SPELL_ATTR_EX3_NO_CASTING_BAR_TEXT`). The cast-bar feed
/// hands `SPELLCAST_START`/`SPELLCAST_CHANNEL_START` an empty name string when this is set, so
/// the bar fills and sweeps with a blank label.
///
/// Only the *name* is suppressed, and only on the bar: the spell still casts, still animates,
/// and its failures still name it. Three shipped rows carry the bit; the one that reached a
/// player is 22810 **"Opening - No Text"**, the internal opener for `LockType 13` ground
/// containers, whose own name spells out what the attribute is for (decision 1312, B247).
pub fn no_casting_bar_text(&self) -> bool {
self.attributes_ex3 & ATTR_EX3_NO_CASTING_BAR_TEXT != 0
}
/// The ranged-shot cooldown pad's gate (`0x6e2b60` at `0x6e2c2c`–`0x6e2c47`, byte-verified —
/// wow-re `ranged-cooldown-sweep.md`): on the caster's own SPELL_GO self-insert, a
/// `Attributes & 0x2` spell WITHOUT `AttributesEx2 & 0x20000`
+9
View File
@@ -350,6 +350,15 @@ const COL_EFFECT_ITEM_TYPE_1: usize = 103;
/// `AttributesEx3` bit `0x8000` — damage renders melee-white (`SPELL_ATTR3_NORMAL_RANGED_ATTACK`;
/// the combat-text emitter's `B`-bit flip, wow-re `combattext-color-law.md`).
const ATTR_EX3_NORMAL_RANGED_ATTACK: u32 = 0x8000;
/// `AttributesEx3` bit `0x4` — **the cast bar shows no name for this spell**
/// (`SPELL_ATTR_EX3_NO_CASTING_BAR_TEXT`, vmangos `Spells/SpellDefines.h:907`). Decision 1312.
///
/// Exactly three rows in the shipped 5875 file carry it, and one of them is *named after the bit*:
/// 6477 "Opening", **22810 "Opening - No Text"**, 26380 "zzOLDSummon Mouth Tentacle Visual". That
/// placeholder name is not a string benilla was ever meant to render — it is Blizzard's own note
/// to themselves about what this attribute does to the spell, and it reached a player's screen
/// because we printed the name unconditionally.
const ATTR_EX3_NO_CASTING_BAR_TEXT: u32 = 0x4;
/// `Attributes` bit `0x2` — the "uses the ranged slot" attribute (mangos `SPELL_ATTR_RANGED`).
/// One half of the client's ranged-stance gate (module docs).
const ATTR_RANGED: u32 = 0x2;
@@ -120,6 +120,7 @@ fn main() -> Result<()> {
spell_id,
success,
reason,
..
} => println!(
" pre: cast_result spell={spell_id} success={success} reason={reason:?} ({})",
reason.map(fail_name).unwrap_or("-")
@@ -149,6 +150,7 @@ fn main() -> Result<()> {
spell_id,
success: false,
reason,
..
} if spell_id == CHARGE => {
println!(
" refused: {:?} ({})",
@@ -183,6 +185,7 @@ fn main() -> Result<()> {
spell_id,
success,
reason,
..
} if spell_id == CHARGE => {
let name = reason.map(fail_name).unwrap_or("-");
println!(
@@ -436,8 +436,11 @@ impl World {
spell_id,
success,
reason,
arg,
} => {
println!("SMSG_CAST_RESULT: spell {spell_id} success={success} reason={reason:?}");
println!(
"SMSG_CAST_RESULT: spell {spell_id} success={success} reason={reason:?} arg={arg:?}"
);
if self.dest_spell == Some(*spell_id) {
self.dest_verdict = Some((*spell_id, *success, *reason));
} else if self.cast_verdict.is_none() {
+5 -1
View File
@@ -467,11 +467,15 @@ pub enum SessionEvent {
new_spell_id: u32,
},
/// The server's verdict on our cast (`SMSG_CAST_RESULT`): `reason` is the failure code when
/// `success` is false (the client's error-text table keys on it; surfaced raw for now).
/// `success` is false (the client's error-text table keys on it), and `arg` the reason-specific
/// argument word that fills that message's `%s` — a `SpellFocusObject.dbc` id for
/// `REQUIRES_SPELL_FOCUS`, an `AreaTable.dbc` id for `REQUIRES_AREA`
/// ([`crate::messages::CastOutcome::Failed`]).
CastResult {
spell_id: u32,
success: bool,
reason: Option<u8>,
arg: Option<u32>,
},
/// The pet action bar's whole state (`SMSG_PET_SPELLS`, decision 0982) — or its **teardown**,
/// which is the same event carrying a zero `pet_guid`. Server-authoritative: this is not a
+6 -2
View File
@@ -80,7 +80,11 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
success: outcome == CastOutcome::Ok,
reason: match outcome {
CastOutcome::Ok => None,
CastOutcome::Failed { reason } => Some(reason),
CastOutcome::Failed { reason, .. } => Some(reason),
},
arg: match outcome {
CastOutcome::Ok => None,
CastOutcome::Failed { arg, .. } => arg,
},
}],
ServerPacket::PetSpells(spells) => vec![SessionEvent::PetSpells(Box::new(spells))],
@@ -92,7 +96,7 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
spell_id,
reason: match outcome {
CastOutcome::Ok => None,
CastOutcome::Failed { reason } => Some(reason),
CastOutcome::Failed { reason, .. } => Some(reason),
},
}],
ServerPacket::ItemQueryResponse { entry, info } => {
@@ -380,12 +380,16 @@ pub(super) fn read_pet_action_feedback(r: &mut impl Read) -> io::Result<u8> {
/// `SMSG_CAST_RESULT` — the same `SpellCastResult` vocabulary, decoded the same way (status `2` =
/// fail) so the one message table renders both. vmangos only ever sends the fail status here, but
/// reading it the shared way costs nothing and keeps the two decodes from drifting.
///
/// The body stops at the reason: unlike `CastResult`, `PetCastFailed::AppendBodyTo` writes **no
/// argument words** at all, so a pet failure never carries a `%s` fill.
pub(super) fn read_pet_cast_failed(r: &mut impl Read) -> io::Result<(u32, super::CastOutcome)> {
let spell_id = read_u32_le(r)?;
let status = read_u8(r)?;
let outcome = if status == 2 {
super::CastOutcome::Failed {
reason: read_u8(r)?,
arg: None,
}
} else {
super::CastOutcome::Ok
+38 -10
View File
@@ -28,24 +28,52 @@ use crate::wire::{
};
/// `SMSG_CAST_RESULT`'s verdict: `u32 spellId, u8 status` — status `0` (`SPELL_RESULT_STATUS_OKAY`)
/// ends the packet, status `2` (`SPELL_RESULT_STATUS_FAIL`) appends a `u8` failure reason
/// (vmangos `CastResult::AppendBodyTo`; some reasons append extra arg words we skip).
/// ends the packet, status `2` (`SPELL_RESULT_STATUS_FAIL`) appends a `u8` failure reason and then
/// up to two **reason-specific argument words** (vmangos `CastResult::AppendBodyTo`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CastOutcome {
Ok,
Failed { reason: u8 },
Failed {
reason: u8,
/// The failure's first argument word, when the server sent one — the `%s` source of the
/// client's argument-formatted messages (the arm table at `0x6e1d8e`): a
/// `SpellFocusObject.dbc` id for `REQUIRES_SPELL_FOCUS` (0x5e), an `AreaTable.dbc` id for
/// `REQUIRES_AREA` (0x5d), an `EquippedItemClass` for the 0x19–0x1b family, a
/// permanent-cooldown flag for `NOT_READY` (0x3c). `None` = the packet ended at the reason.
arg: Option<u32>,
},
}
/// Read `SMSG_CAST_RESULT` → `(spell_id, outcome)`. Reason-specific trailing args (a required
/// spell-focus id, an equip class) are left unread — the slice ends with the packet, nothing
/// follows in the stream.
pub(super) fn read_cast_result(r: &mut impl Read) -> io::Result<(u32, CastOutcome)> {
/// Read `SMSG_CAST_RESULT` → `(spell_id, outcome)`.
///
/// The trailing argument words are **positional, gated on the remaining body length** — and that
/// is the reference's own decode, not merely a convenient one. `CastResultHandler 0x6e7330`
/// (registered at `0x6e7155`) reads `u32 spellId`, `u8 status`, then on `status == 2` a
/// `u8 reason`, then each word only `if cursor < size` (`0x6e737a` and `0x6e738c`, comparing
/// CDataStore `+0x14` against `+0x10`) — never keyed off the reason value (wow-re
/// `system/spell/scratch/cast-fail-strings.md` §WIRE-ARGS, §5 cross-checked). It has to be: vmangos
/// writes `if (arg1 || arg2) << u32 arg1; if (arg2) << u32 arg2;`
/// (`Server/Packets/Spell.cpp`, `CastResult::AppendBodyTo`), so a reason whose argument is
/// legitimately 0 with no second argument behind it sends no word at all, and a reason-keyed decode
/// would desync on exactly that case.
///
/// **The reference's absent value is `-1`, not zero** (`0x6e736e or eax,0xffffffff` initializes both
/// slots before the gated reads). `None` here is that sentinel: every consumer treats it and an
/// out-of-range id the same way — decline the fill — so the two encodings are behaviourally
/// identical, and `Option` says which case it is at the type level.
///
/// The second word is read to keep the shape documented, then dropped: only the
/// `EQUIPPED_ITEM_CLASS*` family (0x19–0x1b) sends one, and that arm's `%s` fill reads the failing
/// spell's own `EquippedItemClass`/`EquippedItemSubClassMask` columns — the same two values the
/// server copied out of `SpellEntry` to build the packet (`Spell::SendCastResult`).
pub(super) fn read_cast_result(r: &mut &[u8]) -> io::Result<(u32, CastOutcome)> {
let spell_id = read_u32_le(r)?;
let status = read_u8(r)?;
let outcome = if status == 2 {
CastOutcome::Failed {
reason: read_u8(r)?,
}
let reason = read_u8(r)?;
let arg = (r.len() >= 4).then(|| read_u32_le(r)).transpose()?;
let _arg2 = (r.len() >= 4).then(|| read_u32_le(r)).transpose()?;
CastOutcome::Failed { reason, arg }
} else {
CastOutcome::Ok
};
+37 -1
View File
@@ -142,7 +142,43 @@ fn spell_and_action_wire() {
spell_id,
success,
reason,
} => assert_eq!((spell_id, success, reason), (6673, false, Some(0x55))),
arg,
} => assert_eq!(
(spell_id, success, reason, arg),
(6673, false, Some(0x55), None),
"a reason carrying no argument ends the body at the reason byte"
),
_ => panic!("cast fail event"),
}
// The reason-specific argument words (`CastResult::AppendBodyTo`) are POSITIONAL — read by
// what is left in the body, never keyed off the reason. One word for 0x5e
// REQUIRES_SPELL_FOCUS (the `SpellFocusObject.dbc` id: 12 = "Starbreeze Village Moonwell",
// the `%s` of "Requires %s"); two for the 0x19 EQUIPPED_ITEM_CLASS family (class + subclass
// mask), whose second is read and dropped.
let focus = messages::parse_server(
messages::opcode::SMSG_CAST_RESULT,
&hx("111a0000025e0c000000"),
)
.unwrap();
match decode(focus).pop().unwrap() {
SessionEvent::CastResult { reason, arg, .. } => {
assert_eq!((reason, arg), (Some(0x5e), Some(12)));
}
_ => panic!("cast fail event"),
}
let equip = messages::parse_server(
messages::opcode::SMSG_CAST_RESULT,
&hx("111a0000021902000000f3a50200"),
)
.unwrap();
match decode(equip).pop().unwrap() {
SessionEvent::CastResult { reason, arg, .. } => {
assert_eq!(
(reason, arg),
(Some(0x19), Some(2)),
"arg2 read and dropped"
);
}
_ => panic!("cast fail event"),
}
+13 -4
View File
@@ -39,10 +39,9 @@ impl Loader<'_> {
// is in the set, `OnDragStop`/`OnReceiveDrag` are NOT. This is XML-load-time
// ONLY: the Lua SetScript binding (`0x7748d0`) never auto-enables, so the law
// lives here and not in SetScript itself (a runtime-created frame still needs an
// explicit `EnableMouse(true)`, like the real client). The keyboard kinds
// (`OnChar` = kind 0, `OnKeyDown`/`OnKeyUp` = kind 1) and the wheel kind
// (`OnMouseWheel` = kind 3) have separate indexes in the real client that this
// engine doesn't model yet — un-modeled beside the SetScript keyboard gap above.
// explicit `EnableMouse(true)`, like the real client). The wheel kind
// (`OnMouseWheel` = kind 3) has a separate index this engine doesn't model yet;
// the KEYBOARD kinds are modelled and armed just below.
const MOUSE_KIND: [&str; 5] = [
"OnEnter",
"OnLeave",
@@ -53,6 +52,16 @@ impl Loader<'_> {
if MOUSE_KIND.iter().any(|k| name.eq_ignore_ascii_case(k)) {
self.call(wrapper, "EnableMouse", true, dbg);
}
// The KEYBOARD kinds, the same walker rule one index over (`OnChar` = kind 0,
// `OnKeyDown`/`OnKeyUp` = kind 1). Bucket membership is what the delivery walk
// iterates ([`crate::script::keyboard`]) and it is the FLAG, never the presence of
// a script — so an XML frame carrying a key handler but no `enableKeyboard`
// attribute would never be reached by the dispatcher at all, and its handler could
// not fire. XML-load-time only, like the mouse half.
const KEY_KIND: [&str; 3] = ["OnChar", "OnKeyDown", "OnKeyUp"];
if KEY_KIND.iter().any(|k| name.eq_ignore_ascii_case(k)) {
self.call(wrapper, "EnableKeyboard", true, dbg);
}
if name.eq_ignore_ascii_case("OnLoad") {
onload = Some(func);
}
+10 -4
View File
@@ -461,18 +461,24 @@ mod loader_tests {
assert!(s.eval::<bool>("return FineRan == true").unwrap());
}
/// Unsupported handler names (e.g. the keyboard-focus handlers `OnKeyDown`/`OnChar`, not yet
/// modeled) are warn-once gaps, not hard errors, and don't stop the frame from building.
/// Unsupported handler names are warn-once gaps, not hard errors, and don't stop the frame
/// from building. The example is `OnCursorChanged` — caret geometry is host-side here, so its
/// four float args would all be zero, which is the silent-drop this warn exists to avoid.
/// (It used to be `OnKeyDown`: that one is *fired* since decision 1319 and now belongs to
/// `script::tests::keyboard`.)
#[test]
fn unsupported_script_name_is_a_warning() {
let s = UiScript::new().unwrap();
let doc = parse(
r#"<Ui><Frame name="Keyed"><Scripts><OnKeyDown>x = 1</OnKeyDown></Scripts></Frame></Ui>"#,
r#"<Ui><Frame name="Keyed"><Scripts><OnCursorChanged>x = 1</OnCursorChanged></Scripts></Frame></Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert_eq!(report.frames, 1);
assert!(report.errors.is_empty(), "{:?}", report.errors);
assert!(report.warnings.iter().any(|w| w.contains("OnKeyDown")));
assert!(report
.warnings
.iter()
.any(|w| w.contains("OnCursorChanged")));
}
/// An unknown frame type is an error that drops that subtree but not the rest of the load.
+32 -7
View File
@@ -459,13 +459,38 @@ fn get_number_parses_the_text() {
#[test]
fn key_and_char_paths_never_fire_generic_handlers() {
// OnKeyDown/OnChar aren't even in SCRIPT_KINDS, so SetScript rejects them — proving the runtime
// has no generic keyboard slots to fire (the C++ override replaced them).
let s = script();
let err = s
.run(r#"CreateFrame("EditBox", "E"):SetScript("OnChar", function() end)"#)
.unwrap_err();
assert!(err.to_string().contains("OnChar"), "{err}");
// The law (wow-re `frame-key-script-delivery.md` §1): `CSimpleEditBox` has its OWN input
// vtable (`0x77a900` char / `0x77b160` key-down) which handles the event and **never chains to
// the base implementation** — so a focused box's typing does not also run a generic `OnChar`
// bound on that same box.
//
// This used to be proved by `SetScript("OnChar", …)` *raising*, which it no longer does
// (decision 1319 made the three key names real). The invariant is unchanged and is now
// asserted directly: bind the generic handler, type into the focused box, and watch the text
// arrive with the handler never called.
let mut s = script();
s.run(
r#"
fired = 0
E = CreateFrame("EditBox", "E")
E:EnableKeyboard(true)
E:SetScript("OnChar", function() fired = fired + 1 end)
E:SetFocus()
"#,
)
.unwrap();
assert!(s.char_input("k"), "the focused box consumed the character");
assert_eq!(
s.eval::<String>("return E:GetText()").unwrap(),
"k",
"…by inserting it through its own path"
);
assert_eq!(
s.eval::<i64>("return fired").unwrap(),
0,
"the box's own vtable never chains to the generic OnChar"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
// ── history recall (`historyLines` / AddHistoryLine — decision 0288 P2) ──────────────────────
+17
View File
@@ -162,6 +162,23 @@ pub(super) fn fire_visibility_changes(lua: &Lua, changed: Vec<FrameHandle>) {
}
}
/// Is a handler bound under `script` for this frame id? — the **existence** question, asked
/// without firing anything.
///
/// It exists for the keyboard walk ([`super::keyboard`]), where the reference's consumption gate is
/// the presence of the slot rather than anything the handler does: `0x76b7d0` reads `[+0x188]`/
/// `[+0x190]`, consumes on either, and fires only the first. A lookup failure reads as "absent" —
/// the gate must never be able to raise out of the middle of a walk.
pub(super) fn has_widget_handler(lua: &Lua, id: u32, script: &str) -> bool {
let Ok(scripts) = lua.named_registry_value::<Table>(REG_SCRIPTS) else {
return false;
};
match scripts.get::<Value>(id) {
Ok(Value::Table(per)) => matches!(per.get::<Value>(script), Ok(Value::Function(_))),
_ => false,
}
}
/// The one firing path. `event_name` is the `event` global + first modern positional (OnEvent only);
/// `extra` are the `arg1..argN` globals + the trailing modern positionals. Globals are saved before
/// and restored after (even on error) — nesting-safe. Holds no model borrow across the call.
+230
View File
@@ -0,0 +1,230 @@
//! **Frame keyboard delivery** — the walk, the existence gate, and the key-name argument.
//!
//! Until this module, keys went straight to the focused EditBox and a plain frame could not receive
//! a key at all: `OnChar`/`OnKeyDown`/`OnKeyUp` were the three script names
//! [`super::object::events_regions`] deliberately *raised* on, because accepting a name this engine
//! never fires is the silent-drop class 1203/1205/1211 each recorded. This is the machinery that
//! note was waiting on, so those three names are now accepted there.
//!
//! ## The law (wow-re `system/ui/scratch/frame-key-script-delivery.md`, §5 trio — VERIFIED)
//!
//! An OS key event reaches **one dispatcher per channel**, and the dispatcher — not the frame — is
//! what walks: `0x765f10` (key-down, kind-1 buckets, vtable `+0x60`) and `0x765df0` (char, kind-0
//! buckets, `+0x5c`). Each walks **9 strata records, TOOLTIP (8) down to WORLD (0)**, and within a
//! record the frame array **index 0 upward**, which the inserter `0x764aa0` keeps ordered by
//! **level descending, ties oldest-registration-first**. Every frame in that order is *called*
//! until one returns nonzero; that one **consumes** and both loops stop. So several frames may be
//! called, at most one consumes.
//!
//! **The consumption gate is EXISTENCE, not handling** (`0x76b7d0`/`0x76bba0`/`0x76b760`, §3):
//!
//! | slot state | key-down returns | fires |
//! |---|---|---|
//! | `OnKeyDown` set | **consume** | `OnKeyDown` |
//! | only `OnKeyUp` set | **consume** | *nothing* |
//! | neither | decline | nothing |
//!
//! That asymmetry is real and is transcribed below: a frame carrying only an `OnKeyUp` swallows
//! every key-down and runs no script. `OnChar` gates on its own slot alone; `OnKeyUp` on `+0x190`
//! alone (no `OnKeyDown` fallback). **A 1.12 handler cannot signal "handled"** — the fire's return
//! is discarded at all three call sites (§3.1) — so a Lua handler never influences consumption.
//!
//! **Bucket membership is the keyboard-enabled flag**, never the presence of a script (§3.2):
//! `EnableKeyboard(true)` on a script-less frame puts it *in* the walk, where it is called and
//! declines. XML `enableKeyboard` enables both kinds; an XML `<Scripts>` block auto-enables per
//! handler; **Lua `SetScript` auto-enables nothing**, so a runtime-created frame needs an explicit
//! `EnableKeyboard(true)` exactly as in the real client.
//!
//! ## Where the EditBox sits — it is a participant, not a separate stage
//!
//! The reference has no "editbox first" rule: `CSimpleEditBox` is a frame in the same buckets whose
//! own vtable (`0x77b160` key-down / `0x77a900` char) consumes while focused and never chains to
//! the base. Modelling it as a stage before or after the walk would get the order wrong whenever a
//! keyboard frame and the focused box are in different strata — a TOOLTIP-strata frame really does
//! pre-empt a focused HIGH-strata box, and a WORLD-strata one really does not. So [`walk`] visits
//! the focused box **at its own strata/level**, handing it to the RF-0082 routing this crate
//! already had ([`super::editbox`]) and taking a `true` as consumption.
//!
//! What is deliberately NOT re-derived here: the box's own focus acquisition (`autoFocus`
//! self-acquire, click-to-focus, `SetFocus`) stays entirely in `editbox`, and an event no frame
//! consumed still falls through to it — so every existing routing test holds unchanged.
//!
//! ## Not modelled, named
//!
//! The **sticky per-key-code key-up slot** (§2.1: `0x765fd0` routes key-up to `[root+code*4+0x84]`,
//! the last frame that consumed *some* down for that code, and does **not** clear it on delivery)
//! is not built: this engine's host feeds no key-up at all today. `OnKeyUp` is therefore accepted,
//! stored, gated in the table above — and never fired. That is stated rather than hidden, because
//! its one observable consequence is live: a frame with only an `OnKeyUp` still consumes key-downs.
use mlua::Lua;
use super::{event, Model};
use crate::widget::FrameHandle;
/// Which channel a dispatch is on — the two walks the reference registers separately.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Channel {
/// `0x765df0` — kind-0 buckets, vtable `+0x5c`, fires `OnChar` with the literal character.
Char,
/// `0x765f10` — kind-1 buckets, vtable `+0x60`, fires `OnKeyDown` with the key NAME.
KeyDown,
}
/// The walk order (§2): every keyboard-enabled, effectively-visible frame, **strata descending,
/// then level descending, then oldest registration first**.
///
/// Built as a sorted `Vec` per dispatch rather than kept as nine live buckets. The reference
/// maintains the buckets incrementally because it is walking them on every key at 1.12 frame
/// budgets; here a key press is a human-rate event and the candidate set is the *keyboard-enabled*
/// frames only — single digits in any real interface — so an index that must be kept coherent with
/// every Show/Hide/SetFrameLevel/SetFrameStrata would be pure drift surface for no measurable win.
/// The ORDER is the law; the storage is ours.
fn walk_order(model: &Model) -> Vec<FrameHandle> {
let mut candidates: Vec<(FrameHandle, u8, u16, u32)> = model
.arena
.iter_frames()
.filter(|(_, f)| f.effective_visible && f.keyboard_enabled)
.map(|(h, f)| (h, f.strata as u8, f.level, f.insertion_seq))
.collect();
// strata DESC, level DESC, insertion ASC — the inserter's own ordering (`0x764aa0`), where a
// frame goes before the first entry of strictly lower level and equal levels keep registration
// order.
candidates.sort_by(|a, b| b.1.cmp(&a.1).then(b.2.cmp(&a.2)).then(a.3.cmp(&b.3)));
candidates.into_iter().map(|(h, ..)| h).collect()
}
/// Does `frame` have a script bound under `name`?
///
/// The existence test the C++ gate performs on `+0x180`/`+0x188`/`+0x190` — asked of the script
/// registry, which is where this engine keeps those slots.
fn has_script(lua: &Lua, id: u32, name: &str) -> bool {
event::has_widget_handler(lua, id, name)
}
/// The dispatcher for one channel — [`Channel`]'s walk, gate and fire, in the reference's order.
///
/// Returns whether the event was **consumed**, which is what the caller must use to suppress the
/// keybinding system: consumption is decided by the C++ existence gate alone, never by what the
/// Lua handler did or returned (§3.1).
fn walk(lua: &Lua, channel: Channel, arg: &str) -> bool {
let order = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
walk_order(&model)
};
for h in order {
// The focused EditBox consumes through its OWN vtable, never the base one — so it is asked
// in its walk position and its answer is final for this event.
let is_focused_box = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.focused_editbox == Some(h)
};
if is_focused_box {
let consumed = match channel {
Channel::Char => super::editbox::char_input(lua, arg),
Channel::KeyDown => super::editbox::key_input(lua, arg),
};
if consumed {
return true;
}
continue;
}
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.frame_id(h)
};
match channel {
// `0x76b760`: gates on the OnChar slot alone.
Channel::Char => {
if has_script(lua, id, "OnChar") {
fire(lua, id, "OnChar", arg);
return true;
}
}
// `0x76b7d0`: the OR gate — either key slot consumes, only `OnKeyDown` fires.
Channel::KeyDown => {
let down = has_script(lua, id, "OnKeyDown");
if down || has_script(lua, id, "OnKeyUp") {
if down {
fire(lua, id, "OnKeyDown", arg);
}
return true;
}
}
}
}
false
}
/// Fire one key handler with its single string argument (`0x7026f0(frame, &slot, "%s", str)` — the
/// argument is a string on all three channels). Errors land in [`Model::errors`] like every other
/// widget handler; a raising handler must not eat the key press.
fn fire(lua: &Lua, id: u32, script: &str, arg: &str) {
let val = mlua::Value::String(match lua.create_string(arg) {
Ok(s) => s,
Err(e) => {
lua.app_data_mut::<Model>()
.expect("model app_data")
.errors
.push(e.to_string());
return;
}
});
if let Err(e) = event::fire_widget_handler(lua, id, script, vec![val]) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.errors
.push(e.to_string());
}
}
/// The char channel (`0x765df0`) — `arg1` is the literal character, UTF-8 (§4.1: the code is
/// UTF-8-encoded with no range guard, so a multi-byte codepoint arrives whole).
pub(super) fn char_input(lua: &Lua, text: &str) -> bool {
walk(lua, Channel::Char, text)
}
/// The key-down channel (`0x765f10`) — `arg1` is the key NAME, with no modifier prefix, from the
/// same table the keybinding chord uses (§4.2). The host speaks those names already
/// (`crate::script::UiScript::key_input`'s contract), so no decode happens here.
pub(super) fn key_input(lua: &Lua, key: &str) -> bool {
walk(lua, Channel::KeyDown, key)
}
/// The same key-down walk, for the keys this engine delivers to a **focused EditBox as a semantic
/// [`crate::script::EditAction`] chord** rather than by name — BACKSPACE, DELETE, the arrows, HOME,
/// END (decision 0301: the host's per-OS keymap owns which chord means what, so the box never sees
/// these as names).
///
/// Those keys still have to reach a keyboard frame — a dialog you type into needs its BACKSPACE —
/// and the ordering question that creates is real: whoever comes FIRST in the walk owns the key.
/// So this runs the identical order and **declines at the focused box** rather than skipping past
/// it: a frame above the box in walk order consumes and fires; a box above the frame ends the walk
/// with `false`, and the caller then dispatches its chord exactly as before. The one thing this
/// must never do is let a frame *below* the focused box steal the box's editing keys.
pub(super) fn frame_key_input(lua: &Lua, key: &str) -> bool {
let order = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
walk_order(&model)
};
for h in order {
{
let model = lua.app_data_ref::<Model>().expect("model app_data");
if model.focused_editbox == Some(h) {
return false; // the box owns this key; its chord path handles it
}
}
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.frame_id(h)
};
let down = has_script(lua, id, "OnKeyDown");
if down || has_script(lua, id, "OnKeyUp") {
if down {
fire(lua, id, "OnKeyDown", key);
}
return true;
}
}
false
}
+31 -3
View File
@@ -72,6 +72,7 @@ mod inspect;
mod item_stats;
mod item_text;
pub mod keybind;
mod keyboard;
mod layout;
mod loot;
mod loot_roll;
@@ -353,7 +354,7 @@ pub const SCREEN: crate::layout::Handle = 0;
/// corpus call sites across 91 addons), but it removes working behaviour from those 30 sites, so it
/// is a change to make deliberately with FrameXML fixed first — not a side effect of widening this
/// list.
const SCRIPT_KINDS: [&str; 32] = [
const SCRIPT_KINDS: [&str; 35] = [
"OnLoad",
"OnEvent",
"OnUpdate",
@@ -404,6 +405,16 @@ const SCRIPT_KINDS: [&str; 32] = [
// [`crate::layout::size_changed`]'s byte-verified epsilon test — see
// [`UiScript::resolve_layout`]. `OnSizeChanged(self, width, height)`.
"OnSizeChanged",
// The three KEY channels, unblocked by [`keyboard`]'s walk (wow-re
// `scratch/frame-key-script-delivery.md`, VERIFIED). They were the standing exception in
// [`object::events_regions`]'s note — accepted only once something fired them, which is that
// module's whole rule. `OnKeyUp` rides in with the other two deliberately: it is *gated* today
// (a frame carrying only an OnKeyUp consumes every key-down and runs nothing — the reference's
// own asymmetry) even though this engine's host feeds no key-up to fire it with, and accepting
// the name is what makes that consumption reachable.
"OnChar",
"OnKeyDown",
"OnKeyUp",
];
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -968,7 +979,11 @@ impl UiScript {
/// focused box, inserts it (numeric/cap/password rules apply) or — for the Ctrl+A control code —
/// selects all. Returns `true` if consumed (a focused box consumes every char).
pub fn char_input(&mut self, text: &str) -> bool {
editbox::char_input(&self.lua, text)
// The frame walk first ([`keyboard`]): the focused box is a PARTICIPANT in it, at its own
// strata/level, so this is not "frames before boxes" — it is the reference's one dispatcher
// in the reference's order. An event no frame consumed still falls through to the box
// routing, which owns focus acquisition (`autoFocus` self-acquire and kin).
keyboard::char_input(&self.lua, text) || editbox::char_input(&self.lua, text)
}
/// Paste text from the host OS clipboard into the focused EditBox. The engine-free runtime can't
@@ -985,7 +1000,20 @@ impl UiScript {
/// owns which chord means what). Routes per §1/§2; a focused box consumes the key even when
/// it does nothing with it. Returns `true` if consumed.
pub fn key_input(&mut self, key: &str) -> bool {
editbox::key_input(&self.lua, key)
// Same two-stage shape as `char_input` — see its note.
keyboard::key_input(&self.lua, key) || editbox::key_input(&self.lua, key)
}
/// A key the host delivers to a focused EditBox as an [`EditAction`] chord rather than by name
/// (BACKSPACE, DELETE, the arrows, HOME, END), offered to the **keyboard frames** first.
///
/// Returns `true` if a frame consumed it, in which case the caller must NOT also dispatch the
/// chord — and the key's binding must not fire either (consumption suppresses it, wow-re §3).
/// A `false` means either nothing wanted it or the focused box owns it; the caller proceeds
/// exactly as it did before this entry point existed. See [`keyboard::frame_key_input`] for
/// why declining at the box is the faithful answer rather than skipping it.
pub fn frame_key_input(&mut self, key: &str) -> bool {
keyboard::frame_key_input(&self.lua, key)
}
/// One semantic text-editing operation on the focused EditBox — the output of the host's
@@ -221,3 +221,65 @@ fn a_nested_long_string_parses_as_lua_50_does() {
.expect("nested long string must parse");
assert_eq!(v, "a [[b]] c");
}
/// **5.0's constructor compat-semicolon, restored** — the third dialect divergence (decision 1315).
///
/// Lua 5.0's `constructor()` opens its field loop with `testnext(ls, ';') /* compatibility
/// only */` — a Lua 4.0 leftover (4.0 separated a constructor's list part from its record part
/// with `;`) that silently eats ONE extra `;` per separator position inside a table constructor.
/// 5.1 deleted the line. AtlasLoot's `ButtonRegistry.lua` writes `Back_Title = AL["Factions"];;`
/// twenty times; the real client loads it (director-verified on the live 1.12.1 client), stock
/// 5.1 answered "unexpected symbol near ';'" and the addon's registry global never existed (B266).
#[test]
fn a_double_semicolon_inside_a_constructor_parses_as_lua_50_does() {
let s = script();
// The exact corpus shape: a record field, its `;` separator, and the compat extra.
let out: String = s
.eval(
r#"
local AL = { Factions = "Factions" }
local reg = {
["REP1"] = { Title = "The Aldor"; Back_Page = "REPMENU"; Back_Title = AL["Factions"];; };
}
return reg["REP1"].Back_Title
"#,
)
.expect("the ButtonRegistry shape must parse");
assert_eq!(out, "Factions");
// The compat skip runs at loop TOP, so a lone `;` before `}` — legal 5.0, an empty record
// part in 4.0 terms — is eaten the same way.
assert_eq!(s.eval::<i64>("local t = {;} return 1").unwrap(), 1);
// List context too: the separator may be `;` and the extra one is still absorbed.
let n: i64 = s.eval("local t = { 1;; 2 } return t[1] + t[2]").unwrap();
assert_eq!(n, 3);
}
/// **The restoration is exactly one semicolon wide, and constructor-only.** Both halves of the
/// boundary are the client's: 5.0 eats ONE extra `;` per position (a third dies in `listfield`),
/// and statement-level `;;` is rejected by 5.0 and 5.1 alike — the half wow-5875-re byte-verified
/// in the client's `luaL_loadbuffer` first (`system/ui/scratch/lua-dialect.md`).
#[test]
fn the_compat_semicolon_is_one_wide_and_statement_level_stays_rejected() {
let s = script();
let err = s
.eval::<i64>("local t = { 1;;; 2 } return t[1]")
.unwrap_err()
.to_string();
assert!(
err.contains("unexpected symbol"),
"a THIRD semicolon must still be a syntax error, as on 5.0: {err}"
);
let err = s
.eval::<i64>("local x = 1;; return x")
.unwrap_err()
.to_string();
assert!(
err.contains("unexpected symbol"),
"statement-level `;;` must stay rejected — that half is byte-verified: {err}"
);
}
+4 -7
View File
@@ -598,13 +598,10 @@ fn the_unfired_script_kinds_still_raise_rather_than_silently_accepting() {
s.run(r#"Raiser = CreateFrame("EditBox", "RaiserBox")"#)
.unwrap();
for name in [
// The FLAG exists (`EnableKeyboard`/`IsKeyboardEnabled`, 1252); what is still absent is the
// keyboard INDEX and the strata walk that would deliver a key to a frame carrying it, so
// these three stay unfired (wow-re `scripts-auto-enable.md` kinds 0/1). Being enabled is
// not being a handler — the carve is explicit that the two are separable.
"OnKeyDown",
"OnKeyUp",
"OnChar",
// `OnKeyDown`/`OnKeyUp`/`OnChar` are NOT in this list any more: decision 1319 built the
// delivery walk, so they are fired and therefore accepted — the rule this test enforces,
// applied in the other direction. Their law lives in `script::tests::keyboard`.
//
// Caret geometry is host-side; its four float args would all be zero.
"OnCursorChanged",
// 2.0's secure-frame system — no such slot exists in any 1.12 resolver.
@@ -0,0 +1,186 @@
//! The frame keyboard delivery law (decision 1319; wow-re
//! `system/ui/scratch/frame-key-script-delivery.md`, §5 trio — VERIFIED).
//!
//! Each test pins one clause that a plausible-but-wrong implementation gets backwards.
use super::common::script;
/// The bucket gate (§3.2): membership is the **keyboard-enabled flag**, not the presence of a
/// script. A Lua-created frame auto-enables nothing (the reference's `SetScript` doesn't either),
/// so its handler is unreachable until `EnableKeyboard(true)` puts it in the walk.
#[test]
fn a_key_script_alone_does_not_put_a_frame_in_the_walk() {
let mut s = script();
s.run(
r#"
got = nil
f = CreateFrame("Frame", "KbUnenabled")
f:SetScript("OnChar", function() got = arg1 end)
"#,
)
.unwrap();
assert!(
!s.char_input("7"),
"an unenabled frame is not in the bucket"
);
assert_eq!(s.eval::<Option<String>>("return got").unwrap(), None);
s.run("f:EnableKeyboard(true)").unwrap();
assert!(
s.char_input("7"),
"enabled: now in the walk, and it consumes"
);
assert_eq!(
s.eval::<Option<String>>("return got").unwrap().as_deref(),
Some("7")
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The existence gate's asymmetry (§3): a frame carrying **only** an `OnKeyUp` consumes every
/// key-down and runs nothing. This is the clause an implementation "tidies away" — and doing so
/// silently un-suppresses that frame's keybindings.
#[test]
fn only_an_onkeyup_still_swallows_the_key_down() {
let mut s = script();
s.run(
r#"
downs = 0
f = CreateFrame("Frame", "KbUpOnly")
f:EnableKeyboard(true)
f:SetScript("OnKeyUp", function() downs = downs + 1 end)
"#,
)
.unwrap();
assert!(s.key_input("ESCAPE"), "the OnKeyUp slot alone consumes");
assert_eq!(
s.eval::<i64>("return downs").unwrap(),
0,
"…and fires nothing"
);
// Neither slot: declines outright.
s.run("f:SetScript(\"OnKeyUp\", nil)").unwrap();
assert!(!s.key_input("ESCAPE"), "no key slot at all: declines");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The walk order (§2): **strata descending first**, and only then level. A LOW frame can never
/// take a key from a HIGH one, however high its level — the same "a raise never changes stratum"
/// shape the toplevel law has.
#[test]
fn the_walk_is_strata_then_level_then_registration() {
let mut s = script();
s.run(
r#"
winner = nil
function mk(name, strata, level)
local f = CreateFrame("Frame", name)
f:SetFrameStrata(strata)
f:SetFrameLevel(level)
f:EnableKeyboard(true)
f:SetScript("OnKeyDown", function() winner = name end)
return f
end
-- registered first, but the LOWEST stratum: must lose despite its huge level
mk("KbLow", "LOW", 99)
mk("KbHigh", "HIGH", 1)
"#,
)
.unwrap();
assert!(s.key_input("ESCAPE"));
assert_eq!(
s.eval::<String>("return winner").unwrap(),
"KbHigh",
"strata beats level"
);
// Within one stratum: higher level wins, and equal levels keep registration order.
s.run(
r#"
winner = nil
mk("KbHighTop", "HIGH", 5)
mk("KbHighTie", "HIGH", 5)
"#,
)
.unwrap();
assert!(s.key_input("ESCAPE"));
assert_eq!(
s.eval::<String>("return winner").unwrap(),
"KbHighTop",
"level 5 beats level 1; the earlier of the two 5s wins the tie"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **At most one frame consumes** (§2, "Delivery"): the walk stops at the first consumer, so a
/// second keyboard frame below it never sees the key.
#[test]
fn the_first_consumer_ends_the_walk() {
let mut s = script();
s.run(
r#"
seen = {}
function mk(name, strata)
local f = CreateFrame("Frame", name)
f:SetFrameStrata(strata)
f:EnableKeyboard(true)
f:SetScript("OnChar", function() table.insert(seen, name) end)
end
mk("KbTip", "TOOLTIP")
mk("KbDlg", "DIALOG")
"#,
)
.unwrap();
assert!(s.char_input("x"));
assert_eq!(s.eval::<i64>("return table.getn(seen)").unwrap(), 1);
assert_eq!(s.eval::<String>("return seen[1]").unwrap(), "KbTip");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A hidden frame is in no bucket (the link gate the walk shares with the draw order), so it
/// neither fires nor consumes — and the frame below it gets the key instead.
#[test]
fn a_hidden_frame_is_not_in_the_walk() {
let mut s = script();
s.run(
r#"
winner = nil
function mk(name, strata)
local f = CreateFrame("Frame", name)
f:SetFrameStrata(strata)
f:EnableKeyboard(true)
f:SetScript("OnChar", function() winner = name end)
return f
end
top = mk("KbHidden", "TOOLTIP")
mk("KbBelow", "DIALOG")
top:Hide()
"#,
)
.unwrap();
assert!(s.char_input("q"));
assert_eq!(s.eval::<String>("return winner").unwrap(), "KbBelow");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A raising handler must not eat the key or abort the walk's caller: the error is recorded, the
/// frame still consumed (consumption is the C++ gate's, never the handler's — §3.1).
#[test]
fn a_raising_handler_still_consumes_and_is_recorded() {
let mut s = script();
s.run(
r#"
f = CreateFrame("Frame", "KbBoom")
f:EnableKeyboard(true)
f:SetScript("OnChar", function() error("boom") end)
"#,
)
.unwrap();
assert!(s.char_input("z"), "the gate consumed regardless");
assert!(
s.errors().iter().any(|e| e.contains("boom")),
"the raise is recorded: {:?}",
s.errors()
);
}
@@ -17,6 +17,7 @@ mod font_object;
mod frame_api;
mod generic_for;
mod input;
mod keyboard;
mod layout_gate;
mod measure;
mod minimap;
+3 -2
View File
@@ -35,7 +35,7 @@ downgrade, and it is the entire reason this is a one-hunk fork rather than a ven
## The delta, and the command that proves it
**Current state: two hunks, in two files.** `src/lib.rs` additionally differs by having Lua
**Current state: three hunks, in three files.** `src/lib.rs` additionally differs by having Lua
5.2/5.3/5.4/5.5 stripped from the `Version` enum (with their source trees deleted) — benilla builds
5.1 and only 5.1, and a fork that still *offered* the other four would answer a request for one with
a missing directory at build time instead of a compile error.
@@ -44,6 +44,7 @@ a missing directory at build time instead of a compile error.
|---|---|---|---|
| `lvm.c` | 5.0's `OP_TFORPREP` table→`next` substitution, folded into the top of `OP_TFORLOOP` | `for k, v in someTable do` raised "attempt to call a table value"; it was the first session-start error for **60** corpus addons | 1215 |
| `luaconf.h` | `LUA_COMPAT_LSTR` `1` → `2` | 5.1 kept 5.0's nesting machinery and put an advisory error in front of it; two corpus addons died on "nesting of `[[...]]` is deprecated" | — |
| `lparser.c` | 5.0's compat-semicolon skip restored at the top of `constructor()`'s field loop — 5.0's own line, verbatim, comment included | one extra `;` after a field separator inside a table constructor (`Back_Title = AL["Factions"];;`, ×20 in AtlasLoot's `ButtonRegistry.lua`) parses on the client and died here with "unexpected symbol near `;`"; statement-level `;;` stays rejected by both dialects | 1315 |
The ordering was deliberate and it held: the fork landed **inert**, byte-identical to upstream, so
the build pipeline was proven transparent *before* any behavioural payload went into it — and when
@@ -59,7 +60,7 @@ diff -r third_party/lua-src/lua-5.1.5 \
~/.cargo/registry/src/*/lua-src-550.0.0/lua-5.1.5
```
That must print **exactly** the two hunks in the table above and nothing else. If it ever prints
That must print **exactly** the three hunks in the table above and nothing else. If it ever prints
more, something crept in.
## Status of the payload
+17
View File
@@ -509,6 +509,23 @@ static void constructor (LexState *ls, expdesc *t) {
checknext(ls, '{');
do {
lua_assert(cc.v.k == VVOID || cc.tostore > 0);
/* BENILLA: Lua 5.0's constructor compat-semicolon, restored (decision 1315;
** see third_party/lua-src/BENILLA.md). 5.0 opened this loop with exactly the
** line below -- its own comment read "compatibility only", a Lua 4.0 leftover
** (4.0 separated a constructor's list part from its record part with `;`).
** 5.1 deleted the line, so one extra `;` after a field separator -- the
** `Back_Title = AL["Factions"];;` shape AtlasLoot's ButtonRegistry.lua writes
** twenty times -- became "unexpected symbol near ';'" here while the 1.12.1
** client (Lua 5.0) accepts it. Statement-level `;;` stays rejected by both,
** which is the half wow-5875-re byte-verified first (system/ui/scratch/
** lua-dialect.md 9.1), and byte-verified in the client itself rather than
** inferred from stock 5.0: constructor is 0x6fd430, and its loop head
** 0x6fd4a0 calls testnext(ls,';') WITHOUT testing EAX -- a skip, not a
** separator check -- while the loop's continuation at 0x6fd4f5/0x6fd505 does
** test it. Of the 16 call sites of testnext 0x6fccf0 image-wide, the only
** other consumer of `;' is chunk 0x6fcccc, at STATEMENT level, with no such
** head skip. This line restores the constructor context only. */
testnext(ls, ';'); /* compatibility only */
if (ls->t.token == '}') break;
closelistfield(fs, &cc);
switch(ls->t.token) {