From ff1524f85f32ccaf0c595e7b98606bfb75349525 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 5 Aug 2026 19:04:33 +0700 Subject: [PATCH] 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 --- crates/benilla-app/assets/ui/ActionBar.xml | 30 +- crates/benilla-app/assets/ui/BagFrame.xml | 18 +- .../benilla-app/assets/ui/GameMenuFrame.xml | 36 +- .../assets/ui/KeyBindingsFrame.xml | 798 ++++++++++++++++++ crates/benilla-app/assets/ui/MacroFrame.xml | 13 +- crates/benilla-app/assets/ui/MicroMenu.xml | 35 +- crates/benilla-app/assets/ui/UiPanels.xml | 133 +-- crates/benilla-app/src/bindings.rs | 768 +++++++++++++++++ crates/benilla-app/src/bindings/chord.rs | 419 +++++++++ crates/benilla-app/src/bindings/commands.rs | 751 ++++++++++++++++ crates/benilla-app/src/bindings/store.rs | 194 +++++ crates/benilla-app/src/capture/fixtures.rs | 13 + crates/benilla-app/src/capture/probes.rs | 36 +- crates/benilla-app/src/capture/scenarios.rs | 18 + crates/benilla-app/src/creature_anim.rs | 10 +- .../src/creature_anim/spell_visual.rs | 52 +- .../src/creature_anim/spell_visual/tests.rs | 109 +++ crates/benilla-app/src/entities/missile.rs | 295 +++++-- crates/benilla-app/src/interact/journal.rs | 7 +- crates/benilla-app/src/lib.rs | 4 + crates/benilla-app/src/local_state.rs | 15 + crates/benilla-app/src/player.rs | 128 +-- crates/benilla-app/src/player/camera.rs | 25 +- crates/benilla-app/src/player/follow.rs | 36 +- crates/benilla-app/src/sound/spell.rs | 18 + crates/benilla-app/src/target/click.rs | 11 +- crates/benilla-app/src/target/scan.rs | 43 +- crates/benilla-app/src/ui_action/drain.rs | 65 +- crates/benilla-app/src/ui_action/errors.rs | 213 ++++- crates/benilla-app/src/ui_action/mod.rs | 5 +- crates/benilla-app/src/ui_chat/edit.rs | 26 +- crates/benilla-app/src/ui_chat/input/mod.rs | 30 +- crates/benilla-app/src/ui_chat/input/parse.rs | 26 +- crates/benilla-app/src/ui_chat/tests.rs | 18 +- crates/benilla-app/src/ui_hide.rs | 182 +--- crates/benilla-app/src/ui_macro/mod.rs | 5 +- crates/benilla-app/src/ui_pet.rs | 287 ++++++- crates/benilla-app/src/ui_script/bag_tests.rs | 5 +- .../src/ui_script/game_menu_tests.rs | 20 +- crates/benilla-app/src/ui_script/input.rs | 116 +-- .../src/ui_script/keybindings_tests.rs | 353 ++++++++ .../benilla-app/src/ui_script/macro_tests.rs | 77 +- crates/benilla-app/src/ui_script/mod.rs | 13 + crates/benilla-app/src/vplates.rs | 54 +- crates/benilla-formats/examples/dbc_head.rs | 38 + .../src/bin/benilla-extract/m2dump.rs | 14 + crates/benilla-mpq/examples/mpqcat.rs | 24 + crates/benilla-protocol/src/lib.rs | 5 +- crates/benilla-protocol/src/messages/mod.rs | 2 +- .../src/messages/update_object/fields/mod.rs | 10 + .../src/messages/update_object/fields/unit.rs | 30 + .../src/messages/update_object/mod.rs | 2 +- crates/benilla-ui/src/script/keybind.rs | 546 ++++++++++++ crates/benilla-ui/src/script/mod.rs | 2 + crates/benilla-ui/src/script/model.rs | 7 + 55 files changed, 5537 insertions(+), 653 deletions(-) create mode 100644 crates/benilla-app/assets/ui/KeyBindingsFrame.xml create mode 100644 crates/benilla-app/src/bindings.rs create mode 100644 crates/benilla-app/src/bindings/chord.rs create mode 100644 crates/benilla-app/src/bindings/commands.rs create mode 100644 crates/benilla-app/src/bindings/store.rs create mode 100644 crates/benilla-app/src/ui_script/keybindings_tests.rs create mode 100644 crates/benilla-formats/examples/dbc_head.rs create mode 100644 crates/benilla-mpq/examples/mpqcat.rs create mode 100644 crates/benilla-ui/src/script/keybind.rs diff --git a/crates/benilla-app/assets/ui/ActionBar.xml b/crates/benilla-app/assets/ui/ActionBar.xml index 5acc53a6..721a85b2 100644 --- a/crates/benilla-app/assets/ui/ActionBar.xml +++ b/crates/benilla-app/assets/ui/ActionBar.xml @@ -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 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) diff --git a/crates/benilla-app/assets/ui/BagFrame.xml b/crates/benilla-app/assets/ui/BagFrame.xml index 13cbb99c..11c8a056 100644 --- a/crates/benilla-app/assets/ui/BagFrame.xml +++ b/crates/benilla-app/assets/ui/BagFrame.xml @@ -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 diff --git a/crates/benilla-app/assets/ui/GameMenuFrame.xml b/crates/benilla-app/assets/ui/GameMenuFrame.xml index 643838e4..72dd1937 100644 --- a/crates/benilla-app/assets/ui/GameMenuFrame.xml +++ b/crates/benilla-app/assets/ui/GameMenuFrame.xml @@ -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 @@ - + 28-bottom padding around nine 21-unit rungs with three 20-unit section gaps (the + ninth rung is the 0997 Key Bindings seat). --> + @@ -245,7 +252,24 @@ - + + + diff --git a/crates/benilla-app/assets/ui/KeyBindingsFrame.xml b/crates/benilla-app/assets/ui/KeyBindingsFrame.xml new file mode 100644 index 00000000..4baf1f96 --- /dev/null +++ b/crates/benilla-app/assets/ui/KeyBindingsFrame.xml @@ -0,0 +1,798 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + this:SetBackdropColor(0, 0, 0, 0) + this:SetBackdropBorderColor(0.6, 0.6, 0.6) + + + + + + + + + + + + + + + + this:SetBackdropColor(0, 0, 0, 0) + this:SetBackdropBorderColor(0.6, 0.6, 0.6) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaFauxScrollFrame_OnLoad(this) + this.updateFunc = KeyBindingFrame_Update + + + if not KeyBindingFrame.selected then + BenillaFauxScrollFrame_OnMouseWheel(this, arg1) + end + + + + + + + + + + + + + + + + + + + + + getglobal(this:GetName() .. "Label"):SetText( + KeyBindings_String("CHARACTER_SPECIFIC_KEYBINDINGS", "Character Specific Key Bindings")) + + + PlaySound("igMainMenuOptionCheckBoxOn") + if KeyBindingFrame.bindingsChanged then + StaticPopup_Show("CONFIRM_LOSE_BINDING_CHANGES") + else + KeyBindings_ChangeBindingProfile() + end + + + 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) + + GameTooltip:Hide() + + + + + + + + + + + KeyBindings_LoadCategories() + KeyBindingFrameCharacterButton:SetChecked(GetCurrentBindingSet() == 2 and 1 or nil) + KeyBindings_UpdateHeader() + KeyBindingFrame.bindingsChanged = nil + KeyBindingFrameScrollFrameScrollBar:SetValue(0) + KeyBindingFrame_Update() + + + -- 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) + + + + diff --git a/crates/benilla-app/assets/ui/MacroFrame.xml b/crates/benilla-app/assets/ui/MacroFrame.xml index 4942ff5c..cf26f2c0 100644 --- a/crates/benilla-app/assets/ui/MacroFrame.xml +++ b/crates/benilla-app/assets/ui/MacroFrame.xml @@ -724,10 +724,21 @@ + 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. --> self.benillaTabPadding = -15 self.benillaTabMaxWidth = 150 + self.benillaTabRightInset = 40 self:SetText(BenillaMacroFrame_TabTwoLabel()) diff --git a/crates/benilla-app/assets/ui/MicroMenu.xml b/crates/benilla-app/assets/ui/MicroMenu.xml index d9800a54..f193babb 100644 --- a/crates/benilla-app/assets/ui/MicroMenu.xml +++ b/crates/benilla-app/assets/ui/MicroMenu.xml @@ -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 @@ - BenillaMicroButton_OnLoad(self, "Spellbook", SPELLBOOK_ABILITIES_BUTTON, "P", NEWBIE_TOOLTIP_SPELLBOOK) + BenillaMicroButton_OnLoad(self, "Spellbook", SPELLBOOK_ABILITIES_BUTTON, "TOGGLESPELLBOOK", NEWBIE_TOOLTIP_SPELLBOOK) ToggleSpellBook(BOOKTYPE_SPELL) @@ -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.) --> - BenillaMicroButton_OnLoad(self, "Quest", QUESTLOG_BUTTON, "L", NEWBIE_TOOLTIP_QUESTLOG); UpdateTalentButton() + BenillaMicroButton_OnLoad(self, "Quest", QUESTLOG_BUTTON, "TOGGLEQUESTLOG", NEWBIE_TOOLTIP_QUESTLOG); UpdateTalentButton() ToggleQuestLog() @@ -304,7 +307,7 @@ - BenillaMicroButton_OnLoad(self, "Socials", SOCIAL_BUTTON, "O", NEWBIE_TOOLTIP_SOCIAL) + BenillaMicroButton_OnLoad(self, "Socials", SOCIAL_BUTTON, "TOGGLESOCIAL", NEWBIE_TOOLTIP_SOCIAL) ToggleFriendsFrame(1) @@ -314,7 +317,7 @@ - BenillaMicroButton_OnLoad(self, "World", WORLDMAP_BUTTON, "M", NEWBIE_TOOLTIP_WORLDMAP) + BenillaMicroButton_OnLoad(self, "World", WORLDMAP_BUTTON, "TOGGLEWORLDMAP", NEWBIE_TOOLTIP_WORLDMAP) ToggleWorldMap() diff --git a/crates/benilla-app/assets/ui/UiPanels.xml b/crates/benilla-app/assets/ui/UiPanels.xml index edd4979f..63b97a0e 100644 --- a/crates/benilla-app/assets/ui/UiPanels.xml +++ b/crates/benilla-app/assets/ui/UiPanels.xml @@ -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 ]]> @@ -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 @@ - + + + + BenillaTabButton_OnUpdate(self) diff --git a/crates/benilla-app/src/bindings.rs b/crates/benilla-app/src/bindings.rs new file mode 100644 index 00000000..5537a289 --- /dev/null +++ b/crates/benilla-app/src/bindings.rs @@ -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-`), 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` + `-.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, + seen_generation: Option, +} + +/// 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, + /// Host-edge commands fired this frame. + fired: Vec, + /// 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, + character: Option, + 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::() + .init_resource::() + .init_resource::() + // 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 { + 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>, mut files: ResMut) { + 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) -> Option)>> { + 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>, + roster: Res, + mut files: ResMut, +) { + 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>, files: Res) { + 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>, mut dispatch: ResMut) { + 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>, + mut keyboard: MessageReader, + keys: Res>, + buttons: Res>, + scroll: Res, + capture: Res, + hover: Res, + over_ui: Res, + dispatch: Res, + mut state: ResMut, +) { + 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>, 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 = 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 = 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>, + run_lua: impl Fn(&mut Option>, &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>, + run_lua: impl Fn(&mut Option>, &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::() + .init_resource::() + .init_resource::() + .init_resource::() + .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::() + } + + #[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::().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::().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::().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::().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" + ); + } +} diff --git a/crates/benilla-app/src/bindings/chord.rs b/crates/benilla-app/src/bindings/chord.rs new file mode 100644 index 00000000..cbace2e8 --- /dev/null +++ b/crates/benilla-app/src/bindings/chord.rs @@ -0,0 +1,419 @@ +//! The chord codec (decision 0997): Bevy's physical input ↔ the reference's canonical binding +//! strings — `[ALT-][CTRL-][SHIFT-]`, 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 { + 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 { + 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) + } + ); + } +} diff --git a/crates/benilla-app/src/bindings/commands.rs b/crates/benilla-app/src/bindings/commands.rs new file mode 100644 index 00000000..2a7667d9 --- /dev/null +++ b/crates/benilla-app/src/bindings/commands.rs @@ -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); + } + } +} diff --git a/crates/benilla-app/src/bindings/store.rs b/crates/benilla-app/src/bindings/store.rs new file mode 100644 index 00000000..9b125fbd --- /dev/null +++ b/crates/benilla-app/src/bindings/store.rs @@ -0,0 +1,194 @@ +//! Binding persistence (decision 0997): `benilla/bindings/account.txt` and +//! `benilla/bindings/-.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 [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 { + 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)> { + 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)]) -> Vec<(String, Vec)> { + let mut table: Vec<(String, Vec)> = 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)> = 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" + ); + } +} diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs index f9d8c08f..a3e27181 100644 --- a/crates/benilla-app/src/capture/fixtures.rs +++ b/crates/benilla-app/src/capture/fixtures.rs @@ -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; diff --git a/crates/benilla-app/src/capture/probes.rs b/crates/benilla-app/src/capture/probes.rs index 4fda18e4..fd867a34 100644 --- a/crates/benilla-app/src/capture/probes.rs +++ b/crates/benilla-app/src/capture/probes.rs @@ -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, time: ProbeClock, self_player: Query<(), With>, mut keys: ResMut>, + mut events: MessageWriter, ) { 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>, mut residency: ResidencyMeter, mut keys: ResMut>, + mut key_events: MessageWriter, mut exit: MessageWriter, mut occlusions: MessageReader, ) { @@ -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); } diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index 79f0c581..490a105d 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -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 { diff --git a/crates/benilla-app/src/creature_anim.rs b/crates/benilla-app/src/creature_anim.rs index 19fbcc3a..16101300 100644 --- a/crates/benilla-app/src/creature_anim.rs +++ b/crates/benilla-app/src/creature_anim.rs @@ -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 }, + /// 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 diff --git a/crates/benilla-app/src/creature_anim/spell_visual.rs b/crates/benilla-app/src/creature_anim/spell_visual.rs index f34ea20e..568d3713 100644 --- a/crates/benilla-app/src/creature_anim/spell_visual.rs +++ b/crates/benilla-app/src/creature_anim/spell_visual.rs @@ -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)>, + /// 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, /// 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::(); @@ -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, diff --git a/crates/benilla-app/src/creature_anim/spell_visual/tests.rs b/crates/benilla-app/src/creature_anim/spell_visual/tests.rs index e6e5564a..24fa7303 100644 --- a/crates/benilla-app/src/creature_anim/spell_visual/tests.rs +++ b/crates/benilla-app/src/creature_anim/spell_visual/tests.rs @@ -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::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::(); + 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::>() + .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::>() + .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::>() + .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` /// 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: diff --git a/crates/benilla-app/src/entities/missile.rs b/crates/benilla-app/src/entities/missile.rs index 901fb795..54c1cc40 100644 --- a/crates/benilla-app/src/entities/missile.rs +++ b/crates/benilla-app/src/entities/missile.rs @@ -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, + /// `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, + }, + /// 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, - /// `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, + /// 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, @@ -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 { + 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 { } } -/// 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, - miss: Option, + missile: &Missile, + at: Vec3, impacts: &mut MessageWriter, defenses: &mut MessageWriter, 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, 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 = 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 = 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::>() + .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] diff --git a/crates/benilla-app/src/interact/journal.rs b/crates/benilla-app/src/interact/journal.rs index ed6ddcef..e6921426 100644 --- a/crates/benilla-app/src/interact/journal.rs +++ b/crates/benilla-app/src/interact/journal.rs @@ -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() diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index 2eac4d2d..efe22442 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -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) diff --git a/crates/benilla-app/src/local_state.rs b/crates/benilla-app/src/local_state.rs index 5990ff96..27786341 100644 --- a/crates/benilla-app/src/local_state.rs +++ b/crates/benilla-app/src/local_state.rs @@ -65,6 +65,21 @@ pub(crate) fn macros_character_path(realm: &str, character: &str) -> Option Option { + home().map(|h| h.join("bindings/account.txt")) +} + +/// `benilla/bindings/-.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 { + 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. diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs index 7de42174..3715756b 100644 --- a/crates/benilla-app/src/player.rs +++ b/crates/benilla-app/src/player.rs @@ -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