key bindings become real, and the pet knows when to refuse

- every rebindable key runs through one chord-to-command engine, set in the era Key Bindings window, and buttons show their real keys
- a spell aimed at the ground sends its projectile to that spot, arcing to the point instead of a target
- a pet ordered onto something it can't attack refuses cleanly, and its frame shows the happiness face read from the client's own data
- a window's tabs clamp to the drawn edge and highlight exactly where they sit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-05 19:04:33 +07:00
co-authored by Claude Fable 5
parent 3b6975fdef
commit ff1524f85f
55 changed files with 5537 additions and 653 deletions
+24 -6
View File
@@ -60,10 +60,19 @@
-- CooldownFrame_SetTimer moved to Cooldown.xml (the ref's own file split) — the bag
-- slots share it, so it loads before both consumers.
-- The static key-binding label each button i shows in its HotKey corner (our bar binds
-- 1..9,0,-,= to slots 1..12). The real client derives these from GetBindingKey; we render
-- them statically since the bindings are fixed.
BENILLA_ACTIONBAR_HOTKEYS = { "1","2","3","4","5","6","7","8","9","0","-","=" }
-- The key-binding label each main-bar button shows in its HotKey corner — LIVE off
-- GetBindingKey("ACTIONBUTTON"..i) since 0997 (the ref's own ActionButton_UpdateHotkeys
-- source), repainted on UPDATE_BINDINGS. Raw chord tokens for now (the ref abbreviates
-- through its KEY_ABBR set — 0997 residue); an unbound slot shows nothing, and so does
-- a harness with no registered bindings.
function BenillaActionBar_UpdateHotkeys()
for i = 1, table.getn(BENILLA_ACTION_BUTTONS) do
local button = BENILLA_ACTION_BUTTONS[i]
if button.hotkeyIndex then
button.hotkey:SetText(GetBindingKey("ACTIONBUTTON" .. button.hotkeyIndex) or "")
end
end
end
-- Whether a placeable cursor payload is held (decision 0216 §7's ACTIONBAR_SHOWGRID/
-- _HIDEGRID pair, simplified to a boolean — see the SCOPE note above).
@@ -209,12 +218,14 @@
button.rangeTimer = nil
if base then
-- Multibar buttons are unbound (ref Bindings.xml ships no default MULTIACTIONBAR*
-- keys): no static label. Their hotkey corner instead carries the ref's
-- keys): no label. Their hotkey corner instead carries the ref's
-- RANGE_INDICATOR dot (ActionButton_UpdateHotkeys' no-binding leg, net effect:
-- a red dot only while out of range), painted by the OnUpdate range timer.
button.hotkey:SetText("")
else
button.hotkey:SetText(BENILLA_ACTIONBAR_HOTKEYS[index])
-- Main-bar buttons read their ACTIONBUTTON<i> binding live (0997).
button.hotkeyIndex = index
button.hotkey:SetText(GetBindingKey("ACTIONBUTTON" .. index) or "")
end
table.insert(BENILLA_ACTION_BUTTONS, button)
-- BOTH mouse buttons, ref-verbatim (ActionButton_OnLoad, ActionButton.lua:108-109):
@@ -466,6 +477,9 @@
frame:RegisterEvent("PLAYER_LEAVE_COMBAT")
frame:RegisterEvent("START_AUTOREPEAT_SPELL")
frame:RegisterEvent("STOP_AUTOREPEAT_SPELL")
-- The binding table moved (a rebind, a set switch, the seed) — repaint the HotKey
-- corners (0997; the ref's own UPDATE_BINDINGS consumer is UpdateHotkeys too).
frame:RegisterEvent("UPDATE_BINDINGS")
end
-- Run one ref per-button handler over the OCCUPIED buttons (the bar-level fan — SCOPE
@@ -495,6 +509,10 @@
end
return
end
if event == "UPDATE_BINDINGS" then
BenillaActionBar_UpdateHotkeys()
return
end
-- The ref ActionButton_OnEvent dispatch (l.332-362), fanned bar-level:
if event == "ACTIONBAR_UPDATE_STATE" then
BenillaActionBar_ForEach(BenillaActionButton_UpdateState)
+11 -7
View File
@@ -725,16 +725,20 @@
-- plate opens toward screen center off a bottom-right button), NOT at the default corner.
--
-- ref MainMenuBarBackpackButton OnEnter (ref-MainMenuBarBagButtons.xml l.91-99). The ref
-- appends the live TOGGLEBACKPACK key with GameTooltip:AppendText; this engine has neither
-- GetBindingKey nor AppendText, so the suffix is concatenated from the one bare-key binding
-- we actually ship (ui_script/input.rs `bare_key_binding`: KeyB) — MicroMenu.xml's own shape
-- for the same problem, and the rendered line is identical.
-- appends the live binding key with GameTooltip:AppendText; ours concatenates the same
-- suffix from a LIVE GetBindingKey read since 0997 (our one bag knob is OPENALLBAGS —
-- the toggle this very button runs), rendered through the Key Bindings window's display
-- law when it is loaded. Unbound: the bare label, the ref's own read.
function BenillaBagToggle_OnEnter(button)
if not GameTooltip then return end -- engine tests load file subsets
GameTooltip:SetOwner(button, "ANCHOR_LEFT")
GameTooltip:SetText(
BACKPACK_TOOLTIP .. " " .. NORMAL_FONT_COLOR_CODE .. "(B)" .. FONT_COLOR_CODE_CLOSE,
1.0, 1.0, 1.0)
local text = BACKPACK_TOOLTIP
local key = GetBindingKey("OPENALLBAGS")
if key then
if KeyBindings_KeyText then key = KeyBindings_KeyText(key) end
text = text .. " " .. NORMAL_FONT_COLOR_CODE .. "(" .. key .. ")" .. FONT_COLOR_CODE_CLOSE
end
GameTooltip:SetText(text, 1.0, 1.0, 1.0)
end
-- ref BagSlotButton_OnEnter (ref-MainMenuBarBagButtons.lua l.86-96): the equipped bag's own
+30 -6
View File
@@ -63,8 +63,13 @@
Rewards / What's New / Ratings are structurally off in Era — store disabled, no
account store, IsStandard() false, koKR only.)
· The layout math those constants produce, precomputed here (this engine has no layout
frames): width 28+144+28 = 200; button tops 32/73/94/115/136/177/198/239; height
239+21+28 = 288. Anchored as a TOPLEFT chain so the gaps read in the XML.
frames): width 28+144+28 = 200; button tops 32/73/94/115/136/157/198/219/260; height
260+21+28 = 309. Anchored as a TOPLEFT chain so the gaps read in the XML.
· **Key Bindings is a carved seat (0997, 0985's seat-carving rule)**: the 1.15 menu
ceded the entry to its Settings panel, but THIS window family's shape source is
1.13/1.14 (KeyBindingsFrame.xml's provenance block), whose menu carries the button
and whose window reopens the menu on hide — so the rung returns, at the middle
section's head, wearing the 1.12 KEY_BINDINGS label like every sibling.
· The header — Classic/Dialog/DialogTemplates.xml l.11-43 (ClassicDialogHeaderTemplate):
the same 256x64 UI-DialogBox-Header art, text = MAINMENU_BUTTON, swapped to
GameFontNormalMed1 (13px) by MainMenuFrameMixin:OnLoad. Both header idioms (1.12's
@@ -85,6 +90,7 @@
-- The reference GlobalStrings (patch-2.MPQ Interface\FrameXML\GlobalStrings.lua), quoted
-- verbatim. `X = X or "..."` so a later real strings table wins, the MicroMenu.xml idiom.
MAIN_MENU = MAIN_MENU or "Main Menu"
KEY_BINDINGS = KEY_BINDINGS or "Key Bindings"
ADDONS = ADDONS or "AddOns"
HUD_EDIT_MODE_MENU = HUD_EDIT_MODE_MENU or "Edit Mode"
GAMEMENU_SUPPORT = GAMEMENU_SUPPORT or "Support"
@@ -195,8 +201,9 @@
<Frame name="GameMenuFrame" frameStrata="DIALOG" enableMouse="true" hidden="true">
<!-- The era layout engine's own numbers (header SCOPE note): 28+144+28 wide, 32-top /
28-bottom padding around eight 21-unit rungs with three 20-unit section gaps. -->
<Size><AbsDimension x="200" y="288"/></Size>
28-bottom padding around nine 21-unit rungs with three 20-unit section gaps (the
ninth rung is the 0997 Key Bindings seat). -->
<Size><AbsDimension x="200" y="309"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<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>
@@ -245,7 +252,24 @@
</Scripts>
</Button>
<!-- The middle section, all pending (header SCOPE note).
<!-- Key Bindings — live (decision 0997; the carved 1.13/1.14 seat, header SCOPE
note): the era menu entry's own body shape — hide the menu, show the window
(whose OnHide brings the menu back, both references' law). -->
<Button name="GameMenuButtonKeybindings" inherits="GameMenuButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonOptions" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-20"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>self:SetText(KEY_BINDINGS)</OnLoad>
<OnClick>
PlaySound("igMainMenuOption")
HideUIPanel(GameMenuFrame)
ShowUIPanel(getglobal("KeyBindingFrame"))
</OnClick>
</Scripts>
</Button>
<!-- The middle section's pending rest (header SCOPE note).
AddOns: the era addon-list dialog — nothing to list yet.
Edit Mode: the era HUD editor — no edit mode exists.
Support: opens the external help site in era — no browser seam.
@@ -253,7 +277,7 @@
HideUIPanel(GameMenuFrame); ShowMacroFrame() -->
<Button name="GameMenuButtonAddOns" inherits="GameMenuButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonOptions" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-20"/></Offset></Anchor>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonKeybindings" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>GameMenuButton_Pending(self, ADDONS)</OnLoad></Scripts>
</Button>
@@ -0,0 +1,798 @@
<Ui>
<!--
The KEY BINDINGS window (decision 0997) — the era-shaped standalone KeyBindingFrame the
game menu's Key Bindings button opens, over the engine's binding table
(benilla_ui::script::keybind + crate::bindings — GetNumBindings/GetBinding/SetBinding/
GetBindingKey/GetBindingAction/LoadBindings/SaveBindings/GetCurrentBindingSet, the 1.12
Lua API verbatim). Rows are the HONEST TREE: only commands the engine really dispatches
are registered, so only they (and only their non-empty categories) appear.
PROVENANCE (0985 — the three-source split; OptionsFrame.xml carries the sibling block):
· STRUCTURE & BEHAVIOR — the era Blizzard_BindingUI (wow-ui-source-era @ 1.15.9.68940,
Interface/AddOns/Blizzard_BindingUI — shipped there though 1.15's Settings supersedes
it; per 0985's per-window rule the newest classic-dialog form of THIS window is the
1.13/1.14 one, and that source IS this file's blueprint, the director's own screenshot):
858x660 CENTER dialog, the 175-wide category sidebar replacing 1.12's inline headers
(only non-empty categories appear), the bordered bindings container, Command/Key 1/
Key 2 columns over 25-tall rows, the top-right Character Specific checkbox, and the
Reset To Default / Unbind Key / Okay / Cancel row. Behavior deltas the era added and
this file keeps: Reset To Default confirms first; checkbox-kit click sounds.
· CAPTURE & SET LAW — 1.12's own Blizzard_BindingUI (extracted from patch.MPQ this
session, `_extracted_addons/`), which the era Lua descends from line-for-line: click a
key capsule to arm it; the next key or mouse-3/4/5 press binds IMMEDIATELY (the host's
capture seam hands the canonical ALT-CTRL-SHIFT chord to KeyBindingFrame_OnHostKey);
a stolen key names its victim in red (KEY_UNBOUND_ERROR); left/right clicks select/
deselect, never bind; lone modifiers are ignored; the wheel binds click-commands but
refuses press+release ones (SetBinding returns nil - KEYBINDINGFRAME_MOUSEWHEEL_ERROR);
binds take effect LIVE; Okay saves (SaveBindings), Cancel/ESC reverts
(LoadBindings(GetCurrentBindingSet())); the character-specific set is real 1.12
(DEFAULT/ACCOUNT/CHARACTER = 0/1/2, both confirm popups).
· ART BYTES — the 1.12 MPQ chain only (0978/0985): the same dialog chrome as
OptionsFrame.xml (BACKDROP_DIALOG_32_32 recipe: tiled UI-DialogBox-Background at its
uniform black-60% + the UI-DialogBox-Border rope; UI-DialogBox-Header title plate),
the tooltip-border OUTLINE boxes, the QuestLogTitleHighlight category wash law, the
UI-Panel-Button four-state red buttons, the UI-CheckBox set, the shared scroll kit.
The era's key capsules are UIMenuButtonStretchTemplate over UI-Silver-Button-* —
VERIFIED ABSENT from the 1.12 chain — so the capsules wear what 1.12's OWN binding
window wore: UIPanelButtonTemplate2's panel-button art (the selected state is the
1.12 window's own LockHighlight glow, not the era's silver overlay).
All strings are the 1.12 GlobalStrings globals (the VM executes the real file at boot):
KEY_BINDINGS, COMMAND/KEY1/KEY2, NOT_BOUND, BIND_KEY_TO_COMMAND, KEY_BOUND,
KEY_UNBOUND_ERROR, RESET_TO_DEFAULT, UNBIND, CHARACTER_SPECIFIC_KEYBINDINGS(+TOOLTIP),
CHARACTER_KEY_BINDINGS, CONFIRM_LOSE_BINDING_CHANGES,
CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS, KEYBINDINGFRAME_MOUSEWHEEL_ERROR, the
BINDING_HEADER_*/BINDING_NAME_* row text and the KEY_* key display names — each read
through a `getglobal(...) or raw` fallback (the era's own `_G[category] or category`
posture), so a headless test without GlobalStrings still renders raw tokens.
The era's CONFIRM_RESET_KEYBINDINGS string is 2.x-era text absent from 1.12
GlobalStrings; the fallback literal below carries it (structure kept, string ours).
SCOPE / divergences (noted, not bugs):
- 20 display rows, not the era's 21: the -52 box top (the title plate's clearance, the
options window's own band) plus the column-label band leaves 500 units of row run.
- No push-to-talk row, no gamepad arm, no quick-keybind mode (era furniture with no
benilla mechanism).
- ESC while a capsule is armed BINDS Escape — 1.12's own law (its OnKeyDown has no
ESCAPE filter while selected); ESC with nothing armed cancels and closes through the
shared ladder (UiPanels.xml), reopening the game menu (both references' OnHide).
-->
<!-- The red panel button at this window's sizes (GameMenuFrame.xml l.145 is the shared
original; templates don't cross files — OptionsFrame.xml carries the same copy). -->
<Button name="KeyBindingsRedButtonTemplate" virtual="true">
<Size><AbsDimension x="96" y="22"/></Size>
<ButtonText name="$parentText"/>
<NormalTexture file="Interface\Buttons\UI-Panel-Button-Up"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Panel-Button-Down"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></PushedTexture>
<DisabledTexture file="Interface\Buttons\UI-Panel-Button-Disabled"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></DisabledTexture>
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></HighlightTexture>
<NormalFont inherits="GameFontHighlight"/>
<HighlightFont inherits="GameFontHighlight"/>
<DisabledFont inherits="GameFontDisable"/>
</Button>
<!-- One KEY CAPSULE (1.12 KeyBindingFrameBindingButtonTemplate: UIPanelButtonTemplate2 at
180x22 with the small-highlight font trio; its RegisterForClicks takes all five
buttons so middle/4/5 presses reach the capture law). The SELECTED state is 1.12's
LockHighlight — the ADD highlight held on. -->
<Button name="KeyBindingButtonTemplate" virtual="true">
<Size><AbsDimension x="180" y="22"/></Size>
<ButtonText name="$parentText"/>
<NormalTexture file="Interface\Buttons\UI-Panel-Button-Up"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Panel-Button-Down"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></PushedTexture>
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></HighlightTexture>
<NormalFont inherits="GameFontHighlightSmall"/>
<HighlightFont inherits="GameFontHighlightSmall"/>
<DisabledFont inherits="GameFontDisableSmall"/>
<Scripts>
<OnLoad>this:RegisterForClicks("LeftButtonUp", "RightButtonUp", "MiddleButtonUp", "Button4Up", "Button5Up")</OnLoad>
<OnClick>KeyBindingButton_OnClick(arg1)</OnClick>
</Scripts>
</Button>
<!-- One BINDING ROW (1.12 KeyBindingFrameBindingTemplate at the era row seat: 25 tall,
command name 170 wide at LEFT, the two capsules at 175/355 — no inline header slot:
the sidebar replaced 1.12's in-list headers, and the honest tree has no sub-headers). -->
<Frame name="KeyBindingRowTemplate" virtual="true">
<Size><AbsDimension x="560" y="25"/></Size>
<Layers>
<Layer level="BACKGROUND">
<FontString name="$parentDescription" inherits="GameFontNormalSmall" justifyH="LEFT">
<Size><AbsDimension x="170" y="24"/></Size>
<Anchors><Anchor point="LEFT"/></Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<Button name="$parentKey1Button" inherits="KeyBindingButtonTemplate" id="1">
<Anchors><Anchor point="LEFT"><Offset><AbsDimension x="175" y="0"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentKey2Button" inherits="KeyBindingButtonTemplate" id="2">
<Anchors><Anchor point="LEFT" relativeTo="$parentKey1Button" relativePoint="RIGHT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Button>
</Frames>
</Frame>
<!-- One CATEGORY row — the options window's 1.14 list-button wash law verbatim (0984:
ONE UI-QuestLogTitleHighlight quad in ADD, steel-blue hover / locked gold selection,
white text both ways; the era KeybindingsCategoryListButtonTemplate is the same
OptionsListButtonTemplate family). 175 wide at the era sidebar seat. -->
<Button name="KeyBindingsCategoryRowTemplate" virtual="true" hidden="true">
<Size><AbsDimension x="175" y="20"/></Size>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentBg" file="Interface\QuestFrame\UI-QuestLogTitleHighlight" alphaMode="ADD" hidden="true">
<Size><AbsDimension x="163" y="21"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
</Texture>
</Layer>
</Layers>
<ButtonText name="$parentText" justifyH="LEFT">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="16" y="1"/></Offset></Anchor>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="1"/></Offset></Anchor>
</Anchors>
</ButtonText>
<NormalFont inherits="GameFontNormal"/>
<HighlightFont inherits="GameFontHighlight"/>
<DisabledFont inherits="GameFontDisable"/>
<Scripts>
<OnClick>KeyBindingsCategory_OnClick(this)</OnClick>
<OnEnter>
if KeyBindingFrame.selectedCategory ~= this.categoryIndex then
local bg = getglobal(this:GetName() .. "Bg")
bg:SetVertexColor(0.196, 0.388, 0.8)
bg:Show()
end
</OnEnter>
<OnLeave>
if KeyBindingFrame.selectedCategory ~= this.categoryIndex then
getglobal(this:GetName() .. "Bg"):Hide()
end
</OnLeave>
</Scripts>
</Button>
<Script><![CDATA[
-- ── Strings ── the 1.12 GlobalStrings globals, each with the raw fallback (a headless
-- harness without GlobalStrings still works; the era's own `_G[x] or x` posture).
function KeyBindings_String(name, fallback)
local s = getglobal(name)
if s then return s end
return fallback
end
-- ── The category model (era KeyBindingFrame_LoadCategories, simplified by the honest
-- tree): walk GetBinding(i), group runs of one category token; order = first
-- appearance, which IS 1.12's Bindings.xml order (the registry registers in it).
-- Rebuilt on every show, not load: the app seeds the table at PostStartup, after this
-- file's OnLoads ran.
function KeyBindings_LoadCategories()
local frame = KeyBindingFrame
frame.categories = {}
local byToken = {}
for i = 1, GetNumBindings() do
local _, category = GetBinding(i)
local cat = byToken[category]
if not cat then
cat = { token = category, indices = {} }
byToken[category] = cat
table.insert(frame.categories, cat)
end
table.insert(cat.indices, i)
end
for n = 1, 12 do
local row = getglobal("KeyBindingFrameCategory" .. n)
local cat = frame.categories[n]
if cat then
row.categoryIndex = n
row:SetText(KeyBindings_String(cat.token, cat.token))
row:Show()
else
row:Hide()
end
end
if not frame.categories[frame.selectedCategory or 0] then
frame.selectedCategory = 1
end
KeyBindings_PaintCategories()
end
-- The selection paint (the options window's gold-lock / blue-hover law).
function KeyBindings_PaintCategories()
for n = 1, 12 do
local bg = getglobal("KeyBindingFrameCategory" .. n .. "Bg")
if n == KeyBindingFrame.selectedCategory then
bg:SetVertexColor(1, 1, 0)
bg:Show()
else
bg:Hide()
end
end
end
function KeyBindingsCategory_OnClick(row)
PlaySound("igMainMenuOptionCheckBoxOn")
KeyBindingFrame.selectedCategory = row.categoryIndex
KeyBindings_SetSelected(nil)
KeyBindingFrameOutputText:SetText("")
KeyBindings_PaintCategories()
KeyBindingFrameScrollFrameScrollBar:SetValue(0)
KeyBindingFrame_Update()
end
-- ── Key display ── the 1.12 GetBindingText law by hand: modifier prefixes stay the
-- canonical uppercase tokens, the base key reads its KEY_* global (Spacebar, Up Arrow,
-- Mouse Button 4...) else itself. "CTRL--" renders CTRL + the minus key.
function KeyBindings_KeyText(key)
if not key then
return NORMAL_FONT_COLOR_CODE .. KeyBindings_String("NOT_BOUND", "Not Bound") .. FONT_COLOR_CODE_CLOSE
end
local prefix = ""
local rest = key
while true do
local p = string.sub(rest, 1, 4)
if p == "ALT-" then
prefix = prefix .. "ALT-"
rest = string.sub(rest, 5)
elseif string.sub(rest, 1, 5) == "CTRL-" then
prefix = prefix .. "CTRL-"
rest = string.sub(rest, 6)
elseif string.sub(rest, 1, 6) == "SHIFT-" then
prefix = prefix .. "SHIFT-"
rest = string.sub(rest, 7)
else
break
end
end
return prefix .. KeyBindings_String("KEY_" .. rest, rest)
end
-- ── The list repaint (1.12 KeyBindingFrame_Update over the era category page). -->
KEY_BINDINGS_DISPLAYED = 20
KEY_BINDING_HEIGHT = 25
function KeyBindingFrame_Update()
local frame = KeyBindingFrame
local cat = frame.categories and frame.categories[frame.selectedCategory]
local indices = cat and cat.indices or {}
local numBindings = table.getn(indices)
local offset = BenillaFauxScrollFrame_GetOffset(KeyBindingFrameScrollFrame)
for i = 1, KEY_BINDINGS_DISPLAYED do
local row = getglobal("KeyBindingFrameBinding" .. i)
local index = indices[offset + i]
if index then
local command, _, key1, key2 = GetBinding(index)
local b1 = getglobal("KeyBindingFrameBinding" .. i .. "Key1Button")
local b2 = getglobal("KeyBindingFrameBinding" .. i .. "Key2Button")
b1.commandName = command
b2.commandName = command
b1:SetText(KeyBindings_KeyText(key1))
b2:SetText(KeyBindings_KeyText(key2))
if key1 then b1:SetAlpha(1) else b1:SetAlpha(0.8) end
if key2 then b2:SetAlpha(1) else b2:SetAlpha(0.8) end
getglobal("KeyBindingFrameBinding" .. i .. "Description"):SetText(
KeyBindings_String("BINDING_NAME_" .. command, command))
-- The 1.12 selection highlight: the pressed capsule holds its glow.
if frame.selected == command and frame.keyID == 1 then
b1:LockHighlight()
else
b1:UnlockHighlight()
end
if frame.selected == command and frame.keyID == 2 then
b2:LockHighlight()
else
b2:UnlockHighlight()
end
row:Show()
else
row:Hide()
end
end
BenillaFauxScrollFrame_Update(KeyBindingFrameScrollFrame, numBindings,
KEY_BINDINGS_DISPLAYED, KEY_BINDING_HEIGHT)
KeyBindings_UpdateUnbindKey()
end
-- ── Selection + the capture arm ── selecting a capsule arms the HOST's capture seam
-- (BenillaBindCapture): raw input is swallowed there and comes back canonical through
-- KeyBindingFrame_OnHostKey. Deselecting (either way) disarms.
function KeyBindings_SetSelected(command, keyID)
KeyBindingFrame.selected = command
KeyBindingFrame.keyID = keyID
BenillaBindCapture(command ~= nil)
end
function KeyBindingButton_OnClick(button)
-- The 1.12 law: while something is selected, only LEFT/RIGHT on a capsule
-- re-select or deselect — a middle/4/5 press is a BIND, and the host's armed
-- capture seam (which runs after this click) takes it, so this handler must not
-- touch the selection for those buttons.
if KeyBindingFrame.selected then
if button ~= "LeftButton" and button ~= "RightButton" then
return
end
if KeyBindingFrame.selected == this.commandName and KeyBindingFrame.keyID == this:GetID() then
KeyBindings_SetSelected(nil)
KeyBindingFrameOutputText:SetText("")
else
KeyBindings_SetSelected(this.commandName, this:GetID())
KeyBindingFrameOutputText:SetText(string.format(
KeyBindings_String("BIND_KEY_TO_COMMAND", "Press Key to Bind to Command -> %s"),
KeyBindings_String("BINDING_NAME_" .. this.commandName, this.commandName)))
end
else
KeyBindings_SetSelected(this.commandName, this:GetID())
KeyBindingFrameOutputText:SetText(string.format(
KeyBindings_String("BIND_KEY_TO_COMMAND", "Press Key to Bind to Command -> %s"),
KeyBindings_String("BINDING_NAME_" .. this.commandName, this.commandName)))
end
KeyBindingFrame_Update()
end
-- The host's capture callback: `chord` is canonical (ALT-CTRL-SHIFT-TOKEN). The body is
-- 1.12's OnKeyDown selected-branch verbatim: steal message, clear the command's keys,
-- rebind in slot order so the pressed slot takes the new chord.
function KeyBindingFrame_OnHostKey(chord)
local frame = KeyBindingFrame
if not frame.selected then
return
end
local selected = frame.selected
local oldAction = GetBindingAction(chord)
if oldAction ~= "" and oldAction ~= selected then
local k1, k2 = GetBindingKey(oldAction)
if (not k1 or k1 == chord) and (not k2 or k2 == chord) then
KeyBindingFrameOutputText:SetText(string.format(
KeyBindings_String("KEY_UNBOUND_ERROR", "|cffff0000%s Function is Now Unbound!|r"),
KeyBindings_String("BINDING_NAME_" .. oldAction, oldAction)))
else
KeyBindingFrameOutputText:SetText(KeyBindings_String("KEY_BOUND", "Key Bound Successfully"))
end
else
KeyBindingFrameOutputText:SetText(KeyBindings_String("KEY_BOUND", "Key Bound Successfully"))
end
local key1, key2 = GetBindingKey(selected)
if key1 then SetBinding(key1) end
if key2 then SetBinding(key2) end
if frame.keyID == 1 then
KeyBindings_SetOrRestore(chord, selected, key1)
if key2 then SetBinding(key2, selected) end
else
if key1 then SetBinding(key1, selected) end
KeyBindings_SetOrRestore(chord, selected, key2)
end
frame.bindingsChanged = 1
KeyBindings_SetSelected(nil)
KeyBindingFrame_Update()
end
-- 1.12 KeyBindingFrame_SetBinding: a refused bind (the wheel on a press+release
-- command) restores the slot's old key and says why.
function KeyBindings_SetOrRestore(key, command, oldKey)
if not SetBinding(key, command) then
if oldKey then SetBinding(oldKey, command) end
KeyBindingFrameOutputText:SetText(KeyBindings_String(
"KEYBINDINGFRAME_MOUSEWHEEL_ERROR",
"Can't bind mousewheel to actions with up and down states"))
end
end
function KeyBindings_UpdateUnbindKey()
if KeyBindingFrame.selected then
KeyBindingFrameUnbindButton:Enable()
else
KeyBindingFrameUnbindButton:Disable()
end
end
-- ── The bottom row + checkbox (1.12 bodies, era confirm on Reset To Default) ──
function KeyBindings_Unbind()
local frame = KeyBindingFrame
local key1, key2 = GetBindingKey(frame.selected)
if key1 then SetBinding(key1) end
if key2 then SetBinding(key2) end
if key1 and frame.keyID == 1 then
if key2 then SetBinding(key2, frame.selected) end
else
if key1 then SetBinding(key1, frame.selected) end
end
frame.bindingsChanged = 1
KeyBindings_SetSelected(nil)
KeyBindingFrameOutputText:SetText("")
KeyBindingFrame_Update()
end
function KeyBindings_UpdateHeader()
if KeyBindingFrameCharacterButton:GetChecked() then
KeyBindingFrameTitleText:SetText(string.format(
KeyBindings_String("CHARACTER_KEY_BINDINGS", "Key Bindings for %s"),
UnitName("player") or UNKNOWNOBJECT or "Unknown"))
else
KeyBindingFrameTitleText:SetText(KeyBindings_String("KEY_BINDINGS", "Key Bindings"))
end
end
function KeyBindings_ChangeBindingProfile()
if KeyBindingFrameCharacterButton:GetChecked() then
LoadBindings(2)
else
LoadBindings(1)
end
KeyBindings_UpdateHeader()
KeyBindingFrameOutputText:SetText("")
KeyBindings_SetSelected(nil)
KeyBindingFrame_Update()
end
function KeyBindings_Okay()
local which
if KeyBindingFrameCharacterButton:GetChecked() then
which = 2
else
which = 1
-- 1.12 guards on `GetCurrentBindingSet() == 2` here; in benilla's set model a
-- character set EXISTS exactly while it is the active one (its file's presence
-- IS the state — decision 0997), so the honest guard for "this Okay will
-- permanently delete the character set" is existence, which also catches the
-- uncheck-then-Okay path 1.12's literal guard let slip past its own warning.
if BenillaCharacterBindingsExist() and not CONFIRMED_DELETING_CHARACTER_SPECIFIC_BINDINGS then
StaticPopup_Show("CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS")
return
end
end
SaveBindings(which)
KeyBindingFrameOutputText:SetText("")
KeyBindings_SetSelected(nil)
HideUIPanel(KeyBindingFrame)
end
-- Cancel / the ESC ladder's rung (UiPanels.xml): revert to the saved set and close.
function KeyBindingFrame_Cancel()
LoadBindings(GetCurrentBindingSet())
KeyBindingFrameOutputText:SetText("")
KeyBindings_SetSelected(nil)
HideUIPanel(KeyBindingFrame)
end
StaticPopupDialogs["CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS"] = {
text = KeyBindings_String("CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS",
"Really switch to general key bindings? All key bindings specific to this character will be permanantly deleted."),
button1 = KeyBindings_String("OKAY", "Okay"),
button2 = KeyBindings_String("CANCEL", "Cancel"),
OnAccept = function()
SaveBindings(1)
KeyBindingFrameOutputText:SetText("")
KeyBindings_SetSelected(nil)
CONFIRMED_DELETING_CHARACTER_SPECIFIC_BINDINGS = 1
HideUIPanel(KeyBindingFrame)
end,
}
StaticPopupDialogs["CONFIRM_LOSE_BINDING_CHANGES"] = {
text = KeyBindings_String("CONFIRM_LOSE_BINDING_CHANGES",
"You will lose any unsaved changes if you switch between general and character specific key bindings."),
button1 = KeyBindings_String("OKAY", "Okay"),
button2 = KeyBindings_String("CANCEL", "Cancel"),
OnAccept = function()
KeyBindings_ChangeBindingProfile()
KeyBindingFrame.bindingsChanged = nil
end,
OnCancel = function()
if KeyBindingFrameCharacterButton:GetChecked() then
KeyBindingFrameCharacterButton:SetChecked(nil)
else
KeyBindingFrameCharacterButton:SetChecked(1)
end
end,
}
-- Era addition kept (structure law): Reset To Default confirms first. 1.12 has no
-- string for it; the literal is ours (the era's lives in its client binary).
StaticPopupDialogs["CONFIRM_RESET_KEYBINDINGS"] = {
text = "Are you sure you want to reset all keybindings to their defaults?",
button1 = KeyBindings_String("OKAY", "Okay"),
button2 = KeyBindings_String("CANCEL", "Cancel"),
OnAccept = function()
PlaySound("igMainMenuOptionCheckBoxOn")
LoadBindings(0)
KeyBindingFrameOutputText:SetText("")
KeyBindings_SetSelected(nil)
KeyBindingFrame_Update()
end,
}
]]></Script>
<Frame name="KeyBindingFrame" frameStrata="DIALOG" enableMouse="true" hidden="true">
<Size><AbsDimension x="858" y="660"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<!-- The 1.14 dialog recipe, OptionsFrame.xml's own block (BACKDROP_DIALOG_32_32 —
which is ALSO the era KeyBindingFrame's declared backdrop, a rare exact meet). -->
<Backdrop bgFile="Interface\DialogFrame\UI-DialogBox-Background" edgeFile="Interface\DialogFrame\UI-DialogBox-Border" tile="true">
<TileSize><AbsValue val="32"/></TileSize>
<EdgeSize><AbsValue val="32"/></EdgeSize>
<BackgroundInsets><AbsInset left="11" right="12" top="12" bottom="11"/></BackgroundInsets>
</Backdrop>
<Layers>
<Layer level="ARTWORK">
<Texture name="$parentHeader" file="Interface\DialogFrame\UI-DialogBox-Header">
<Size><AbsDimension x="300" y="68"/></Size>
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="0" y="12"/></Offset></Anchor></Anchors>
</Texture>
<!-- The column labels + their hairline (the options divider idiom: a 1-unit
white fill at 0.3 — what the era's atlas hairline actually is), across the
bindings box's label band. Seats echo the era chain (command at the box
left; Key 1/Key 2 over their capsule columns). -->
<FontString name="$parentCommandLabel" inherits="GameFontNormal" text="Command">
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="227" y="-64"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="$parentKey1Label" inherits="GameFontNormal" text="Key 1">
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="60" y="-64"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="$parentKey2Label" inherits="GameFontNormal" text="Key 2">
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="240" y="-64"/></Offset></Anchor></Anchors>
</FontString>
<Texture name="$parentColumnLine">
<Size><AbsDimension y="1"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="221" y="-82"/></Offset></Anchor>
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-30" y="-82"/></Offset></Anchor>
</Anchors>
<Color r="1" g="1" b="1" a="0.3"/>
</Texture>
</Layer>
<Layer level="OVERLAY">
<FontString name="$parentTitleText" inherits="GameFontNormal" text="Key Bindings">
<Anchors>
<Anchor point="TOP" relativeTo="$parentHeader"><Offset><AbsDimension x="0" y="-14"/></Offset></Anchor>
</Anchors>
</FontString>
<FontString name="$parentOutputText" inherits="GameFontNormal">
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="90" y="46"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- The two outline boxes (the options window's tooltip-border law). -->
<Frame name="$parentCategoryBox">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="14" y="-52"/></Offset></Anchor>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-669" y="52"/></Offset></Anchor>
</Anchors>
<Backdrop bgFile="Interface\Tooltips\UI-Tooltip-Background" edgeFile="Interface\Tooltips\UI-Tooltip-Border" tile="true">
<EdgeSize><AbsValue val="16"/></EdgeSize>
<TileSize><AbsValue val="16"/></TileSize>
<BackgroundInsets><AbsInset left="5" right="5" top="5" bottom="5"/></BackgroundInsets>
</Backdrop>
<Scripts>
<OnLoad>
this:SetBackdropColor(0, 0, 0, 0)
this:SetBackdropBorderColor(0.6, 0.6, 0.6)
</OnLoad>
</Scripts>
</Frame>
<Frame name="$parentBindingsBox">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="215" y="-88"/></Offset></Anchor>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-14" y="52"/></Offset></Anchor>
</Anchors>
<Backdrop bgFile="Interface\Tooltips\UI-Tooltip-Background" edgeFile="Interface\Tooltips\UI-Tooltip-Border" tile="true">
<EdgeSize><AbsValue val="16"/></EdgeSize>
<TileSize><AbsValue val="16"/></TileSize>
<BackgroundInsets><AbsInset left="5" right="5" top="5" bottom="5"/></BackgroundInsets>
</Backdrop>
<Scripts>
<OnLoad>
this:SetBackdropColor(0, 0, 0, 0)
this:SetBackdropBorderColor(0.6, 0.6, 0.6)
</OnLoad>
</Scripts>
</Frame>
<!-- The category rows (12 static slots; the honest tree has 8 categories today —
spares stay hidden, the fill is KeyBindings_LoadCategories). -->
<Button name="$parentCategory1" inherits="KeyBindingsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentCategoryBox"><Offset><AbsDimension x="6" y="-8"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentCategory2" inherits="KeyBindingsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentCategory1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentCategory3" inherits="KeyBindingsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentCategory2" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentCategory4" inherits="KeyBindingsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentCategory3" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentCategory5" inherits="KeyBindingsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentCategory4" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentCategory6" inherits="KeyBindingsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentCategory5" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentCategory7" inherits="KeyBindingsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentCategory6" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentCategory8" inherits="KeyBindingsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentCategory7" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentCategory9" inherits="KeyBindingsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentCategory8" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentCategory10" inherits="KeyBindingsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentCategory9" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentCategory11" inherits="KeyBindingsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentCategory10" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentCategory12" inherits="KeyBindingsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentCategory11" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
</Button>
<!-- The 20 binding rows, chained at the era +2 spacing inside the bindings box. -->
<Frame name="$parentBinding1" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBindingsBox"><Offset><AbsDimension x="12" y="-8"/></Offset></Anchor></Anchors>
</Frame>
<Frame name="$parentBinding2" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding1" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding3" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding2" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding4" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding3" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding5" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding4" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding6" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding5" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding7" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding6" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding8" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding7" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding9" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding8" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding10" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding9" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding11" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding10" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding12" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding11" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding13" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding12" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding14" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding13" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding15" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding14" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding16" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding15" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding17" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding16" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding18" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding17" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding19" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding18" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding20" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding19" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<!-- The faux scroll over the rows (the shared kit; the ref's FauxScrollFrame at the
same seam). The wheel guard is the era's own: while a capsule is armed, the
wheel is a BIND (the host arm swallows it) — never a scroll. -->
<Frame name="$parentScrollFrame" inherits="BenillaFauxScrollFrameTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentBindingsBox"><Offset><AbsDimension x="8" y="-8"/></Offset></Anchor>
<Anchor point="BOTTOMRIGHT" relativeTo="$parentBindingsBox"><Offset><AbsDimension x="-26" y="8"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>
BenillaFauxScrollFrame_OnLoad(this)
this.updateFunc = KeyBindingFrame_Update
</OnLoad>
<OnMouseWheel>
if not KeyBindingFrame.selected then
BenillaFauxScrollFrame_OnMouseWheel(this, arg1)
end
</OnMouseWheel>
</Scripts>
</Frame>
<!-- Character Specific Key Bindings — the 1.12 checkbox at the era's top-right seat,
with 1.12's own confirm-on-unsaved-changes law and tooltip. -->
<CheckButton name="$parentCharacterButton">
<Size><AbsDimension x="20" y="20"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-260" y="-24"/></Offset></Anchor></Anchors>
<Layers>
<Layer level="OVERLAY">
<FontString name="$parentLabel" inherits="GameFontHighlightSmall" justifyH="LEFT">
<Anchors><Anchor point="LEFT" relativePoint="RIGHT"><Offset><AbsDimension x="2" y="0"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<NormalTexture file="Interface\Buttons\UI-CheckBox-Up"/>
<PushedTexture file="Interface\Buttons\UI-CheckBox-Down"/>
<CheckedTexture file="Interface\Buttons\UI-CheckBox-Check"/>
<DisabledCheckedTexture file="Interface\Buttons\UI-CheckBox-Check-Disabled"/>
<Scripts>
<OnLoad>
getglobal(this:GetName() .. "Label"):SetText(
KeyBindings_String("CHARACTER_SPECIFIC_KEYBINDINGS", "Character Specific Key Bindings"))
</OnLoad>
<OnClick>
PlaySound("igMainMenuOptionCheckBoxOn")
if KeyBindingFrame.bindingsChanged then
StaticPopup_Show("CONFIRM_LOSE_BINDING_CHANGES")
else
KeyBindings_ChangeBindingProfile()
end
</OnClick>
<OnEnter>
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetText(KeyBindings_String("CHARACTER_SPECIFIC_KEYBINDING_TOOLTIP",
"Click this to toggle between general key bindings and key bindings specific to this character."), nil, nil, nil, nil, 1)
</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</CheckButton>
<!-- The bottom row — the era seats verbatim (defaults left; unbind/okay/cancel right). -->
<Button name="$parentDefaultButton" inherits="KeyBindingsRedButtonTemplate">
<Size><AbsDimension x="130" y="22"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="16" y="16"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>this:SetText(KeyBindings_String("RESET_TO_DEFAULT", "Reset To Default"))</OnLoad>
<OnClick>StaticPopup_Show("CONFIRM_RESET_KEYBINDINGS")</OnClick>
</Scripts>
</Button>
<Button name="$parentUnbindButton" inherits="KeyBindingsRedButtonTemplate">
<Size><AbsDimension x="130" y="22"/></Size>
<Anchors><Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-205" y="16"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>this:SetText(KeyBindings_String("UNBIND", "Unbind Key"))</OnLoad>
<OnClick>
PlaySound("igMainMenuOptionCheckBoxOn")
KeyBindings_Unbind()
</OnClick>
</Scripts>
</Button>
<Button name="$parentOkayButton" inherits="KeyBindingsRedButtonTemplate">
<Anchors><Anchor point="BOTTOMLEFT" relativeTo="$parentUnbindButton" relativePoint="BOTTOMRIGHT"/></Anchors>
<Scripts>
<OnLoad>this:SetText(KeyBindings_String("OKAY", "Okay"))</OnLoad>
<OnClick>KeyBindings_Okay()</OnClick>
</Scripts>
</Button>
<Button name="$parentCancelButton" inherits="KeyBindingsRedButtonTemplate">
<Anchors><Anchor point="BOTTOMLEFT" relativeTo="$parentOkayButton" relativePoint="BOTTOMRIGHT"/></Anchors>
<Scripts>
<OnLoad>this:SetText(KeyBindings_String("CANCEL", "Cancel"))</OnLoad>
<OnClick>KeyBindingFrame_Cancel()</OnClick>
</Scripts>
</Button>
</Frames>
<Scripts>
<OnShow>
KeyBindings_LoadCategories()
KeyBindingFrameCharacterButton:SetChecked(GetCurrentBindingSet() == 2 and 1 or nil)
KeyBindings_UpdateHeader()
KeyBindingFrame.bindingsChanged = nil
KeyBindingFrameScrollFrameScrollBar:SetValue(0)
KeyBindingFrame_Update()
</OnShow>
<OnHide>
-- Both references reopen the game menu (1.12 + era OnHide); a straggling
-- selection disarms the host capture seam.
KeyBindings_SetSelected(nil)
KeyBindingFrameOutputText:SetText("")
ShowUIPanel(GameMenuFrame)
</OnHide>
</Scripts>
</Frame>
</Ui>
+12 -1
View File
@@ -724,10 +724,21 @@
<Scripts>
<!-- ref l.511: `PanelTemplates_TabResize(-15, nil, nil, 150)`. The cap is
load-bearing here and nowhere else — this label carries a player name, so
it is the one tab whose width is not known when the window is written. -->
it is the one tab whose width is not known when the window is written.
`benillaTabRightInset` is what makes it hold: **this window is narrower
than its frame says.** The rect is 384 wide, but the art is
`UI-Character-General-TopLeft` (256) + `-TopRight` (128 — VERIFIED 128 in
the BLP header, so no squash), and that second slice carries a transparent
tail: decoding its DXT3 alpha, the last opaque column is 92 of 128, so the
**drawn plate ends at unit 349**. 40 is that boundary with a few units of
breathing room, so a clamped tab's end cap doesn't sit flush on the border.
Clamping to the frame rect — the first attempt (0997) — clamped to an edge
the player cannot see, and the tab still walked out over the world. -->
<OnLoad>
self.benillaTabPadding = -15
self.benillaTabMaxWidth = 150
self.benillaTabRightInset = 40
self:SetText(BenillaMacroFrame_TabTwoLabel())
</OnLoad>
<OnClick>
+19 -16
View File
@@ -35,11 +35,10 @@
greyed with the ref's `-Disabled` art: a dead grey button mid-row reads as a broken bar,
and the point of this slice is that the bar reads whole. It is one OnClick line from live.
(Socials went live with decision 0668, Main Menu with 0674.)
- No GetBindingKey in this engine, so MicroButtonTooltipText's "(key)" suffix comes from a
static table of the bindings we actually ship (ui_script/input.rs `bare_key_binding`:
C/P/N/L/M/O) rather than from a live binding lookup — the same call ActionBar.xml makes
for its HotKey corners. The two unbound buttons (Main Menu, Help) show a bare label —
ESCAPE is a real binding, but not one that table carries.
- The "(key)" suffix is LIVE since 0997: `button.binding` carries the binding ACTION
name (TOGGLECHARACTER0, TOGGLESPELLBOOK, ...) and the tooltip looks the key up through
GetBindingKey at hover time — the ref MicroButtonTooltipText's own contract, restored
now that the binding table exists. A genuinely unbound action shows the bare label.
- The hover is the ref's real two-line GameTooltip_AddNewbieTip (GameTooltip.xml): the label,
then the button's NEWBIE_TOOLTIP_* explanation, at the default screen corner. An earlier
note here claimed detailed tips were OFF in 1.12 and open-coded the else branch — the ref
@@ -109,13 +108,17 @@
button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
end
-- ref MicroButtonTooltipText (l.11-18) with a static binding label — see the SCOPE note.
-- Benilla-named, not the ref global: the ref's takes (text, bindingAction) and looks the key
-- up live, so reusing that name for a different contract would be a trap for the next
-- ref-quoted transcription that calls it.
-- ref MicroButtonTooltipText (l.11-18), the LIVE contract since 0997: `button.binding`
-- is the binding ACTION name and the key resolves through GetBindingKey at hover time
-- (rendered through the Key Bindings window's display law when it is loaded — the
-- shipped order always has it; a lone-file harness shows the raw token).
function BenillaMicroButton_TooltipText(button)
if button.binding then
return button.label .. " " .. NORMAL_FONT_COLOR_CODE .. "(" .. button.binding .. ")" .. FONT_COLOR_CODE_CLOSE
local key = GetBindingKey(button.binding)
if key then
if KeyBindings_KeyText then key = KeyBindings_KeyText(key) end
return button.label .. " " .. NORMAL_FONT_COLOR_CODE .. "(" .. key .. ")" .. FONT_COLOR_CODE_CLOSE
end
end
return button.label
end
@@ -196,7 +199,7 @@
SetPortraitTexture(MicroButtonPortrait, "player")
button:SetNormalTexture("Interface\\Buttons\\UI-MicroButtonCharacter-Up")
button:SetPushedTexture("Interface\\Buttons\\UI-MicroButtonCharacter-Down")
BenillaMicroButton_OnLoad(button, nil, CHARACTER_BUTTON, "C", NEWBIE_TOOLTIP_CHARACTER)
BenillaMicroButton_OnLoad(button, nil, CHARACTER_BUTTON, "TOGGLECHARACTER0", NEWBIE_TOOLTIP_CHARACTER)
end
-- ref UpdateTalentButton (l.133-141): no talents before level 10, so the button goes away
@@ -216,7 +219,7 @@
end
function TalentMicroButton_OnLoad(button)
BenillaMicroButton_OnLoad(button, "Talents", TALENTS_BUTTON, "N", NEWBIE_TOOLTIP_TALENTS)
BenillaMicroButton_OnLoad(button, "Talents", TALENTS_BUTTON, "TOGGLETALENTS", NEWBIE_TOOLTIP_TALENTS)
button:RegisterEvent("UNIT_LEVEL")
button:RegisterEvent("PLAYER_ENTERING_WORLD")
end
@@ -269,7 +272,7 @@
<Anchor point="BOTTOMLEFT" relativeTo="CharacterMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>BenillaMicroButton_OnLoad(self, "Spellbook", SPELLBOOK_ABILITIES_BUTTON, "P", NEWBIE_TOOLTIP_SPELLBOOK)</OnLoad>
<OnLoad>BenillaMicroButton_OnLoad(self, "Spellbook", SPELLBOOK_ABILITIES_BUTTON, "TOGGLESPELLBOOK", NEWBIE_TOOLTIP_SPELLBOOK)</OnLoad>
<OnClick>ToggleSpellBook(BOOKTYPE_SPELL)</OnClick>
</Scripts>
</Button>
@@ -294,7 +297,7 @@
is the button UpdateTalentButton re-anchors, so it must already exist. (The ref gets
its first pass from PLAYER_ENTERING_WORLD, which we register too — this just means
the row is right before any event lands, e.g. under the capture harness.) -->
<OnLoad>BenillaMicroButton_OnLoad(self, "Quest", QUESTLOG_BUTTON, "L", NEWBIE_TOOLTIP_QUESTLOG); UpdateTalentButton()</OnLoad>
<OnLoad>BenillaMicroButton_OnLoad(self, "Quest", QUESTLOG_BUTTON, "TOGGLEQUESTLOG", NEWBIE_TOOLTIP_QUESTLOG); UpdateTalentButton()</OnLoad>
<OnClick>ToggleQuestLog()</OnClick>
</Scripts>
</Button>
@@ -304,7 +307,7 @@
<Anchor point="BOTTOMLEFT" relativeTo="QuestLogMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>BenillaMicroButton_OnLoad(self, "Socials", SOCIAL_BUTTON, "O", NEWBIE_TOOLTIP_SOCIAL)</OnLoad>
<OnLoad>BenillaMicroButton_OnLoad(self, "Socials", SOCIAL_BUTTON, "TOGGLESOCIAL", NEWBIE_TOOLTIP_SOCIAL)</OnLoad>
<OnClick>ToggleFriendsFrame(1)</OnClick>
</Scripts>
</Button>
@@ -314,7 +317,7 @@
<Anchor point="BOTTOMLEFT" relativeTo="SocialsMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>BenillaMicroButton_OnLoad(self, "World", WORLDMAP_BUTTON, "M", NEWBIE_TOOLTIP_WORLDMAP)</OnLoad>
<OnLoad>BenillaMicroButton_OnLoad(self, "World", WORLDMAP_BUTTON, "TOGGLEWORLDMAP", NEWBIE_TOOLTIP_WORLDMAP)</OnLoad>
<OnClick>ToggleWorldMap()</OnClick>
</Scripts>
</Button>
+82 -51
View File
@@ -101,6 +101,10 @@
-- the client. (The ref's l.17-18 register SoundOptionsFrame/UIOptionsFrame the same way;
-- those panels are this one window's category rows now, so the rows have no frames.)
UIPanelWindows["OptionsFrame"] = { area = "center", pushable = 0, whileDead = 1 }
-- The Key Bindings window (decision 0997, KeyBindingsFrame.xml) — the ref's own
-- registration verbatim (`UIPanelWindows["KeyBindingFrame"] = { area = "center",
-- pushable = 0, whileDead = 1 }`, 1.12 Blizzard_BindingUI.lua l.8).
UIPanelWindows["KeyBindingFrame"] = { area = "center", pushable = 0, whileDead = 1 }
-- The world map (decision 0203 phase 2) — the ref's own line verbatim (UIParent.lua l.30)
-- and the first **full** row. It was a plain toplevel until now, showing itself with a bare
-- `WorldMapFrame:Show()` and so bypassing this manager entirely: with the game menu up it
@@ -550,6 +554,16 @@
if StaticPopup_EscapePressed() then
return
end
-- The Key Bindings window (decision 0997): ESC with nothing armed is the Cancel
-- gesture — revert to the saved set and close (both references' own law; while a
-- capsule IS armed the host's capture seam swallowed the press before this chain
-- ever saw it, and ESC became a bind — 1.12's law). Its OnHide reopens the menu.
-- Looked up like the rest (headless tests load this file without the window).
local keybinds = getglobal("KeyBindingFrame")
if keybinds and keybinds:IsVisible() then
KeyBindingFrame_Cancel()
return
end
-- The options window next (l.1483-1484, live since decision 0950 — this rung sat
-- unfilled while GameMenuFrame.xml's disabled buttons were the gap). The ref
-- cancel-clicks (`OptionsFrameCancel:Click()`); ours hides: benilla applies changes
@@ -1308,70 +1322,70 @@
getglobal(name .. "Middle"):SetWidth(width)
getglobal(name .. "MiddleDisabled"):SetWidth(width)
tab:SetWidth(tabWidth)
-- benilla addition: hand back the width just declared. `tab:GetWidth()` cannot serve
-- it — that reads the last RESOLVED rect, which inside a settle is this frame's stale
-- input, not the value one line above just set. A clamp that read it back saw the old
-- width, declined to fire, and the settle's gate then locked the un-clamped result in
-- for good (decision 1002).
return tabWidth
end
-- The tab template's per-frame settle: the ref does TabResize(0) + the highlight width
-- in OnShow (CharacterFrameTemplates.xml l.77-80), trusting a synchronous text measure.
-- Our GetStringWidth has a frame of latency, so the template's OnUpdate calls this until
-- the measured width settles (then it's a no-op compare per frame).
-- The tab template's per-frame settle: the ref does TabResize(0) once, in OnShow
-- (CharacterFrameTemplates.xml l.77-80), trusting a synchronous text measure. Our
-- GetStringWidth has a frame of latency, so the template's OnUpdate calls this until the
-- measured width settles (then it's a no-op compare per frame). The ref sizes the hover
-- highlight alongside it; this kit does not — both tab templates anchor their highlight to
-- their own side edges, so it is the tab by construction and no settle can desync it
-- (decision 1004).
--
-- The fit PARAMETERS live on the tab (`benillaTabPadding`/`benillaTabMaxWidth`, defaulting
-- to the +0 fit every window tab has always used) rather than at the call site, because
-- there is exactly one call site and it is this settle loop: an instance that ran its own
-- TabResize in OnLoad — as the reference's macro tabs do — would just be overwritten on the
-- next frame. Setting the two fields IS how an instance asks for the reference's own fit.
-- THE STRUCTURAL CAP — a tab may never grow past its parent's right edge, whatever the
-- instance asked for (decision 0997, the director's "fix this properly to prevent it ever
-- again"). The reference's own caps are hand-tuned numbers against ITS font metrics: the
-- macro window's 150 is enough for the enUS FRIZQT__ the 1.12 client rasterizes and not
-- obviously enough for anything else, and a character name — the one label whose width no
-- window author can know — is exactly where that runs out. So the per-instance cap becomes
-- a PREFERENCE and the window's own edge becomes the law.
-- THE STRUCTURAL CLAMP — a tab may never grow past its window's drawn right edge, whatever
-- the instance asked for (decision 1002; the director's "fix this properly to prevent it
-- ever again"). The reference's own caps are numbers hand-tuned to ITS font: the macro
-- window's 150 is right for the enUS FRIZQT__ the 1.12 client rasterizes and for nothing
-- else in particular, and the label it guards carries a CHARACTER NAME — the one string
-- whose width no window author can know. So the instance's cap is a preference and the
-- window's edge is the law.
--
-- The arithmetic is TabResize's, inverted: its capped branch yields
-- `tabWidth = cap + padding + sideWidths`, so `cap = room - padding - sideWidths`.
function BenillaTab_Cap(tab, padding)
local cap = tab.benillaTabMaxWidth
-- `benillaTabRightInset` is how a window declares how far its DRAWN plate stops short of
-- its frame rect, because no kit can introspect an art margin. The macro window's is 40:
-- its 384-unit frame is painted by a 256-wide slice plus a 128-wide one that carries a
-- transparent tail. Clamping to the frame rect instead — the first attempt — clamped to a
-- boundary the player cannot see, and the tab still walked out over the world.
--
-- The clamp is applied to the RESULT, through TabResize's own `absoluteSize` arm, rather
-- than folded into `maxWidth`: that parameter is compared against `text + padding` but
-- assigned as `maxWidth + padding`, so a room budget expressed through it is off by the
-- padding and silently fails to bind. `absoluteSize` sets the tab width outright, which is
-- exactly what a room budget is.
function BenillaTab_Room(tab)
local parent = tab:GetParent()
if not parent then return cap end
if not parent then return nil end
local left, right = tab:GetLeft(), parent:GetRight()
if not left or not right then return cap end
local sideWidths = 2 * getglobal(tab:GetName() .. "Left"):GetWidth()
local room = (right - left) - padding - sideWidths - (tab.benillaTabRightInset or 0)
if not left or not right then return nil end
local room = (right - (tab.benillaTabRightInset or 0)) - left
if room < 1 then room = 1 end
if not cap or room < cap then cap = room end
return cap
return room
end
-- The tab template's per-frame settle: the ref does TabResize(0) + the highlight width
-- in OnShow (CharacterFrameTemplates.xml l.77-80), trusting a synchronous text measure.
-- Our GetStringWidth has a frame of latency, so the template's OnUpdate calls this until
-- the measured width settles (then it's a no-op compare per frame).
--
-- The fit PARAMETERS live on the tab (`benillaTabPadding`/`benillaTabMaxWidth`, defaulting
-- to the +0 fit every window tab has always used) rather than at the call site, because
-- there is exactly one call site and it is this settle loop: an instance that ran its own
-- TabResize in OnLoad — as the reference's macro tabs do — would just be overwritten on the
-- next frame. Setting the two fields IS how an instance asks for the reference's own fit.
--
-- The settle compares BOTH inputs. Watching only the string width used to be enough; it
-- isn't once the cap can move, because a tab's room depends on where the tab to its left
-- ended up — so tab 2 must be able to re-fit on a frame where its own label did not change.
function BenillaTabButton_OnUpdate(tab)
local text = getglobal(tab:GetName() .. "Text")
local w = (text and text:GetStringWidth()) or 0
if w <= 0 then return end
local pad = tab.benillaTabPadding or 0
local cap = BenillaTab_Cap(tab, pad)
if tab.benillaFitWidth == w and tab.benillaFitCap == cap then return end
tab.benillaFitWidth, tab.benillaFitCap = w, cap
PanelTemplates_TabResize(pad, tab, nil, cap)
-- The ref sizes the highlight off the label too (MacroFrame's tabs spell it
-- `GetTextWidth() + 31`); +30 is this kit's long-standing number for the +0 fit. Capped
-- at the tab so a clamped tab never wears a highlight wider than itself.
local hl = w + 30
if hl > tab:GetWidth() + 14 then hl = tab:GetWidth() + 14 end
getglobal(tab:GetName() .. "HighlightTexture"):SetWidth(hl)
local room = BenillaTab_Room(tab)
if tab.benillaFitWidth == w and tab.benillaFitRoom == room then return end
tab.benillaFitWidth, tab.benillaFitRoom = w, room
local fitted = PanelTemplates_TabResize(pad, tab, nil, tab.benillaTabMaxWidth)
if room and fitted > room then
PanelTemplates_TabResize(pad, tab, room)
end
-- No highlight sizing here on purpose — BOTH tab templates anchor their highlight to
-- their own two side edges, so it IS the tab by construction (decision 1004).
end
]]></Script>
@@ -1389,10 +1403,13 @@
mpqx, transcribed VERBATIM: 115x32, the Active/InActive three-slice pair with the ref's
exact TexCoords, text CENTER +2, the ADD highlight inset 10px). "Disabled" IS "selected"
here — PanelTemplates_SelectTab (script kit above) disables the button and shows the
*Disabled (= Active) slices. The ref's OnShow text-fit (PanelTemplates_TabResize(0) +
the highlight width) runs from OnUpdate here instead — BenillaTabButton_OnUpdate settles
once the async text measure lands, then no-ops (the ref's C measure is synchronous; the
115px XML width is just the pre-fit state). One deliberate deviation: the ref's OnLoad
*Disabled (= Active) slices. The ref's OnShow text-fit (PanelTemplates_TabResize(0)) runs
from OnUpdate here instead — BenillaTabButton_OnUpdate settles once the async text measure
lands, then no-ops (the ref's C measure is synchronous; the 115px XML width is just the
pre-fit state). Its OnShow *also* sets the highlight width, which is dead code in the
reference and dropped here: this highlight is anchored LEFT/RIGHT, and two opposing anchors
resolve the size from the span without consulting the width at all (wow-re layout.md,
VERIFIED). The small tab now says the same thing the same way (1004). One deviation: the ref's OnLoad
SetFrameLevel(+4) raise (our loader has no cross-frame level raise wired into templates
yet; the tabs sit below the window art's hit-rect so nothing overlaps them). OnClick is
the instance's to define (the ref merchant overrides it too).
@@ -1453,7 +1470,8 @@
<!--
The SMALL tab button (ref `TabButtonTemplate`, UIPanelTemplates.xml l.303-402, transcribed
VERBATIM: the HelpFrameTab Active/Inactive three-slice pair on 16/32/16 slices, text at
BOTTOM +5, the ADD highlight at BOTTOM +2,-8). Not the same thing as
BOTTOM +5, the ADD highlight at +2,-8 — anchored to both side edges rather than sized in
Lua, the one departure, and why: see the highlight below). Not the same thing as
`CharacterFrameTabButtonTemplate` above — that one is the big *window* tab along a panel's
bottom edge; this is the compact tab a panel uses to switch a list INSIDE itself. The
social window's Friends/Ignore pair is the first customer (decision 0668); the reference
@@ -1506,9 +1524,22 @@
<NormalFont inherits="GameFontNormalSmall"/>
<HighlightFont inherits="GameFontHighlightSmall"/>
<DisabledFont inherits="GameFontHighlightSmall"/>
<!-- The highlight spans the tab's own two side edges, so it tracks any fit for free. The
ref sizes it in Lua instead — `SetWidth(GetTextWidth() + 31)` at OnLoad, which on the
template's own +0 fit (tab = text + 32) lands at tab − 1: "the highlight is the tab",
said in arithmetic. It only stops meaning that when an instance passes a padding or a
cap, and the macro window's tabs pass both — which is how the director got a highlight
13 units proud of tab 1 and 35 short of tab 2 in the same screenshot (decision 1004).
The big tab template above already says it this way (LEFT/RIGHT, no Lua); this is the
same statement on 16-unit end slices. Two opposing anchors resolve the size from the
span and never consult the width, so the declared 5 is inert on x (wow-re layout.md,
VERIFIED); the y anchors are both BOTTOM, so 32 still sets the height. -->
<HighlightTexture name="$parentHighlightTexture" file="Interface\PaperDollInfoFrame\UI-Character-Tab-Highlight" alphaMode="ADD">
<Size><AbsDimension x="5" y="32"/></Size>
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="2" y="-8"/></Offset></Anchor></Anchors>
<Anchors>
<Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="2" y="-8"/></Offset></Anchor>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="2" y="-8"/></Offset></Anchor>
</Anchors>
</HighlightTexture>
<Scripts>
<OnUpdate>BenillaTabButton_OnUpdate(self)</OnUpdate>
+768
View File
@@ -0,0 +1,768 @@
//! **Key bindings** (decision 0997) — the one chord→command engine every rebindable input runs
//! through, replacing the per-site hardcoded key reads (and the four independent copies of
//! 0585's bare-binding modifier rule they carried).
//!
//! The split mirrors the CVar table (0954): the **string-domain truth** lives engine-side
//! ([`benilla_ui::script::keybind`] — the table the Key Bindings window's Lua edits
//! synchronously), and this module derives the app's **dispatch view** from it whenever its
//! generation moves: canonical chord strings parsed ([`chord`]) into an exact-match map.
//!
//! Dispatch (the [`latch_and_dispatch`] system, ordered inside [`crate::ui_script::UiInput`]
//! right after the UI key feed):
//! - a press matches a chord only on the **exact** modifier set (0585, engine-wide now; Super
//! held matches nothing);
//! - [`Kind::Held`] commands **latch** on the matching press and unlatch on the *base key's*
//! release — the reference's `runOnUp` movement law, which is why tapping Shift mid-run does
//! not stop you, and why a chat box taking focus stops movement (latches clear on the capture
//! edge — the reference's own focus handler, `0x514490`) without eating the release;
//! - [`Kind::Edge`]/[`Kind::EdgeUpDown`] run their 1.12 Lua bodies in the VM;
//! - [`Kind::Host`] lands in [`BindingsState::fired`] for engine consumers (chat open, TAB
//! targeting, nameplates, autorun, camera zoom, …).
//!
//! While the Key Bindings window has a capsule selected it arms the **capture seam**
//! (`BenillaBindCapture`): raw input is swallowed here, canonicalized
//! (`ALT-CTRL-SHIFT-<TOKEN>`), and handed back to the window's own capture handler — the 1.12
//! law (lone modifiers ignored, left/right clicks stay UI clicks, ESC binds like any key).
//!
//! Persistence: `benilla/bindings/account.txt` + `<Realm>-<Char>.txt` ([`store`], through
//! [`crate::local_state`]); the character file's existence is the character-set state, deleted
//! on the confirmed switch back to general — the reference's own semantics.
use bevy::input::keyboard::KeyboardInput;
use bevy::input::mouse::AccumulatedMouseScroll;
use bevy::input::ButtonState;
use bevy::prelude::*;
use benilla_ui::script::keybind::{KeybindCommand, KeybindRequest};
use benilla_ui::script::UiScript;
use crate::char_select::ClientState;
use crate::ui_script::{PlayerUiHover, PointerOverUi, UiKeyboardCapture};
pub(crate) mod chord;
pub(crate) mod commands;
mod store;
use chord::{BindKey, Chord};
pub(crate) use commands::cmd;
use commands::{Cmd, Kind, SPECS};
/// The derived chord→command map (exact-modifier law) — rebuilt whenever the engine table's
/// generation moves.
#[derive(Resource, Default)]
struct BindingDispatch {
map: std::collections::HashMap<Chord, Cmd>,
seen_generation: Option<u64>,
}
/// This frame's binding activity — what the engine-side consumers read instead of raw keys.
#[derive(Resource, Default)]
pub(crate) struct BindingsState {
/// Live latches: (base key, command) — a [`Kind::Held`] or [`Kind::EdgeUpDown`] press that
/// has not released yet.
latched: Vec<(BindKey, Cmd)>,
/// Commands whose first latch began this frame (the press edge).
just: Vec<Cmd>,
/// Host-edge commands fired this frame.
fired: Vec<Cmd>,
/// Accumulated analog amount per host command this frame (wheel notches; a key press adds
/// the reference's own 1.0 step) — the camera zoom's input.
amounts: Vec<(Cmd, f32)>,
/// Rising edge of the keyboard-capture gate last frame (internal: clears Held latches once).
was_typing: bool,
}
impl BindingsState {
/// Is a held command latched right now? (The reference's held movement bit.)
pub(crate) fn pressed(&self, c: Cmd) -> bool {
self.latched.iter().any(|&(_, l)| l == c)
}
/// Did this command's latch begin this frame? (The key-DOWN edge — the autorun cancel set.)
pub(crate) fn just_pressed(&self, c: Cmd) -> bool {
self.just.contains(&c)
}
/// Did this host-edge command fire this frame?
pub(crate) fn fired(&self, c: Cmd) -> bool {
self.fired.contains(&c)
}
/// Total analog amount for a host command this frame (0.0 when idle).
pub(crate) fn amount(&self, c: Cmd) -> f32 {
self.amounts
.iter()
.filter(|&&(a, _)| a == c)
.map(|&(_, v)| v)
.sum()
}
/// Test seam for consumer systems: a state in which these host commands fired this frame.
#[cfg(test)]
pub(crate) fn test_fired(cmds: &[Cmd]) -> Self {
Self {
fired: cmds.to_vec(),
..Default::default()
}
}
}
/// Which files this session's bindings live in — the macros-files pattern
/// ([`crate::ui_macro::MacroFiles`]), resolved once the character is known.
#[derive(Resource, Default)]
struct BindingFiles {
account: Option<std::path::PathBuf>,
character: Option<std::path::PathBuf>,
identity: Option<(String, String)>,
}
/// Label for this module's systems inside [`crate::ui_script::UiInput`] — the UI key feed is
/// ordered `.before()` it (a key a focused box consumes must already be reflected in the
/// capture gate when dispatch runs).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct BindingSet;
pub(crate) struct BindingsPlugin;
impl Plugin for BindingsPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<BindingDispatch>()
.init_resource::<BindingsState>()
.init_resource::<BindingFiles>()
// PostStartup like the macro catalog: needs the VM, which lands at the Startup
// schedule boundary.
.add_systems(PostStartup, seed_bindings)
.add_systems(
Update,
(
(sync_dispatch, latch_and_dispatch)
.chain()
.in_set(crate::ui_script::UiInput)
.in_set(BindingSet)
.before(crate::schedule::WorldStage::Input)
.run_if(in_state(ClientState::InWorld)),
load_character_bindings.run_if(in_state(ClientState::InWorld)),
save_bindings.after(load_character_bindings),
),
);
}
}
/// The registry as the engine table's registration payload — shared by the boot seed and the
/// hermetic capture fixtures (which have no plugin seed to race, the `register_cvars` posture).
pub(crate) fn registry_commands() -> Vec<KeybindCommand> {
SPECS
.iter()
.map(|s| KeybindCommand {
name: s.name,
category: s.category,
run_on_up: s.run_on_up(),
default1: s.d1,
default2: s.d2,
})
.collect()
}
/// Register the command registry with the engine table and seed the account set from disk —
/// boot-time, before any window opens.
fn seed_bindings(script: Option<NonSendMut<UiScript>>, mut files: ResMut<BindingFiles>) {
let Some(mut script) = script else { return };
script.register_bindings(&registry_commands());
files.account = crate::local_state::bindings_account_path();
if let Some(overrides) = read_diff(&files.account) {
script.seed_binding_set(1, Some(store::resolve(&overrides)));
} else {
script.seed_binding_set(1, Some(store::resolve(&[])));
}
script.load_binding_set(1);
info!("bindings: {} commands registered", SPECS.len());
}
/// Read + parse one diff file; `None` when absent/unreadable (defaults).
fn read_diff(path: &Option<std::path::PathBuf>) -> Option<Vec<(String, Vec<String>)>> {
let path = path.as_ref()?;
match std::fs::read_to_string(path) {
Ok(text) => Some(store::from_diff(&text)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
warn!("bindings: reading {}: {e}", path.display());
None
}
}
}
/// Load the character-specific set once the roster names the character (the macros-load
/// pattern); its file existing makes it the active set, the reference's own rule.
fn load_character_bindings(
script: Option<NonSendMut<UiScript>>,
roster: Res<crate::char_select::Roster>,
mut files: ResMut<BindingFiles>,
) {
let Some(mut script) = script else { return };
let Some(id) = crate::ui_macro::identity(&roster) else {
return;
};
if files.identity.as_ref() == Some(&id) {
return;
}
files.character = crate::local_state::bindings_character_path(&id.0, &id.1);
files.identity = Some(id);
match read_diff(&files.character) {
Some(overrides) => {
script.seed_binding_set(2, Some(store::resolve(&overrides)));
script.load_binding_set(2);
info!("bindings: character-specific set loaded");
}
None => {
script.seed_binding_set(2, None);
script.load_binding_set(1);
}
}
}
/// Persist on the window's SaveBindings (Okay): write the set's diff; saving account while a
/// character file exists deletes it — the confirmed permanent delete.
fn save_bindings(script: Option<NonSendMut<UiScript>>, files: Res<BindingFiles>) {
let Some(mut script) = script else { return };
for req in script.take_keybind_requests() {
let KeybindRequest::Save(which) = req;
let snapshot = script.keybind_snapshot();
let text = store::to_diff(&snapshot);
let path = match which {
1 => &files.account,
2 => &files.character,
_ => continue,
};
if let Some(path) = path {
if let Err(e) = crate::local_state::write_atomic(path, &text) {
warn!("bindings: saving {}: {e}", path.display());
}
}
if which == 1 {
if let Some(chr) = &files.character {
match std::fs::remove_file(chr) {
Ok(()) => info!("bindings: character-specific set deleted"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => warn!("bindings: deleting {}: {e}", chr.display()),
}
}
}
}
}
/// Rebuild the dispatch map when the engine table moved (a rebind, a set switch, the seed),
/// and fire the reference's own `UPDATE_BINDINGS` so the Lua consumers repaint (the action
/// bar's HotKey corners; the ref registers the same event for the same job).
fn sync_dispatch(script: Option<NonSendMut<UiScript>>, mut dispatch: ResMut<BindingDispatch>) {
let Some(mut script) = script else { return };
let generation = script.keybinds_generation();
if dispatch.seen_generation == Some(generation) {
return;
}
dispatch.seen_generation = Some(generation);
dispatch.map.clear();
let by_name: std::collections::HashMap<&str, Cmd> = SPECS
.iter()
.enumerate()
.map(|(i, s)| (s.name, Cmd(i as u16)))
.collect();
for (name, keys) in script.keybind_snapshot() {
let Some(&cmd) = by_name.get(name.as_str()) else {
continue;
};
for key in keys {
match Chord::parse(&key) {
Some(ch) => {
dispatch.map.insert(ch, cmd);
}
None => warn!("bindings: {name}: unpressable chord '{key}' (unknown token)"),
}
}
}
script.fire_event("UPDATE_BINDINGS", vec![]);
}
/// The dispatch pass — see the module doc. Runs right after the UI key feed (same frame's
/// capture gate), before `WorldStage::Input` (a bound key must act this frame, once).
#[allow(clippy::too_many_arguments)]
fn latch_and_dispatch(
script: Option<NonSendMut<UiScript>>,
mut keyboard: MessageReader<KeyboardInput>,
keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
scroll: Res<AccumulatedMouseScroll>,
capture: Res<UiKeyboardCapture>,
hover: Res<PlayerUiHover>,
over_ui: Res<PointerOverUi>,
dispatch: Res<BindingDispatch>,
mut state: ResMut<BindingsState>,
) {
state.just.clear();
state.fired.clear();
state.amounts.clear();
let shift = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
let ctrl = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight);
let alt = keys.pressed(KeyCode::AltLeft) || keys.pressed(KeyCode::AltRight);
let sup = keys.pressed(KeyCode::SuperLeft) || keys.pressed(KeyCode::SuperRight);
let mut script = script;
let armed = script.as_ref().is_some_and(|s| s.bind_capture_armed());
let run_lua = |script: &mut Option<NonSendMut<UiScript>>, lua: &str, tag: &str| {
if let Some(s) = script.as_mut() {
if let Err(e) = s.run(lua) {
warn!("bindings({tag}): {e}");
}
}
};
// ── The capture seam ── the Key Bindings window has a capsule selected: swallow raw input,
// canonicalize, hand the chord string to the window's Lua (1.12's OnKeyDown law: lone
// modifiers and unknown keys ignored; Super is not a 1.12 modifier — a Super press is
// ignored outright; left/right stay UI clicks; the wheel is a chord like any other).
if armed {
let mut captured: Option<String> = None;
for ev in keyboard.read() {
if ev.state != ButtonState::Pressed || ev.repeat || sup {
continue;
}
if let Some(token) = chord::key_token(ev.key_code) {
captured = Some(chord::chord_string(alt, ctrl, shift, token));
}
}
for b in [MouseButton::Middle, MouseButton::Forward, MouseButton::Back] {
if buttons.just_pressed(b) && !sup {
if let Some(token) = chord::mouse_token(b) {
captured = Some(chord::chord_string(alt, ctrl, shift, token));
}
}
}
if scroll.delta.y != 0.0 && !sup {
let token = if scroll.delta.y > 0.0 {
"MOUSEWHEELUP"
} else {
"MOUSEWHEELDOWN"
};
captured = Some(chord::chord_string(alt, ctrl, shift, token));
}
if let Some(chord_str) = captured {
run_lua(
&mut script,
&format!("KeyBindingFrame_OnHostKey(\"{chord_str}\")"),
"capture",
);
}
// Releases still unlatch below (a key held across the arm must not stick), but no new
// latches or fires happen while armed.
}
// ── The typing edge ── a box taking focus stops movement (the reference's focus handler
// releases every direction bit, `0x514490`): Held latches clear once on the rising edge;
// EdgeUpDown latches stay armed — their release half still fires (the reference delivers
// the up of a pressed binding regardless).
let typing = capture.0;
if typing && !state.was_typing {
state
.latched
.retain(|&(_, c)| !matches!(SPECS[c.0 as usize].kind, Kind::Held));
}
state.was_typing = typing;
// ── Keyboard ── press edges latch/fire (exact-modifier chord match, no repeats, gated on
// typing and the capture arm); release edges unlatch and fire the runOnUp up-half.
for ev in keyboard.read() {
let key = chord::normalize_key(ev.key_code);
match ev.state {
ButtonState::Pressed => {
if armed || typing || sup || ev.repeat {
continue;
}
if state.latched.iter().any(|&(k, _)| k == BindKey::Key(key)) {
continue; // already latched (missed release would double-latch)
}
let chord = Chord {
alt,
ctrl,
shift,
key: BindKey::Key(key),
};
if let Some(&cmd) = dispatch.map.get(&chord) {
press(&mut state, &mut script, run_lua, cmd, BindKey::Key(key));
}
}
ButtonState::Released => {
release(&mut state, &mut script, run_lua, BindKey::Key(key));
}
}
}
// ── Mouse buttons ── same law; presses only while the cursor is over the world (a frame
// under the cursor owns its clicks), releases always.
for b in [
MouseButton::Left,
MouseButton::Right,
MouseButton::Middle,
MouseButton::Forward,
MouseButton::Back,
] {
if buttons.just_pressed(b)
&& !armed
&& !sup
&& hover.0.is_none()
&& !state.latched.iter().any(|&(k, _)| k == BindKey::Mouse(b))
{
let chord = Chord {
alt,
ctrl,
shift,
key: BindKey::Mouse(b),
};
if let Some(&cmd) = dispatch.map.get(&chord) {
press(&mut state, &mut script, run_lua, cmd, BindKey::Mouse(b));
}
}
if buttons.just_released(b) {
release(&mut state, &mut script, run_lua, BindKey::Mouse(b));
}
}
// ── Wheel ── a notch is a press with no release (which is exactly why the table refuses
// wheel chords on runOnUp commands); over UI the wheel belongs to the hovered frame.
// Trackpads report pixel deltas — normalized to line-equivalents so the zoom consumer's
// feel is unchanged from when it read the scroll itself.
let wheel = match scroll.unit {
bevy::input::mouse::MouseScrollUnit::Line => scroll.delta.y,
bevy::input::mouse::MouseScrollUnit::Pixel => {
scroll.delta.y / bevy::input::mouse::MouseScrollUnit::SCROLL_UNIT_CONVERSION_FACTOR
}
};
if wheel != 0.0 && !armed && !sup && !over_ui.0 {
let (key, amount) = if wheel > 0.0 {
(BindKey::WheelUp, wheel)
} else {
(BindKey::WheelDown, -wheel)
};
let chord = Chord {
alt,
ctrl,
shift,
key,
};
if let Some(&cmd) = dispatch.map.get(&chord) {
match &SPECS[cmd.0 as usize].kind {
Kind::Edge(lua) => run_lua(&mut script, lua, SPECS[cmd.0 as usize].name),
Kind::Host => {
state.fired.push(cmd);
state.amounts.push((cmd, amount));
}
// Unreachable by construction (SetBinding refuses these); harmless if a
// hand-edited file smuggles one in.
Kind::Held | Kind::EdgeUpDown(..) => {}
}
}
}
// ── The stuck-latch sweep ── a release the window never saw (focus loss, the macOS
// modifier eater's cousin): any latch whose base key reads up in the input state unlatches
// now, firing its up-half so a pushed action button unsticks visibly.
let mut stuck: Vec<BindKey> = Vec::new();
for &(k, _) in &state.latched {
let up = match k {
BindKey::Key(kc) => {
!keys.pressed(kc) && !(kc == KeyCode::Enter && keys.pressed(KeyCode::NumpadEnter))
}
BindKey::Mouse(b) => !buttons.pressed(b),
BindKey::WheelUp | BindKey::WheelDown => true,
};
if up && !stuck.contains(&k) {
stuck.push(k);
}
}
for k in stuck {
release(&mut state, &mut script, run_lua, k);
}
}
#[cfg(test)]
impl BindingDispatch {
/// A dispatch seeded straight from the registry defaults — the no-VM test seam.
fn test_defaults() -> Self {
let mut map = std::collections::HashMap::new();
for (i, s) in SPECS.iter().enumerate() {
for d in [s.d1, s.d2].into_iter().flatten() {
map.insert(Chord::parse(d).expect("default parses"), Cmd(i as u16));
}
}
Self {
map,
seen_generation: None,
}
}
}
/// One matching press: latch the held kinds, run/fire by class.
fn press(
state: &mut BindingsState,
script: &mut Option<NonSendMut<UiScript>>,
run_lua: impl Fn(&mut Option<NonSendMut<UiScript>>, &str, &str),
cmd: Cmd,
key: BindKey,
) {
let spec = &SPECS[cmd.0 as usize];
match &spec.kind {
Kind::Held => {
if !state.pressed(cmd) {
state.just.push(cmd);
}
state.latched.push((key, cmd));
}
Kind::Edge(lua) => run_lua(script, lua, spec.name),
Kind::EdgeUpDown(down, _) => {
run_lua(script, down, spec.name);
state.latched.push((key, cmd));
}
Kind::Host => {
state.fired.push(cmd);
if !state.pressed(cmd) {
state.just.push(cmd);
}
state.amounts.push((cmd, 1.0));
}
}
}
/// A base key's release: drop its latch; a runOnUp pair fires its up-half (delivered even while
/// typing — the reference completes a pressed binding's release regardless of focus).
fn release(
state: &mut BindingsState,
script: &mut Option<NonSendMut<UiScript>>,
run_lua: impl Fn(&mut Option<NonSendMut<UiScript>>, &str, &str),
key: BindKey,
) {
let mut i = 0;
while i < state.latched.len() {
if state.latched[i].0 == key {
let (_, cmd) = state.latched.remove(i);
if let Kind::EdgeUpDown(_, up) = &SPECS[cmd.0 as usize].kind {
run_lua(script, up, SPECS[cmd.0 as usize].name);
}
} else {
i += 1;
}
}
}
#[cfg(test)]
mod tests {
use bevy::input::keyboard::{Key, KeyboardInput};
use bevy::input::mouse::{MouseButtonInput, MouseScrollUnit, MouseWheel};
use super::*;
/// A minimal app around [`latch_and_dispatch`] with the registry defaults and NO VM — the
/// keyboard/mouse laws end-to-end from real input events, exactly the way winit feeds them
/// (InputPlugin turns the events into `ButtonInput`/`AccumulatedMouseScroll` in PreUpdate;
/// the dispatch runs in Update).
fn harness() -> App {
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::input::InputPlugin))
.init_resource::<UiKeyboardCapture>()
.init_resource::<PlayerUiHover>()
.init_resource::<PointerOverUi>()
.init_resource::<BindingsState>()
.insert_resource(BindingDispatch::test_defaults())
.add_systems(Update, latch_and_dispatch);
app
}
fn key(app: &mut App, k: KeyCode, state: bevy::input::ButtonState, repeat: bool) {
app.world_mut().write_message(KeyboardInput {
key_code: k,
logical_key: Key::Unidentified(bevy::input::keyboard::NativeKey::Unidentified),
state,
text: None,
repeat,
window: Entity::PLACEHOLDER,
});
}
fn press_key(app: &mut App, k: KeyCode) {
key(app, k, bevy::input::ButtonState::Pressed, false);
}
fn release_key(app: &mut App, k: KeyCode) {
key(app, k, bevy::input::ButtonState::Released, false);
}
fn state(app: &App) -> &BindingsState {
app.world().resource::<BindingsState>()
}
#[test]
fn held_commands_latch_across_frames_and_release_per_base_key() {
let mut app = harness();
// W and UP are both MOVEFORWARD: press both, release one — still moving.
press_key(&mut app, KeyCode::KeyW);
app.update();
assert!(state(&app).pressed(cmd::MOVE_FORWARD));
assert!(state(&app).just_pressed(cmd::MOVE_FORWARD), "press edge");
press_key(&mut app, KeyCode::ArrowUp);
app.update();
assert!(state(&app).pressed(cmd::MOVE_FORWARD));
assert!(
!state(&app).just_pressed(cmd::MOVE_FORWARD),
"second key on an already-held command is no new edge"
);
release_key(&mut app, KeyCode::KeyW);
app.update();
assert!(state(&app).pressed(cmd::MOVE_FORWARD), "UP still holds it");
release_key(&mut app, KeyCode::ArrowUp);
app.update();
assert!(!state(&app).pressed(cmd::MOVE_FORWARD));
// A repeat press (held-key auto-repeat) neither re-latches nor re-edges.
press_key(&mut app, KeyCode::KeyW);
app.update();
key(
&mut app,
KeyCode::KeyW,
bevy::input::ButtonState::Pressed,
true,
);
app.update();
assert!(state(&app).pressed(cmd::MOVE_FORWARD));
assert!(!state(&app).just_pressed(cmd::MOVE_FORWARD));
}
#[test]
fn the_exact_modifier_law_holds_table_wide() {
// Bare Z is the sheath toggle; ALT-Z is TOGGLEUI; CTRL-ALT-Z is nobody's.
let mut app = harness();
press_key(&mut app, KeyCode::KeyZ);
app.update();
assert!(state(&app).fired(cmd::TOGGLE_SHEATH));
assert!(!state(&app).fired(cmd::TOGGLE_UI));
release_key(&mut app, KeyCode::KeyZ);
press_key(&mut app, KeyCode::AltLeft);
press_key(&mut app, KeyCode::KeyZ);
app.update();
assert!(
state(&app).fired(cmd::TOGGLE_UI),
"ALT-Z is TOGGLEUI (0870)"
);
assert!(!state(&app).fired(cmd::TOGGLE_SHEATH));
release_key(&mut app, KeyCode::KeyZ);
press_key(&mut app, KeyCode::ControlLeft);
press_key(&mut app, KeyCode::KeyZ);
app.update();
assert!(
!state(&app).fired(cmd::TOGGLE_UI) && !state(&app).fired(cmd::TOGGLE_SHEATH),
"CTRL-ALT-Z matches neither entry (0585)"
);
// TAB vs SHIFT-TAB are two different commands now.
let mut app = harness();
press_key(&mut app, KeyCode::Tab);
app.update();
assert!(state(&app).fired(cmd::TARGET_NEAREST_ENEMY));
release_key(&mut app, KeyCode::Tab);
press_key(&mut app, KeyCode::ShiftLeft);
press_key(&mut app, KeyCode::Tab);
app.update();
assert!(state(&app).fired(cmd::TARGET_PREVIOUS_ENEMY));
assert!(!state(&app).fired(cmd::TARGET_NEAREST_ENEMY));
// Super/Cmd is never a binding modifier: a super-held press matches nothing.
let mut app = harness();
press_key(&mut app, KeyCode::SuperLeft);
press_key(&mut app, KeyCode::KeyZ);
app.update();
assert!(!state(&app).fired(cmd::TOGGLE_SHEATH));
}
#[test]
fn the_typing_gate_blocks_new_input_and_clears_held_latches_once() {
let mut app = harness();
press_key(&mut app, KeyCode::KeyW);
app.update();
assert!(state(&app).pressed(cmd::MOVE_FORWARD));
// A box takes focus: movement stops (the reference's focus handler releases the
// direction bits), and new presses do nothing.
app.world_mut().resource_mut::<UiKeyboardCapture>().0 = true;
app.update();
assert!(
!state(&app).pressed(cmd::MOVE_FORWARD),
"latches clear on the capture edge"
);
press_key(&mut app, KeyCode::KeyX);
app.update();
assert!(
!state(&app).fired(cmd::SIT_OR_STAND),
"typed keys are not bindings"
);
// Focus drops; keys work again.
release_key(&mut app, KeyCode::KeyX);
app.world_mut().resource_mut::<UiKeyboardCapture>().0 = false;
app.update();
press_key(&mut app, KeyCode::KeyX);
app.update();
assert!(state(&app).fired(cmd::SIT_OR_STAND));
}
#[test]
fn mouse_buttons_bind_only_over_the_world_and_the_wheel_respects_ui() {
let mut app = harness();
// BUTTON4 (winit Forward) is TOGGLEAUTORUN's second default.
app.world_mut().write_message(MouseButtonInput {
button: MouseButton::Forward,
state: bevy::input::ButtonState::Pressed,
window: Entity::PLACEHOLDER,
});
app.update();
assert!(state(&app).fired(cmd::TOGGLE_AUTORUN));
// Over a UI frame the press belongs to the frame.
app.world_mut().write_message(MouseButtonInput {
button: MouseButton::Forward,
state: bevy::input::ButtonState::Released,
window: Entity::PLACEHOLDER,
});
app.update();
app.world_mut().resource_mut::<PlayerUiHover>().0 = Some(7);
app.world_mut().write_message(MouseButtonInput {
button: MouseButton::Forward,
state: bevy::input::ButtonState::Pressed,
window: Entity::PLACEHOLDER,
});
app.update();
assert!(!state(&app).fired(cmd::TOGGLE_AUTORUN));
// MOVEANDSTEER (BUTTON3) is a HELD command on a mouse button: press latches, release ends.
let mut app = harness();
app.world_mut().write_message(MouseButtonInput {
button: MouseButton::Middle,
state: bevy::input::ButtonState::Pressed,
window: Entity::PLACEHOLDER,
});
app.update();
assert!(state(&app).pressed(cmd::MOVE_AND_STEER));
app.world_mut().write_message(MouseButtonInput {
button: MouseButton::Middle,
state: bevy::input::ButtonState::Released,
window: Entity::PLACEHOLDER,
});
app.update();
assert!(!state(&app).pressed(cmd::MOVE_AND_STEER));
// The wheel: a notch fires CAMERAZOOMIN with its amount; over UI it belongs to the frame.
let mut app = harness();
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: 2.0,
window: Entity::PLACEHOLDER,
});
app.update();
assert!(state(&app).fired(cmd::CAMERA_ZOOM_IN));
assert_eq!(state(&app).amount(cmd::CAMERA_ZOOM_IN), 2.0);
app.world_mut().resource_mut::<PointerOverUi>().0 = true;
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: 2.0,
window: Entity::PLACEHOLDER,
});
app.update();
assert!(
!state(&app).fired(cmd::CAMERA_ZOOM_IN),
"a UI wheel is the frame's"
);
}
}
+419
View File
@@ -0,0 +1,419 @@
//! The chord codec (decision 0997): Bevy's physical input ↔ the reference's canonical binding
//! strings — `[ALT-][CTRL-][SHIFT-]<TOKEN>`, where the token set is 1.12's own (`W`, `SPACE`,
//! `NUMPAD0`, `BUTTON4`, `MOUSEWHEELUP`, the bare punctuation characters). These strings are what
//! the table stores, the window displays (through the `KEY_*` GlobalStrings), and the files save —
//! matched by string equality exactly as the client matches them (decision 0585's law).
//!
//! Prefix order is ALT-CTRL-SHIFT, verified from 1.12's own capture Lua (`Blizzard_BindingUI.lua`
//! prepends SHIFT, then CTRL, then ALT) and its saved cache (`CTRL-SHIFT-PAGEDOWN`). The Super/Cmd
//! key is **not** a 1.12 binding modifier: a chord never carries it, a super-modified press never
//! matches (0585's `sup` addition), and a capture with Super held is ignored outright.
use bevy::input::mouse::MouseButton;
use bevy::prelude::KeyCode;
/// A bindable base input: a keyboard key, a mouse button, or one wheel direction.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub(crate) enum BindKey {
Key(KeyCode),
Mouse(MouseButton),
WheelUp,
WheelDown,
}
/// One parsed binding chord: the exact modifier set + the base input. Equality is the whole
/// matching law — a press matches iff its held modifiers equal the chord's exactly.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub(crate) struct Chord {
pub alt: bool,
pub ctrl: bool,
pub shift: bool,
pub key: BindKey,
}
impl Chord {
/// Parse a canonical chord string (`"ALT-CTRL-SHIFT-F1"`, `"CTRL--"` = Ctrl+minus). Unknown
/// base tokens return `None` — the table may hold them (a future command's key), the
/// dispatcher just can't press them.
pub(crate) fn parse(s: &str) -> Option<Chord> {
let (mut alt, mut ctrl, mut shift) = (false, false, false);
let mut rest = s;
loop {
if let Some(r) = rest.strip_prefix("ALT-") {
alt = true;
rest = r;
} else if let Some(r) = rest.strip_prefix("CTRL-") {
ctrl = true;
rest = r;
} else if let Some(r) = rest.strip_prefix("SHIFT-") {
shift = true;
rest = r;
} else {
break;
}
}
Some(Chord {
alt,
ctrl,
shift,
key: token_key(rest)?,
})
}
}
/// Build the canonical chord string from live modifier state + a base token — the capture arm's
/// output (prefix order ALT-CTRL-SHIFT, the 1.12 canon).
pub(crate) fn chord_string(alt: bool, ctrl: bool, shift: bool, token: &str) -> String {
let mut s = String::new();
if alt {
s.push_str("ALT-");
}
if ctrl {
s.push_str("CTRL-");
}
if shift {
s.push_str("SHIFT-");
}
s.push_str(token);
s
}
/// The 1.12 token for a physical key — `None` for keys the reference has no name for (they are
/// ignored for binding, the client's own `UNKNOWN` posture). Modifier keys are deliberately
/// absent: they are chord *prefixes*, never base keys (`IsKeyPressIgnoredForBinding`).
///
/// `NumpadEnter` shares `ENTER` with the main Enter key (1.12 has one token; [`normalize_key`]
/// folds the pair for dispatch too).
pub(crate) fn key_token(k: KeyCode) -> Option<&'static str> {
use KeyCode::*;
Some(match k {
KeyA => "A",
KeyB => "B",
KeyC => "C",
KeyD => "D",
KeyE => "E",
KeyF => "F",
KeyG => "G",
KeyH => "H",
KeyI => "I",
KeyJ => "J",
KeyK => "K",
KeyL => "L",
KeyM => "M",
KeyN => "N",
KeyO => "O",
KeyP => "P",
KeyQ => "Q",
KeyR => "R",
KeyS => "S",
KeyT => "T",
KeyU => "U",
KeyV => "V",
KeyW => "W",
KeyX => "X",
KeyY => "Y",
KeyZ => "Z",
Digit1 => "1",
Digit2 => "2",
Digit3 => "3",
Digit4 => "4",
Digit5 => "5",
Digit6 => "6",
Digit7 => "7",
Digit8 => "8",
Digit9 => "9",
Digit0 => "0",
F1 => "F1",
F2 => "F2",
F3 => "F3",
F4 => "F4",
F5 => "F5",
F6 => "F6",
F7 => "F7",
F8 => "F8",
F9 => "F9",
F10 => "F10",
F11 => "F11",
F12 => "F12",
Space => "SPACE",
Tab => "TAB",
Enter | NumpadEnter => "ENTER",
Escape => "ESCAPE",
Backspace => "BACKSPACE",
Insert => "INSERT",
Delete => "DELETE",
Home => "HOME",
End => "END",
PageUp => "PAGEUP",
PageDown => "PAGEDOWN",
ArrowUp => "UP",
ArrowDown => "DOWN",
ArrowLeft => "LEFT",
ArrowRight => "RIGHT",
Numpad0 => "NUMPAD0",
Numpad1 => "NUMPAD1",
Numpad2 => "NUMPAD2",
Numpad3 => "NUMPAD3",
Numpad4 => "NUMPAD4",
Numpad5 => "NUMPAD5",
Numpad6 => "NUMPAD6",
Numpad7 => "NUMPAD7",
Numpad8 => "NUMPAD8",
Numpad9 => "NUMPAD9",
NumpadAdd => "NUMPADPLUS",
NumpadSubtract => "NUMPADMINUS",
NumpadDivide => "NUMPADDIVIDE",
NumpadMultiply => "NUMPADMULTIPLY",
NumpadDecimal => "NUMPADDECIMAL",
NumLock => "NUMLOCK",
PrintScreen => "PRINTSCREEN",
ScrollLock => "SCROLLLOCK",
Pause => "PAUSE",
CapsLock => "CAPSLOCK",
Minus => "-",
Equal => "=",
BracketLeft => "[",
BracketRight => "]",
Backslash => "\\",
Semicolon => ";",
Quote => "'",
Comma => ",",
Period => ".",
Slash => "/",
Backquote => "`",
_ => return None,
})
}
/// Fold key aliases that share one 1.12 token (`NumpadEnter` → `Enter`) so a chord parsed from
/// `ENTER` matches either physical key.
pub(crate) fn normalize_key(k: KeyCode) -> KeyCode {
match k {
KeyCode::NumpadEnter => KeyCode::Enter,
other => other,
}
}
/// The 1.12 token for a mouse button. BUTTON4 is winit's `Forward` and BUTTON5 `Back` —
/// deliberately keeping the thumb button that toggled autorun before 0997 (macOS winit maps
/// NSEvent buttonNumber 4 → `Forward`; see the old `player.rs` site) on the 1.12 default
/// (`BUTTON4 TOGGLEAUTORUN`), so the director's in-hand behavior survives the refactor.
pub(crate) fn mouse_token(b: MouseButton) -> Option<&'static str> {
Some(match b {
MouseButton::Left => "BUTTON1",
MouseButton::Right => "BUTTON2",
MouseButton::Middle => "BUTTON3",
MouseButton::Forward => "BUTTON4",
MouseButton::Back => "BUTTON5",
MouseButton::Other(_) => return None,
})
}
/// Token → base input (the parse side of [`key_token`]/[`mouse_token`], plus the wheel pair).
fn token_key(t: &str) -> Option<BindKey> {
use KeyCode::*;
if let Some(b) = match t {
"BUTTON1" => Some(MouseButton::Left),
"BUTTON2" => Some(MouseButton::Right),
"BUTTON3" => Some(MouseButton::Middle),
"BUTTON4" => Some(MouseButton::Forward),
"BUTTON5" => Some(MouseButton::Back),
_ => None,
} {
return Some(BindKey::Mouse(b));
}
match t {
"MOUSEWHEELUP" => return Some(BindKey::WheelUp),
"MOUSEWHEELDOWN" => return Some(BindKey::WheelDown),
_ => {}
}
let k = match t {
"A" => KeyA,
"B" => KeyB,
"C" => KeyC,
"D" => KeyD,
"E" => KeyE,
"F" => KeyF,
"G" => KeyG,
"H" => KeyH,
"I" => KeyI,
"J" => KeyJ,
"K" => KeyK,
"L" => KeyL,
"M" => KeyM,
"N" => KeyN,
"O" => KeyO,
"P" => KeyP,
"Q" => KeyQ,
"R" => KeyR,
"S" => KeyS,
"T" => KeyT,
"U" => KeyU,
"V" => KeyV,
"W" => KeyW,
"X" => KeyX,
"Y" => KeyY,
"Z" => KeyZ,
"1" => Digit1,
"2" => Digit2,
"3" => Digit3,
"4" => Digit4,
"5" => Digit5,
"6" => Digit6,
"7" => Digit7,
"8" => Digit8,
"9" => Digit9,
"0" => Digit0,
"F1" => F1,
"F2" => F2,
"F3" => F3,
"F4" => F4,
"F5" => F5,
"F6" => F6,
"F7" => F7,
"F8" => F8,
"F9" => F9,
"F10" => F10,
"F11" => F11,
"F12" => F12,
"SPACE" => Space,
"TAB" => Tab,
"ENTER" => Enter,
"ESCAPE" => Escape,
"BACKSPACE" => Backspace,
"INSERT" => Insert,
"DELETE" => Delete,
"HOME" => Home,
"END" => End,
"PAGEUP" => PageUp,
"PAGEDOWN" => PageDown,
"UP" => ArrowUp,
"DOWN" => ArrowDown,
"LEFT" => ArrowLeft,
"RIGHT" => ArrowRight,
"NUMPAD0" => Numpad0,
"NUMPAD1" => Numpad1,
"NUMPAD2" => Numpad2,
"NUMPAD3" => Numpad3,
"NUMPAD4" => Numpad4,
"NUMPAD5" => Numpad5,
"NUMPAD6" => Numpad6,
"NUMPAD7" => Numpad7,
"NUMPAD8" => Numpad8,
"NUMPAD9" => Numpad9,
"NUMPADPLUS" => NumpadAdd,
"NUMPADMINUS" => NumpadSubtract,
"NUMPADDIVIDE" => NumpadDivide,
"NUMPADMULTIPLY" => NumpadMultiply,
"NUMPADDECIMAL" => NumpadDecimal,
"NUMLOCK" => NumLock,
"PRINTSCREEN" => PrintScreen,
"SCROLLLOCK" => ScrollLock,
"PAUSE" => Pause,
"CAPSLOCK" => CapsLock,
"-" => Minus,
"=" => Equal,
"[" => BracketLeft,
"]" => BracketRight,
"\\" => Backslash,
";" => Semicolon,
"'" => Quote,
"," => Comma,
"." => Period,
"/" => Slash,
"`" => Backquote,
_ => return None,
};
Some(BindKey::Key(k))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_codec_round_trips_every_keyboard_token() {
// Every key with a token parses back to itself (through the alias fold).
let mut checked = 0;
for k in [
KeyCode::KeyW,
KeyCode::Digit0,
KeyCode::F11,
KeyCode::Space,
KeyCode::NumLock,
KeyCode::NumpadDivide,
KeyCode::ArrowUp,
KeyCode::Minus,
KeyCode::Backquote,
KeyCode::Quote,
] {
let t = key_token(k).unwrap();
assert_eq!(
token_key(t),
Some(BindKey::Key(normalize_key(k))),
"token {t}"
);
checked += 1;
}
assert_eq!(checked, 10);
// The alias fold: both Enters share the token and the parsed key.
assert_eq!(key_token(KeyCode::NumpadEnter), Some("ENTER"));
assert_eq!(token_key("ENTER"), Some(BindKey::Key(KeyCode::Enter)));
}
#[test]
fn chords_parse_with_the_112_prefix_order_and_punctuation_bases() {
assert_eq!(
Chord::parse("ALT-CTRL-SHIFT-F1"),
Some(Chord {
alt: true,
ctrl: true,
shift: true,
key: BindKey::Key(KeyCode::F1)
})
);
// `CTRL--` is Ctrl + the minus key — the prefix strip never splits the base token.
assert_eq!(
Chord::parse("CTRL--"),
Some(Chord {
alt: false,
ctrl: true,
shift: false,
key: BindKey::Key(KeyCode::Minus)
})
);
assert_eq!(
Chord::parse("SHIFT-MOUSEWHEELUP"),
Some(Chord {
alt: false,
ctrl: false,
shift: true,
key: BindKey::WheelUp
})
);
assert_eq!(
Chord::parse("BUTTON4"),
Some(Chord {
alt: false,
ctrl: false,
shift: false,
key: BindKey::Mouse(MouseButton::Forward)
})
);
assert_eq!(Chord::parse("BOGUS"), None);
// The builder emits the same canon the parser reads.
assert_eq!(
chord_string(true, false, true, "PAGEDOWN"),
"ALT-SHIFT-PAGEDOWN"
);
assert_eq!(
Chord::parse("ALT-SHIFT-PAGEDOWN").unwrap(),
Chord {
alt: true,
ctrl: false,
shift: true,
key: BindKey::Key(KeyCode::PageDown)
}
);
}
}
+751
View File
@@ -0,0 +1,751 @@
//! The command registry (decision 0997) — every binding command benilla actually implements, in
//! 1.12 `Bindings.xml` order, with the 1.12 default chords (byte-real: the client's own
//! `bindings-cache.wtf`, account ONE) and each command's dispatch class.
//!
//! **Honest tree**: a command appears here only over a real engine action — the same law as the
//! options rows (0954). The 1.12 commands with no benilla mechanism yet (pitch keys, walk toggle,
//! action pages, camera views, screenshot, combat log, …) are absent, not stubbed; the window
//! shows only what's here, and only non-empty categories (era law). Labels/headers are the 1.12
//! GlobalStrings (`BINDING_NAME_*`/`BINDING_HEADER_*`), defined in the window's XML.
//!
//! Three dispatch classes:
//! - [`Kind::Held`] — press latches, base-key release unlatches (the reference's `runOnUp`
//! movement pairs); engine systems read the latch ([`super::BindingsState`]).
//! - [`Kind::Edge`] / [`Kind::EdgeUpDown`] — fires Lua in the VM (the reference's binding body,
//! quoted 1:1 where our FrameXML port has the same functions).
//! - [`Kind::Host`] — fires into [`super::BindingsState::fired`]; an engine system consumes it
//! (chat open, TAB targeting, nameplates, autorun, …).
//!
//! Recorded default divergences (0997): `TOGGLEUI` ships `ALT-Z` (0870: the cache's `CTRL-Z` is
//! a player rebind all three accounts inherited, not the shipped default — the one row that does
//! not trust the cache); `OPENALLBAGS` ships `B` + `SHIFT-B` (1.12 splits B/TOGGLEBACKPACK from
//! SHIFT-B/OPENALLBAGS; benilla's one bag knob is the open-all toggle that already lived on B).
/// A command's index into [`SPECS`] — the engine-side handle (`cmd::JUMP`).
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub(crate) struct Cmd(pub u16);
pub(crate) enum Kind {
/// Latched while the base key is held; engine systems read the state.
Held,
/// Fires this Lua on the matching press.
Edge(&'static str),
/// The reference's `runOnUp` button pair: Lua on press, Lua on base-key release.
EdgeUpDown(&'static str, &'static str),
/// Fires into [`super::BindingsState`]; an engine system consumes it.
Host,
}
pub(crate) struct Spec {
pub name: &'static str,
/// The category header's global-string key (`BINDING_HEADER_MOVEMENT`) — the window's
/// sidebar groups by it, era-style.
pub category: &'static str,
pub kind: Kind,
/// The 1.12 default chords (canonical strings; `None` = shipped unbound).
pub d1: Option<&'static str>,
pub d2: Option<&'static str>,
}
impl Spec {
/// Press+release semantics — the mousewheel-refusal law rides this at `SetBinding` time.
pub(crate) fn run_on_up(&self) -> bool {
matches!(self.kind, Kind::Held | Kind::EdgeUpDown(..))
}
}
macro_rules! spec {
($name:literal, $cat:ident, $kind:expr, $d1:expr, $d2:expr) => {
Spec {
name: $name,
category: concat!("BINDING_HEADER_", stringify!($cat)),
kind: $kind,
d1: $d1,
d2: $d2,
}
};
}
/// Engine-side handles for the host-dispatched commands (indexes into [`SPECS`], asserted by
/// test). Lua-bodied commands need no handle — nothing in Rust names them.
pub(crate) mod cmd {
use super::Cmd;
pub(crate) const MOVE_AND_STEER: Cmd = Cmd(0);
pub(crate) const MOVE_FORWARD: Cmd = Cmd(1);
pub(crate) const MOVE_BACKWARD: Cmd = Cmd(2);
pub(crate) const TURN_LEFT: Cmd = Cmd(3);
pub(crate) const TURN_RIGHT: Cmd = Cmd(4);
pub(crate) const STRAFE_LEFT: Cmd = Cmd(5);
pub(crate) const STRAFE_RIGHT: Cmd = Cmd(6);
pub(crate) const JUMP: Cmd = Cmd(7);
pub(crate) const SIT_OR_STAND: Cmd = Cmd(8);
pub(crate) const TOGGLE_SHEATH: Cmd = Cmd(9);
pub(crate) const TOGGLE_AUTORUN: Cmd = Cmd(10);
pub(crate) const OPEN_CHAT: Cmd = Cmd(12);
pub(crate) const OPEN_CHAT_SLASH: Cmd = Cmd(13);
pub(crate) const REPLY: Cmd = Cmd(17);
pub(crate) const TARGET_NEAREST_ENEMY: Cmd = Cmd(40);
pub(crate) const TARGET_PREVIOUS_ENEMY: Cmd = Cmd(41);
pub(crate) const NAMEPLATES: Cmd = Cmd(52);
pub(crate) const FRIEND_NAMEPLATES: Cmd = Cmd(53);
pub(crate) const ALL_NAMEPLATES: Cmd = Cmd(54);
pub(crate) const ATTACK_TARGET: Cmd = Cmd(55);
pub(crate) const TOGGLE_UI: Cmd = Cmd(76);
pub(crate) const CAMERA_ZOOM_IN: Cmd = Cmd(77);
pub(crate) const CAMERA_ZOOM_OUT: Cmd = Cmd(78);
}
/// The registry, 1.12 `Bindings.xml` order. Sub-tables (action buttons, shapeshift, raid
/// targets) are written out because each row carries its own Lua body string.
pub(crate) static SPECS: &[Spec] = &[
// ── Movement (BINDING_HEADER_MOVEMENT) ──────────────────────────────────────────────
spec!("MOVEANDSTEER", MOVEMENT, Kind::Held, Some("BUTTON3"), None),
spec!("MOVEFORWARD", MOVEMENT, Kind::Held, Some("W"), Some("UP")),
spec!(
"MOVEBACKWARD",
MOVEMENT,
Kind::Held,
Some("S"),
Some("DOWN")
),
spec!("TURNLEFT", MOVEMENT, Kind::Held, Some("A"), Some("LEFT")),
spec!("TURNRIGHT", MOVEMENT, Kind::Held, Some("D"), Some("RIGHT")),
spec!("STRAFELEFT", MOVEMENT, Kind::Held, Some("Q"), None),
spec!("STRAFERIGHT", MOVEMENT, Kind::Held, Some("E"), None),
spec!("JUMP", MOVEMENT, Kind::Host, Some("SPACE"), Some("NUMPAD0")),
spec!("SITORSTAND", MOVEMENT, Kind::Host, Some("X"), None),
spec!("TOGGLESHEATH", MOVEMENT, Kind::Host, Some("Z"), None),
spec!(
"TOGGLEAUTORUN",
MOVEMENT,
Kind::Host,
Some("NUMLOCK"),
Some("BUTTON4")
),
spec!(
"FOLLOWTARGET",
MOVEMENT,
Kind::Edge(r#"FollowUnit("target")"#),
None,
None
),
// ── Chat (BINDING_HEADER_CHAT) ──────────────────────────────────────────────────────
spec!("OPENCHAT", CHAT, Kind::Host, Some("ENTER"), None),
spec!("OPENCHATSLASH", CHAT, Kind::Host, Some("/"), None),
spec!(
"CHATPAGEUP",
CHAT,
Kind::Edge(r#"getglobal("ChatFrame" .. BenillaFCF.selected):PageUp()"#),
Some("PAGEUP"),
None
),
spec!(
"CHATPAGEDOWN",
CHAT,
Kind::Edge(r#"getglobal("ChatFrame" .. BenillaFCF.selected):PageDown()"#),
Some("PAGEDOWN"),
None
),
spec!(
"CHATBOTTOM",
CHAT,
Kind::Edge(r#"getglobal("ChatFrame" .. BenillaFCF.selected):ScrollToBottom()"#),
Some("SHIFT-PAGEDOWN"),
None
),
spec!("REPLY", CHAT, Kind::Host, Some("R"), None),
// ── Action bar (BINDING_HEADER_ACTIONBAR) ───────────────────────────────────────────
// The ref's runOnUp pair (Bindings.xml:121: DOWN shows the pushed visual, UP fires) —
// exactly what the old hardcoded number-row table sent.
spec!(
"ACTIONBUTTON1",
ACTIONBAR,
Kind::EdgeUpDown("BenillaActionButtonDown(1)", "BenillaActionButtonUp(1)"),
Some("1"),
None
),
spec!(
"ACTIONBUTTON2",
ACTIONBAR,
Kind::EdgeUpDown("BenillaActionButtonDown(2)", "BenillaActionButtonUp(2)"),
Some("2"),
None
),
spec!(
"ACTIONBUTTON3",
ACTIONBAR,
Kind::EdgeUpDown("BenillaActionButtonDown(3)", "BenillaActionButtonUp(3)"),
Some("3"),
None
),
spec!(
"ACTIONBUTTON4",
ACTIONBAR,
Kind::EdgeUpDown("BenillaActionButtonDown(4)", "BenillaActionButtonUp(4)"),
Some("4"),
None
),
spec!(
"ACTIONBUTTON5",
ACTIONBAR,
Kind::EdgeUpDown("BenillaActionButtonDown(5)", "BenillaActionButtonUp(5)"),
Some("5"),
None
),
spec!(
"ACTIONBUTTON6",
ACTIONBAR,
Kind::EdgeUpDown("BenillaActionButtonDown(6)", "BenillaActionButtonUp(6)"),
Some("6"),
None
),
spec!(
"ACTIONBUTTON7",
ACTIONBAR,
Kind::EdgeUpDown("BenillaActionButtonDown(7)", "BenillaActionButtonUp(7)"),
Some("7"),
None
),
spec!(
"ACTIONBUTTON8",
ACTIONBAR,
Kind::EdgeUpDown("BenillaActionButtonDown(8)", "BenillaActionButtonUp(8)"),
Some("8"),
None
),
spec!(
"ACTIONBUTTON9",
ACTIONBAR,
Kind::EdgeUpDown("BenillaActionButtonDown(9)", "BenillaActionButtonUp(9)"),
Some("9"),
None
),
spec!(
"ACTIONBUTTON10",
ACTIONBAR,
Kind::EdgeUpDown("BenillaActionButtonDown(10)", "BenillaActionButtonUp(10)"),
Some("0"),
None
),
spec!(
"ACTIONBUTTON11",
ACTIONBAR,
Kind::EdgeUpDown("BenillaActionButtonDown(11)", "BenillaActionButtonUp(11)"),
Some("-"),
None
),
spec!(
"ACTIONBUTTON12",
ACTIONBAR,
Kind::EdgeUpDown("BenillaActionButtonDown(12)", "BenillaActionButtonUp(12)"),
Some("="),
None
),
// The stance/shapeshift row (ref ShapeshiftBar_ChangeForm(n)) — ours clicks the bar's own
// buttons, which carry the full form-switch law (StanceBar.xml).
spec!(
"SHAPESHIFTBUTTON1",
ACTIONBAR,
Kind::Edge(
r#"local b = getglobal("BenillaShapeshiftButton1"); if b and b:IsVisible() then b:Click() end"#
),
Some("CTRL-F1"),
None
),
spec!(
"SHAPESHIFTBUTTON2",
ACTIONBAR,
Kind::Edge(
r#"local b = getglobal("BenillaShapeshiftButton2"); if b and b:IsVisible() then b:Click() end"#
),
Some("CTRL-F2"),
None
),
spec!(
"SHAPESHIFTBUTTON3",
ACTIONBAR,
Kind::Edge(
r#"local b = getglobal("BenillaShapeshiftButton3"); if b and b:IsVisible() then b:Click() end"#
),
Some("CTRL-F3"),
None
),
spec!(
"SHAPESHIFTBUTTON4",
ACTIONBAR,
Kind::Edge(
r#"local b = getglobal("BenillaShapeshiftButton4"); if b and b:IsVisible() then b:Click() end"#
),
Some("CTRL-F4"),
None
),
spec!(
"SHAPESHIFTBUTTON5",
ACTIONBAR,
Kind::Edge(
r#"local b = getglobal("BenillaShapeshiftButton5"); if b and b:IsVisible() then b:Click() end"#
),
Some("CTRL-F5"),
None
),
spec!(
"SHAPESHIFTBUTTON6",
ACTIONBAR,
Kind::Edge(
r#"local b = getglobal("BenillaShapeshiftButton6"); if b and b:IsVisible() then b:Click() end"#
),
Some("CTRL-F6"),
None
),
spec!(
"SHAPESHIFTBUTTON7",
ACTIONBAR,
Kind::Edge(
r#"local b = getglobal("BenillaShapeshiftButton7"); if b and b:IsVisible() then b:Click() end"#
),
Some("CTRL-F7"),
None
),
spec!(
"SHAPESHIFTBUTTON8",
ACTIONBAR,
Kind::Edge(
r#"local b = getglobal("BenillaShapeshiftButton8"); if b and b:IsVisible() then b:Click() end"#
),
Some("CTRL-F8"),
None
),
spec!(
"SHAPESHIFTBUTTON9",
ACTIONBAR,
Kind::Edge(
r#"local b = getglobal("BenillaShapeshiftButton9"); if b and b:IsVisible() then b:Click() end"#
),
Some("CTRL-F9"),
None
),
spec!(
"SHAPESHIFTBUTTON10",
ACTIONBAR,
Kind::Edge(
r#"local b = getglobal("BenillaShapeshiftButton10"); if b and b:IsVisible() then b:Click() end"#
),
Some("CTRL-F10"),
None
),
// ── Targeting (BINDING_HEADER_TARGETING) ────────────────────────────────────────────
spec!(
"TARGETNEARESTENEMY",
TARGETING,
Kind::Host,
Some("TAB"),
None
),
spec!(
"TARGETPREVIOUSENEMY",
TARGETING,
Kind::Host,
Some("SHIFT-TAB"),
None
),
// The self/party bodies are 1.12's own, 1:1 (Bindings.xml:460-509 — already-targeted
// falls through to the pet).
spec!(
"TARGETSELF",
TARGETING,
Kind::Edge(
r#"if UnitIsUnit("player", "target") then TargetUnit("pet") else TargetUnit("player") end"#
),
Some("F1"),
None
),
spec!(
"TARGETPARTYMEMBER1",
TARGETING,
Kind::Edge(
r#"if UnitIsUnit("party1", "target") then TargetUnit("partypet1") else TargetUnit("party1") end"#
),
Some("F2"),
None
),
spec!(
"TARGETPARTYMEMBER2",
TARGETING,
Kind::Edge(
r#"if UnitIsUnit("party2", "target") then TargetUnit("partypet2") else TargetUnit("party2") end"#
),
Some("F3"),
None
),
spec!(
"TARGETPARTYMEMBER3",
TARGETING,
Kind::Edge(
r#"if UnitIsUnit("party3", "target") then TargetUnit("partypet3") else TargetUnit("party3") end"#
),
Some("F4"),
None
),
spec!(
"TARGETPARTYMEMBER4",
TARGETING,
Kind::Edge(
r#"if UnitIsUnit("party4", "target") then TargetUnit("partypet4") else TargetUnit("party4") end"#
),
Some("F5"),
None
),
spec!(
"TARGETPET",
TARGETING,
Kind::Edge(r#"TargetUnit("pet")"#),
Some("SHIFT-F1"),
None
),
spec!(
"TARGETPARTYPET1",
TARGETING,
Kind::Edge(r#"TargetUnit("partypet1")"#),
Some("SHIFT-F2"),
None
),
spec!(
"TARGETPARTYPET2",
TARGETING,
Kind::Edge(r#"TargetUnit("partypet2")"#),
Some("SHIFT-F3"),
None
),
spec!(
"TARGETPARTYPET3",
TARGETING,
Kind::Edge(r#"TargetUnit("partypet3")"#),
Some("SHIFT-F4"),
None
),
spec!(
"TARGETPARTYPET4",
TARGETING,
Kind::Edge(r#"TargetUnit("partypet4")"#),
Some("SHIFT-F5"),
None
),
spec!("NAMEPLATES", TARGETING, Kind::Host, Some("V"), None),
spec!(
"FRIENDNAMEPLATES",
TARGETING,
Kind::Host,
Some("SHIFT-V"),
None
),
spec!("ALLNAMEPLATES", TARGETING, Kind::Host, Some("CTRL-V"), None),
spec!("ATTACKTARGET", TARGETING, Kind::Host, Some("T"), None),
spec!(
"PETATTACK",
TARGETING,
Kind::Edge("PetAttack()"),
Some("SHIFT-T"),
None
),
// ── Interface panels (BINDING_HEADER_INTERFACE) ─────────────────────────────────────
spec!(
"TOGGLECHARACTER0",
INTERFACE,
Kind::Edge(r#"ToggleCharacter("BenillaPaperDollFrame")"#),
Some("C"),
None
),
// 1.12 splits TOGGLEBACKPACK (B, F12) from OPENALLBAGS (SHIFT-B); benilla's one bag knob is
// the open-all toggle that already lived on B — so OPENALLBAGS ships with both defaults
// (recorded divergence, 0997).
spec!(
"OPENALLBAGS",
INTERFACE,
Kind::Edge("BenillaBagToggle_OnClick()"),
Some("B"),
Some("SHIFT-B")
),
spec!(
"TOGGLECHARACTER1",
INTERFACE,
Kind::Edge(r#"ToggleCharacter("BenillaSkillFrame")"#),
Some("K"),
None
),
spec!(
"TOGGLESPELLBOOK",
INTERFACE,
Kind::Edge("ToggleSpellBook(BOOKTYPE_SPELL)"),
Some("P"),
None
),
spec!(
"TOGGLETALENTS",
INTERFACE,
Kind::Edge("ToggleTalentFrame()"),
Some("N"),
None
),
spec!(
"TOGGLEQUESTLOG",
INTERFACE,
Kind::Edge("ToggleQuestLog()"),
Some("L"),
None
),
spec!(
"TOGGLEGAMEMENU",
INTERFACE,
Kind::Edge("ToggleGameMenu()"),
Some("ESCAPE"),
None
),
spec!(
"TOGGLEMINIMAP",
INTERFACE,
Kind::Edge(
r#"if MinimapCluster:IsVisible() then MinimapCluster:Hide() else MinimapCluster:Show() end"#
),
None,
None
),
spec!(
"TOGGLEWORLDMAP",
INTERFACE,
Kind::Edge("ToggleWorldMap()"),
Some("M"),
None
),
spec!(
"TOGGLESOCIAL",
INTERFACE,
Kind::Edge("ToggleFriendsFrame()"),
Some("O"),
None
),
spec!(
"TOGGLEFRIENDSTAB",
INTERFACE,
Kind::Edge("ToggleFriendsFrame(1)"),
None,
None
),
spec!(
"TOGGLEWHOTAB",
INTERFACE,
Kind::Edge("ToggleFriendsFrame(2)"),
None,
None
),
spec!(
"TOGGLEGUILDTAB",
INTERFACE,
Kind::Edge("ToggleFriendsFrame(3)"),
None,
None
),
// ── Miscellaneous (BINDING_HEADER_MISC) ─────────────────────────────────────────────
spec!(
"MINIMAPZOOMIN",
MISC,
Kind::Edge("Minimap_ZoomInClick()"),
Some("NUMPADPLUS"),
None
),
spec!(
"MINIMAPZOOMOUT",
MISC,
Kind::Edge("Minimap_ZoomOutClick()"),
Some("NUMPADMINUS"),
None
),
// The sound toggles/steps flip the same CVars the ref's SoundOptionsFrame_* bodies do
// (1.12 SoundOptionsFrame.lua: master enable = MasterSoundEffects, step 0.1).
spec!(
"TOGGLEMUSIC",
MISC,
Kind::Edge(
r#"if GetCVar("EnableMusic") == "1" then SetCVar("EnableMusic", "0") else SetCVar("EnableMusic", "1") end"#
),
Some("CTRL-M"),
None
),
spec!(
"TOGGLESOUND",
MISC,
Kind::Edge(
r#"if GetCVar("MasterSoundEffects") == "1" then SetCVar("MasterSoundEffects", "0") else SetCVar("MasterSoundEffects", "1") end"#
),
Some("CTRL-S"),
None
),
spec!(
"MASTERVOLUMEUP",
MISC,
Kind::Edge(
r#"local v = tonumber(GetCVar("MasterVolume")) + 0.1; if v > 1 then v = 1 end; SetCVar("MasterVolume", v)"#
),
Some("CTRL-="),
None
),
spec!(
"MASTERVOLUMEDOWN",
MISC,
Kind::Edge(
r#"local v = tonumber(GetCVar("MasterVolume")) - 0.1; if v < 0 then v = 0 end; SetCVar("MasterVolume", v)"#
),
Some("CTRL--"),
None
),
// ALT-Z, not the cache's CTRL-Z — settled by 0870: all three of the install's accounts
// descend from one profile whose TOGGLEUI had been rebound; ALT-Z is the shipped default
// (the one command whose default does NOT trust the cache file).
spec!("TOGGLEUI", MISC, Kind::Host, Some("ALT-Z"), None),
// ── Camera (BINDING_HEADER_CAMERA) ──────────────────────────────────────────────────
spec!(
"CAMERAZOOMIN",
CAMERA,
Kind::Host,
Some("MOUSEWHEELUP"),
None
),
spec!(
"CAMERAZOOMOUT",
CAMERA,
Kind::Host,
Some("MOUSEWHEELDOWN"),
None
),
// ── Raid targeting (BINDING_HEADER_RAID_TARGET) ─────────────────────────────────────
// 1.12 bodies 1:1 (SetRaidTargetIcon toggles when the unit already wears the icon; 0
// clears — party.rs's registered semantics). All unbound by default, like the client.
spec!(
"RAIDTARGET1",
RAID_TARGET,
Kind::Edge(r#"SetRaidTargetIcon("target", 1)"#),
None,
None
),
spec!(
"RAIDTARGET2",
RAID_TARGET,
Kind::Edge(r#"SetRaidTargetIcon("target", 2)"#),
None,
None
),
spec!(
"RAIDTARGET3",
RAID_TARGET,
Kind::Edge(r#"SetRaidTargetIcon("target", 3)"#),
None,
None
),
spec!(
"RAIDTARGET4",
RAID_TARGET,
Kind::Edge(r#"SetRaidTargetIcon("target", 4)"#),
None,
None
),
spec!(
"RAIDTARGET5",
RAID_TARGET,
Kind::Edge(r#"SetRaidTargetIcon("target", 5)"#),
None,
None
),
spec!(
"RAIDTARGET6",
RAID_TARGET,
Kind::Edge(r#"SetRaidTargetIcon("target", 6)"#),
None,
None
),
spec!(
"RAIDTARGET7",
RAID_TARGET,
Kind::Edge(r#"SetRaidTargetIcon("target", 7)"#),
None,
None
),
spec!(
"RAIDTARGET8",
RAID_TARGET,
Kind::Edge(r#"SetRaidTargetIcon("target", 8)"#),
None,
None
),
spec!(
"RAIDTARGETNONE",
RAID_TARGET,
Kind::Edge(r#"SetRaidTargetIcon("target", 0)"#),
None,
None
),
];
#[cfg(test)]
mod tests {
use super::*;
/// The named handles in [`cmd`] must index the rows they claim — the table is the truth,
/// the consts are the readable view of it.
#[test]
fn the_cmd_handles_index_their_rows() {
for (handle, name) in [
(cmd::MOVE_AND_STEER, "MOVEANDSTEER"),
(cmd::MOVE_FORWARD, "MOVEFORWARD"),
(cmd::MOVE_BACKWARD, "MOVEBACKWARD"),
(cmd::TURN_LEFT, "TURNLEFT"),
(cmd::TURN_RIGHT, "TURNRIGHT"),
(cmd::STRAFE_LEFT, "STRAFELEFT"),
(cmd::STRAFE_RIGHT, "STRAFERIGHT"),
(cmd::JUMP, "JUMP"),
(cmd::SIT_OR_STAND, "SITORSTAND"),
(cmd::TOGGLE_SHEATH, "TOGGLESHEATH"),
(cmd::TOGGLE_AUTORUN, "TOGGLEAUTORUN"),
(cmd::OPEN_CHAT, "OPENCHAT"),
(cmd::OPEN_CHAT_SLASH, "OPENCHATSLASH"),
(cmd::REPLY, "REPLY"),
(cmd::TARGET_NEAREST_ENEMY, "TARGETNEARESTENEMY"),
(cmd::TARGET_PREVIOUS_ENEMY, "TARGETPREVIOUSENEMY"),
(cmd::NAMEPLATES, "NAMEPLATES"),
(cmd::FRIEND_NAMEPLATES, "FRIENDNAMEPLATES"),
(cmd::ALL_NAMEPLATES, "ALLNAMEPLATES"),
(cmd::ATTACK_TARGET, "ATTACKTARGET"),
(cmd::TOGGLE_UI, "TOGGLEUI"),
(cmd::CAMERA_ZOOM_IN, "CAMERAZOOMIN"),
(cmd::CAMERA_ZOOM_OUT, "CAMERAZOOMOUT"),
] {
assert_eq!(
SPECS[handle.0 as usize].name, name,
"cmd handle {name} points at SPECS[{}] = {}",
handle.0, SPECS[handle.0 as usize].name
);
}
}
/// Every default chord in the table parses — a typo'd token would otherwise silently ship
/// an unpressable default.
#[test]
fn every_default_chord_parses() {
for s in SPECS {
for d in [s.d1, s.d2].into_iter().flatten() {
assert!(
crate::bindings::chord::Chord::parse(d).is_some(),
"{}: default '{d}' does not parse",
s.name
);
}
}
}
/// Names are unique (the table is keyed by them everywhere: files, Lua, the window).
#[test]
fn names_are_unique() {
let mut seen = std::collections::HashSet::new();
for s in SPECS {
assert!(seen.insert(s.name), "duplicate command {}", s.name);
}
}
}
+194
View File
@@ -0,0 +1,194 @@
//! Binding persistence (decision 0997): `benilla/bindings/account.txt` and
//! `benilla/bindings/<Realm>-<Char>.txt`, through [`crate::local_state`] like every resident.
//!
//! Format is **command-centric diff-vs-defaults** — one line per command whose keys moved:
//!
//! ```text
//! # benilla key bindings (decision 0997)
//! bind JUMP F
//! bind MOVEFORWARD W
//! bind TOGGLESHEATH
//! ```
//!
//! `bind <COMMAND> [key...]` replaces that command's whole key list; no tokens = deliberately
//! unbound; a command absent from the file = its registered defaults. This diverges from the
//! reference's full-snapshot `bindings-cache.wtf` deliberately (recorded in 0997): benilla grows
//! commands weekly, and a wholesale snapshot would ship every new command unbound to anyone with
//! a saved file. Tokens are the canonical chord strings, which never contain spaces, so
//! whitespace splitting is safe (`-` and `=` are key tokens, hence no sentinel for "empty" —
//! absence of tokens is the sentinel).
use std::collections::HashMap;
use super::commands::SPECS;
/// Serialize the live table as the diff file. `snapshot` is `(command, keys)` in registry order
/// ([`benilla_ui::script::UiScript::keybind_snapshot`]'s shape).
pub(crate) fn to_diff(snapshot: &[(String, Vec<String>)]) -> String {
let defaults: HashMap<&str, Vec<&str>> = SPECS
.iter()
.map(|s| (s.name, [s.d1, s.d2].into_iter().flatten().collect()))
.collect();
let mut out = String::from("# benilla key bindings (decision 0997) — diff vs defaults\n");
for (name, keys) in snapshot {
let is_default = defaults
.get(name.as_str())
.is_some_and(|d| d.iter().copied().eq(keys.iter().map(String::as_str)));
if is_default {
continue;
}
out.push_str("bind ");
out.push_str(name);
for k in keys {
out.push(' ');
out.push_str(k);
}
out.push('\n');
}
out
}
/// Parse a diff file into `(command, keys)` overrides — the shape
/// [`benilla_ui::script::UiScript::seed_binding_set`] takes. Unknown commands are kept (a file
/// from a newer build stays intact through load/save); malformed lines are skipped.
pub(crate) fn from_diff(text: &str) -> Vec<(String, Vec<String>)> {
let mut out = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut it = line.split_whitespace();
if it.next() != Some("bind") {
continue;
}
let Some(name) = it.next() else { continue };
out.push((
name.to_ascii_uppercase(),
it.map(|t| t.to_ascii_uppercase()).collect(),
));
}
out
}
/// Resolve a diff into the **full** per-command key table (defaults with the overrides applied,
/// steal law honored file-order) — what a stored set seeds. The steal pass matters: a file that
/// binds `W` to JUMP must also strip `W` from MOVEFORWARD even if MOVEFORWARD has no line (older
/// file, new default collision) — one key, one command, always.
pub(crate) fn resolve(diff: &[(String, Vec<String>)]) -> Vec<(String, Vec<String>)> {
let mut table: Vec<(String, Vec<String>)> = SPECS
.iter()
.map(|s| {
(
s.name.to_string(),
[s.d1, s.d2]
.into_iter()
.flatten()
.map(str::to_owned)
.collect(),
)
})
.collect();
for (name, keys) in diff {
// Steal each key from wherever it currently sits, then install the command's list.
for k in keys {
for (_, held) in table.iter_mut() {
held.retain(|h| h != k);
}
}
if let Some((_, slot)) = table.iter_mut().find(|(n, _)| n == name) {
*slot = keys.clone();
}
}
table
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_diff_round_trips_and_defaults_stay_silent() {
// All-defaults snapshot → header-only file.
let snapshot: Vec<(String, Vec<String>)> = SPECS
.iter()
.map(|s| {
(
s.name.to_string(),
[s.d1, s.d2]
.into_iter()
.flatten()
.map(str::to_owned)
.collect(),
)
})
.collect();
let text = to_diff(&snapshot);
assert_eq!(text.lines().count(), 1, "defaults produce no bind lines");
// Move JUMP to F (unbinding SPACE/NUMPAD0), unbind TOGGLESHEATH entirely.
let mut moved = snapshot.clone();
moved.iter_mut().find(|(n, _)| n == "JUMP").unwrap().1 = vec!["F".into()];
moved
.iter_mut()
.find(|(n, _)| n == "TOGGLESHEATH")
.unwrap()
.1 = vec![];
let text = to_diff(&moved);
assert!(text.contains("bind JUMP F\n"));
assert!(
text.contains("bind TOGGLESHEATH\n"),
"unbound = a bare line"
);
assert!(
!text.contains("MOVEFORWARD"),
"untouched commands stay absent"
);
let parsed = from_diff(&text);
let resolved = resolve(&parsed);
let get = |n: &str| {
resolved
.iter()
.find(|(name, _)| name == n)
.map(|(_, k)| k.clone())
.unwrap()
};
assert_eq!(get("JUMP"), vec!["F".to_string()]);
assert!(get("TOGGLESHEATH").is_empty());
assert_eq!(get("MOVEFORWARD"), vec!["W".to_string(), "UP".to_string()]);
}
#[test]
fn resolve_steals_across_commands_even_without_a_line_for_the_victim() {
// A file that binds W to JUMP: MOVEFORWARD keeps UP but loses W, with no MOVEFORWARD
// line in the file at all.
let resolved = resolve(&[("JUMP".to_string(), vec!["W".to_string()])]);
let fwd = &resolved.iter().find(|(n, _)| n == "MOVEFORWARD").unwrap().1;
assert_eq!(fwd, &vec!["UP".to_string()]);
let jump = &resolved.iter().find(|(n, _)| n == "JUMP").unwrap().1;
assert_eq!(jump, &vec!["W".to_string()]);
}
#[test]
fn comments_junk_and_case_survive_parsing() {
let parsed = from_diff("# header\n\nbind jump f\nnot-a-line\nbind BOGUSCMD Q\n");
assert_eq!(
parsed,
vec![
("JUMP".to_string(), vec!["F".to_string()]),
("BOGUSCMD".to_string(), vec!["Q".to_string()]),
]
);
// Unknown commands pass through parse (kept for forward compat); resolve just drops
// what the registry doesn't know — after stealing its keys, so the file's intent
// ("Q belongs to BOGUSCMD, not STRAFELEFT") still holds for the commands we do have.
let resolved = resolve(&parsed);
assert!(!resolved.iter().any(|(n, _)| n == "BOGUSCMD"));
let strafe = &resolved.iter().find(|(n, _)| n == "STRAFELEFT").unwrap().1;
assert!(
strafe.is_empty(),
"Q was stolen by the unknown command's line"
);
}
}
@@ -973,6 +973,19 @@ pub(super) fn seed_ui_fixture(
warn!("capture: ui-options-worlddetail seed failed: {e}");
}
}
UiFixture::KeyBindings => {
let Some(mut script) = script else {
return;
};
// The Key Bindings window (0997), the page fixtures' posture: the real command
// registry first (hermetic capture — the plugin's PostStartup seed isn't raced,
// the register_cvars precedent), then the live open path. Movement is the default
// category; the rows read the registry's byte-real 1.12 defaults.
script.register_bindings(&crate::bindings::registry_commands());
if let Err(e) = script.run("ShowUIPanel(KeyBindingFrame)") {
warn!("capture: ui-keybindings seed failed: {e}");
}
}
UiFixture::OptionsSearch => {
let Some(mut script) = script else {
return;
+35 -1
View File
@@ -197,16 +197,35 @@ struct ProbeKeyTap {
}
/// Press each due tap (in-world gated, like the chat probe) and release it after its hold window.
///
/// Both input currencies, deliberately (0997): `ButtonInput` for every held-state reader (and
/// the binding dispatch's stuck-latch sweep, which treats "latched but not pressed" as a missed
/// release), plus the raw [`KeyboardInput`] message the binding dispatch's press/release edges
/// actually consume — a state-only synthetic press was invisible to the chord latcher, which
/// would have silently killed this instrument the day the dispatch landed.
fn fire_probe_key(
mut probe: ResMut<ProbeKeys>,
time: ProbeClock,
self_player: Query<(), With<crate::net::SelfPlayer>>,
mut keys: ResMut<ButtonInput<KeyCode>>,
mut events: MessageWriter<bevy::input::keyboard::KeyboardInput>,
) {
if probe.taps.is_empty() || self_player.is_empty() {
return;
}
let now = time.elapsed_secs();
let mut synth = |key: KeyCode, state: bevy::input::ButtonState| {
events.write(bevy::input::keyboard::KeyboardInput {
key_code: key,
logical_key: bevy::input::keyboard::Key::Unidentified(
bevy::input::keyboard::NativeKey::Unidentified,
),
state,
text: None,
repeat: false,
window: Entity::PLACEHOLDER,
});
};
for tap in &mut probe.taps {
if !tap.pressed && now >= tap.at {
info!(
@@ -214,9 +233,11 @@ fn fire_probe_key(
tap.key, tap.hold
);
keys.press(tap.key);
synth(tap.key, bevy::input::ButtonState::Pressed);
tap.pressed = true;
} else if tap.pressed && !tap.released && now >= tap.at + tap.hold {
keys.release(tap.key);
synth(tap.key, bevy::input::ButtonState::Released);
tap.released = true;
}
}
@@ -666,6 +687,7 @@ fn drive_live_fps(
palettes: Option<Res<crate::rig_palette::RigPalettes>>,
mut residency: ResidencyMeter,
mut keys: ResMut<ButtonInput<KeyCode>>,
mut key_events: MessageWriter<bevy::input::keyboard::KeyboardInput>,
mut exit: MessageWriter<AppExit>,
mut occlusions: MessageReader<bevy::window::WindowOccluded>,
) {
@@ -691,8 +713,20 @@ fn drive_live_fps(
);
if probe.run {
// A synthetic held key: `ButtonInput` persists a press until its release, and the
// winit feed only releases keys it saw go down, so this holds across frames.
// winit feed only releases keys it saw go down, so this holds across frames. The
// raw KeyboardInput message rides along for the binding dispatch's press edge
// (0997 — MOVEFORWARD latches off the event, holds off the state).
keys.press(KeyCode::KeyW);
key_events.write(bevy::input::keyboard::KeyboardInput {
key_code: KeyCode::KeyW,
logical_key: bevy::input::keyboard::Key::Unidentified(
bevy::input::keyboard::NativeKey::Unidentified,
),
state: bevy::input::ButtonState::Pressed,
text: None,
repeat: false,
window: Entity::PLACEHOLDER,
});
}
probe.phase = LiveFpsPhase::Warmup(0);
}
@@ -126,6 +126,14 @@ pub(super) enum UiFixture {
/// the box. Hermetic like the other options fixtures; pixels move only with the window
/// or a registered default.
OptionsSearch,
/// The KEY BINDINGS window (decision 0997) — the era-shaped standalone KeyBindingFrame on
/// its Movement page: the category sidebar (gold-locked Movement Keys), Command/Key 1/
/// Key 2 columns over the honest tree's rows with the byte-real 1.12 defaults ("Move and
/// Steer — Middle Mouse" leading, straight off the real GlobalStrings the capture VM loads
/// like any run), the character-specific checkbox, the four bottom buttons. The registry
/// registers in-fixture (hermetic capture = the plugin's PostStartup seed is not raced),
/// so the pixels move only with the window, a registered default, or a 1.12 string.
KeyBindings,
/// A **floating overhead name with the river surface behind it** — the world-text-vs-liquid
/// draw-order instrument. A named unit stands 25 yd out in the Elwynn river (the `water-noon`
/// camera), so its name projects onto the water *beyond* it: the exact geometry of the
@@ -932,6 +940,16 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 720,
ui: Some(UiFixture::OptionsWorldDetail),
},
// The Key Bindings window on its Movement page (0997). Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-keybindings`.
Scenario {
name: "ui-keybindings",
map: MAP_AZEROTH,
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
ui: Some(UiFixture::KeyBindings),
},
// The same window MID-SEARCH (0984): the "volume" results view. Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options-search`.
Scenario {
+9 -1
View File
@@ -281,7 +281,7 @@ pub(crate) struct CastEvent {
}
/// Which cast edge fired (decision 0107 verdict 2's stage wiring).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
#[derive(Clone, Copy, PartialEq, Debug)]
pub(crate) enum CastEventKind {
/// `SMSG_SPELL_START` — the precast kit persists from here (subliminal for an instant, whose
/// GO follows at once).
@@ -300,6 +300,14 @@ pub(crate) enum CastEventKind {
/// flight (the caster may despawn mid-flight; the client's missile carries its kit context)
/// — the `0x60d450` fallback a basic shot's impact kit resolves through (decision 0370).
Impact { weapon_visual: Option<u32> },
/// A projectile arrived at a **ground point** instead of on a unit — the client's per-tick
/// missile dispatch taking its ground arm (`0x61e1d0` → `0x61d870`), reached by the single
/// missile a dest-targeted GO with an empty hit list launches
/// ([`spell_visual::MissileSpawn::ground_aim`]). `entity` is the **caster** — `0x61d870`
/// plays the kit on it, not on anything at the point — and `pos` is the arrival position,
/// the client's `extra` override (`0x61d8e7`: `lea edx,[esi+0x20]`). Plays `SpellVisual`
/// **field 13 at stage 3** (wow-re `spell-visual-lifecycle.md` §Q4).
GroundImpact { pos: Vec3 },
}
/// The resolved casting **hold** — the animation a unit sustains while a cast or channel is in
@@ -74,6 +74,13 @@ pub(crate) enum SpellKitSound {
/// The cast/channel hold ended (GO / fail / channel-clear / a replacing cast) — reap the
/// unit's tracked hold loop, if any (the client kills the effect's sound at `0x614150`).
StopHold { entity: Entity },
/// Ring this kit at a bare **world point**, with no owning unit — the kit-sound leg's
/// `extra`-override arm (wow-re `kit-sound-leg.md`: "position = `extra` if non-NULL else the
/// unit's own position", `60f49e`–`60f4bc`). Its one caller is the missile's ground arrival,
/// where `0x61d870` passes the landing point: the bomb's boom belongs where it landed, not at
/// the thrower. Always a one-shot — a LOOPING sound here would need a tracked node with an
/// owner, and none of the six shipped ground-arrival kits carries one (census below).
PlayAt { pos: Vec3, kit_sound: u32 },
/// Stop exactly `kit_sound`'s channels on this unit — the aura-drop reap of a LOOPING
/// **state**-kit sound (wow-re `kit-sound-leg.md`: a looping kit sound rides a tracked,
/// spell-id-tagged CEffect on the unit's list, and `0x614150` stops it with a 0.15 s fade
@@ -202,6 +209,17 @@ pub(crate) struct MissileSpawn {
/// wire's `SpellMissInfo`: the missile still flies, and arrival plays the victim's defense
/// clip for DODGE(3)/BLOCK(5) instead (the client's `Missile_C::Update` dispatch).
pub(crate) targets: Vec<(Entity, Option<u8>)>,
/// The **location fallback**: when [`Self::targets`] is empty and the GO carried a ground
/// point, exactly ONE projectile flies at it instead (the client's `0x6e8a50` empty-hit-array
/// arm — `0x6e8aa2 test al,0x60; setne cl` latches "SOURCE or DEST location present",
/// `0x6e8bd4` then calls the spawner once, `0x60a5b6`–`0x60a5c9` copies `targets+0x3c` into
/// the aim point and `0x60a5ec` forces the owning unit slot to −1; wow-re
/// `spell-go-dest-effect.md` §3). This is what a pure ground cast — Flare, a bomb thrown at
/// empty dirt — actually shows travelling. Its arrival is [`super::CastEventKind::GroundImpact`],
/// never the unit hand-off. Named divergence: the reference latches on `flags & 0x60` and then
/// reads the DEST vector regardless, so a SOURCE-only cast aims at whatever the zero-initialised
/// DEST slot holds; we carry only a real DEST (`flags & 0x40`) and launch nothing without one.
pub(crate) ground_aim: Option<Vec3>,
/// The caster's ranged-weapon fallback `SpellVisual` id, resolved at GO time — rides the
/// flight and returns in [`super::CastEventKind::Impact`] so a basic shot's impact kit
/// resolves through it at arrival (the caster may be gone by then; decision 0370).
@@ -777,6 +795,32 @@ pub(super) fn route_cast_visuals(
&mut out,
);
}
CastEventKind::GroundImpact { pos } => {
// A missile arrived at a POINT (`0x61e1d0`'s ground arm → `0x61d870`): play
// `SpellVisual` field 13 — the **area kit** — at stage 3 on the caster, with
// `extra` = the landing point. The reference then walks the missile's own
// server-recorded hit array (`CMissile+0x58/+0x5c`) for per-unit impact/state
// kits; ours is empty by construction — this arm is only reached because the
// GO's hit list was empty, which is what selected the location fallback.
//
// The kit legs, against the shipped data: all six `SpellVisual` rows that can
// reach here (Volley 3229, the bomb/dynamite family 1704/3270, Goblin Mortar
// 695, Arcane Bomb 4831, Firecrackers 6447 — the whole census of speed>0 ∧
// `Targets & 0x40` ∧ field 6 ≠ 0 ∧ field 13 ≠ 0) carry **no body animation and
// no effect slots, only a sound**. So the arrival is exactly the kit's field-13
// `SoundEntries` id at the landing point, and the anim/slot legs that would need
// stage 3's forever-relive lifetime ([`FxStage::Relive`]) are dead data here —
// left unbuilt rather than guessed at.
// `|| None`: the weapon merge pointedly skips the dest-anchored `+0x2c/+0x30/
// +0x34` block ([`VisualStages::merged_over_weapon`]), so even Volley's area kit
// is its own row's — the lookup would be paid for nothing.
if let Some(kit_sound) =
resolve_kit(spells, &visuals.0, ev.spell_id, |s| s.area_kit, || None)
.and_then(|k| k.sound)
{
out.sounds.write(SpellKitSound::PlayAt { pos, kit_sound });
}
}
CastEventKind::Fail => {
if held_spell(&pending, ev.entity) == Some(ev.spell_id) {
commands.entity(ev.entity).try_remove::<CastHold>();
@@ -865,7 +909,12 @@ pub(super) fn route_cast_visuals(
.map(|&e| (e, None))
.chain(go.misses.iter().map(|&(e, code)| (e, Some(code))))
.collect();
if !targets.is_empty() {
// The location fallback ([`MissileSpawn::ground_aim`]): an empty hit array plus a
// point on the wire flies ONE projectile at the point. The hit array wins whenever
// it has anything in it — the client only consults the latch after
// `0x6e8abc … je 0x6e8ba2` has found the array empty.
let ground_aim = targets.is_empty().then_some(go.dest).flatten();
if !targets.is_empty() || ground_aim.is_some() {
// The release gate (the client's `0x6e7a70` flush condition, inverted): a cast
// kit that plays a body animation defers the launch to its release keyframe;
// no kit / no anim (`kit+8 < 1` ⇔ our `anim_id: None`) launches at GO.
@@ -881,6 +930,7 @@ pub(super) fn route_cast_visuals(
dest_tag,
speed: display.speed,
targets,
ground_aim,
weapon_visual: wv,
missile_sound,
awaits_release,
@@ -703,6 +703,115 @@ fn missile_spawn_defers_iff_the_cast_kit_animates() {
);
}
/// The **location fallback** and its arrival, end to end through the router (wow-re
/// `spell-go-dest-effect.md` §3 + `spell-visual-lifecycle.md` §Q4): a Speed>0 GO whose hit and
/// miss lists are empty but which carries a ground point spawns one projectile aimed at the
/// point — the flight a pure ground cast (Flare, a bomb thrown at empty dirt) shows — and that
/// projectile's ground arrival rings `SpellVisual` field 13's kit sound **at the landing point**,
/// not at the caster.
#[test]
fn a_targetless_dest_go_spawns_a_ground_missile_whose_arrival_sounds_at_the_point() {
const GROUND: u32 = 1543; // Flare's shape: speed>0, dest-targeted, empty hit list
const VISUAL: u32 = 318;
const AREA_KIT: u32 = 3270;
const BOOM: u32 = 4100;
let at = Vec3::new(11.0, 2.0, -3.0);
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_message::<CastEvent>()
.add_message::<SpellGoTargets>()
.add_message::<KitPush>()
.add_message::<EmoteAnim>()
.add_message::<WoundAnim>()
.add_message::<SpellKitSound>()
.add_message::<SpellKitFx>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
.add_message::<super::ChainProcPlay>()
.add_message::<SheathRequest>();
app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables(
HashMap::from([(
VISUAL,
VisualStages {
// Field 6 ≠ 0: the missile owns the arrival, so the GO plays no dest burst.
missile_gate: 1,
area_effect: 3021,
area_kit: AREA_KIT,
..Default::default()
},
)]),
HashMap::from([(
AREA_KIT,
VisualKit {
sound: Some(BOOM),
..Default::default()
},
)]),
)));
app.insert_resource(crate::ui_action::Spells {
catalog: SpellCatalog::from_displays(HashMap::from([(
GROUND,
SpellDisplay {
visual: VISUAL,
speed: 5.0,
..Default::default()
},
)])),
..crate::ui_action::Spells::empty_for_tests()
});
app.add_systems(Update, route_cast_visuals);
let caster = app.world_mut().spawn_empty().id();
app.world_mut().write_message(SpellGoTargets {
caster,
spell_id: GROUND,
hits: Vec::new(),
misses: Vec::new(),
dest: Some(at),
ammo_display_id: None,
seq: 1,
});
app.update();
let spawns: Vec<_> = app
.world_mut()
.resource_mut::<Messages<MissileSpawn>>()
.drain()
.collect();
assert_eq!(spawns.len(), 1, "one projectile at the point");
assert_eq!(spawns[0].ground_aim, Some(at));
assert!(spawns[0].targets.is_empty(), "no unit owns it");
assert!(
app.world_mut()
.resource_mut::<Messages<crate::entities::dest_fx::GroundBurst>>()
.drain()
.next()
.is_none(),
"field 6 ≠ 0 suppresses the GO's dest one-shot — the missile owns the arrival"
);
// The arrival the missile lane writes back.
app.world_mut().write_message(CastEvent {
entity: caster,
spell_id: GROUND,
kind: CastEventKind::GroundImpact { pos: at },
seq: 2,
});
app.update();
let sounds: Vec<_> = app
.world_mut()
.resource_mut::<Messages<SpellKitSound>>()
.drain()
.collect();
assert!(
matches!(
sounds[..],
[SpellKitSound::PlayAt { pos, kit_sound }] if pos == at && kit_sound == BOOM
),
"the area kit's sound, at the landing point: {sounds:?}"
);
}
/// **B130's crash** — the second ever reported: a release build panicked on `insert<CastHold>`
/// while flying at high speed through the Wetlands, applying hold commands to a unit that had
/// despawned. Both windows are exercised here, because they fail for different reasons:
+227 -68
View File
@@ -47,8 +47,18 @@
//! homing aims at the dest attach point rather than solving the client's
//! ray-vs-bounding-sphere intercept (`0x61d230` — same body point in practice); a missed
//! target's arrival plays its dodge/block clip but the projectile itself still ends there
//! rather than deflecting (`0x61dd50`'s bounce); and the ground/dest-location arrival path
//! (`0x61d870` — needs the wire's dest position and positional effect spawning) stays deferred.
//! rather than deflecting (`0x61dd50`'s bounce).
//!
//! A GO with **no unit targets at all** but a point on the wire flies **one** projectile at the
//! point — the client's location fallback (`0x6e8a50`'s empty-hit-array arm → `0x60a3d0` once,
//! owning unit slot −1; wow-re `spell-go-dest-effect.md` §3). That is the whole visible flight of
//! a pure ground cast: the hunter's Flare arcing out to where it was placed, a bomb thrown at
//! empty dirt. Such a missile homes to nothing — its aim is a fixed world point ([`Aim::Ground`])
//! — and it arrives as [`CastEventKind::GroundImpact`] on the CASTER (`0x61e1d0`'s ground arm
//! `0x61d870`), never through the unit hand-off. Named approximation: it flies the same straight
//! arrive-on-time line a unit missile does; the reference's trajectory *class* comes from
//! `CMissile+0x48` through `0x61d720`'s remap table, whose wire origin wow-re traced to
//! consumption only — so a lobbed shot reads as a straight glide here.
use bevy::animation::transition::AnimationTransitions;
use bevy::ecs::entity::EntityHashMap;
@@ -128,20 +138,38 @@ pub(crate) enum MissileSound {
Stop { entity: Entity },
}
/// What a projectile flies at — the client's two `CMissile` flavours, split by whether the spawn
/// had an owning unit slot (`0x60a5ec` writes −1 for the location fallback) and dispatched apart
/// on arrival by `0x61e1d0` (`0x61dc50` a live unit, `0x61d870` the ground).
#[derive(Clone, Copy)]
enum Aim {
/// A unit target: the flight homes to its live attach point, so a moving target bends the path.
Unit {
/// The unit it flies at (despawn quietly if it streams out mid-flight).
target: Entity,
/// The M2 attach tag it homes to on the target (`SpellVisual` field 9), `None` = base.
dest_tag: Option<u16>,
/// `None` — the spell landed: arrival plays the impact hand-off. `Some(code)` — the wire's
/// `SpellMissInfo` miss: arrival instead plays the victim's defense clip for DODGE(3)/
/// BLOCK(5) (the client's `Missile_C::Update 0x61ceb0` dispatch — Dodge 30 / ShieldBlock
/// 24, never Parry; wow-re `smsg-attackerstate-consequences.md` §Q4) and no impact kit.
miss: Option<u8>,
},
/// A fixed world point — the GO's dest, with no unit to home to and nothing that can make the
/// flight end early. Arrival is the ground hand-off on the caster.
Ground(Vec3),
}
/// One projectile in flight.
#[derive(Component)]
pub(super) struct Missile {
/// The spell that fired it — the impact event's key.
spell_id: u32,
/// The unit it flies at (despawn quietly if it streams out mid-flight).
target: Entity,
/// The M2 attach tag it homes to on the target (`SpellVisual` field 9), `None` = base.
dest_tag: Option<u16>,
/// `None` — the spell landed: arrival plays the impact hand-off. `Some(code)` — the wire's
/// `SpellMissInfo` miss: arrival instead plays the victim's defense clip for DODGE(3)/
/// BLOCK(5) (the client's `Missile_C::Update 0x61ceb0` dispatch — Dodge 30 / ShieldBlock 24,
/// never Parry; wow-re `smsg-attackerstate-consequences.md` §Q4) and no impact kit.
miss: Option<u8>,
/// The unit that launched it — the ground arrival's kit subject (`0x61d870` plays on the
/// caster). Unused by a unit arrival, which plays on the target.
caster: Entity,
/// Where it is going.
aim: Aim,
/// The model-cache key ([`SpellFx`]); parts attach once the M2 loads. `None` = nothing to
/// show (no visual-chain model, no resolvable ammo) — an invisible flight that still impacts.
path: Option<String>,
@@ -178,6 +206,27 @@ pub(super) fn attach_world_pos(
)
}
/// Where a missile is heading **this frame**: a unit target's live dest attach point (so the
/// path bends as it moves — the client's re-solve per tick), or the ground shot's fixed point.
/// `None` only when a unit target is gone.
fn aim_point(
aim: Aim,
units: &Query<(&GlobalTransform, Option<&BoneAttach>)>,
joints: &Query<&GlobalTransform>,
) -> Option<Vec3> {
match aim {
Aim::Unit {
target, dest_tag, ..
} => attach_world_pos(
target,
dest_tag.into_iter().chain(DEST_FALLBACKS),
units,
joints,
),
Aim::Ground(pos) => Some(pos),
}
}
/// The caster's **launch position**: the fired release event's own marker when there is one (the
/// dispatcher passes the event's live position, `0x5ffbd0` → `0x60c940(param_4)`), else the
/// `$CSL` → `$CSR` → `$CST` marker cascade at the current pose, else the unit's base — the
@@ -270,31 +319,40 @@ fn miss_defense_state(code: u8) -> Option<u32> {
}
}
/// The arrival hand-off — the client's `Missile_C::Update` (`0x61ceb0`) outcome dispatch: a
/// landed spell routes back to the router as [`CastEventKind::Impact`] (the impact/state kits +
/// wound flinch); a missed one instead plays the victim's defense clip per
/// [`miss_defense_state`], and no impact kit plays.
/// The arrival hand-off — the client's per-tick outcome dispatch (`0x61e1d0` over `0x61ceb0`'s
/// clock): a landed spell routes back to the router as [`CastEventKind::Impact`] on the target
/// (the impact/state kits + wound flinch); a missed one instead plays the victim's defense clip
/// per [`miss_defense_state`], and no impact kit plays; a **ground** arrival routes
/// [`CastEventKind::GroundImpact`] to the CASTER carrying the landing point (`0x61d870`).
fn arrival_handoff(
target: Entity,
spell_id: u32,
weapon_visual: Option<u32>,
miss: Option<u8>,
missile: &Missile,
at: Vec3,
impacts: &mut MessageWriter<CastEvent>,
defenses: &mut MessageWriter<DefenseAnim>,
play_seq: &mut crate::creature_anim::PlaySeq,
) {
match miss {
None => {
impacts.write(CastEvent {
entity: target,
spell_id,
kind: CastEventKind::Impact { weapon_visual },
// Scene-time stamp: the client plays the impact at arrival, after the frame's
// packet handlers — a fresh tick sorts it the same way.
seq: play_seq.next(),
});
// Scene-time stamp: the client plays the arrival after the frame's packet handlers — a fresh
// tick sorts it the same way.
let mut event = |entity, kind| CastEvent {
entity,
spell_id: missile.spell_id,
kind,
seq: play_seq.next(),
};
match missile.aim {
Aim::Unit {
target, miss: None, ..
} => {
let kind = CastEventKind::Impact {
weapon_visual: missile.weapon_visual,
};
impacts.write(event(target, kind));
}
Some(code) => {
Aim::Unit {
target,
miss: Some(code),
..
} => {
if let Some(victim_state) = miss_defense_state(code) {
defenses.write(DefenseAnim {
victim: target,
@@ -302,10 +360,17 @@ fn arrival_handoff(
});
}
}
Aim::Ground(_) => {
impacts.write(event(
missile.caster,
CastEventKind::GroundImpact { pos: at },
));
}
}
}
/// Launch one queued GO's projectiles from `launch`: one missile entity per target, its
/// Launch one queued GO's projectiles from `launch`: one missile entity per target — or the
/// single ground projectile when the GO had no unit targets and a point instead — its
/// **remaining** travel time `distance / speed − time queued` (the client's `0x61ceb0` — the
/// arrival deadline was fixed at GO). A release already past its deadline arrives on the spot:
/// the arrival hand-off plays now, no flight entity, no flight loop — the melee-range cast
@@ -322,38 +387,43 @@ fn launch_go(
defenses: &mut MessageWriter<DefenseAnim>,
play_seq: &mut crate::creature_anim::PlaySeq,
) {
for &(target, miss) in &go.spawn.targets {
let dest = go.spawn.dest_tag.into_iter().chain(DEST_FALLBACKS);
let Some(aim) = attach_world_pos(target, dest, units, joints) else {
// The unit targets, else the location fallback's lone ground shot (the client consults the
// `flags & 0x60` latch only once the hit array has come back empty).
let aims: Vec<Aim> = if go.spawn.targets.is_empty() {
go.spawn.ground_aim.map(Aim::Ground).into_iter().collect()
} else {
go.spawn
.targets
.iter()
.map(|&(target, miss)| Aim::Unit {
target,
dest_tag: go.spawn.dest_tag,
miss,
})
.collect()
};
for aim_at in aims {
let Some(aim) = aim_point(aim_at, units, joints) else {
continue; // target already gone — its impact would be invisible anyway
};
let arrive_in = launch.distance(aim) / go.spawn.speed.max(f32::EPSILON) - go.queued;
if arrive_in <= 0.0 {
arrival_handoff(
target,
go.spawn.spell_id,
go.spawn.weapon_visual,
miss,
impacts,
defenses,
play_seq,
);
let missile = Missile {
spell_id: go.spawn.spell_id,
caster: go.spawn.caster,
aim: aim_at,
path: go.key.clone(),
arrive_in: launch.distance(aim) / go.spawn.speed.max(f32::EPSILON) - go.queued,
// Nothing to show → nothing to wait on (the invisible flight).
parts_spawned: go.key.is_none(),
weapon_visual: go.spawn.weapon_visual,
};
if missile.arrive_in <= 0.0 {
arrival_handoff(&missile, aim, impacts, defenses, play_seq);
continue;
}
let dir = (aim - launch).normalize_or(-Vec3::Z);
let entity = commands
.spawn((
Missile {
spell_id: go.spawn.spell_id,
target,
dest_tag: go.spawn.dest_tag,
miss,
path: go.key.clone(),
arrive_in,
// Nothing to show → nothing to wait on (the invisible flight).
parts_spawned: go.key.is_none(),
weapon_visual: go.spawn.weapon_visual,
},
missile,
Transform::from_translation(launch).with_rotation(missile_facing(dir)),
Visibility::default(),
))
@@ -600,25 +670,16 @@ pub(super) fn move_missiles(
) {
let dt = time.delta_secs();
for (entity, mut missile, mut transform) in &mut missiles {
let dest = missile.dest_tag.into_iter().chain(DEST_FALLBACKS);
let Some(aim) = attach_world_pos(missile.target, dest, &units, &joints) else {
let Some(aim) = aim_point(missile.aim, &units, &joints) else {
// The target streamed out mid-flight (the client's dead-handle ground path — ours
// just ends the flight).
// just ends the flight). A ground shot has no target to lose.
sounds.write(MissileSound::Stop { entity });
commands.entity(entity).despawn();
continue;
};
let to_target = aim - transform.translation;
if missile.arrive_in <= dt {
arrival_handoff(
missile.target,
missile.spell_id,
missile.weapon_visual,
missile.miss,
&mut impacts,
&mut defenses,
&mut play_seq,
);
arrival_handoff(&missile, aim, &mut impacts, &mut defenses, &mut play_seq);
sounds.write(MissileSound::Stop { entity });
commands.entity(entity).despawn();
continue;
@@ -700,12 +761,22 @@ mod tests {
dest_tag: None,
speed: SPEED,
targets: vec![(target, None)],
ground_aim: None,
weapon_visual: None,
missile_sound: None,
awaits_release,
}
}
/// The location-fallback flavour of [`go`]: no unit targets, one point on the wire.
fn ground_go(caster: Entity, at: Vec3) -> MissileSpawn {
MissileSpawn {
targets: Vec::new(),
ground_aim: Some(at),
..go(caster, caster, false)
}
}
fn missiles(app: &mut App) -> Vec<(f32, Vec3)> {
app.world_mut()
.query::<(&Missile, &Transform)>()
@@ -779,6 +850,94 @@ mod tests {
assert_eq!(impacts[0].spell_id, 133);
}
/// The location fallback (`0x6e8a50`'s empty-hit-array arm): a GO with no unit targets and a
/// point on the wire flies **exactly one** projectile, at the point — a pure ground cast's
/// whole visible flight, which before this had none at all.
#[test]
fn a_targetless_dest_go_flies_one_projectile_at_the_point() {
let mut app = app();
let hand = Vec3::new(0.0, 1.5, 0.0);
let caster = caster(&mut app, hand);
let at = Vec3::new(24.0, 1.5, 0.0);
app.world_mut().write_message(ground_go(caster, at));
step(&mut app, 0.016);
let launched = missiles(&mut app);
assert_eq!(launched.len(), 1, "one shot, not one per (absent) target");
assert_eq!(launched[0].1, hand, "launched from the caster's marker");
// 24 units at speed 24, no cast-kit anim to wait on (`awaits_release: false`).
assert!(
(launched[0].0 - 1.0).abs() < 1e-3,
"flight {}",
launched[0].0
);
let ground = app
.world_mut()
.query::<&Missile>()
.iter(app.world())
.filter(|m| matches!(m.aim, Aim::Ground(p) if p == at))
.count();
assert_eq!(ground, 1, "aimed at the point, homing to nothing");
}
/// The hit array wins: the client consults the location latch only after finding the array
/// empty (`0x6e8abc … je 0x6e8ba2`), so a dest-carrying GO that *did* hit units flies at the
/// units — never an extra shot at the ground.
#[test]
fn a_dest_go_that_hit_units_flies_at_the_units_not_the_point() {
let mut app = app();
let caster = caster(&mut app, Vec3::ZERO);
let target = app
.world_mut()
.spawn(GlobalTransform::from_xyz(24.0, 0.0, 0.0))
.id();
let mut spawn = go(caster, target, false);
spawn.ground_aim = Some(Vec3::new(-50.0, 0.0, 0.0));
app.world_mut().write_message(spawn);
step(&mut app, 0.016);
let aims: Vec<bool> = app
.world_mut()
.query::<&Missile>()
.iter(app.world())
.map(|m| matches!(m.aim, Aim::Unit { .. }))
.collect();
assert_eq!(aims, vec![true], "one unit-homing shot, no ground shot");
}
/// A ground arrival hands off as [`CastEventKind::GroundImpact`] on the **caster**, carrying
/// the landing point — the client's `0x61e1d0` ground arm (`0x61d870`), whose kit plays on the
/// caster with `extra` = that position. Never the unit impact hand-off.
#[test]
fn a_ground_arrival_hands_off_to_the_caster_with_the_landing_point() {
let mut app = app();
let caster = caster(&mut app, Vec3::ZERO);
// 2.4 units at speed 24 = a 0.1 s flight, parked 0.2 s → it arrives at the release.
let at = Vec3::new(2.4, 0.0, 0.0);
let mut spawn = ground_go(caster, at);
spawn.awaits_release = true;
app.world_mut().write_message(spawn);
step(&mut app, 0.10);
step(&mut app, 0.10);
app.world_mut().write_message(AnimSoundEvent {
entity: caster,
ident: *b"$CSL",
data: 0,
});
step(&mut app, 0.016);
assert!(missiles(&mut app).is_empty(), "no flight entity");
let events: Vec<_> = app
.world_mut()
.resource_mut::<Messages<CastEvent>>()
.drain()
.collect();
assert_eq!(events.len(), 1, "exactly one arrival edge");
assert_eq!(events[0].entity, caster, "the kit plays on the caster");
assert_eq!(
events[0].kind,
CastEventKind::GroundImpact { pos: at },
"the ground arm, carrying the landing point"
);
}
/// A one-shot that never starts (the kit anim didn't resolve on this model) flushes after
/// [`RELEASE_WAIT_MAX`] through the marker cascade instead of hanging forever.
#[test]
+4 -3
View File
@@ -124,9 +124,10 @@ pub(super) fn record_casts(
impacts: 0,
});
}
// The struck-target edge: credit the newest launched row for this spell (the caster
// isn't on the message — decision 0099 phase 4's impact hand-off).
CastEventKind::Impact { .. } => {
// The arrival edges: credit the newest launched row for this spell (the caster isn't
// on the unit hand-off's message — decision 0099 phase 4). A ground arrival is an
// arrival too — a pure dest cast's only one, and the journal's proof it landed.
CastEventKind::Impact { .. } | CastEventKind::GroundImpact { .. } => {
if let Some(row) = journal
.records
.iter_mut()
+4
View File
@@ -28,6 +28,7 @@ mod assets;
mod aura_visual;
mod bgwin;
mod billboard;
mod bindings;
mod blob_shadow;
mod bowstring;
mod build_id;
@@ -653,6 +654,9 @@ pub fn run(build: BuildId) -> AppExit {
// The CVar host (decision 0954): registration, knob sync, config.toml persistence. After
// UiScriptPlugin only for reading order — its systems gate on the VM existing anyway.
.add_plugins(cvars::CvarPlugin)
// The key-binding engine (decision 0997): the chord→command dispatch every rebindable input
// runs through, its persistence, and the Key Bindings window's capture seam.
.add_plugins(bindings::BindingsPlugin)
// The unit snapshot + event feed (decision 0068 §3): pushes ECS game state into the VM as the
// plain data the `Unit*` bindings read, and fires the matching WoW events.
.add_plugins(UiUnitPlugin)
+15
View File
@@ -65,6 +65,21 @@ pub(crate) fn macros_character_path(realm: &str, character: &str) -> Option<Path
home().map(|h| h.join("macros").join(format!("{key}.txt")))
}
/// `benilla/bindings/account.txt` — the account-wide key bindings (decision 0997; the
/// `bindings-cache.wtf` analog, command-centric diff-vs-defaults so a growing command set keeps
/// its new defaults).
pub(crate) fn bindings_account_path() -> Option<PathBuf> {
home().map(|h| h.join("bindings/account.txt"))
}
/// `benilla/bindings/<realm>-<character>.txt` — the character-specific binding set (decision
/// 0997). Its existence IS the "character specific key bindings" state: the window's checkbox
/// writes it, Okay-back-to-general deletes it (the reference's confirmed permanent delete).
pub(crate) fn bindings_character_path(realm: &str, character: &str) -> Option<PathBuf> {
let key = format!("{}-{}", file_token(realm), file_token(character));
home().map(|h| h.join("bindings").join(format!("{key}.txt")))
}
/// Make an arbitrary realm/character name safe as one path component: anything outside
/// `[A-Za-z0-9_]` becomes `_`, so a realm called `Hydraxian Waterlords` or one with a slash cannot
/// escape the folder or collide with the path separator.
+68 -60
View File
@@ -16,7 +16,7 @@
//!
//! Movement is a thin kinematic capsule controller over avian's `MoveAndSlide` (decision 0009).
use bevy::input::mouse::{AccumulatedMouseMotion, AccumulatedMouseScroll};
use bevy::input::mouse::AccumulatedMouseMotion;
use bevy::prelude::*;
use bevy::window::{CursorOptions, PrimaryWindow};
@@ -265,12 +265,9 @@ fn control(
time: Res<Time>,
keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
// Nested into one param to stay within Bevy's 16-element system-param tuple limit.
mouse: (
Res<AccumulatedMouseMotion>,
Res<AccumulatedMouseScroll>,
Res<camera::LookConfig>,
),
// Nested into one param to stay within Bevy's 16-element system-param tuple limit. (The
// scroll wheel left this tuple with 0997: zoom reads the CAMERAZOOM bindings now.)
mouse: (Res<AccumulatedMouseMotion>, Res<camera::LookConfig>),
// The net bridge, bundled into one param (16-param limit): the outbound command channel + the
// inbound teleport/worldport messages `apply_net_updates` wrote earlier this frame
// (WorldStage::Net), + the sheath-setter queue (the Z toggle's request — decision 0080).
@@ -308,6 +305,10 @@ fn control(
Res<InspectMode>,
Res<crate::ui_script::UiKeyboardCapture>,
Res<crate::ui_script::PlayerUiClickConsumed>,
// The binding dispatch (decision 0997): every rebindable input below reads command
// state from here — raw `keys` remain only for the dev instruments (F free-fly, the
// Ctrl boost) and the look-session mouse.
Res<crate::bindings::BindingsState>,
),
mut commands: Commands,
mut player: ResMut<Player>,
@@ -380,8 +381,8 @@ fn control(
return;
};
let (mut window, mut cursor_opts) = window.into_inner();
let (mouse_motion, mouse_scroll) = (&mouse.0, &mouse.1);
let invert_pitch = mouse.2.invert_pitch;
let mouse_motion = &mouse.0;
let invert_pitch = mouse.1.invert_pitch;
let (move_speed, capsule, cam_probe, pointer_over_ui, inspect, ui_capture, click_consumed) = (
&speed_capsule.0,
&speed_capsule.1 .0,
@@ -391,6 +392,7 @@ fn control(
&speed_capsule.5,
&speed_capsule.6,
);
let binds = &speed_capsule.7;
let dt = time.delta_secs();
// While a focused UI EditBox (the chat input, a mail field) owns the keyboard, keyboard reads see
// "no keys held" — so WASD/F/Ctrl/Z don't also drive the avatar while typing (a `.tele` command).
@@ -398,13 +400,11 @@ fn control(
let typing = ui_capture.0;
let keys_pressed = |k: KeyCode| !typing && keys.pressed(k);
let keys_just_pressed = |k: KeyCode| !typing && keys.just_pressed(k);
// Decision 0585's bare-binding rule, for this module's discrete letter bindings (F/X/Z): the
// reference names a binding `[ALT-][CTRL-][SHIFT-]<key>` and matches it by string equality, so a
// modified press is a *different* entry and must never fall through to the unmodified one —
// `CTRL-Z` is TOGGLEUI ([`crate::ui_hide`]), not the sheath toggle. Same definition of "modified"
// as the key feed's (`ui_script::input`, which adds our Cmd plane to the reference's three).
// Held-state reads (WASD, Space, the Ctrl sprint) stay outside the rule deliberately: they read
// keys as *state*, not as binding edges, and must keep working with a modifier down.
// The rebindable inputs all read `binds` (decision 0997): the dispatch already enforced the
// typing gate and 0585's exact-modifier law when it latched, so this module carries neither
// anymore. What stays on raw keys: the F free-fly toggle and the Ctrl boost (dev
// instruments, not 1.12 bindings — F still wants the bare-binding rule locally so CTRL-F
// and friends stay distinct) and the mouse look session.
let modified = keys.any_pressed([
KeyCode::ShiftLeft,
KeyCode::ShiftRight,
@@ -420,7 +420,11 @@ fn control(
// Both mouse buttons held together = vanilla's "both-button run": the avatar runs forward while
// the character steers with the mouse (turns like a right-drag), regardless of which button went
// down first. Checked directly here rather than through the single-button look mode below.
let both_buttons = buttons.pressed(MouseButton::Left) && buttons.pressed(MouseButton::Right);
// MOVEANDSTEER (default Middle Mouse) is the same state through a binding — 1.12's own body
// runs the identical CameraOrSelectOrMove + TurnOrAction pair a both-button press does.
let steer_held = binds.pressed(crate::bindings::cmd::MOVE_AND_STEER);
let both_buttons =
(buttons.pressed(MouseButton::Left) && buttons.pressed(MouseButton::Right)) || steer_held;
// The look session gets a SHADOW copy of `CursorOptions`, written back only on a real change:
// handing it the component's `Mut` directly reborrowed mutably every frame, which marks it
@@ -484,11 +488,13 @@ fn control(
}
}
// The wheel belongs to whatever UI frame the cursor is over (the quest log's list/detail
// scroll, chat) — the client's own routing: a consumed wheel never also zooms the camera.
if !pointer_over_ui.0 {
apply_zoom_scroll(mouse_scroll, dt, &mut rig);
}
// Camera zoom rides the CAMERAZOOMIN/OUT bindings (0997; defaults = the wheel pair). The
// wheel-over-UI routing lives in the dispatch now — a wheel the quest log consumed never
// reaches these commands — and a rebound zoom KEY steps 1.0 per press, 1.12's own
// `CameraZoomIn(1.0)` argument.
let zoom = binds.amount(crate::bindings::cmd::CAMERA_ZOOM_IN)
- binds.amount(crate::bindings::cmd::CAMERA_ZOOM_OUT);
apply_zoom_scroll(zoom, dt, &mut rig);
if bare_binding(KeyCode::KeyF) {
player.detached = !player.detached;
@@ -590,16 +596,14 @@ fn control(
);
return;
}
// ── Autorun ── mouse button 5 is our TOGGLEAUTORUN (the reference's own default is Numlock;
// benilla has no keybind table yet, so the binding is a host choice — wow-re RF-0078's note).
// On macOS winit maps NSEvent `buttonNumber` 4 → `MouseButton::Forward` (`macos/view.rs:1096`),
// which is the thumb-forward button most mice call "5"; a mouse that reports something else
// lands in the `Other(n)` log below rather than silently doing nothing.
// The toggle is deliberately NOT gated on `typing`: it's a latched mode, not a held key — and
// the reference agrees explicitly, its focus-loss handler releasing every direction bit while
// preserving `0x1000` (`0x514490`'s `and eax,0xfffff00f`, VERIFIED).
// ── Autorun ── TOGGLEAUTORUN through the binding table (0997; 1.12 defaults NUMLOCK +
// BUTTON4 — the latter is winit's `Forward`, the thumb button this toggle lived on before
// the table existed, kept by the codec's BUTTON4 mapping). A latched mode, not a held key:
// the keyboard chord is typing-gated at dispatch like every binding, the mouse chord is
// not — and the reference agrees, its focus-loss handler releasing every direction bit
// while preserving `0x1000` (`0x514490`'s `and eax,0xfffff00f`, VERIFIED).
let mut autorun_armed = false;
if buttons.just_pressed(MouseButton::Forward) {
if binds.fired(crate::bindings::cmd::TOGGLE_AUTORUN) {
player.autorun = !player.autorun;
autorun_armed = player.autorun;
}
@@ -607,7 +611,7 @@ fn control(
// "nothing happened" is the least debuggable report there is. Name what did arrive.
for b in buttons.get_just_pressed() {
if let MouseButton::Other(n) = b {
info!("mouse: unmapped button Other({n}) — autorun is on MouseButton::Forward");
info!("mouse: unmapped button Other({n}) — bindings know BUTTON4/BUTTON5 as winit Forward/Back");
}
}
// ── The cancel set ── autorun is NOT simply "held forward" — the thing that makes it its own
@@ -631,12 +635,13 @@ fn control(
//
// Deliberately absent, each VERIFIED as a *survivor*: a jump, a chat EditBox taking focus, and
// a zone change. Mounting is genuinely unsettled in the reference and left alone here.
let both_buttons_engaged = both_buttons
let both_buttons_engaged = (both_buttons
&& (buttons.just_pressed(MouseButton::Left)
|| buttons.just_pressed(MouseButton::Right));
|| buttons.just_pressed(MouseButton::Right)))
|| binds.just_pressed(crate::bindings::cmd::MOVE_AND_STEER);
if state::autorun_cancelled(
keys_just_pressed(KeyCode::KeyW),
keys_just_pressed(KeyCode::KeyS),
binds.just_pressed(crate::bindings::cmd::MOVE_FORWARD),
binds.just_pressed(crate::bindings::cmd::MOVE_BACKWARD),
both_buttons_engaged,
player.modes.rooted || player.server_riding,
) {
@@ -658,8 +663,8 @@ fn control(
// rides the HELD state and not the key-DOWN edge, so it never trips the autorun cancel set
// above — which is right: synthesized input is not a keypress.
let fwd_axis = state::forward_axis(
keys_pressed(KeyCode::KeyW) || player.follow_forward,
keys_pressed(KeyCode::KeyS),
binds.pressed(crate::bindings::cmd::MOVE_FORWARD) || player.follow_forward,
binds.pressed(crate::bindings::cmd::MOVE_BACKWARD),
both_buttons,
autorun,
);
@@ -677,30 +682,33 @@ fn control(
// packets in one session, 0 received by a watching client, against 0 in the reference
// client's entire 1.12.1 capture. That is decision 0056's invariant — the wire mirrors the
// avatar's actual motion — violated on this one axis only; the swim branch already nets.
let side_axis = i32::from(keys_pressed(KeyCode::KeyE))
- i32::from(keys_pressed(KeyCode::KeyQ))
let strafe_left = binds.pressed(crate::bindings::cmd::STRAFE_LEFT);
let strafe_right = binds.pressed(crate::bindings::cmd::STRAFE_RIGHT);
let turn_left = binds.pressed(crate::bindings::cmd::TURN_LEFT);
let turn_right = binds.pressed(crate::bindings::cmd::TURN_RIGHT);
let side_axis = i32::from(strafe_right) - i32::from(strafe_left)
+ if mouselook {
i32::from(keys_pressed(KeyCode::KeyD)) - i32::from(keys_pressed(KeyCode::KeyA))
i32::from(turn_right) - i32::from(turn_left)
} else {
0
};
// A/D turn the facing when not mouse-looking (yaw increases turning left, matching mouse-left).
// …and never while stunned: the reference skips the keyboard turn emitter `0x514f50`
// entirely (and force-stops an in-flight turn) behind the same `0x514755` gate. Killing the
// turn here is also what ends B179's *second* half — the walk animation a stunned character
// was still playing was the turn-in-place shuffle, which `gait` derives from real yaw change.
let turning =
!mouselook && !stunned && (keys_pressed(KeyCode::KeyA) || keys_pressed(KeyCode::KeyD));
// TURNLEFT/TURNRIGHT turn the facing when not mouse-looking (yaw increases turning left,
// matching mouse-left). …and never while stunned: the reference skips the keyboard turn
// emitter `0x514f50` entirely (and force-stops an in-flight turn) behind the same
// `0x514755` gate. Killing the turn here is also what ends B179's *second* half — the walk
// animation a stunned character was still playing was the turn-in-place shuffle, which
// `gait` derives from real yaw change.
let turning = !mouselook && !stunned && (turn_left || turn_right);
// This frame's keyboard-turn rotation — `seat_camera` carries the camera by it rigidly
// (char and camera turn as one on the reference; director's call, closing 0050's open
// "camera follow on turn" feel item).
let mut turn_delta = 0.0;
if turning {
let mut turn = 0.0;
if keys_pressed(KeyCode::KeyA) {
if turn_left {
turn += 1.0;
}
if keys_pressed(KeyCode::KeyD) {
if turn_right {
turn -= 1.0;
}
// 0.75× while also translating — the verified `flags & 0x200f` case, so it reads the
@@ -760,7 +768,7 @@ fn control(
// the key is read now. The last writer wins, and every one of them lands on the single
// commit-and-send below.
let mut request_stand = net.10.read().last().map(|r| r.state);
if bare_binding(KeyCode::KeyX) {
if binds.fired(crate::bindings::cmd::SIT_OR_STAND) {
request_stand = Some(u8::from(stand_state == 0));
}
// Any movement input stands the avatar back up (the client volunteers the stand — the
@@ -773,7 +781,7 @@ fn control(
// director's ref observation (it stands you up) is the ground truth this keeps; the
// byte trigger is flagged LIVE-CAPTURE in the wow-re note.
let turned = turn_delta != 0.0 || mouse_turned;
if (moving || turned || keys_just_pressed(KeyCode::Space))
if (moving || turned || binds.fired(crate::bindings::cmd::JUMP))
&& stand_state != 0
&& request_stand.is_none()
{
@@ -808,7 +816,7 @@ fn control(
// in the whole client that plays it (`bInstant = 0` at the 4 ToggleSheath sites — wow-re
// `sheath-policy.md`). No body model yet (no driver) drops the toggle, the client's own
// refusal.
if bare_binding(KeyCode::KeyZ) {
if binds.fired(crate::bindings::cmd::TOGGLE_SHEATH) {
if let Ok((e, _, _, _, Some(drv), store, engaged, _, _, wielded)) = self_player.single()
{
// The manual toggle's guard chain (decision 0080d) — the guards of the client's
@@ -878,7 +886,7 @@ fn control(
} else {
run_speed
};
let mut want_jump = keys_just_pressed(KeyCode::Space) && !player.modes.rooted;
let mut want_jump = binds.fired(crate::bindings::cmd::JUMP) && !player.modes.rooted;
// Swim vs walk: the water over our feet decides. Hysteresis-latched (`update_swimming`,
// the verified `0x6030c0` boundary — B7 resolved, decision 0226) so wading the line
@@ -913,17 +921,17 @@ fn control(
let mut swim_side = 0.0_f32; // +right
if swimming {
swim_fwd += fwd_axis.signum() as f32;
if keys_pressed(KeyCode::KeyE) {
if strafe_right {
swim_side += 1.0;
}
if keys_pressed(KeyCode::KeyQ) {
if strafe_left {
swim_side -= 1.0;
}
if mouselook {
if keys_pressed(KeyCode::KeyD) {
if turn_right {
swim_side += 1.0;
}
if keys_pressed(KeyCode::KeyA) {
if turn_left {
swim_side -= 1.0;
}
}
@@ -1222,10 +1230,10 @@ fn control(
_ => {}
}
if !mouselook {
if keys_pressed(KeyCode::KeyA) {
if binds.pressed(crate::bindings::cmd::TURN_LEFT) {
move_flags_now |= move_flags::TURN_LEFT;
}
if keys_pressed(KeyCode::KeyD) {
if binds.pressed(crate::bindings::cmd::TURN_RIGHT) {
move_flags_now |= move_flags::TURN_RIGHT;
}
}
+8 -17
View File
@@ -5,7 +5,7 @@
//! avatar/movement/networking [`super::control`] drives with it.
use bevy::ecs::entity::EntityHashSet;
use bevy::input::mouse::{AccumulatedMouseMotion, AccumulatedMouseScroll, MouseScrollUnit};
use bevy::input::mouse::AccumulatedMouseMotion;
use bevy::mesh::MeshTag;
use bevy::prelude::*;
use bevy::window::{CursorGrabMode, CursorOptions};
@@ -353,22 +353,13 @@ pub(super) fn run_look_session(
}
}
/// Wheel-zoom: the scroll wheel sets a new target orbit distance, and the actual distance glides toward
/// it at a constant `cameraDistanceMoveSpeed` (vanilla's linear, frame-delta-scaled glide — not an
/// ease). Runs every frame regardless of active/detached state, mirroring the reference camera.
pub(super) fn apply_zoom_scroll(
mouse_scroll: &AccumulatedMouseScroll,
dt: f32,
rig: &mut CameraControl,
) {
// Scroll wheel zooms the third-person orbit distance (wheel up = closer). Trackpads report pixel
// deltas (much larger than a wheel's per-notch line), so normalize them to line-equivalents.
let scroll = match mouse_scroll.unit {
MouseScrollUnit::Line => mouse_scroll.delta.y,
MouseScrollUnit::Pixel => {
mouse_scroll.delta.y / MouseScrollUnit::SCROLL_UNIT_CONVERSION_FACTOR
}
};
/// Wheel-zoom: the CAMERAZOOMIN/OUT bindings set a new target orbit distance, and the actual
/// distance glides toward it at a constant `cameraDistanceMoveSpeed` (vanilla's linear,
/// frame-delta-scaled glide — not an ease). Runs every frame regardless of active/detached state,
/// mirroring the reference camera. `scroll` is this frame's net zoom-in amount (wheel notches in
/// line-equivalents — the binding dispatch normalizes trackpad pixels — or the 1.12 key step of
/// 1.0 per press; positive = closer), so a rebound zoom key feels exactly like a wheel notch.
pub(super) fn apply_zoom_scroll(scroll: f32, dt: f32, rig: &mut CameraControl) {
if scroll != 0.0 {
rig.target_distance =
(rig.target_distance - scroll * CAM_ZOOM_STEP).clamp(CAM_DIST_MIN, CAM_DIST_MAX);
+19 -17
View File
@@ -250,32 +250,34 @@ fn steer(face: f32, bearing: f32, dt: f32) -> f32 {
/// the motion.
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct FollowInput<'w> {
keys: Res<'w, ButtonInput<KeyCode>>,
buttons: Res<'w, ButtonInput<MouseButton>>,
/// The binding dispatch (0997) — the movement commands' press edges, wherever they are bound.
/// It already carries the typing gate this struct used to hold `UiKeyboardCapture` for
/// (typing "we should go" in chat must not break the follow).
binds: Res<'w, crate::bindings::BindingsState>,
/// The camera rig, for the mouse-look level. Read one frame behind `control`'s own update,
/// which is deliberate and harmless: the cancel then lands on the frame *after* the look
/// begins, versus the reference's same-frame commit.
rig: Res<'w, CameraControl>,
/// While a focused UI EditBox owns the keyboard, keyboard reads see "no keys held" — so typing
/// "we should go" in chat must not break your follow. `control`'s own gate, applied here for
/// the same reason.
capture: Res<'w, crate::ui_script::UiKeyboardCapture>,
}
impl FollowInput<'_> {
/// The key-DOWN edge of any movement start. A/D are turn keys normally and strafe keys under
/// mouse-look; either way they are a movement start and either way they cancel, so the
/// distinction the controller draws does not matter here.
/// The press edge of any movement command (0997: wherever the six are bound today). The turn
/// pair are turn commands normally and strafe under mouse-look; either way they are a
/// movement start and either way they cancel, so the distinction the controller draws does
/// not matter here.
fn move_start(&self) -> bool {
!self.capture.0
&& self.keys.any_just_pressed([
KeyCode::KeyW,
KeyCode::KeyS,
KeyCode::KeyA,
KeyCode::KeyD,
KeyCode::KeyQ,
KeyCode::KeyE,
])
use crate::bindings::cmd;
[
cmd::MOVE_FORWARD,
cmd::MOVE_BACKWARD,
cmd::TURN_LEFT,
cmd::TURN_RIGHT,
cmd::STRAFE_LEFT,
cmd::STRAFE_RIGHT,
]
.iter()
.any(|&c| self.binds.just_pressed(c))
}
}
+18
View File
@@ -95,6 +95,24 @@ fn route_spell_kit_sounds(
Err(e) => warn!("spell kit sound {kit_sound}: {e:#}"),
}
}
SpellKitSound::PlayAt { pos, kit_sound } => {
// The kit-sound leg's `extra`-override arm: a bare positional one-shot, no owner
// to track a loop on and no dedup ledger — its one caller is a missile's ground
// arrival, and two projectiles never land on the same point in the same frame.
debug!("spell kit sound {kit_sound} at {pos:?}");
if let Err(e) = play_kit(
&mut kits,
&assets,
&mut out,
&config,
listener,
KitRef::Id(kit_sound),
Some(pos),
SoundCategory::Sfx,
) {
warn!("spell kit sound {kit_sound}: {e:#}");
}
}
SpellKitSound::StopHold { entity } => {
if let Some(kit_sound) = hold_loops.remove(&entity) {
super::kit::stop_source_kit(&mut out, entity, kit_sound);
+6 -5
View File
@@ -323,12 +323,13 @@ pub(super) fn act_on_right_click(
attack,
);
if attack {
// The mounted attack block (decision 0481, the shared `0x613039` refusal): the click
// still SELECTED — the commit above already ran, matching the ref's select-then-refuse
// order — but the melee auto-draw and the swing never happen; the red
// ERR_ATTACK_MOUNTED line shows instead.
if crate::ui_action::attack_mounted_refusal(
// The actor-eligibility block (decision 0481, widened to `0x612df0`'s full Phase A): the
// click still SELECTED — the commit above already ran, matching the ref's
// select-then-refuse order — but the melee auto-draw and the swing never happen; the red
// `ERR_ATTACK_*` line shows instead.
if crate::ui_action::attack_actor_refusal(
me.and_then(|(e, _, _)| stores.get(e).ok()).map(|(s, _)| s),
me.map(|(_, g, _)| g.0),
&mut ui_error_keys,
) {
// refused — selection stands, no swing
+30 -13
View File
@@ -483,14 +483,13 @@ pub(super) fn commit(
}
}
/// TAB / Shift-TAB — the `TargetNearestEnemy` command, Classic-priority style: re-score the
/// TARGETNEARESTENEMY / TARGETPREVIOUSENEMY (0997: two commands through the binding table now,
/// defaults TAB / SHIFT-TAB — no shift fork here anymore), Classic-priority style: re-score the
/// live world, pool by tier, skip the recent history forward (or walk it backward), commit
/// through the byte-law [`commit`]. Inert while the UI captures the keyboard (a focused
/// EditBox owns TAB).
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
/// through the byte-law [`commit`]. The dispatch already applied the typing gate (a focused
/// EditBox owns TAB) and the exact-modifier law.
pub(super) fn tab_target(
keys: Res<ButtonInput<KeyCode>>,
ui_capture: Res<crate::ui_script::UiKeyboardCapture>,
binds: Res<crate::bindings::BindingsState>,
time: Res<Time>,
scan: EnemyScan,
mut history: ResMut<TabHistory>,
@@ -498,10 +497,10 @@ pub(super) fn tab_target(
net: Res<NetCommands>,
engaged: Query<(), (With<Engaged>, With<SelfPlayer>)>,
) {
if ui_capture.0 || !keys.just_pressed(KeyCode::Tab) {
let reverse = binds.fired(crate::bindings::cmd::TARGET_PREVIOUS_ENEMY);
if !reverse && !binds.fired(crate::bindings::cmd::TARGET_NEAREST_ENEMY) {
return;
}
let reverse = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
let now = time.elapsed_secs_f64();
history.prune(now);
let trace = tab_trace_on();
@@ -615,15 +614,33 @@ pub(super) fn acquire_and_attack(
if selection.guid.is_some() {
return; // something got selected between the action and this frame — the normal path owns it
}
// The mounted attack block (decision 0481): the ref's `0x613039` refusal sits BEFORE the
// nearest-core `0x6130b5` — a mounted press never even scans. Gated at the responder so
// every requester (the Attack button's no-target arm, the melee probe) shares it.
if crate::ui_action::attack_mounted_refusal(self_store.iter().next(), &mut ui_error_keys) {
// The actor-eligibility block (decision 0481, widened to `0x612df0`'s full Phase A): every one
// of its refusals sits BEFORE the nearest-core `0x6130b5` — a mounted, stunned or dead press
// never even scans. Gated at the responder so every requester (the Attack button's no-target
// arm, the melee probe) shares it. The actor here is us.
let self_guid = scan
.self_q
.iter()
.next()
.and_then(|(_, _, g)| g)
.map(|g| g.0);
if crate::ui_action::attack_actor_refusal(
self_store.iter().next(),
self_guid,
&mut ui_error_keys,
) {
return;
}
let cands = scan.build();
let Some(c) = cands.first() else {
debug!("attack acquire: nothing to attack (error 0xa0 path)");
// `0x6130d9` — the acquire ran and came back empty, so the selection re-read at `0x6130c1`
// still finds nothing: errorId `0xa0` `ERR_NO_ATTACK_TARGET`. Logged only until wow-re
// named the key (`pet-command-validators.md` §2); it is a red line, like every other
// `ERR_ATTACK_*` in that registry run.
debug!("attack acquire: nothing to attack (ERR_NO_ATTACK_TARGET)");
ui_error_keys
.0
.push(crate::ui_action::UiError::key("ERR_NO_ATTACK_TARGET"));
return;
};
debug!(
+60 -5
View File
@@ -24,7 +24,7 @@ use benilla_ui::script::UiScript;
use crate::net::{ClientCommand, NetCommands};
use super::cast_send::{CastCommit, CastLadder};
use super::{attack_mounted_refusal, cast_target, PlayerActions, UiErrorKeys, SPELL_ATTACK};
use super::{attack_actor_refusal, cast_target, PlayerActions, UiErrorKeys, SPELL_ATTACK};
/// What clicking an ITEM action does, and to which copy — [`item_action_route`]'s verdict.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -84,6 +84,54 @@ pub(super) fn item_action_route(
}
}
/// ATTACKTARGET through the binding table (0997; default T — 1.12's `AttackTarget()`): exactly
/// the action-bar attack arm below, without the action slot — the Phase A actor refusal first
/// ([`attack_actor_refusal`], the full `0x612df0` gate set), then the with-target attack-start
/// (auto-draw + swing) or the no-target nearest-acquire. One law, two doors, the reference's
/// own shape (`AttackTarget` and `UseAction`'s SPELL_ATTACK both land in `0x612df0`).
pub(super) fn attack_target_binding(
binds: Res<crate::bindings::BindingsState>,
targeting: cast_target::CastTargeting,
mut acquire: MessageWriter<crate::target::AttackNearestRequest>,
mut ui_errors: ResMut<UiErrorKeys>,
mut ladder: CastLadder,
) {
if !binds.fired(crate::bindings::cmd::ATTACK_TARGET) {
return;
}
if attack_actor_refusal(
targeting.self_store.iter().next(),
targeting.context().self_guid,
&mut ui_errors,
) {
return;
}
match targeting.selection.guid {
Some(guid) => {
debug!("bindings: ATTACKTARGET swing at {guid:#x}");
if let Ok((e, _)) = ladder.self_player.single() {
ladder.sheath.write(crate::creature_anim::SheathRequest {
entity: e,
state: 1,
ceremony: false,
});
}
let self_e = ladder.self_player.single().ok().map(|(e, _)| e);
crate::creature_anim::cancel_auto_repeat_local(
self_e,
&mut ladder.auto_repeat,
&mut ladder.ecs,
&ladder.commands,
);
let _ = ladder.commands.0.send(ClientCommand::AttackSwing { guid });
}
None => {
debug!("bindings: ATTACKTARGET with no target — acquiring nearest");
acquire.write(crate::target::AttackNearestRequest);
}
}
}
pub(super) fn drain_action_uses(
script: Option<NonSendMut<UiScript>>,
actions: Res<PlayerActions>,
@@ -105,10 +153,17 @@ pub(super) fn drain_action_uses(
};
match actions.buttons.get(&slot) {
Some(b) if b.kind == ACTION_KIND_SPELL && b.action == SPELL_ATTACK => {
// The mounted attack block ([`attack_mounted_refusal`]): the ref's validator
// refuses BEFORE the with-target swing and before the nearest-enemy scan
// (`0x613039` precedes `0x6130b5`), so both arms gate here.
if attack_mounted_refusal(targeting.self_store.iter().next(), &mut ui_errors) {
// The attack-start validator's Phase A ([`attack_actor_refusal`]) — for a melee
// swing the actor is US. It refuses BEFORE the with-target swing and before the
// nearest-enemy scan (every Phase A gate precedes `0x6130b5`), so both arms gate
// here. Widened from mounted-only when wow-re carved the rest of `0x612df0`:
// stunned, pacified, fleeing, confused, charmed by somebody else, and dead now
// refuse the swing too, each with its own red line.
if attack_actor_refusal(
targeting.self_store.iter().next(),
targeting.context().self_guid,
&mut ui_errors,
) {
continue;
}
match selection.guid {
+199 -14
View File
@@ -80,22 +80,77 @@ pub(crate) fn ui_error_text(e: &UiError, get: &dyn Fn(&str) -> Option<String>) -
(!text.is_empty()).then_some(text)
}
/// The client-side attack-start mounted refusal (decision 0481; wow-re
/// `mounted-action-gate.md` §5: the shared attack-start validator `0x612df0` reads
/// `UNIT_FIELD_MOUNTDISPLAYID` at `0x613039` and, if live, shows `DisplayError` errorId `0xa4`
/// = `ERR_ATTACK_MOUNTED` "Can't attack while mounted." — BEFORE the nearest-enemy core at
/// `0x6130b5`). The ref checks once at its single attack-start funnel; our attack initiations
/// (the Attack button, the right-click attack, the nearest-acquire) each ask here first.
pub(crate) fn attack_mounted_refusal(
self_store: Option<&ObjectStore>,
/// `UNIT_FIELD_FLAGS` bits the attack-start validator refuses on, paired with the message each
/// raises — read at `0x612eec`+ in the binary's own test order (wow-re
/// `object-layer/scratch/pet-command-validators.md` §2). All four are crowd control: the actor is
/// not refusing, it is unable.
///
/// Each has a second face in the reference — errorId `0xa9`
/// `ERR_ATTACK_PREVENTED_BY_MECHANIC_S`, which substitutes a resolved mechanic name — chosen by
/// five `0x6e9…` resolvers whose bodies wow-re records as **not carved**. We raise the plain form
/// only; the fill is a strictly better message for the same refusal, never a different one.
const ATTACK_FLAG_REFUSALS: [(u32, &str); 4] = [
(0x0004_0000, "ERR_ATTACK_STUNNED"),
(0x0002_0000, "ERR_ATTACK_PACIFIED"),
(0x0080_0000, "ERR_ATTACK_FLEEING"),
(0x0040_0000, "ERR_ATTACK_CONFUSED"),
];
/// Phase A of the shared attack-start validator `0x612df0` — **the actor's own eligibility**
/// (wow-re `object-layer/scratch/pet-command-validators.md` §2, carved 2026-08-05; it supersedes
/// the mounted-only fragment decision 0481 built from the one gate that was known then).
///
/// `0x612df0(ecx = actor, &outGuid)` is ONE function with three call sites, and **the actor is
/// whoever the caller passes in `ecx`** — the player for the melee attack-start router `0x6131aa`,
/// and **the pet** for the pet bar's ATTACK arm (`0x4bd40d` passes `edi`, the pet object). Every
/// gate below reads the actor's own descriptor, so one function answers both; that is why this
/// takes an actor rather than reaching for the self store the way the fragment did.
///
/// A veto is total: `EAX = 0`, one of the ten consecutive `ERR_ATTACK_*` registry ids `0xa0`–`0xa9`
/// through `CGGameUI::DisplayError`, and **no packet at all** — on the pet arm the refusal's
/// `0x4bd414 je 0x4bd4c6` lands on the function epilogue, not on the shared send.
///
/// Order is the binary's, and it is observable: a dead **and** mounted actor says "Can't attack
/// while dead."
///
/// `dead` is `0x605f30(actor)`, which for any non-player actor — a pet never carries the player
/// typemask bit — degenerates to exactly `health <= 0`. Its further leg for a *player* actor
/// (`[[obj+0xe68]+8]` bit 4, reached only when health is positive, so plainly the ghost state) is
/// byte-read but unnamed in wow-re's note, so it is left to the caller: pass `dead` yourself when
/// you know more than the health field does.
pub(crate) fn attack_actor_refusal(
actor: Option<&ObjectStore>,
self_guid: Option<u64>,
errors: &mut UiErrorKeys,
) -> bool {
let mounted = self_store.is_some_and(|s| s.0.unit_mount_display_id() > 0);
if mounted {
debug!("attack refused locally — mounted (ERR_ATTACK_MOUNTED)");
errors.0.push(UiError::key("ERR_ATTACK_MOUNTED"));
}
mounted
// No descriptor is no refusal. The reference resolves the actor object first and skips the
// whole chain when it cannot (`0x4bd403`: no pet ⇒ send unmodified) — an un-streamed unit is
// not an ineligible one.
let Some(fields) = actor.map(|s| &s.0) else {
return false;
};
let key = if fields.unit_health().is_some_and(|h| h == 0) {
"ERR_ATTACK_DEAD"
} else if fields
.unit_charmed_by()
.is_some_and(|g| Some(g) != self_guid)
{
// Charmed by somebody who is not us. Charmed BY US is not a refusal — a mind-controlled
// unit is one you are allowed to swing with.
"ERR_ATTACK_CHARMED"
} else if let Some((_, key)) = ATTACK_FLAG_REFUSALS
.iter()
.find(|(bit, _)| fields.unit_flags() & bit != 0)
{
key
} else if fields.unit_mount_display_id() > 0 {
"ERR_ATTACK_MOUNTED"
} else {
return false;
};
debug!("attack refused locally by the actor's own state — {key}");
errors.0.push(UiError::key(key));
true
}
/// The ref's pre-send totem/reagent possession check — `CheckReagentsAndTotems 0x6e4000`,
@@ -340,6 +395,136 @@ mod ui_error_tests {
}
}
#[cfg(test)]
mod attack_actor_tests {
use super::*;
use benilla_protocol::ObjectFields;
const HEALTH: u16 = 22;
const FLAGS: u16 = 46;
const CHARMEDBY: u16 = 10;
const MOUNT: u16 = 133;
fn actor(pairs: &[(u16, u32)]) -> ObjectStore {
// A live, unowned, unmounted, unimpaired unit unless a case says otherwise.
let mut all = vec![(HEALTH, 100)];
all.extend_from_slice(pairs);
ObjectStore(ObjectFields::from_pairs(&all))
}
fn refusal(store: Option<&ObjectStore>, self_guid: Option<u64>) -> Option<&'static str> {
let mut errors = UiErrorKeys::default();
let refused = attack_actor_refusal(store, self_guid, &mut errors);
assert_eq!(
refused,
!errors.0.is_empty(),
"a refusal and a message are the same event — the ref raises one per veto"
);
errors.0.first().map(|e| e.key)
}
/// Every gate of `0x612df0`'s Phase A, each on its own, against an otherwise-healthy actor.
#[test]
fn each_actor_gate_raises_its_own_error() {
assert_eq!(
refusal(Some(&actor(&[])), Some(7)),
None,
"a fit actor swings"
);
assert_eq!(
refusal(Some(&actor(&[(HEALTH, 0)])), Some(7)),
Some("ERR_ATTACK_DEAD")
);
assert_eq!(
refusal(Some(&actor(&[(MOUNT, 1147)])), Some(7)),
Some("ERR_ATTACK_MOUNTED")
);
for (bit, key) in ATTACK_FLAG_REFUSALS {
assert_eq!(
refusal(Some(&actor(&[(FLAGS, bit)])), Some(7)),
Some(key),
"unit flag {bit:#x}"
);
}
// An unrelated flag bit is not a refusal — the mask is four specific bits, not "any flag".
assert_eq!(refusal(Some(&actor(&[(FLAGS, 0x1000)])), Some(7)), None);
}
/// `0x612e33`'s charm test compares against the ACTIVE PLAYER's guid, so being charmed **by
/// us** is not a refusal — that is the whole point of mind control, and reading the field as a
/// plain "is charmed" boolean would make every controlled unit unable to swing.
#[test]
fn charmed_by_us_still_swings_and_charmed_away_does_not() {
let mine = actor(&[(CHARMEDBY, 7), (CHARMEDBY + 1, 0)]);
assert_eq!(refusal(Some(&mine), Some(7)), None);
let theirs = actor(&[(CHARMEDBY, 9), (CHARMEDBY + 1, 0)]);
assert_eq!(refusal(Some(&theirs), Some(7)), Some("ERR_ATTACK_CHARMED"));
// Not knowing our own guid yet cannot make somebody else's charm look like ours.
assert_eq!(refusal(Some(&theirs), None), Some("ERR_ATTACK_CHARMED"));
}
/// The order is the binary's, and it is observable: the FIRST gate to fail names the message,
/// so a dead-and-mounted actor says "Can't attack while dead."
#[test]
fn the_first_failing_gate_names_the_message() {
let both = actor(&[(HEALTH, 0), (MOUNT, 1147), (FLAGS, 0x0004_0000)]);
assert_eq!(refusal(Some(&both), Some(7)), Some("ERR_ATTACK_DEAD"));
// …and with the death removed, the flag block still precedes the mount check.
let cc = actor(&[(MOUNT, 1147), (FLAGS, 0x0004_0000)]);
assert_eq!(refusal(Some(&cc), Some(7)), Some("ERR_ATTACK_STUNNED"));
}
/// An un-streamed actor is not an ineligible one: the reference skips the whole chain when it
/// cannot resolve the object (`0x4bd403` — no pet ⇒ send unmodified). A descriptor that simply
/// has not sent a health field yet must not read as a corpse.
#[test]
fn an_unresolved_actor_is_never_a_refusal() {
assert_eq!(refusal(None, Some(7)), None);
assert_eq!(
refusal(Some(&ObjectStore(ObjectFields::default())), Some(7)),
None
);
}
/// The RUNTIME leg (the mount/lock-refusal pattern): all ten of the consecutive `ERR_ATTACK_*`
/// registry ids `0xa0`–`0xa9` resolve to non-empty strings in the shipped 1.12
/// `GlobalStrings.lua`. This is the guard against a typo'd key turning a refusal into a silent
/// one — a veto that shows nothing is indistinguishable from a click that did nothing.
#[test]
fn every_attack_error_key_resolves_in_the_real_global_strings() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let src = chain
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
.expect("GlobalStrings.lua in the chain");
let s = benilla_ui::script::UiScript::new().expect("VM");
s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
let g = |key: &str| s.lua().globals().get::<String>(key).ok();
for key in [
"ERR_NO_ATTACK_TARGET", // 0xa0
"ERR_INVALID_ATTACK_TARGET", // 0xa1
"ERR_ATTACK_STUNNED", // 0xa2
"ERR_ATTACK_PACIFIED", // 0xa3
"ERR_ATTACK_MOUNTED", // 0xa4
"ERR_ATTACK_FLEEING", // 0xa5
"ERR_ATTACK_CONFUSED", // 0xa6
"ERR_ATTACK_CHARMED", // 0xa7
"ERR_ATTACK_DEAD", // 0xa8
"ERR_ATTACK_PREVENTED_BY_MECHANIC_S", // 0xa9
] {
assert!(!g(key).unwrap_or_default().is_empty(), "{key} missing");
}
// The one wow-re quotes from the file, as a spot check that the ids line up with the keys.
assert_eq!(g("ERR_ATTACK_DEAD").unwrap(), "Can't attack while dead.");
}
}
#[cfg(test)]
mod totem_reagent_tests {
use super::*;
+4 -1
View File
@@ -64,7 +64,7 @@ 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_mounted_refusal, reagent_totem_refusal, ui_error_text, CastErrors, MountErrors, UiError,
attack_actor_refusal, reagent_totem_refusal, ui_error_text, CastErrors, MountErrors, UiError,
UiErrorKeys,
};
// `pub(crate)`: the target chain registers the cursor pre-empt + the click commit, and the
@@ -272,6 +272,9 @@ impl Plugin for UiActionPlugin {
.before(UiInput),
drain::drain_action_sets.after(UiInput),
drain::drain_action_uses.after(UiInput),
// The T binding (0997): the attack arm's twin door, after the dispatch wrote
// this frame's fires.
drain::attack_target_binding.after(UiInput),
// The learned-ability latches must be current before the target chain's
// cursor classifier reads them; the book feed runs in `UnitFeed`, so sitting
// right after it is enough.
+13 -13
View File
@@ -431,27 +431,27 @@ pub(super) fn parse_type_switch(
None
}
/// The open keys (Bindings.xml: OPENCHAT = Enter, OPENCHATSLASH = `/`, REPLY = R): Enter opens
/// with the sticky type, downgrading an invalid PARTY/RAID to SAY against the live group state
/// (the ref's `ChatEdit_UpdateHeader` invalid-type law; the sticky itself keeps its value, so
/// rejoining a group restores it — GUILD/BG downgrades wait on their arcs' state); `/` opens
/// pre-slashed; R opens as a reply to the last teller. All gated on nothing owning the keyboard.
/// The open commands through the binding table (0997; 1.12 defaults OPENCHAT = Enter,
/// OPENCHATSLASH = `/`, REPLY = R): OPENCHAT opens with the sticky type, downgrading an invalid
/// PARTY/RAID to SAY against the live group state (the ref's `ChatEdit_UpdateHeader`
/// invalid-type law; the sticky itself keeps its value, so rejoining a group restores it —
/// GUILD/BG downgrades wait on their arcs' state); OPENCHATSLASH opens pre-slashed; REPLY opens
/// as a reply to the last teller. The dispatch already gated on nothing owning the keyboard
/// (a focused box's Enter is the box's), and Shift-Enter no longer opens chat — `SHIFT-ENTER`
/// is a different chord than `ENTER`, the exact-modifier law the reference applies.
pub(super) fn open_chat_keys(
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
keys: Res<ButtonInput<KeyCode>>,
ui_capture: Res<UiKeyboardCapture>,
binds: Res<crate::bindings::BindingsState>,
mut state: ResMut<ChatEditState>,
group: Res<crate::ui_party::GroupState>,
) {
let Some(mut script) = script else {
return;
};
if ui_capture.0 {
return;
}
let open_plain = keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::NumpadEnter);
let open_slash = keys.just_pressed(KeyCode::Slash);
let open_reply = keys.just_pressed(KeyCode::KeyR) && !state.last_tell.is_empty();
use crate::bindings::cmd;
let open_plain = binds.fired(cmd::OPEN_CHAT);
let open_slash = binds.fired(cmd::OPEN_CHAT_SLASH);
let open_reply = binds.fired(cmd::REPLY) && !state.last_tell.is_empty();
if !(open_plain || open_slash || open_reply) {
return;
}
+26 -4
View File
@@ -655,7 +655,11 @@ pub(super) fn drain_chat_input(
Err(_) => warn!("chat: not connected; dropped {msg:?}"),
}
}
ParsedChat::CastVis { spell_id, kind } => {
ParsedChat::CastVis {
spell_id,
kind,
ground,
} => {
// The dev instrument: fire the synthesized cast edge at the selection, else self —
// the same message the net bridge writes, so it exercises the whole resolve/hold/
// release path (decision 0099's iteration loop).
@@ -663,7 +667,7 @@ pub(super) fn drain_chat_input(
let subject = selection.target.or(me);
match subject {
Some(entity) => {
info!("castvis: spell {spell_id} {kind:?} on {entity}");
info!("castvis: spell {spell_id} {kind:?} ground={ground} on {entity}");
cast_events.write(crate::creature_anim::CastEvent {
entity,
spell_id,
@@ -673,9 +677,27 @@ pub(super) fn drain_chat_input(
// A selected caster's GO also fires its target list at *us* — the one
// live unit the dev loop always has — so a Speed>0 spell's missile and
// impact run end to end (0099 phase 4). A self-cast has no target: no
// missile, matching a hit-less GO.
// missile, matching a hit-less GO. `ground` instead sends the pure DEST
// shape — empty lists, a point 15 yd ahead of the player at the player's
// own height (a dev stand-in for the terrain pick; flat enough to watch a
// Flare fly) — which is the only shape that reaches the location fallback.
if kind == crate::creature_anim::CastEventKind::Go {
if let Some(me) = me.filter(|&m| m != entity) {
let dest = ground.then(|| {
self_player.single().ok().map(|(_, _, tf)| {
tf.translation() + tf.forward().as_vec3() * 15.0
})
});
if let Some(dest) = dest {
go_targets.write(crate::creature_anim::SpellGoTargets {
caster: entity,
spell_id,
hits: Vec::new(),
misses: Vec::new(),
dest,
ammo_display_id: None,
seq: play_seq.next(),
});
} else if let Some(me) = me.filter(|&m| m != entity) {
go_targets.write(crate::creature_anim::SpellGoTargets {
caster: entity,
spell_id,
+19 -7
View File
@@ -102,6 +102,11 @@ pub(in crate::ui_chat) enum ParsedChat {
CastVis {
spell_id: u32,
kind: crate::creature_anim::CastEventKind,
/// `ground` — send the GO as a **pure dest cast**: empty hit and miss lists, a point on
/// the wire. That is the only shape that reaches the location fallback (one projectile at
/// the point, the ground arrival), so without it the instrument cannot see Flare or a
/// bomb thrown at empty dirt at all. Ignored on the non-GO edges.
ground: bool,
},
/// A command whose reference handler is a **one-line call** into FrameXML — `/trade`
/// (`InitiateTrade("target")`), `/inspect` (`InspectUnit("target")`), the loot-method trio
@@ -398,21 +403,28 @@ fn slash_command(index: SlashIndex, args: &str) -> ParsedChat {
/// Benilla's own instruments' grammar ([`DevCmd`] — no reference strings behind these).
fn dev_command(dev: DevCmd, args: &str) -> ParsedChat {
match dev {
// `/castvis <spell_id> [go|fail]` — bare = Start (the precast hold begins), `go` = the
// release, `fail` = the reap. Malformed → Unknown (dropped loudly).
// `/castvis <spell_id> [go|ground|fail]` — bare = Start (the precast hold begins), `go` =
// the release at the selection, `ground` = the release as a pure DEST cast (a point in
// front of the player, no unit targets — the ground-missile lane), `fail` = the reap.
// Malformed → Unknown (dropped loudly).
DevCmd::CastVis => {
use crate::creature_anim::CastEventKind;
let mut words = args.split_whitespace();
let Some(spell_id) = words.next().and_then(|w| w.parse::<u32>().ok()) else {
return ParsedChat::Unknown;
};
let kind = match words.next() {
None => CastEventKind::Start,
Some(w) if w.eq_ignore_ascii_case("go") => CastEventKind::Go,
Some(w) if w.eq_ignore_ascii_case("fail") => CastEventKind::Fail,
let (kind, ground) = match words.next() {
None => (CastEventKind::Start, false),
Some(w) if w.eq_ignore_ascii_case("go") => (CastEventKind::Go, false),
Some(w) if w.eq_ignore_ascii_case("ground") => (CastEventKind::Go, true),
Some(w) if w.eq_ignore_ascii_case("fail") => (CastEventKind::Fail, false),
Some(_) => return ParsedChat::Unknown,
};
ParsedChat::CastVis { spell_id, kind }
ParsedChat::CastVis {
spell_id,
kind,
ground,
}
}
DevCmd::ChatTest => ParsedChat::ChatTest,
DevCmd::PartyTest => ParsedChat::PartyTest {
+15 -3
View File
@@ -489,21 +489,33 @@ fn castvis_parses_id_and_phase() {
parse_line("/castvis 133"),
ParsedChat::CastVis {
spell_id: 133,
kind: CastEventKind::Start
kind: CastEventKind::Start,
ground: false
}
);
assert_eq!(
parse_line("/castvis 133 go"),
ParsedChat::CastVis {
spell_id: 133,
kind: CastEventKind::Go
kind: CastEventKind::Go,
ground: false
}
);
// `ground` is a GO too — the pure-dest shape, the only one that flies the location fallback.
assert_eq!(
parse_line("/castvis 1543 GROUND"),
ParsedChat::CastVis {
spell_id: 1543,
kind: CastEventKind::Go,
ground: true
}
);
assert_eq!(
parse_line("/castvis 689 FAIL"),
ParsedChat::CastVis {
spell_id: 689,
kind: CastEventKind::Fail
kind: CastEventKind::Fail,
ground: false
}
);
assert_eq!(parse_line("/castvis"), ParsedChat::Unknown);
+32 -150
View File
@@ -8,7 +8,7 @@
use bevy::prelude::*;
use crate::char_select::ClientState;
use crate::ui_script::{UiInput, UiKeyboardCapture};
use crate::ui_script::UiInput;
/// Is the player UI hidden right now? (`ALT-Z` — [`toggle_ui_hidden`].)
///
@@ -45,52 +45,21 @@ impl Plugin for UiHidePlugin {
}
}
/// `ALT-Z` — the reference's own TOGGLEUI default (`BINDING_NAME_TOGGLEUI` in `GlobalStrings.lua`),
/// on every platform.
///
/// **This binding was `CTRL-Z` here until 0870, and that was wrong** — a fidelity error worth naming
/// because of *how* it got in. The source was this install's `bindings-cache.wtf`, which reads
/// `bind CTRL-Z TOGGLEUI` on all three of its accounts; that file is what the client last *wrote*,
/// not what it ships with, and all three accounts descend from one profile whose TOGGLEUI had been
/// rebound. `ALT-Z` is the shipped default (director's correction; vanilla binding lists agree, and
/// `ALT-Z` is unbound in that same cache — exactly the hole a rebind leaves). A saved-state file is
/// evidence about a *player*, never about the client.
///
/// `ALT` and nothing else: the reference names a binding `[ALT-][CTRL-][SHIFT-]<key>` and matches it
/// by string equality, so `CTRL-ALT-Z` is a *different* entry and a modified press must never fall
/// through to another binding (decision 0585's bare-binding rule — the discipline
/// `vplates::toggle_vplates` spends `SHIFT` under). That block also keeps this clear of the
/// dev-overlay plane (`Ctrl`+`Shift` — [`crate::debug_panel::dev_chord`]). Inert while an EditBox
/// holds the keyboard: an `Alt-Z` in a chat line is the box's business.
///
/// Read off `ButtonInput`'s edge. It used to be driven off the raw key message stream because AppKit
/// swallows `keyUp` for a key released while **Cmd** is held, which would have latched the old
/// `Cmd-Z` twin after one use; with no Cmd anywhere in the binding that hazard doesn't exist, and
/// `just_pressed` doesn't repeat, so the repeat filter went with it (0870).
fn toggle_ui_hidden(
keys: Res<ButtonInput<KeyCode>>,
ui_capture: Res<UiKeyboardCapture>,
mut hidden: ResMut<UiHidden>,
) {
if !keys.just_pressed(KeyCode::KeyZ) || ui_capture.0 || !toggle_chord(&keys) {
/// TOGGLEUI through the binding table (0997; default `ALT-Z` — 0870's finding stands: the
/// install's `bindings-cache.wtf` says `CTRL-Z` on all three accounts, but they descend from one
/// rebound profile, and a saved-state file is evidence about a *player*, never about the client).
/// The exact-modifier law (0585) and the typing gate this site used to enforce by hand — the
/// `toggle_chord` alt-and-nothing-else check, the AppKit `keyUp` repeat hazard — now live once,
/// in the dispatch.
fn toggle_ui_hidden(binds: Res<crate::bindings::BindingsState>, mut hidden: ResMut<UiHidden>) {
if !binds.fired(crate::bindings::cmd::TOGGLE_UI) {
return;
}
hidden.0 = !hidden.0;
info!("ui: {} (Alt-Z)", if hidden.0 { "HIDDEN" } else { "shown" });
}
/// The modifier half of the binding: `ALT` held, and nothing else. See [`toggle_ui_hidden`].
fn toggle_chord(keys: &ButtonInput<KeyCode>) -> bool {
let alt = keys.any_pressed([KeyCode::AltLeft, KeyCode::AltRight]);
let blocked = keys.any_pressed([
KeyCode::ControlLeft,
KeyCode::ControlRight,
KeyCode::ShiftLeft,
KeyCode::ShiftRight,
KeyCode::SuperLeft,
KeyCode::SuperRight,
]);
alt && !blocked
info!(
"ui: {} (TOGGLEUI)",
if hidden.0 { "HIDDEN" } else { "shown" }
);
}
/// Leaving the world clears the hide — safety, not fidelity: the binding is `InWorld`-only, so a UI
@@ -101,114 +70,27 @@ fn show_ui(mut hidden: ResMut<UiHidden>) {
#[cfg(test)]
mod tests {
use bevy::input::keyboard::KeyboardInput;
use bevy::input::ButtonState;
use super::*;
/// The plugin's own wiring, driven the way winit drives it: the modifier mirror plus a real
/// `Z` edge. Covers what the pure-chord tests can't — the typing gate and the toggle itself.
fn app(held: &[KeyCode]) -> App {
/// The consumer half only: TOGGLEUI fired → the flag flips, both ways. The chord/typing laws
/// this file used to test by hand (ALT-and-nothing-else, the capture gate, bare Z belonging
/// to the sheath toggle) are the DISPATCH's now, tested table-wide in
/// `crate::bindings::tests` against real key events.
#[test]
fn a_fired_toggleui_flips_the_flag_both_ways() {
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
bevy::input::InputPlugin,
bevy::state::app::StatesPlugin,
))
.insert_state(ClientState::InWorld)
.init_resource::<UiKeyboardCapture>()
.add_plugins(UiHidePlugin);
for &k in held {
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(k);
}
app
}
/// One `Z` keystroke, down then up, fed the way winit feeds it — as [`KeyboardInput`] messages
/// that Bevy's own input system turns into the `just_pressed` edge this binding reads. The
/// release matters: `ButtonInput::press` only raises the edge for a key that wasn't already
/// down, so a second tap without one is not a second press (which is also why a held key's
/// auto-repeat can't strobe the toggle).
fn tap_z(app: &mut App) {
for state in [ButtonState::Pressed, ButtonState::Released] {
app.world_mut().write_message(KeyboardInput {
key_code: KeyCode::KeyZ,
logical_key: bevy::input::keyboard::Key::Character("z".into()),
state,
text: Some("z".into()),
repeat: false,
window: Entity::PLACEHOLDER,
});
app.update();
}
}
fn hidden(app: &App) -> bool {
app.world().resource::<UiHidden>().0
}
/// `ALT-Z` toggles both ways — the reference's own TOGGLEUI default (0870).
#[test]
fn alt_z_hides_and_shows_again() {
let mut app = app(&[KeyCode::AltLeft]);
tap_z(&mut app);
assert!(hidden(&app), "Alt-Z hides");
tap_z(&mut app);
assert!(!hidden(&app), "Alt-Z again shows");
}
/// While an EditBox owns the keyboard the binding is inert — an `Alt-Z` in a chat line is the
/// box's business, not a binding.
#[test]
fn inert_while_typing() {
let mut app = app(&[KeyCode::AltLeft]);
app.world_mut().resource_mut::<UiKeyboardCapture>().0 = true;
tap_z(&mut app);
assert!(!hidden(&app), "a captured keyboard eats the binding");
}
/// The bare-binding rule at the wiring level: an unmodified `Z` is the sheath toggle
/// (`player::control`, and `Z TOGGLESHEATH` in the reference), and it must not also hide the UI.
#[test]
fn bare_z_is_not_the_binding() {
let mut app = app(&[]);
tap_z(&mut app);
assert!(!hidden(&app), "bare Z belongs to the sheath toggle");
}
fn keys(held: &[KeyCode]) -> ButtonInput<KeyCode> {
let mut k = ButtonInput::default();
for &c in held {
k.press(c);
}
k
}
/// `ALT` alone is the binding, either side of the family.
#[test]
fn either_alt_alone_fires() {
for held in [KeyCode::AltLeft, KeyCode::AltRight] {
assert!(toggle_chord(&keys(&[held])), "{held:?} alone fires");
}
}
/// Decision 0585's bare-binding rule, both directions: an unmodified `Z` is the *sheath*
/// binding, and a press carrying an extra modifier names a different entry (`CTRL-ALT-Z`,
/// `ALT-SHIFT-Z`) that this one must not answer for. The old `CTRL-Z`/`Cmd-Z` arms are gone
/// (0870) — neither is the reference's default, and `Win+Z` is Snap Layouts.
#[test]
fn bare_and_over_modified_presses_are_not_the_binding() {
assert!(!toggle_chord(&keys(&[])), "bare Z is the sheath binding");
for held in [
vec![KeyCode::ControlLeft],
vec![KeyCode::SuperLeft],
vec![KeyCode::AltLeft, KeyCode::ControlRight],
vec![KeyCode::AltLeft, KeyCode::ShiftRight],
vec![KeyCode::AltLeft, KeyCode::SuperLeft],
] {
assert!(!toggle_chord(&keys(&held)), "{held:?} is another binding");
}
app.add_systems(Update, toggle_ui_hidden)
.init_resource::<UiHidden>()
.insert_resource(crate::bindings::BindingsState::test_fired(&[
crate::bindings::cmd::TOGGLE_UI,
]));
app.update();
assert!(app.world().resource::<UiHidden>().0, "fired → hidden");
app.update();
assert!(!app.world().resource::<UiHidden>().0, "fired again → shown");
app.world_mut()
.insert_resource(crate::bindings::BindingsState::default());
app.update();
assert!(!app.world().resource::<UiHidden>().0, "no fire → no change");
}
}
+3 -2
View File
@@ -103,8 +103,9 @@ fn load_icon_catalog(script: Option<NonSendMut<UiScript>>, assets: Option<Res<Wo
}
/// Who we are, for the per-character file: `(realm, character)` off the roster's own login pick.
/// `None` until the roster and the pick agree — the load simply waits a frame.
fn identity(roster: &crate::char_select::Roster) -> Option<(String, String)> {
/// `None` until the roster and the pick agree — the load simply waits a frame. Shared with the
/// bindings load ([`crate::bindings`]), whose per-character file is keyed the same way (0997).
pub(crate) fn identity(roster: &crate::char_select::Roster) -> Option<(String, String)> {
let guid = roster.pending_pick?;
let name = roster.chars.iter().find(|c| c.guid == guid)?.name.clone();
let realm = roster
+261 -26
View File
@@ -92,6 +92,12 @@ pub(crate) struct PetBar {
/// different question with a different answer: a defensive pet that retaliates on its own has
/// a target the player never ordered, and the reference does not light the Attack button for
/// it.
///
/// **It is not the source of `PET_ATTACK_START`/`PET_ATTACK_STOP`**, however much it reads like
/// one — decision 0990 made exactly that inference and it was wrong. Those ride the pet's
/// server-owned `UNIT_FIELD_FLAGS` bit `0x800` instead ([`feed_pet_unit`]). This latch answers
/// "did the player order an attack", the flag answers "is the pet fighting", and the pet's own
/// initiative is the gap between them.
pub(crate) attacking: bool,
}
@@ -351,8 +357,42 @@ struct PetUnitMemory {
/// The last pet guid — `UNIT_PET`'s trigger. `None` until the first feed, so a login with a
/// pet already out still announces it once.
guid: Option<u64>,
/// The last attack-latch state — the `PET_ATTACK_START`/`PET_ATTACK_STOP` pair's trigger.
attacking: bool,
/// The pet's last `UNIT_FIELD_FLAGS & 0x800` — the `PET_ATTACK_START`/`PET_ATTACK_STOP` pair's
/// trigger. `None` until the first resolved pet, so a login with a pet already fighting
/// announces it once instead of reading as a transition from "calm".
in_combat: Option<bool>,
}
/// `UNIT_FIELD_FLAGS` bit `0x800` — the **pet-in-combat** flag, and the whole trigger for
/// `PET_ATTACK_START`/`PET_ATTACK_STOP` (`0x5ff75e test ah,8`, wow-re
/// `object-layer/scratch/pet-command-validators.md` §4).
///
/// Server-owned and server-written: nothing client-side sets it, which is exactly why it — and not
/// the local click latch — is what the reference watches.
const UNIT_FLAG_PET_IN_COMBAT: u32 = 0x0000_0800;
/// **The unit behind [`PetBar`]'s cached guid, plus our own identity** — the one lookup both pet
/// systems need and neither owns.
///
/// It exists because the client's two newest pet answers are both *about the pet's own
/// descriptor* rather than about the bar: the `PET_ATTACK_*` edge reads the pet's
/// `UNIT_FIELD_FLAGS`, and the ATTACK order's validator reads the pet's whole eligibility. Both
/// also need the active player's guid, because both test **ownership** before they trust what they
/// read (`0x5ff780` and `0x612e33`).
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct PetUnit<'w, 's> {
index: Res<'w, GuidIndex>,
stores: Query<'w, 's, &'static ObjectStore>,
self_guid: Res<'w, crate::net::SelfGuid>,
}
impl PetUnit<'_, '_> {
/// The pet's streamed descriptor, or `None` while the bar names a guid whose object has not
/// arrived (or has left) — the case the reference treats as "no pet", never as "a bad pet".
fn store(&self, pet_guid: u64) -> Option<&ObjectStore> {
let e = *self.index.0.get(&pet_guid)?;
self.stores.get(e).ok()
}
}
/// Feed the **`"pet"` unit token** and the pet frame's three events (decision 0990).
@@ -372,8 +412,7 @@ struct PetUnitMemory {
fn feed_pet_unit(
script: Option<NonSendMut<UiScript>>,
bar: Res<PetBar>,
index: Res<GuidIndex>,
stores: Query<&ObjectStore>,
pet: PetUnit,
mut names: ResMut<NameCache>,
commands: Res<NetCommands>,
mut memory: Local<PetUnitMemory>,
@@ -386,9 +425,8 @@ fn feed_pet_unit(
// reads false and the frame hides, which is honest — we have the guid but none of the fields
// the frame draws.
let fresh = (pet_guid != 0)
.then(|| index.0.get(&pet_guid))
.then(|| pet.store(pet_guid))
.flatten()
.and_then(|&e| stores.get(e).ok())
.map(|store| {
let name = names.resolve(pet_guid, &commands).map(str::to_string);
let mut s = snapshot(store, name, 0);
@@ -432,21 +470,46 @@ fn feed_pet_unit(
script.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
}
// PET_ATTACK_START / PET_ATTACK_STOP — the pet frame's flashing `UI-Player-AttackStatus`
// overlay. DERIVED, not carved: the two events' own fire sites are outside the pet TU and
// wow-re has not pinned them, but the attack latch `[0xb714b0]` IS "the pet is attacking" and
// its three writers are carved exhaustively (§1, and [`PetBar::attacking`]), so its edges are
// the honest source. Named as derived in decision 0990 rather than claimed verified.
if memory.attacking != bar.attacking {
memory.attacking = bar.attacking;
script.fire_event(
if bar.attacking {
"PET_ATTACK_START"
} else {
"PET_ATTACK_STOP"
},
vec![],
);
// PET_ATTACK_START (334) / PET_ATTACK_STOP (335) — the pet frame's flashing
// `UI-Player-AttackStatus` overlay.
//
// **CORRECTED.** Decision 0990 derived these from the attack latch `[0xb714b0]`'s edges and
// said so honestly; the derivation was wrong. wow-re later carved the real fire site —
// `0x5ff793`/`0x5ff79a` inside `0x5ff580`, a per-field change callback registered *by field
// byte offset* (`0x6042e2 mov edx,0xa0`), which is why walking the call graph out of the pet
// TU never reached it. The trigger is the unit's own server-supplied
// `UNIT_FIELD_FLAGS & 0x800` **transition**, gated on the unit's owner guid being ours.
//
// The two are not the same question and they visibly diverge: the latch is a local click
// record with exactly three writers, so a pet that disengages on its own — its target dies, it
// runs out of range, it is feared off — clears the flag with no client-side call at all, and
// the latch-driven version would hold the frame's combat glow lit until the player pressed
// something. Conversely a defensive pet that retaliates unbidden raises the flag without any
// press, and the latch never moves.
//
// (`PetActionBarFrame`'s Attack *button* is the other mechanism and keeps the latch — it is
// driven by `PET_BAR_UPDATE` + `IsPetAttackActive`. Two frames, two sources, genuinely
// independent.)
let in_combat = fresh
.as_ref()
.and_then(|_| pet_combat_flag(pet.store(pet_guid)?, pet.self_guid.0));
if let Some(now) = in_combat {
if memory.in_combat != Some(now) {
memory.in_combat = Some(now);
debug!("ui_pet: pet in-combat flag → {now}");
script.fire_event(
if now {
"PET_ATTACK_START"
} else {
"PET_ATTACK_STOP"
},
vec![],
);
}
} else {
// No pet, or not ours: forget the edge rather than firing a STOP the reference never
// sends. `0x5ff580` is a *change* callback — a unit going away does not call it.
memory.in_combat = None;
}
}
@@ -478,6 +541,8 @@ fn drain_pet_actions(
mut bar: ResMut<PetBar>,
selection: Res<Selection>,
commands: Res<NetCommands>,
pet: PetUnit,
mut ui_errors: ResMut<crate::ui_action::UiErrorKeys>,
) {
let Some(mut script) = script else {
return;
@@ -495,10 +560,21 @@ fn drain_pet_actions(
}
let target_guid = selection.guid.unwrap_or(0);
// The pet's own descriptor — the ATTACK arm's validator reads it, not ours (see below).
let pet_store = pet.store(pet_guid);
for slot in pressed {
let Some(entry) = slot_entry(&bar, slot) else {
continue;
};
// The ATTACK order's validator runs FIRST, and its actor is the pet — see
// [`commit_press`], which owns what a veto actually costs.
let refused = is_attack_order(entry)
&& crate::ui_action::attack_actor_refusal(pet_store, pet.self_guid.0, &mut ui_errors);
if !commit_press(&mut bar, entry, refused) {
debug!("ui_pet: slot {slot} refused by the pet's own state — no packet");
continue;
}
debug!(
"ui_pet: press slot {slot} (action {} kind {:#04x}) at {target_guid:#x}",
entry.action(),
@@ -509,7 +585,6 @@ fn drain_pet_actions(
packed: entry.packed,
target_guid,
});
latch_press(&mut bar, entry);
}
for slot in toggles {
let Some(entry) = slot_entry(&bar, slot).filter(|e| e.autocast_allowed()) else {
@@ -568,10 +643,59 @@ fn drain_pet_actions(
/// the Attack button lit forever (`isActive`'s state compare keeps matching), blanks Follow and
/// Stay, and survives the `PetStopAttack` that should have put it out.
///
/// The latch is raised unconditionally here, where the client first clears two gates —
/// `0x612df0` (which also picks the target, and vetoes the whole press when it refuses) and
/// `0x5ee5a0`. Both are UNRESOLVED in wow-re's own note, so this is the all-gates-pass case and
/// nothing else; a refused attack currently lights the button where the real client would not.
/// Reaching here at all means the press already cleared the client's two ATTACK gates, because
/// [`drain_pet_actions`] runs them first — so the latch is still raised unconditionally, and now
/// that is *correct* rather than a residual. Both gates are carved
/// (`object-layer/scratch/pet-command-validators.md`): `0x612df0` refuses on the **pet's** own
/// state and never reaches the send, and `0x5ee5a0` re-derives the pet from the player's own
/// descriptor. The second is structurally true whenever we hold a bar — the bar's guid *came* from
/// the server naming us this pet's controller — so only the first is a test we can fail.
/// What the `PET_ATTACK_*` field-change callback would see for this unit: `Some(fighting)` when
/// the unit is **ours**, `None` when it is nobody's business of ours and no event may fire.
///
/// Both halves are `0x5ff580`'s, in its order: the owner test at `0x5ff780` first — CHARMEDBY when
/// set, else SUMMONEDBY, which is *not* the fallback `0x5ee5a0` uses for the same-shaped read — and
/// only then the flag at `0x5ff78d`. Reading the flag without the owner test would let any unit in
/// the world flash our pet frame.
fn pet_combat_flag(store: &ObjectStore, self_guid: Option<u64>) -> Option<bool> {
(store
.0
.unit_owner(benilla_protocol::OwnerFallback::SummonedBy)
== self_guid)
.then(|| store.0.unit_flags() & UNIT_FLAG_PET_IN_COMBAT != 0)
}
/// Is this press the ATTACK **order** — the one slot word that runs a validator before it sends?
///
/// Type 7 action 2 and nothing else. The reference's type-7 arm branches exactly twice
/// (`cmp ecx,1; jle` then `cmp ecx,2; jne`), so DISMISS, every `action >= 4`, and both mode
/// commands leave down paths that send unconditionally.
fn is_attack_order(entry: PetActionEntry) -> bool {
entry.kind() == benilla_protocol::messages::PET_ACT_COMMAND
&& entry.action() == PET_COMMAND_ATTACK
}
/// Commit one press against the bar and answer **whether it goes on the wire** — the composition
/// of the gate and the latch, in one place because that composition is where the last bug lived.
///
/// `refused` is the shared attack-start validator's verdict on the **pet** as actor (`0x4bd40d`
/// passes `ecx = edi`, the pet object resolved four instructions earlier). A veto costs
/// everything: `0x4bd414 je 0x4bd4c6` jumps to the **function epilogue, not the send**, so there
/// is no packet, no `[0xb714b0] = 1`, and no `PET_BAR_UPDATE`. The click is as if it never
/// happened, except for the red line the validator itself raised.
///
/// This closes the residual decision 0998 named in its own text — *"a refused attack currently
/// lights the button where the real client would not"* — and it is deliberately the one function
/// that both decides and records, because 0998's bug was invisible to thirteen tests that checked
/// the read side and the write side separately and never their join.
fn commit_press(bar: &mut PetBar, entry: PetActionEntry, refused: bool) -> bool {
if refused {
return false;
}
latch_press(bar, entry);
true
}
fn latch_press(bar: &mut PetBar, entry: PetActionEntry) {
let action = entry.action();
match entry.kind() {
@@ -984,4 +1108,115 @@ mod tests {
latch_press(&mut bar, packed(PET_COMMAND_STAY, PET_ACT_COMMAND));
assert_eq!(bar.spells.command_state(), PET_COMMAND_STAY);
}
/// **A refused ATTACK costs everything** — no packet, no latch, no light. The reference's veto
/// jumps to the function epilogue rather than the shared send (`0x4bd414 je 0x4bd4c6`), so a
/// pet that is dead, stunned, feared, confused, pacified or charmed away simply does not take
/// the order, and the button must not report that it did.
///
/// This is the join decision 0998 could not test: the gate and the latch, composed.
#[test]
fn a_refused_attack_neither_sends_nor_lights_the_button() {
let mut bar = PetBar {
spells: state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE),
..Default::default()
};
let attack = packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND);
assert!(
!commit_press(&mut bar, attack, true),
"a vetoed order never reaches the wire"
);
assert!(!bar.attacking, "and never raises the latch");
assert!(
!slot_view(attack, &bar.spells, None, None, bar.attacking).active,
"so the button stays dark — the bug the director saw was it lighting anyway"
);
assert_eq!(
bar.spells.command_state(),
PET_COMMAND_FOLLOW,
"a refusal cannot move the standing command either"
);
// The same press with the gate clear is the ordinary attack, unchanged.
assert!(commit_press(&mut bar, attack, false));
assert!(bar.attacking);
assert!(slot_view(attack, &bar.spells, None, None, bar.attacking).active);
}
/// `PET_ATTACK_START`/`STOP` read the pet's **server-owned** in-combat flag, and only for a
/// unit we own — the correction to decision 0990, which derived them from the local click
/// latch. The owner test uses SUMMONEDBY as its fallback, which is the callback's own choice
/// and not the one `0x5ee5a0` makes for the same-shaped read.
#[test]
fn the_attack_events_read_the_pets_combat_flag_not_the_click_latch() {
const FLAGS: u16 = 46;
const CHARMEDBY: u16 = 10;
const SUMMONEDBY: u16 = 12;
let unit =
|pairs: &[(u16, u32)]| ObjectStore(benilla_protocol::ObjectFields::from_pairs(pairs));
let me = Some(0x77u64);
// Ours by SUMMONEDBY, fighting / not fighting.
let mine = |flags: u32| unit(&[(SUMMONEDBY, 0x77), (SUMMONEDBY + 1, 0), (FLAGS, flags)]);
assert_eq!(
pet_combat_flag(&mine(UNIT_FLAG_PET_IN_COMBAT), me),
Some(true)
);
assert_eq!(pet_combat_flag(&mine(0), me), Some(false));
// An unrelated flag bit is not combat — the callback tests one bit.
assert_eq!(pet_combat_flag(&mine(0x1000), me), Some(false));
// Somebody else's minion never fires, however hard it is fighting.
let theirs = unit(&[
(SUMMONEDBY, 0x99),
(SUMMONEDBY + 1, 0),
(FLAGS, UNIT_FLAG_PET_IN_COMBAT),
]);
assert_eq!(pet_combat_flag(&theirs, me), None);
// CHARMEDBY wins over SUMMONEDBY: a mob WE mind-controlled is ours even though it was
// summoned by nobody, and a minion charmed AWAY from us stops being ours.
let charmed_by_me = unit(&[
(CHARMEDBY, 0x77),
(CHARMEDBY + 1, 0),
(FLAGS, UNIT_FLAG_PET_IN_COMBAT),
]);
assert_eq!(pet_combat_flag(&charmed_by_me, me), Some(true));
let stolen = unit(&[
(CHARMEDBY, 0x99),
(CHARMEDBY + 1, 0),
(SUMMONEDBY, 0x77),
(SUMMONEDBY + 1, 0),
(FLAGS, UNIT_FLAG_PET_IN_COMBAT),
]);
assert_eq!(pet_combat_flag(&stolen, me), None);
}
/// Only the ATTACK order consults the validator. The reference's type-7 arm branches exactly
/// twice, and every other action — the two modes, DISMISS, and anything `>= 4` — leaves down a
/// path that sends unconditionally. Gating them too would make a stunned pet impossible to
/// dismiss or to put back on Follow, which is not what the binary does.
#[test]
fn only_the_attack_order_is_gated() {
assert!(is_attack_order(packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND)));
for action in [
PET_COMMAND_STAY,
PET_COMMAND_FOLLOW,
PET_COMMAND_DISMISS,
4,
9,
] {
assert!(
!is_attack_order(packed(action, PET_ACT_COMMAND)),
"command {action} sends unconditionally"
);
}
// A REACTION slot whose action happens to equal ATTACK's 2 (that is Aggressive) is a
// different word entirely — the type byte decides, never the action alone.
assert!(!is_attack_order(packed(
PET_REACT_AGGRESSIVE,
PET_ACT_REACTION
)));
}
}
@@ -1169,6 +1169,9 @@ fn bag_slot_cooldown_sweeps_through_the_xml() {
#[test]
fn the_bar_bag_buttons_name_themselves_on_hover() {
let mut s = UiScript::new().unwrap();
// The suffix reads GetBindingKey live since 0997 — register the real command set the way
// the app's seed does, so the pin below is OPENALLBAGS's actual default.
s.register_bindings(&crate::bindings::registry_commands());
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
@@ -1184,7 +1187,7 @@ fn the_bar_bag_buttons_name_themselves_on_hover() {
}
s.resolve();
// The backpack: its label plus the one bare-key binding benilla actually ships ('B').
// The backpack: its label plus OPENALLBAGS's live key (default B; 0997's divergence note).
s.run("BenillaBagToggle_OnEnter(BenillaBagToggle)").unwrap();
let line = s
.eval::<String>("return GameTooltipTextLeft1:GetText()")
@@ -45,10 +45,13 @@ fn harness() -> UiScript {
harness_with(&[])
}
/// The eight buttons, top to bottom — the ERA ladder (the director's call on the 0951 review;
/// GameMenuFrame.xml's header SCOPE note quotes the era source).
const LADDER: [&str; 8] = [
/// The nine buttons, top to bottom — the ERA ladder (the director's call on the 0951 review;
/// GameMenuFrame.xml's header SCOPE note quotes the era source) plus the 0997 Key Bindings
/// seat carved at the middle section's head (the 1.13/1.14 menu's own entry — the window's
/// shape source — returning under 0985's seat-carving rule).
const LADDER: [&str; 9] = [
"GameMenuButtonOptions",
"GameMenuButtonKeybindings",
"GameMenuButtonAddOns",
"GameMenuButtonEditMode",
"GameMenuButtonSupport",
@@ -59,9 +62,10 @@ const LADDER: [&str; 8] = [
];
/// The ladder geometry — the era layout engine's own numbers (MainMenuFrameTemplates: padding
/// 32/28/28/28, spacing 0, AddSection gap 20): 200×288, each button 144×21 at x=28, tops at
/// 32/73/94/115/136/177/198/239 — three sections split by the 20-unit gaps after Options, after
/// Macros, and before Return to Game. First thing to break if the era shape is ever "tidied".
/// 32/28/28/28, spacing 0, AddSection gap 20) with the 0997 Key Bindings rung: 200×309, each
/// button 144×21 at x=28, tops at 32/73/94/115/136/157/198/219/260 — three sections split by
/// the 20-unit gaps after Options, after Macros, and before Return to Game. First thing to
/// break if the era shape is ever "tidied".
#[test]
fn the_menu_has_the_era_frame_and_button_ladder() {
let mut s = harness();
@@ -71,11 +75,11 @@ fn the_menu_has_the_era_frame_and_button_ladder() {
let (w, h) = s
.eval::<(f64, f64)>("return GameMenuFrame:GetWidth(), GameMenuFrame:GetHeight()")
.unwrap();
assert_eq!((w, h), (200.0, 288.0), "the era frame size");
assert_eq!((w, h), (200.0, 309.0), "the era frame size");
let top = s.eval::<f64>("return GameMenuFrame:GetTop()").unwrap();
let left = s.eval::<f64>("return GameMenuFrame:GetLeft()").unwrap();
const TOPS: [f64; 8] = [32.0, 73.0, 94.0, 115.0, 136.0, 177.0, 198.0, 239.0];
const TOPS: [f64; 9] = [32.0, 73.0, 94.0, 115.0, 136.0, 157.0, 198.0, 219.0, 260.0];
for (name, down) in LADDER.iter().zip(TOPS) {
let (bw, bh, btop, bleft) = s
.eval::<(f64, f64, f64, f64)>(&format!(
+11 -105
View File
@@ -191,38 +191,7 @@ pub(super) fn feed_ui_input(
sup,
};
let mac = cfg!(target_os = "macos");
// A *bare*-key binding is exactly that: bare. The reference names a binding
// `[ALT-][CTRL-][SHIFT-]<key>` and matches it by string equality — its own FrameXML rebuilds the
// name that way to recognise a press (`WorldMapFrame.xml:615-627`, the three prefixes prepended
// around `arg1` and compared to `GetBindingKey("TOGGLEWORLDMAP")`), and `ALT-`/`CTRL-`/`SHIFT-`
// are three separate strings in `WoW.exe`. So `CTRL-P` is a *different* entry from `P` and a
// modified press never falls through to the unmodified binding. Ours must not either — otherwise
// the dev-overlay chords would ALSO fire the letter's game action (`Ctrl`+`Shift`+key —
// decisions 0585/0867/0870). `sup` is our one addition to the reference's three: Cmd is not a
// binding modifier in any client's scheme, so a bare binding has no business firing under it
// either. (A binding that *wants* a modifier declares it — see `vplates::toggle_vplates`, which
// spends SHIFT itself for the V / SHIFT-V pair.)
let modified = shift || ctrl || alt || sup;
for ev in keyboard.read() {
// ACTIONBUTTON1..12 (the number row 1..9,0,-,= — the bar's own HotKey labels): the ref's
// two-edge binding (Bindings.xml:121-127 `runOnUp` → ActionButton.lua:15-45) — DOWN shows
// the pushed visual, UP fires the action; the PUSHED/NORMAL state gate in the Lua makes a
// held key's auto-repeat DOWNs no-ops. Gated on no EditBox holding the keyboard — else
// "1" is a typed character — plus the bare-binding rule (`modified`), and consumed either
// edge so a bar key never also lands as text.
if let Some(i) = action_button_index(ev.key_code) {
if !capture.0 && !modified {
let edge = if ev.state == ButtonState::Pressed {
"Down"
} else {
"Up"
};
if let Err(e) = script.run(&format!("BenillaActionButton{edge}({i})")) {
warn!("ui_script(actionbutton): {e}");
}
continue;
}
}
if ev.state != ButtonState::Pressed {
continue;
}
@@ -237,18 +206,13 @@ pub(super) fn feed_ui_input(
// read arrows after UiInput see them exactly as before).
let chord = keymap::chord(ev.key_code, mods, mac);
if let Some(name) = named {
let consumed = script.key_input(name);
// ESCAPE with no EditBox focused runs the escape binding: the reference's own
// TOGGLEGAMEMENU action (UiPanels.xml `ToggleGameMenu`) — one eater per press down the
// close/cancel ladder, and the game menu when nothing is left to eat. No argument: the
// `clicked` form is the micro button's plain toggle. A focused box consumes ESCAPE
// first (`key_input` returns true), so this never fires while typing — the real
// client's ESC precedence.
if name == "ESCAPE" && !consumed {
if let Err(e) = script.run("ToggleGameMenu()") {
warn!("ui_script(escape): {e}");
}
}
// 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,
// ENTER's chat open, the number row's action buttons — now lives in the binding
// 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);
} else if let Some(chord) = chord {
match chord {
keymap::Chord::Edit(action) => {
@@ -274,16 +238,6 @@ pub(super) fn feed_ui_input(
}
}
}
} else if let Some((lua, tag)) =
bare_key_binding(ev.key_code).filter(|_| !capture.0 && !modified)
{
// A default bare-key binding ([`bare_key_binding`]) — the key-feed seam ESCAPE uses; the
// action itself lives in Lua. Both gates are in the *branch condition*, not the body, so a
// gated-out key still falls through to plain character input below ('b' typed into a chat
// line is text, not the bag toggle).
if let Err(e) = script.run(lua) {
warn!("ui_script({tag}): {e}");
}
} else if !(sup || (ctrl && !alt)) {
// Plain character input. Command-modified chars never insert (an unbound Cmd/Ctrl
// chord like Cmd+L must not type "l") — but Ctrl+Alt passes: that's AltGr, the char
@@ -305,55 +259,7 @@ pub(super) fn feed_ui_input(
}
}
/// The main-bar button (1-based) a key drives — the vanilla default ACTIONBUTTON1..12 row
/// (`1..9,0,-,=`), the exact labels the bar's HotKey corners render (`BENILLA_ACTIONBAR_HOTKEYS`).
fn action_button_index(key: KeyCode) -> Option<u8> {
Some(match key {
KeyCode::Digit1 => 1,
KeyCode::Digit2 => 2,
KeyCode::Digit3 => 3,
KeyCode::Digit4 => 4,
KeyCode::Digit5 => 5,
KeyCode::Digit6 => 6,
KeyCode::Digit7 => 7,
KeyCode::Digit8 => 8,
KeyCode::Digit9 => 9,
KeyCode::Digit0 => 10,
KeyCode::Minus => 11,
KeyCode::Equal => 12,
_ => return None,
})
}
/// The default **bare-key** game bindings we've implemented: key → (the Lua the binding runs, its
/// `warn!` tag). One table so the two gates every one of them needs — no EditBox holding the keyboard,
/// no command modifier held (decision 0585) — are written once at the single call site instead of
/// re-derived per branch, which is how a seventh binding would quietly have shipped without them.
///
/// Vanilla names, for the ref cross-check:
/// - **B** OPENALLBAGS — open/close ALL bags at once, the real client's behavior, shared with the
/// backpack button (`BagFrame.xml` `BenillaBagToggle_OnClick` → `BenillaOpenAllBags`/`…Close…`).
/// - **L** TOGGLEQUESTLOG — ref `Bindings.xml:603-605` → `QuestLogFrame.lua:24-30`.
/// - **C** TOGGLECHARACTER0 — ref `CharacterFrame.lua:3-23`'s `ToggleCharacter`, tab-aware (hides the
/// window if the paper doll is already the visible tab, else shows/switches to it).
/// - **M** TOGGLEWORLDMAP — the fullscreen map (decision 0203 phase 2).
/// - **P** TOGGLESPELLBOOK — ref `Bindings.xml:582-583` (decision 0216 §8).
/// - **N** TOGGLETALENTS — ref `Bindings.xml`: `ToggleTalentFrame()` (decision 0304).
/// - **O** TOGGLEFRIENDSTAB — ref `Bindings.xml:618-620`: `ToggleFriendsFrame(1)`, the social
/// window on its Friends tab (decision 0668). The bare TOGGLESOCIAL binding is the same window
/// without a tab argument; 'O' is the tab-1 one in the client's default set.
fn bare_key_binding(key: KeyCode) -> Option<(&'static str, &'static str)> {
Some(match key {
KeyCode::KeyB => ("BenillaBagToggle_OnClick()", "togglebackpack"),
KeyCode::KeyL => ("ToggleQuestLog()", "togglequestlog"),
KeyCode::KeyC => (
"ToggleCharacter(\"BenillaPaperDollFrame\")",
"togglecharacter",
),
KeyCode::KeyM => ("ToggleWorldMap()", "toggleworldmap"),
KeyCode::KeyP => ("ToggleSpellBook(BOOKTYPE_SPELL)", "togglespellbook"),
KeyCode::KeyN => ("ToggleTalentFrame()", "toggletalents"),
KeyCode::KeyO => ("ToggleFriendsFrame(1)", "togglesocial"),
_ => return None,
})
}
// The two hardcoded key tables that used to live here — the ACTIONBUTTON number row and the
// bare-key window toggles — moved into the command registry (`crate::bindings::commands`,
// decision 0997): one chord→command table, rebindable, with 0585's modifier law enforced once
// in the dispatch instead of per branch here.
@@ -0,0 +1,353 @@
//! The shipped `assets/ui/KeyBindingsFrame.xml` — the era-shaped, 1.12-skinned Key Bindings
//! window over the engine's binding table (decision 0997; the provenance block in the XML).
//!
//! What these guard: the file loads clean inside its real neighbourhood; the game menu's Key
//! Bindings button is the door in and the window's OnHide brings the menu back (both
//! references' law); the sidebar shows exactly the honest tree's non-empty categories in 1.12
//! `Bindings.xml` order; the rows read the live table (defaults byte-real from the client's own
//! `bindings-cache.wtf`); the capture flow — select a capsule → the host arm arms → the
//! canonical chord lands through `KeyBindingFrame_OnHostKey` — binds live, steals with the red
//! 1.12 message only when the victim goes bare, refuses the wheel on press+release commands,
//! and restores the old key on refusal; Unbind/Okay/Cancel/Reset and the character-specific
//! checkbox run the 1.12 set model (Save queues the host persist; Cancel/ESC reverts through
//! `LoadBindings(GetCurrentBindingSet())`; the ESC ladder rung is that exact Cancel).
//!
//! Labels here are the RAW tokens (`BINDING_HEADER_MOVEMENT`, `BUTTON3`): the harness loads no
//! GlobalStrings, exercising the window's `getglobal(...) or raw` fallback — the app's VM
//! executes the real 1.12 GlobalStrings.lua at boot, which turns them into "Movement Keys" /
//! "Middle Mouse". One test seeds a handful of the real strings to pin that path too.
use benilla_ui::script::keybind::{KeybindCommand, KeybindRequest};
use benilla_ui::script::UiScript;
use crate::bindings::commands::SPECS;
/// The window's real neighbourhood, in the manifest's own order, with the registry seeded the
/// way the app seeds it (`crate::bindings::seed_bindings` — registration before any show).
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
let cmds: Vec<KeybindCommand> = SPECS
.iter()
.map(|spec| KeybindCommand {
name: spec.name,
category: spec.category,
run_on_up: spec.run_on_up(),
default1: spec.d1,
default2: spec.d2,
})
.collect();
s.register_bindings(&cmds);
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"UiPanels.xml",
"GameTooltip.xml",
"ScrollTemplates.xml",
"KeyBindingsFrame.xml",
"GameMenuFrame.xml",
] {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
if file == "KeyBindingsFrame.xml" {
assert!(
report.warnings.is_empty(),
"{file}: loader warnings (dropped subtrees?): {:?}",
report.warnings
);
}
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s
}
fn shown(s: &mut UiScript) {
s.run("ShowUIPanel(KeyBindingFrame)").unwrap();
assert!(s.errors().is_empty(), "on show: {:?}", s.errors());
}
#[test]
fn the_menu_button_is_the_door_in_and_the_window_hands_the_menu_back() {
let s = harness();
s.run("ShowUIPanel(GameMenuFrame)").unwrap();
s.run("GameMenuButtonKeybindings:Click()").unwrap();
assert!(
s.eval::<bool>("return KeyBindingFrame:IsVisible()")
.unwrap(),
"the window opened"
);
assert!(
!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap(),
"…and the menu went away first"
);
// Cancel closes AND reopens the menu — both references' OnHide.
s.run("KeyBindingFrameCancelButton:Click()").unwrap();
assert!(!s
.eval::<bool>("return KeyBindingFrame:IsVisible()")
.unwrap());
assert!(
s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap(),
"closing the window brings the game menu back"
);
}
#[test]
fn the_sidebar_is_the_honest_tree_in_bindings_xml_order() {
let mut s = harness();
shown(&mut s);
// The registry's category tokens, first-appearance order — exactly 1.12's file order.
let mut expected: Vec<&str> = Vec::new();
for spec in SPECS {
if !expected.contains(&spec.category) {
expected.push(spec.category);
}
}
for (i, token) in expected.iter().enumerate() {
let text = s
.eval::<String>(&format!(
"return KeyBindingFrameCategory{}:GetText()",
i + 1
))
.unwrap();
assert_eq!(&text, token, "category {} (raw-token fallback)", i + 1);
}
assert!(
!s.eval::<bool>(&format!(
"return KeyBindingFrameCategory{}:IsVisible()",
expected.len() + 1
))
.unwrap(),
"no spare category rows show"
);
// Movement is selected by default and wears the locked-gold wash.
assert!(s
.eval::<bool>("return KeyBindingFrameCategory1Bg:IsVisible()")
.unwrap());
// Row 1 is MOVEANDSTEER with its byte-real default (raw token without GlobalStrings).
assert_eq!(
s.eval::<String>("return KeyBindingFrameBinding1Description:GetText()")
.unwrap(),
"MOVEANDSTEER"
);
assert_eq!(
s.eval::<String>("return KeyBindingFrameBinding1Key1ButtonText:GetText()")
.unwrap(),
"BUTTON3"
);
// With the real strings present (the app's GlobalStrings), the same row reads 1.12's text.
s.run(
r#"BINDING_HEADER_MOVEMENT = "Movement Keys"
BINDING_NAME_MOVEANDSTEER = "Move and Steer"
KEY_BUTTON3 = "Middle Mouse""#,
)
.unwrap();
s.run("KeyBindings_LoadCategories(); KeyBindingFrame_Update()")
.unwrap();
assert_eq!(
s.eval::<String>("return KeyBindingFrameCategory1:GetText()")
.unwrap(),
"Movement Keys"
);
assert_eq!(
s.eval::<String>("return KeyBindingFrameBinding1Description:GetText()")
.unwrap(),
"Move and Steer"
);
assert_eq!(
s.eval::<String>("return KeyBindingFrameBinding1Key1ButtonText:GetText()")
.unwrap(),
"Middle Mouse"
);
}
#[test]
fn the_capture_flow_binds_steals_and_refuses_like_112() {
let mut s = harness();
shown(&mut s);
// Row 2 is MOVEFORWARD (W, UP). Selecting its Key 1 capsule arms the host seam.
assert!(!s.bind_capture_armed());
s.run("KeyBindingFrameBinding2Key1Button:Click()").unwrap();
assert!(
s.bind_capture_armed(),
"a selected capsule arms the capture"
);
assert!(
s.eval::<bool>("return KeyBindingFrameUnbindButton:IsEnabled() ~= nil and KeyBindingFrameUnbindButton:IsEnabled() ~= 0")
.unwrap(),
"Unbind arms with the selection"
);
// The host hands back a canonical chord: F binds into slot 1, W's old seat; UP survives
// in slot 2; the capture disarms; the table is LIVE (GetBindingAction).
s.run(r#"KeyBindingFrame_OnHostKey("F")"#).unwrap();
assert!(!s.bind_capture_armed(), "a completed bind disarms");
assert!(s
.eval::<bool>(
r#"local k1, k2 = GetBindingKey("MOVEFORWARD"); return k1 == "F" and k2 == "UP""#
)
.unwrap());
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("W")"#).unwrap(),
"",
"the old key is free"
);
assert_eq!(
s.eval::<String>("return KeyBindingFrameOutputText:GetText()")
.unwrap(),
"Key Bound Successfully"
);
// Stealing the LAST key of another command names the victim in red (1.12's
// KEY_UNBOUND_ERROR). T is ATTACKTARGET's only key.
s.run("KeyBindingFrameBinding2Key2Button:Click()").unwrap();
s.run(r#"KeyBindingFrame_OnHostKey("T")"#).unwrap();
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("T")"#).unwrap(),
"MOVEFORWARD"
);
assert!(
s.eval::<String>("return KeyBindingFrameOutputText:GetText()")
.unwrap()
.contains("ATTACKTARGET"),
"the newly-bare victim is named"
);
// The wheel refusal: MOVEFORWARD has press+release state — SetBinding refuses the wheel
// and the slot's old key is restored (1.12's KeyBindingFrame_SetBinding).
s.run("KeyBindingFrameBinding2Key1Button:Click()").unwrap();
s.run(r#"KeyBindingFrame_OnHostKey("MOUSEWHEELUP")"#)
.unwrap();
assert!(
s.eval::<bool>(r#"local k1 = GetBindingKey("MOVEFORWARD"); return k1 == "F""#)
.unwrap(),
"the refused slot restored its key"
);
assert_eq!(
s.eval::<String>("return KeyBindingFrameOutputText:GetText()")
.unwrap(),
"Can't bind mousewheel to actions with up and down states"
);
// A right-click on the armed capsule deselects without binding.
s.run("KeyBindingFrameBinding2Key1Button:Click()").unwrap();
assert!(s.bind_capture_armed());
s.run(r#"KeyBindingFrameBinding2Key1Button:Click("RightButton")"#)
.unwrap();
assert!(!s.bind_capture_armed(), "right-click deselects");
}
#[test]
fn unbind_okay_cancel_and_reset_run_the_112_set_model() {
let mut s = harness();
shown(&mut s);
// Unbind: select JUMP's Key 1 (row 8), unbind — both defaults gone from slot 1, slot 2
// survives (the 1.12 slot dance).
assert_eq!(
s.eval::<String>("return KeyBindingFrameBinding8Description:GetText()")
.unwrap(),
"JUMP"
);
s.run("KeyBindingFrameBinding8Key1Button:Click()").unwrap();
s.run("KeyBindingFrameUnbindButton:Click()").unwrap();
assert!(s
.eval::<bool>(
r#"local k1, k2 = GetBindingKey("JUMP"); return k1 == "NUMPAD0" and k2 == nil"#
)
.unwrap());
// Okay persists the account set: the host request queue carries Save(1), window closes.
s.run("KeyBindingFrameOkayButton:Click()").unwrap();
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
assert!(!s
.eval::<bool>("return KeyBindingFrame:IsVisible()")
.unwrap());
// Reopen; rebind something and CANCEL — the live table reverts to the saved set.
shown(&mut s);
s.run("KeyBindingFrameBinding8Key1Button:Click()").unwrap();
s.run(r#"KeyBindingFrame_OnHostKey("G")"#).unwrap();
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("G")"#).unwrap(),
"JUMP"
);
s.run("KeyBindingFrameCancelButton:Click()").unwrap();
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("G")"#).unwrap(),
"",
"Cancel reverted the unsaved bind"
);
assert!(
s.take_keybind_requests().is_empty(),
"Cancel persists nothing"
);
// Reset To Default: era confirm popup, then LoadBindings(0) — JUMP's defaults return
// (Okay earlier saved it as NUMPAD0-only, so this really is the DEFAULT set, not the
// saved one).
shown(&mut s);
s.run("KeyBindingFrameDefaultButton:Click()").unwrap();
s.run("StaticPopup1Button1:Click()").unwrap();
assert!(s
.eval::<bool>(
r#"local k1, k2 = GetBindingKey("JUMP"); return k1 == "SPACE" and k2 == "NUMPAD0""#
)
.unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn the_esc_ladder_rung_cancels_and_the_character_checkbox_switches_sets() {
let mut s = harness();
shown(&mut s);
// An unsaved bind, then the ladder (the ESC binding's own body): reverted + closed +
// menu back.
s.run("KeyBindingFrameBinding8Key1Button:Click()").unwrap();
s.run(r#"KeyBindingFrame_OnHostKey("G")"#).unwrap();
s.run("ToggleGameMenu()").unwrap();
assert!(!s
.eval::<bool>("return KeyBindingFrame:IsVisible()")
.unwrap());
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("G")"#).unwrap(),
"",
"the ladder's rung is the Cancel gesture"
);
assert!(s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap());
s.run("HideUIPanel(GameMenuFrame)").unwrap();
// The character-specific checkbox: no unsaved changes → straight profile switch; the
// title takes 1.12's per-character form; Okay saves set 2.
shown(&mut s);
s.run("KeyBindingFrameCharacterButton:Click()").unwrap();
assert_eq!(s.current_binding_set(), 2);
assert!(s
.eval::<String>("return KeyBindingFrameTitleText:GetText()")
.unwrap()
.starts_with("Key Bindings for"));
s.run("KeyBindingFrameOkayButton:Click()").unwrap();
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(2)]);
assert!(s.character_bindings_exist());
// Back to general: Okay from the character set asks the 1.12 confirm, whose accept
// saves set 1 and drops the character set.
shown(&mut s);
s.run("KeyBindingFrameCharacterButton:Click()").unwrap();
assert_eq!(s.current_binding_set(), 1);
s.run("KeyBindingFrameOkayButton:Click()").unwrap();
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"leaving the character set confirms the permanent delete"
);
s.run("StaticPopup1Button1:Click()").unwrap();
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
assert!(
!s.character_bindings_exist(),
"the confirmed delete dropped set 2"
);
assert!(!s
.eval::<bool>("return KeyBindingFrame:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
@@ -352,9 +352,16 @@ fn the_two_tabs_fit_inside_the_window() {
for (label_width, expect_tab2) in [
// A short name: under the cap, so the tab is text + PAD + SIDES.
(121.0, 121.0 + PAD + SIDES),
// A long one: the cap engages and the tab stops growing at CAP + PAD + SIDES.
(240.0, CAP + PAD + SIDES),
// A long one: the reference's cap would give CAP + PAD + SIDES = 167, but the structural
// clamp is tighter here and wins at 164 — the room left between tab 1's right edge and the
// window's DRAWN edge (decision 1002). Pinned as the smaller of the two on purpose: the
// whole point of the clamp is that it, not the hand-tuned cap, is what holds the line.
(240.0, 164.0),
] {
assert!(
expect_tab2 <= CAP + PAD + SIDES,
"the clamp may tighten the reference's cap, never loosen it"
);
let mut s = harness();
s.run("ShowMacroFrame()").unwrap();
s.resolve();
@@ -487,9 +494,11 @@ fn the_tab_row_settles_and_stays_inside_the_window() {
settled.iter().all(|w| *w == last),
"the tab fit never settles — it changes every frame: {widths:?}"
);
// 344, not 384: this window's art stops 40 units short of its frame rect (MacroFrame.xml's
// `benillaTabRightInset`), and the frame rect is not what the player sees.
assert!(
last.2 <= 384.0,
"tab 2 ends at {} of a 384-wide window: {widths:?}",
last.2 <= 344.0,
"tab 2 ends at {} of a window whose plate stops at 344: {widths:?}",
last.2
);
no_errors(&s, "tab settle");
@@ -533,9 +542,65 @@ fn no_character_name_can_push_the_tab_row_off_the_window() {
)
.unwrap();
assert!(
right <= 384.0,
"{name:?}: the tab row ends at {right} of a 384-wide window (tab 2 is {w2} wide)"
right <= 344.0,
"{name:?}: the tab row ends at {right}, past the drawn plate's edge at 344 \
(tab 2 is {w2} wide)"
);
no_errors(&s, name);
}
}
/// **The hover highlight IS the tab, at every width and from the first frame** — the director's
/// report on the landed 1002 build: in one screenshot the glow stood proud of tab 1 and fell well
/// short of tab 2.
///
/// Both halves were the same defect, and it is 1002's own read-back trap in the one line 1002 did
/// not fix: the old kit sized the highlight from the label and then capped it at `tab:GetWidth()`,
/// which serves the last RESOLVED rect — inside a settle, the template's pre-fit 115. So every tab
/// in the client wore a 129-unit highlight regardless of its own width, and the settle's change-gate
/// latched it there. The fix is structural (the highlight anchors to the tab's two side edges), so
/// this test asserts the property that makes the whole class of bug impossible: **no frame, no
/// label, no clamp can put the glow at a width other than its tab's.**
#[test]
fn the_tab_highlight_is_exactly_its_tab() {
// A name under the reference's cap, one over it, and one long enough that the structural
// drawn-edge clamp (1002) is what sets the width — all three must hold the same property.
for name in ["Ai", "Onehunter", &"W".repeat(40)] {
let mut s = harness();
s.run(&format!("UnitName = function() return \"{name}\" end"))
.unwrap();
s.run("ShowMacroFrame()").unwrap();
s.run("BenillaMacroFrameTab2:SetText(BenillaMacroFrame_TabTwoLabel())")
.unwrap();
for frame in 0..12 {
s.resolve();
pump_measures(&mut s);
s.tick(0.016);
s.resolve();
for tab in ["BenillaMacroFrameTab1", "BenillaMacroFrameTab2"] {
let (tl, tr, hl, hr): (f64, f64, f64, f64) = s
.eval(&format!(
"return {tab}:GetLeft(), {tab}:GetRight(), \
{tab}HighlightTexture:GetLeft(), {tab}HighlightTexture:GetRight()"
))
.unwrap();
assert_eq!(
hr - hl,
tr - tl,
"{tab} f{frame} ({name}): highlight {} wide on a {} tab",
hr - hl,
tr - tl
);
// …and seated where the reference seats it: its own +2 nudge off the tab's edges
// (TabButtonTemplate's highlight anchor offset), not centred on some other rect.
assert_eq!(
hl,
tl + 2.0,
"{tab} f{frame} ({name}): highlight not on the tab"
);
}
}
no_errors(&s, "tab highlight");
}
}
+13
View File
@@ -193,6 +193,10 @@ impl Plugin for UiScriptPlugin {
)
.chain()
.in_set(UiInput)
// The binding dispatch (0997) runs in this same set, after the feed: it
// reads the capture gate the feed just wrote, so a key a focused box
// consumed this frame never also fires a binding.
.before(crate::bindings::BindingSet)
.before(WorldStage::Input),
)
// Combine the two pointer contributions (dev overlay + player UI) into the single
@@ -614,6 +618,13 @@ pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
// show/hide), and it must come AFTER SpellBookFrame, whose shift-click reaches
// `BenillaMacroFrame_AddMacroLine`. Before GameMenuFrame, whose Macros button opens it.
"MacroFrame.xml",
// The Key Bindings window (decision 0997): the era-shaped standalone KeyBindingFrame
// over the engine's binding table (benilla_ui::script::keybind + crate::bindings — the
// 1.12 GetBinding/SetBinding Lua API). Needs Fonts, UiPanels (panel manager + popup
// engine + the ESC ladder rung that reverts it), ScrollTemplates (the faux kit), and
// GameTooltip (the character-specific checkbox's hover); before GameMenuFrame, whose
// Key Bindings button reaches it by name at click time.
"KeyBindingsFrame.xml",
// The options window (the 0985 provenance split: 0950's era structure, 0978's
// 1.12-native art, 0981/0984's 1.14 chrome + working era search, 0989's directed
// cuts — bare fully-live sliders, no corner X; 0992's dropdown rows + the Nameplates
@@ -935,6 +946,8 @@ mod game_menu_tests;
#[cfg(test)]
mod macro_tests;
#[cfg(test)]
mod keybindings_tests;
#[cfg(test)]
mod options_tests;
+23 -31
View File
@@ -274,43 +274,35 @@ pub(crate) fn text_px(h: f32, basis: f32) -> f32 {
(h * basis).round().min(32.0)
}
/// V / Shift-V — the FrameXML default bindings for the Show/Hide nameplate script pairs.
/// Inert while the UI captures the keyboard.
fn toggle_vplates(
keys: Res<ButtonInput<KeyCode>>,
ui_capture: Res<crate::ui_script::UiKeyboardCapture>,
mut mode: ResMut<VPlateMode>,
) {
if ui_capture.0 || !keys.just_pressed(KeyCode::KeyV) {
return;
}
// SHIFT is this pair's own binding modifier — but CTRL/ALT/Cmd are not, and `CTRL-V` is a
// different binding name from `V` in the reference's scheme, so a press carrying one must fall
// through to neither half (the bare-binding rule the key feed applies — decision 0585).
if keys.any_pressed([
KeyCode::ControlLeft,
KeyCode::ControlRight,
KeyCode::AltLeft,
KeyCode::AltRight,
KeyCode::SuperLeft,
KeyCode::SuperRight,
]) {
return;
}
let shift = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
if shift {
mode.friends = !mode.friends;
info!(
"nameplates: friendly {}",
if mode.friends { "ON" } else { "OFF" }
);
} else {
/// NAMEPLATES / FRIENDNAMEPLATES / ALLNAMEPLATES through the binding table (0997; defaults V /
/// SHIFT-V / CTRL-V). The typing gate and 0585's modifier law live in the dispatch now.
///
/// The V and SHIFT-V halves stay benilla's **independent** toggles (the shipped behavior) rather
/// than 1.12's exclusive dance (whose NAMEPLATES body also force-hides friendly plates — a
/// recorded divergence, 0997 residue). ALLNAMEPLATES arrives with the table and takes the 1.12
/// body's semantics as-is: both on unless both already on, else both off.
fn toggle_vplates(binds: Res<crate::bindings::BindingsState>, mut mode: ResMut<VPlateMode>) {
use crate::bindings::cmd;
if binds.fired(cmd::NAMEPLATES) {
mode.enemies = !mode.enemies;
info!(
"nameplates: enemy {}",
if mode.enemies { "ON" } else { "OFF" }
);
}
if binds.fired(cmd::FRIEND_NAMEPLATES) {
mode.friends = !mode.friends;
info!(
"nameplates: friendly {}",
if mode.friends { "ON" } else { "OFF" }
);
}
if binds.fired(cmd::ALL_NAMEPLATES) {
let both = mode.enemies && mode.friends;
mode.enemies = !both;
mode.friends = !both;
info!("nameplates: all {}", if !both { "ON" } else { "OFF" });
}
}
/// The world/reaction inputs of the plate gate, bundled (the 16-param ceiling).
@@ -0,0 +1,38 @@
//! Throwaway: dump a DBC's header (record count / field count / record size) and the first rows'
//! raw dwords, so a claimed column offset can be checked against the real shipped file.
fn main() -> anyhow::Result<()> {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
let mut chain = benilla_formats::open_chain(&data)?;
for name in std::env::args().skip(1) {
let path = format!("DBFilesClient\\{name}.dbc");
let Ok(bytes) = chain.read_file(&path) else {
println!("{name}: NOT IN CHAIN");
continue;
};
let g = |o: usize| u32::from_le_bytes(bytes[o..o + 4].try_into().unwrap());
let (rows, fields, rec) = (g(4), g(8), g(12));
println!(
"{name}: {rows} rows x {fields} fields, record {rec} bytes (0x{rec:x}), stringblock {}",
g(16)
);
for r in 0..rows.min(4) as usize {
let base = 20 + r * rec as usize;
let mut out = String::new();
for f in 0..fields as usize {
let v = g(base + f * 4);
let fl = f32::from_le_bytes(v.to_le_bytes());
out += &format!(
" [{:#04x}]{}",
f * 4,
if (0.0001..100000.0).contains(&fl.abs()) && v > 0x1000 {
format!("{fl:.3}f")
} else {
format!("{v}")
}
);
}
println!(" row{r}:{out}");
}
}
Ok(())
}
@@ -675,6 +675,20 @@ pub fn m2bones(chain: &mut Chain, internal_path: &str) -> Result<()> {
);
}
}
// Scale keys. A bone whose ONLY channel is scale — the pulsing card a spell
// effect hangs off a billboard, the freezing trap's ice shard — printed nothing
// here at all: the header row said `S2` and no detail line followed, so "how big
// does this thing actually get, and in which sequence" could not be answered from
// the dump. Non-uniform scale is exactly the interesting case (a square card
// stretched into a column), so all three components print.
if !bk.scale.is_empty() && bk.scale.len() <= 8 {
let keys: Vec<String> = bk
.scale
.iter()
.map(|(t, v)| format!("{t:.3}s ({:.3}, {:.3}, {:.3})", v[0], v[1], v[2]))
.collect();
println!(" seq{si} (anim {}) S: {}", s.anim_id, keys.join(" "));
}
}
}
eprintln!("{} bones", m.bones.len());
+24
View File
@@ -0,0 +1,24 @@
//! `mpqcat` — write one file out of an MPQ to stdout. The instrument for reading the real client's
//! own data (FrameXML, GlobalStrings, a BLP header) when a fidelity question needs the source
//! rather than a memory of it. Several decision records cite an external `mpqx` for this; nothing
//! in the workspace could do it until now, so every lookup was a hunt.
//!
//! ```text
//! cargo run -q -p benilla-mpq --example mpqcat -- <archive.MPQ> 'Interface\FrameXML\MacroFrame.xml'
//! ```
//!
//! Archive names are the client's own backslash paths. A miss is an error, not empty output — the
//! same file often lives in several archives (patch beats interface beats base), and "not here"
//! and "here but empty" are answers you must be able to tell apart.
use std::io::Write;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = std::env::args().skip(1);
let (Some(archive), Some(name)) = (args.next(), args.next()) else {
eprintln!("usage: mpqcat <archive.MPQ> <file-in-archive>");
std::process::exit(2);
};
let bytes = benilla_mpq::Archive::open(&archive)?.read_file(&name)?;
std::io::stdout().write_all(&bytes)?;
Ok(())
}
+3 -2
View File
@@ -16,8 +16,9 @@ pub use auth::AuthReject;
pub use events::{decode, CharAction, EntityKind, LoginStage, MoveSpeeds, Poll, SessionEvent};
pub use messages::{
CharCreateReq, CharEnumItem, Character, CreateSpline, ItemInfo, JumpInfo, MonsterMoveFacing,
MoveMode, ObjectFields, ServerPacket, SpeedKind, TransportPose, CHARACTER_FLAG_GHOST,
CHARACTER_FLAG_HIDE_CLOAK, CHARACTER_FLAG_HIDE_HELM, CHARACTER_FLAG_RENAME,
MoveMode, ObjectFields, OwnerFallback, ServerPacket, SpeedKind, TransportPose,
CHARACTER_FLAG_GHOST, CHARACTER_FLAG_HIDE_CLOAK, CHARACTER_FLAG_HIDE_HELM,
CHARACTER_FLAG_RENAME,
};
pub use world::{WardenRequired, WorldReader, WorldSession, WorldWriter, WORLD_PORT};
+1 -1
View File
@@ -176,7 +176,7 @@ pub use trade::{
};
pub use trainer::{train_fail, trainer_buy_spell, trainer_list, trainer_spell_state, TrainerSpell};
pub use update_object::{
quest_slot_state, CreateSpline, MovementBlock, Object, ObjectFields, ObjectType,
quest_slot_state, CreateSpline, MovementBlock, Object, ObjectFields, ObjectType, OwnerFallback,
PlayerSkillSlot, QuestLogSlot, UnitAuraSlot, AURA_FLAG_CANCELABLE, AURA_FLAG_EFF_INDEX_MASK,
PLAYER_EXPLORED_ZONES_SLOTS, PLAYER_QUEST_LOG_SLOTS, PLAYER_SKILL_SLOTS,
UNIT_AURA_POSITIVE_SLOTS, UNIT_AURA_SLOTS,
@@ -42,6 +42,14 @@ const FIELD_UNIT_TARGET: u16 = 16;
/// token; the pet action bar reads its own guid off `SMSG_PET_SPELLS` instead, which is why the
/// two can legitimately disagree for a beat around a summon (decision 0982).
const FIELD_UNIT_SUMMON: u16 = 8;
/// `UNIT_FIELD_CHARMEDBY` (idx 10, `OBJECT_END(6) + 0x4`, a 2-field guid — vmangos
/// `UpdateFields_1_12_1.h`) — whoever is currently charming this unit, `0` when nobody is.
/// Byte-verified client-side as `[unit+0x110]+0x10`: the shared attack-start validator
/// `0x612df0` reads it at `0x612e33` and refuses the swing when it is nonzero and is **not** the
/// active player's own guid (`ERR_ATTACK_CHARMED`), and the owner-guid pair in both `0x5ee5a0`
/// and the `PET_ATTACK_*` callback `0x5ff580` prefers it over their respective fallbacks
/// (wow-re `object-layer/scratch/pet-command-validators.md` §1/§2/§4).
const FIELD_UNIT_CHARMEDBY: u16 = 10;
/// `UNIT_FIELD_SUMMONEDBY` (idx 12, `OBJECT_END(6) + 0x6`, a 2-field guid — vmangos
/// `UpdateFields_1_12_1.h`) — the summoner of a pet/guardian/totem. With CREATEDBY below, the
/// combat-text source-ownership classifier's "owned by me" test (wow-re `0x5efea0`).
@@ -590,5 +598,7 @@ impl ObjectFields {
mod player;
mod unit;
pub use unit::OwnerFallback;
#[cfg(test)]
mod tests;
@@ -4,6 +4,17 @@
use super::*;
/// Which field [`ObjectFields::unit_owner`] falls back to when `CHARMEDBY` is clear — the one
/// thing the client's two owner-guid readers do differently. Named rather than defaulted because
/// picking the wrong one is invisible until a totem or a guardian behaves like a pet.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OwnerFallback {
/// `UNIT_FIELD_CREATEDBY` — what `0x5ee5a0` ("resolve my own pet") uses.
CreatedBy,
/// `UNIT_FIELD_SUMMONEDBY` — what the `PET_ATTACK_*` callback `0x5ff580` uses.
SummonedBy,
}
impl ObjectFields {
/// `OBJECT_FIELD_SCALE_X` — the per-object size multiplier.
pub fn object_scale_x(&self) -> Option<f32> {
@@ -43,6 +54,25 @@ impl ObjectFields {
pub fn unit_summoned_by(&self) -> Option<u64> {
self.get_guid(FIELD_UNIT_SUMMONEDBY).filter(|&g| g != 0)
}
/// `UNIT_FIELD_CHARMEDBY` — whoever is charming this unit, or `None` when nobody is.
pub fn unit_charmed_by(&self) -> Option<u64> {
self.get_guid(FIELD_UNIT_CHARMEDBY).filter(|&g| g != 0)
}
/// The unit's **owner** as the client's own two owner-guid readers compute it:
/// `charmedBy` when set, else the caller's fallback field.
///
/// Both readers are `(charmedBy != 0) ? charmedBy : <fallback>` and **they disagree on the
/// fallback**, which is why this takes it rather than picking one. `0x5ee5a0` — "resolve my own
/// pet" — falls back to `CREATEDBY` (`0x5ee62f`: `fields+0x10` else `fields+0x20`); the
/// `PET_ATTACK_*` field-change callback `0x5ff580` falls back to `SUMMONEDBY` (`0x5ff769`:
/// `+0x10` else `+0x18`). Reading one where the reference reads the other silently changes
/// which units count as yours (wow-re `object-layer/scratch/pet-command-validators.md` §1, §4).
pub fn unit_owner(&self, fallback: OwnerFallback) -> Option<u64> {
self.unit_charmed_by().or_else(|| match fallback {
OwnerFallback::CreatedBy => self.unit_created_by(),
OwnerFallback::SummonedBy => self.unit_summoned_by(),
})
}
/// `UNIT_FIELD_CREATEDBY` — the creator's guid (totems and created objects), or `None`.
pub fn unit_created_by(&self) -> Option<u64> {
self.get_guid(FIELD_UNIT_CREATEDBY).filter(|&g| g != 0)
@@ -13,7 +13,7 @@ mod fields;
mod movement;
pub use fields::{
quest_slot_state, ObjectFields, PlayerSkillSlot, QuestLogSlot, UnitAuraSlot,
quest_slot_state, ObjectFields, OwnerFallback, PlayerSkillSlot, QuestLogSlot, UnitAuraSlot,
AURA_FLAG_CANCELABLE, AURA_FLAG_EFF_INDEX_MASK, PLAYER_EXPLORED_ZONES_SLOTS,
PLAYER_QUEST_LOG_SLOTS, PLAYER_SKILL_SLOTS, UNIT_AURA_POSITIVE_SLOTS, UNIT_AURA_SLOTS,
};
+546
View File
@@ -0,0 +1,546 @@
//! The **key-binding table** — the client's chord→command store, engine side (decision 0997).
//!
//! In the reference every rebindable action is a *binding*: a command name (`MOVEFORWARD`)
//! carrying a category header and up to two bound key chords, stored key→command and saved to
//! `bindings-cache.wtf`. 1.12 exposes the whole system to Lua (`Blizzard_BindingUI` is plain
//! FrameXML over it): `GetNumBindings`/`GetBinding(i)`, `SetBinding(key[, command])` with its
//! steal-the-key law, `GetBindingKey`/`GetBindingAction`, and the three-set model —
//! `LoadBindings(0 default | 1 account | 2 character)`, `SaveBindings(which)`,
//! `GetCurrentBindingSet()`.
//!
//! benilla keeps the same shape at the same seam, split like the CVar table (0954): the **host
//! registers** the commands it actually implements (honest-tree — no row without a real engine
//! action) with their 1.12 default chords, **this table is the string-domain truth** the window's
//! Lua reads and writes synchronously, and the app derives its dispatch view (parsed chords →
//! latched commands) whenever [`super::UiScript::keybinds_generation`] moves, persisting on the
//! queued [`KeybindRequest::Save`]. Chord strings are the reference's own canon —
//! `[ALT-][CTRL-][SHIFT-]<TOKEN>` (`W`, `SPACE`, `NUMPAD0`, `BUTTON4`, `MOUSEWHEELUP`) — matched
//! by string equality exactly as the client does (decision 0585's law, now engine-wide).
//!
//! The stored account/character sets live here too (seeded by the app from
//! `benilla/bindings/…`, re-snapshotted on save), so `LoadBindings` is **synchronous** like the
//! reference's — the window calls it and repaints in the same tick, no host round-trip.
use std::collections::HashMap;
use mlua::{Lua, MultiValue, Value};
use super::Model;
/// One host-registered command: the 1.12 name, its category header's global-string key
/// (`BINDING_HEADER_MOVEMENT`), whether it has press+release semantics (`runOnUp` — the
/// mousewheel-refusal law rides this), and the 1.12 default chords.
#[derive(Clone, Copy, Debug)]
pub struct KeybindCommand {
pub name: &'static str,
pub category: &'static str,
pub run_on_up: bool,
pub default1: Option<&'static str>,
pub default2: Option<&'static str>,
}
/// A host request queued by Lua: persist the live table as set `1`/`2` (`Save`), on which the
/// app writes `benilla/bindings/…` — and, for `Save(1)` issued while the character set was
/// active, deletes the character file (the reference's confirmed delete-on-switch).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum KeybindRequest {
Save(u32),
}
#[derive(Clone, Debug)]
struct Entry {
name: String,
category: String,
run_on_up: bool,
defaults: [Option<String>; 2],
/// The live bound chords, in bind order (the reference's per-command key list; the first two
/// are what `GetBindingKey` and the window's Key 1/Key 2 columns show).
keys: Vec<String>,
}
/// The table + the stored sets + the Lua→host seams. Lives in [`Model`].
#[derive(Default)]
pub(crate) struct KeybindState {
entries: Vec<Entry>,
by_name: HashMap<String, usize>,
/// Saved snapshots: `stored[0]` = account (set 1), `stored[1]` = character (set 2, `None`
/// when no character-specific bindings exist). Each is per-entry key lists, entry order.
stored: [Option<Vec<Vec<String>>>; 2],
/// `1` account, `2` character — which set the live table edits (`GetCurrentBindingSet`).
current_set: u32,
/// Bumped by every live-table mutation and every seed — the app's re-derive-dispatch signal.
generation: u64,
requests: Vec<KeybindRequest>,
/// The Key Bindings window's capture arm (`BenillaBindCapture(armed)`): while set, the host
/// swallows raw input and calls the window back with the canonical chord string.
capture_armed: bool,
}
impl KeybindState {
/// Strip the reference's modifier prefixes; what remains is the base token (which may itself
/// be `-` — `CTRL--` is Ctrl+minus, so the strip walks prefixes, never splits on `-`).
fn base_token(chord: &str) -> &str {
let mut rest = chord;
loop {
let Some(next) = ["ALT-", "CTRL-", "SHIFT-"]
.iter()
.find_map(|p| rest.strip_prefix(p))
else {
return rest;
};
rest = next;
}
}
/// The reference's `SetBinding(key[, command])`: unbind `key` wherever it is, then (with a
/// command) append it to that command's key list. Returns `false` for the one refusal the
/// reference has — a mousewheel chord on a press+release (`runOnUp`) command
/// (`KEYBINDINGFRAME_MOUSEWHEEL_ERROR`: a wheel tick cannot be released).
fn set_binding(&mut self, key: &str, command: Option<&str>) -> bool {
let key = key.to_ascii_uppercase();
let cmd_idx = match command {
Some(name) => match self.by_name.get(&name.to_ascii_uppercase()) {
Some(&i) => Some(i),
None => return false, // unknown command — refuse, the window never offers one
},
None => None,
};
if let Some(i) = cmd_idx {
let wheel = matches!(Self::base_token(&key), "MOUSEWHEELUP" | "MOUSEWHEELDOWN");
if wheel && self.entries[i].run_on_up {
return false;
}
}
for e in &mut self.entries {
e.keys.retain(|k| k != &key);
}
if let Some(i) = cmd_idx {
self.entries[i].keys.push(key);
}
self.generation += 1;
true
}
/// Snapshot the live key lists (entry order) — the stored-set payload.
fn snapshot(&self) -> Vec<Vec<String>> {
self.entries.iter().map(|e| e.keys.clone()).collect()
}
/// Replace the live key lists from a snapshot (entry-count mismatch pads/truncates — a
/// stored set from before a command was registered simply leaves the new command on its
/// live value).
fn apply(&mut self, snap: &[Vec<String>]) {
for (i, e) in self.entries.iter_mut().enumerate() {
if let Some(keys) = snap.get(i) {
e.keys = keys.clone();
}
}
self.generation += 1;
}
fn defaults_snapshot(&self) -> Vec<Vec<String>> {
self.entries
.iter()
.map(|e| e.defaults.iter().flatten().cloned().collect())
.collect()
}
/// The reference's `LoadBindings(set)`: 0 = defaults, 1 = account, 2 = character (falling
/// back to the account snapshot when no character set exists — the reference's character
/// set starts as a copy). Loading 1/2 also moves `current_set`; loading defaults does not
/// (you are still *editing* the set you had, as in the window's Reset To Default).
fn load(&mut self, set: u32) {
match set {
0 => {
let d = self.defaults_snapshot();
self.apply(&d);
}
1 => {
let s = self.stored[0]
.clone()
.unwrap_or_else(|| self.defaults_snapshot());
self.apply(&s);
self.current_set = 1;
}
2 => {
let s = self.stored[1]
.clone()
.or_else(|| self.stored[0].clone())
.unwrap_or_else(|| self.defaults_snapshot());
self.apply(&s);
self.current_set = 2;
}
_ => {}
}
}
/// The reference's `SaveBindings(which)`: snapshot live into set `which`, make it current,
/// and — saving account while character bindings exist — drop the character set (the
/// window's confirmed permanent delete). Queues the host persist.
fn save(&mut self, which: u32) {
if which != 1 && which != 2 {
return;
}
self.stored[(which - 1) as usize] = Some(self.snapshot());
if which == 1 {
self.stored[1] = None;
}
self.current_set = which;
self.requests.push(KeybindRequest::Save(which));
}
}
impl super::UiScript {
/// Register the host-implemented commands (boot-time, order = the 1.12 `Bindings.xml`
/// order). Idempotent per name; live keys seed from the defaults.
pub fn register_bindings(&mut self, commands: &[KeybindCommand]) {
let mut model = self.model_mut();
for c in commands {
let key = c.name.to_ascii_uppercase();
if model.keybinds.by_name.contains_key(&key) {
continue;
}
let defaults = [c.default1.map(str::to_owned), c.default2.map(str::to_owned)];
let idx = model.keybinds.entries.len();
model.keybinds.by_name.insert(key, idx);
model.keybinds.entries.push(Entry {
name: c.name.to_owned(),
category: c.category.to_owned(),
run_on_up: c.run_on_up,
keys: defaults.iter().flatten().cloned().collect(),
defaults,
});
}
model.keybinds.current_set = 1;
model.keybinds.generation += 1;
}
/// Host-side seed of a stored set (`1` account / `2` character) — the loaded
/// `benilla/bindings/…` state, already resolved to full per-command key lists by the app's
/// diff layer. Passing the character set marks it existing (the window's checkbox state);
/// `seed_binding_set(2, None)` clears it. Does not touch the live table — call
/// [`Self::load_binding_set`] after seeding to activate one.
pub fn seed_binding_set(&mut self, set: u32, keys: Option<Vec<(String, Vec<String>)>>) {
let mut model = self.model_mut();
let kb = &mut model.keybinds;
let snap = keys.map(|pairs| {
let by_name: HashMap<_, _> = pairs.into_iter().collect();
kb.entries
.iter()
.map(|e| {
by_name
.get(&e.name)
.cloned()
.unwrap_or_else(|| e.keys.clone())
})
.collect()
});
match set {
1 => kb.stored[0] = snap,
2 => kb.stored[1] = snap,
_ => {}
}
}
/// Host-side `LoadBindings` (world entry: character set if it exists, else account).
pub fn load_binding_set(&mut self, set: u32) {
self.model_mut().keybinds.load(set);
}
/// The live table as `(command, bound chords)` in registration order — the app's dispatch
/// derivation and the save diff read this.
pub fn keybind_snapshot(&self) -> Vec<(String, Vec<String>)> {
self.model_mut()
.keybinds
.entries
.iter()
.map(|e| (e.name.clone(), e.keys.clone()))
.collect()
}
/// Bumped by every table mutation (Lua or host) — cheap to poll; re-derive dispatch when it
/// moves (the macros-generation pattern).
pub fn keybinds_generation(&self) -> u64 {
self.model_mut().keybinds.generation
}
/// Drain the queued host requests (persist-on-save).
pub fn take_keybind_requests(&mut self) -> Vec<KeybindRequest> {
std::mem::take(&mut self.model_mut().keybinds.requests)
}
/// Which set the live table edits: 1 account, 2 character.
pub fn current_binding_set(&self) -> u32 {
self.model_mut().keybinds.current_set
}
/// Whether a character-specific stored set exists (the checkbox's persisted truth).
pub fn character_bindings_exist(&self) -> bool {
self.model_mut().keybinds.stored[1].is_some()
}
/// The Key Bindings window's capture arm — while true, the host swallows raw input and
/// calls `KeyBindingFrame_OnHostKey("<chord>")` instead of dispatching it.
pub fn bind_capture_armed(&self) -> bool {
self.model_mut().keybinds.capture_armed
}
}
/// Register the binding globals (1.12 names; `GetBinding` returns the era 4-tuple —
/// command, category token, key1, key2 — the categorized window's shape).
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.globals().set(
"GetNumBindings",
lua.create_function(|lua, ()| {
let model = lua.app_data_mut::<Model>().expect("model app_data");
Ok(model.keybinds.entries.len())
})?,
)?;
lua.globals().set(
"GetBinding",
lua.create_function(|lua, i: usize| {
let model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(e) = i.checked_sub(1).and_then(|i| model.keybinds.entries.get(i)) else {
return Ok(MultiValue::new());
};
let mut out = vec![
Value::String(lua.create_string(&e.name)?),
Value::String(lua.create_string(&e.category)?),
];
for k in e.keys.iter().take(2) {
out.push(Value::String(lua.create_string(k)?));
}
Ok(MultiValue::from_iter(out))
})?,
)?;
lua.globals().set(
"SetBinding",
lua.create_function(|lua, (key, command): (String, Option<String>)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let ok = model.keybinds.set_binding(&key, command.as_deref());
Ok(if ok { Some(1) } else { None })
})?,
)?;
lua.globals().set(
"GetBindingKey",
lua.create_function(|lua, command: String| {
let model = lua.app_data_mut::<Model>().expect("model app_data");
let kb = &model.keybinds;
let mut out = Vec::new();
if let Some(&i) = kb.by_name.get(&command.to_ascii_uppercase()) {
for k in kb.entries[i].keys.iter().take(2) {
out.push(Value::String(lua.create_string(k)?));
}
}
Ok(MultiValue::from_iter(out))
})?,
)?;
lua.globals().set(
"GetBindingAction",
lua.create_function(|lua, key: String| {
let model = lua.app_data_mut::<Model>().expect("model app_data");
let key = key.to_ascii_uppercase();
let name = model
.keybinds
.entries
.iter()
.find(|e| e.keys.iter().any(|k| k == &key))
.map(|e| e.name.as_str())
.unwrap_or("");
lua.create_string(name)
})?,
)?;
lua.globals().set(
"LoadBindings",
lua.create_function(|lua, set: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.keybinds.load(set);
Ok(())
})?,
)?;
lua.globals().set(
"SaveBindings",
lua.create_function(|lua, which: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.keybinds.save(which);
Ok(())
})?,
)?;
lua.globals().set(
"GetCurrentBindingSet",
lua.create_function(|lua, ()| {
let model = lua.app_data_mut::<Model>().expect("model app_data");
Ok(model.keybinds.current_set)
})?,
)?;
lua.globals().set(
"BenillaCharacterBindingsExist",
lua.create_function(|lua, ()| {
let model = lua.app_data_mut::<Model>().expect("model app_data");
Ok(model.keybinds.stored[1].is_some())
})?,
)?;
lua.globals().set(
"BenillaBindCapture",
lua.create_function(|lua, armed: Option<bool>| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.keybinds.capture_armed = armed.unwrap_or(false);
Ok(())
})?,
)
}
#[cfg(test)]
mod tests {
use super::KeybindCommand;
use crate::script::keybind::KeybindRequest;
use crate::script::UiScript;
fn script() -> UiScript {
let mut s = UiScript::new().unwrap();
s.register_bindings(&[
KeybindCommand {
name: "MOVEFORWARD",
category: "BINDING_HEADER_MOVEMENT",
run_on_up: true,
default1: Some("W"),
default2: Some("UP"),
},
KeybindCommand {
name: "JUMP",
category: "BINDING_HEADER_MOVEMENT",
run_on_up: false,
default1: Some("SPACE"),
default2: Some("NUMPAD0"),
},
KeybindCommand {
name: "CAMERAZOOMIN",
category: "BINDING_HEADER_CAMERA",
run_on_up: false,
default1: Some("MOUSEWHEELUP"),
default2: None,
},
]);
s
}
#[test]
fn the_table_reads_like_the_reference() {
let s = script();
assert_eq!(s.eval::<usize>("return GetNumBindings()").unwrap(), 3);
// Era 4-tuple: command, category token, key1, key2 — defaults seeded live.
assert!(s
.eval::<bool>(
r#"local c, cat, k1, k2 = GetBinding(1)
return c == "MOVEFORWARD" and cat == "BINDING_HEADER_MOVEMENT"
and k1 == "W" and k2 == "UP""#
)
.unwrap());
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("NUMPAD0")"#)
.unwrap(),
"JUMP"
);
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("F")"#).unwrap(),
"",
"an unbound key reads as the empty command, like the client"
);
}
#[test]
fn set_binding_steals_and_the_wheel_refusal_holds() {
let s = script();
let g0 = s.keybinds_generation();
// Bind W to JUMP: stolen from MOVEFORWARD (its slot 1 empties, UP slides up), appended
// to JUMP's list after its defaults.
assert!(s
.eval::<bool>(r#"return SetBinding("W", "JUMP") == 1"#)
.unwrap());
assert!(s
.eval::<bool>(
r#"local k1, k2 = GetBindingKey("MOVEFORWARD"); return k1 == "UP" and k2 == nil"#
)
.unwrap());
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("W")"#).unwrap(),
"JUMP"
);
// Unbind by key: SetBinding(key) with no command.
s.run(r#"SetBinding("SPACE")"#).unwrap();
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("SPACE")"#)
.unwrap(),
""
);
// The one refusal: a wheel chord on a press+release command (1.12's
// KEYBINDINGFRAME_MOUSEWHEEL_ERROR); a wheel on a click command binds fine.
assert!(s
.eval::<bool>(r#"return SetBinding("SHIFT-MOUSEWHEELUP", "MOVEFORWARD") == nil"#)
.unwrap());
assert!(s
.eval::<bool>(r#"return SetBinding("SHIFT-MOUSEWHEELUP", "CAMERAZOOMIN") == 1"#)
.unwrap());
assert!(
s.keybinds_generation() > g0,
"mutations bump the generation"
);
}
#[test]
fn the_three_set_model_loads_saves_and_deletes_like_the_reference() {
let mut s = script();
assert_eq!(s.current_binding_set(), 1);
assert!(!s.character_bindings_exist());
// Rebind, save as character: stored set 2 exists, current set moves.
s.run(r#"SetBinding("F", "JUMP")"#).unwrap();
s.run("SaveBindings(2)").unwrap();
assert_eq!(s.current_binding_set(), 2);
assert!(s.character_bindings_exist());
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(2)]);
// Reset To Default: LoadBindings(0) restores defaults but stays on the character set.
s.run("LoadBindings(0)").unwrap();
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("F")"#).unwrap(),
""
);
assert_eq!(s.current_binding_set(), 2);
// Cancel's revert: LoadBindings(current) restores the saved character set.
s.run("LoadBindings(2)").unwrap();
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("F")"#).unwrap(),
"JUMP"
);
// Okay back to general: SaveBindings(1) drops the character set — the confirmed
// permanent delete — and the app is asked to persist (and unlink the character file).
s.run("SaveBindings(1)").unwrap();
assert_eq!(s.current_binding_set(), 1);
assert!(!s.character_bindings_exist());
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
}
#[test]
fn host_seeding_feeds_load_and_the_capture_arm_reads_back() {
let mut s = script();
s.seed_binding_set(1, Some(vec![("JUMP".into(), vec!["F".into()])]));
s.load_binding_set(1);
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("F")"#).unwrap(),
"JUMP"
);
// Commands absent from the seed keep their live keys (a stored set from before a
// command was registered leaves the new command alone).
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("W")"#).unwrap(),
"MOVEFORWARD"
);
assert!(!s.bind_capture_armed());
s.run("BenillaBindCapture(true)").unwrap();
assert!(s.bind_capture_armed());
s.run("BenillaBindCapture(false)").unwrap();
assert!(!s.bind_capture_armed());
let snap = s.keybind_snapshot();
assert_eq!(snap[1].0, "JUMP");
assert_eq!(snap[1].1, vec!["F".to_string()]);
}
}
+2
View File
@@ -59,6 +59,7 @@ mod gossip;
mod inspect;
mod item_stats;
mod item_text;
pub mod keybind;
mod layout;
mod loot;
mod loot_roll;
@@ -273,6 +274,7 @@ impl UiScript {
death::install(&lua)?;
aura::install(&lua)?;
cvars::install(&lua)?;
keybind::install(&lua)?;
sound::install(&lua)?;
pointer::install(&lua)?;
action::install(&lua)?;
+7
View File
@@ -208,6 +208,12 @@ pub(crate) struct Model {
/// Unknown CVar names already warned about (warn-once, the era-atlas-miss posture).
pub(crate) cvars_warned: HashSet<String>,
/// The key-binding table (decision 0997, [`super::keybind`]) — the chord→command store the
/// Key Bindings window edits, plus its stored account/character sets. The CVar table's twin:
/// host-registered commands, Lua reads/writes synchronously, the app re-derives dispatch when
/// [`super::UiScript::keybinds_generation`] moves and persists on the queued save requests.
pub(crate) keybinds: super::keybind::KeybindState,
/// The action-slot snapshot (keyed by Lua action id 1..120) + the stance page offset the app
/// pushes, and the `UseAction` intents it drains — the action seam ([`action`]).
pub(crate) actions: HashMap<u32, ActionSlot>,
@@ -761,6 +767,7 @@ impl Model {
cvars: HashMap::new(),
cvar_changes: Vec::new(),
cvars_warned: HashSet::new(),
keybinds: super::keybind::KeybindState::default(),
actions: HashMap::new(),
action_states: HashMap::new(),
bonus_bar_offset: 0,