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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(®istry_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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user