footprints on snow and sand, and the drunk effect staggers in

- drink enough and the screen blurs and your walk meanders, the camera carried along with the stagger; the underwater blur ships in the same lane
- boots leave prints on snow and sand that fade away in about six seconds, paws and hooves included
- key bindings move into the Options window with the rest of the settings, and action buttons abbreviate their hotkeys the way the client does
- the pet bar rearranges by drag, a running pet spell shows its active icon and a click cancels it, and spell ribbons follow their verified physics

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-05 19:05:51 +07:00
co-authored by Claude Fable 5
parent ff1524f85f
commit 8549f14dd6
77 changed files with 6013 additions and 1391 deletions
@@ -6,7 +6,8 @@
// out = lerp(screen, blur, z) + w · blur² (FFXGlow.bls, exact)
//
// `w` is the per-zone LightParams glow weight (authored data — ≈0.647 in Elwynn), `z` is the haze
// mix (0 in the standard glow pass). The reference runs this in GAMMA bytes, and since 0161 the
// mix — the drunk/underwater screen-toward-blur cross-fade (0 sober and dry; wow-re
// drunk-blur-z.md, decision 1009 §A). The reference runs this in GAMMA bytes, and since 0161 the
// whole frame composites in gamma too — but in a FLOAT buffer, which unlike the reference's byte
// buffer does not saturate at 1.0 per draw. Every read of the scene RT below therefore clamps to
// 1.0 first (the byte semantics restored at the read), does the byte math, and decodes once — the
@@ -96,17 +97,25 @@ fn fs_combine(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
// hard-white after the min() below — the screen term must saturate BEFORE the glow add.
let sg = clamp(scene.rgb, vec3<f32>(0.0), vec3<f32>(1.0));
let bg = max(textureSample(blur_tex, in_samp, in.uv).rgb, vec3<f32>(0.0));
let glowed = sg + glow.x * bg * bg;
// The scene alpha rides through untouched: the window surface ignores it and the opaque-clear
// booths hold 1 everywhere — only the transparent-clear create booth reads it, to composite
// its baked model straight over the UI page.
if glow.y > 0.0 {
// The FFXDeath pack has no z lane (its COLOR rgb is the constant tint) — the ghost
// combine reads the un-hazed screen.
let glowed = sg + glow.x * bg * bg;
let luma = clamp(dot(glowed, vec3<f32>(0.299, 0.587, 0.144)), 0.0, 1.0);
let p = clamp(4.0 * luma * (1.0 - luma), 0.0, 1.0);
let ghost_tint = vec3<f32>(83.0, 147.0, 168.0) / 255.0; // 0x5393A8
let outg = min(vec3<f32>(luma) + ghost_tint * p, vec3<f32>(1.0));
return vec4<f32>(srgb_to_linear(outg), scene.a);
}
let outg = min(glowed, vec3<f32>(1.0));
// The z lane — the drunk/underwater haze: cross-fade the screen toward the ¼-res blur
// BEFORE the glow add (`out = lerp(screen, blur, z) + w·blur²`, the shipped FFXGlow.bls;
// wow-re drunk-blur-z.md). z = max(drunkFraction, 84/255 while the eye is submerged) — 0 in
// the ordinary sober pass, 1 = fully blurred at 100 inebriation. The blur term clamps like
// the screen term: it stands for the same byte RT.
let hazed = mix(sg, min(bg, vec3<f32>(1.0)), glow.z);
let outg = min(hazed + glow.x * bg * bg, vec3<f32>(1.0));
return vec4<f32>(srgb_to_linear(outg), scene.a);
}
+45 -31
View File
@@ -19,9 +19,11 @@
- No frame scale: the real bar is UIParent-scaled; we render 1:1. Geometry is otherwise exact.
- Page up/down + the page number are ART ONLY (we have no bar paging — only the bonus-bar
offset; ref-ActionBarFrame.xml l.159-217). The page number renders a static "1".
- HotKey renders the static binding labels — always non-empty, so the ref's RANGE_INDICATOR
dot path (shown only when a button has NO binding text, ActionButton_UpdateHotkeys) never
applies; the out-of-range read is the hotkey's red tint (the OnUpdate range timer below).
- HotKey renders the LIVE binding label, abbreviated the ref's own way since 1008
(GetBindingText(key, "KEY_", 1) — UIParent.xml carries the transcription; "s-2" for
SHIFT-2, "·" for a 2+-modifier chord). A slot with NO binding wears the ref's
RANGE_INDICATOR dot path instead (red dot only while out of range); a bound slot's
out-of-range read is the label's red tint (the OnUpdate range timer below).
- The state/feedback layer (decision 0137 phase 4) is the ref ActionButton.lua transcribed:
UpdateState (checked on IsCurrentAction/IsAutoRepeatAction), UpdateUsable (the 1/1/1 ·
0.5/0.5/1 · 0.4/0.4/0.4 vertex-color trio), UpdateCooldown (CooldownFrame_SetTimer),
@@ -60,17 +62,31 @@
-- CooldownFrame_SetTimer moved to Cooldown.xml (the ref's own file split) — the bag
-- slots share it, so it loads before both consumers.
-- 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.
-- One button's HotKey corner — the ref's ActionButton_UpdateHotkeys law in FULL since
-- 1008 (closing 0997's raw-token residue): the ABBREVIATED binding text through the
-- real GetBindingText transcription (UIParent.xml — one modifier abbreviates, "s-2";
-- two or more collapse to the ref's "·"), and with NO binding the ref's no-binding
-- leg — an empty label the OnUpdate range timer fills with the RANGE_INDICATOR dot
-- while out of range (until 1008 that leg was multibar-only; now any unbound slot,
-- main bar included, wears it). A harness with no registered bindings paints nothing.
function BenillaActionButton_PaintHotkey(button)
local key = GetBindingKey(button.bindingCommand)
local text
if GetBindingText then
text = GetBindingText(key, "KEY_", 1)
else
-- lone-file harness (the shipped manifest always has UIParent.xml first):
-- the raw token, the MicroMenu.xml fallback idiom.
text = key or ""
end
button.noBindingLabel = (text == "")
button.hotkey:SetText(text)
end
-- Every button's label, repainted on UPDATE_BINDINGS (a rebind, a set switch, the seed).
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
BenillaActionButton_PaintHotkey(BENILLA_ACTION_BUTTONS[i])
end
end
@@ -203,7 +219,7 @@
GameTooltip:SetAction(BenillaActionButton_Action(button))
end
function BenillaActionButton_OnLoad(button, index, base)
function BenillaActionButton_OnLoad(button, index, base, commandPrefix)
button.index = index
button.base = base
button.icon = getglobal(button:GetName() .. "Icon")
@@ -216,17 +232,12 @@
button.flashing = 0
button.flashtime = 0
button.rangeTimer = nil
if base then
-- Multibar buttons are unbound (ref Bindings.xml ships no default MULTIACTIONBAR*
-- 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
-- Main-bar buttons read their ACTIONBUTTON<i> binding live (0997).
button.hotkeyIndex = index
button.hotkey:SetText(GetBindingKey("ACTIONBUTTON" .. index) or "")
end
-- Every bar button carries its binding COMMAND (0997 main bar, 1008 multibars):
-- the caller names the command family (MultiBars.xml passes MULTIACTIONBAR1/2BUTTON
-- — the ref's own bar↔page mapping quoted there); nothing passed = the main bar's
-- ACTIONBUTTON. The label paints from it here and repaints on UPDATE_BINDINGS.
button.bindingCommand = (commandPrefix or "ACTIONBUTTON") .. index
BenillaActionButton_PaintHotkey(button)
table.insert(BENILLA_ACTION_BUTTONS, button)
-- BOTH mouse buttons, ref-verbatim (ActionButton_OnLoad, ActionButton.lua:108-109):
-- an action button is a two-button button. The OnClick body reads no `arg1` — in the
@@ -353,9 +364,10 @@
BenillaActionButton_UpdateState(button)
end
-- Ref ActionButton_OnUpdate:384-428, minus the RANGE_INDICATOR dot (our hotkeys always
-- carry binding text — SCOPE note) and the tooltip refresh (no per-button tooltip yet):
-- the 0.4 s flash toggle, and the 0.2 s range recheck tinting the hotkey red/grey.
-- Ref ActionButton_OnUpdate:384-428, minus the tooltip refresh (no per-button tooltip
-- yet): the 0.4 s flash toggle, and the 0.2 s range recheck — a bound slot's label
-- tints red/grey, an unbound one wears the RANGE_INDICATOR dot (both legs the ref's,
-- unified in 1008 — the SCOPE note).
function BenillaActionButton_OnUpdate(button, elapsed)
if ( button.flashing == 1 ) then
button.flashtime = button.flashtime - elapsed
@@ -376,14 +388,16 @@
button.rangeTimer = button.rangeTimer - elapsed
if ( button.rangeTimer <= 0 ) then
if ( IsActionInRange(BenillaActionButton_Action(button)) == 0 ) then
-- An unbound (multibar) button has no label to tint: paint the ref's
-- RANGE_INDICATOR dot instead (net ref behavior — red dot iff out of range).
if button.base then
-- An UNBOUND button has no label to tint: paint the ref's
-- RANGE_INDICATOR dot instead (net ref behavior — red dot iff out of
-- range). Since 1008 the fork is the binding text itself (any bar's
-- slot can be bound or bare now), not which bar owns the button.
if button.noBindingLabel then
button.hotkey:SetText("●")
end
button.hotkey:SetVertexColor(1.0, 0.1, 0.1)
else
if button.base then
if button.noBindingLabel then
button.hotkey:SetText("")
end
button.hotkey:SetVertexColor(0.6, 0.6, 0.6)
+9 -31
View File
@@ -28,7 +28,7 @@
not the gold a normal panel button wears, and grey when disabled.
· **The labels** — GlobalStrings.lua (patch-2.MPQ), verbatim: VIDEOOPTIONS_MENU "Video
Options", SOUNDOPTIONS_MENU "Sound Options", UIOPTIONS_MENU "Interface Options",
KEY_BINDINGS "Key Bindings", MACROS "Macros", LOGOUT "Logout", EXIT_GAME "Exit Game",
MACROS "Macros", LOGOUT "Logout", EXIT_GAME "Exit Game",
RETURN_TO_GAME "Return to Game", MAIN_MENU "Main Menu". Declared as the real globals
(they are addon-visible API, decision 0068) and read back into each button's label in its
OnLoad — the ref's `text="LOGOUT"` attribute IS a global lookup, which this engine's
@@ -63,13 +63,12 @@
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/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.
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.
· No Key Bindings rung — the era menu's own shape: 1.15 ceded the entry to the
Settings panel, and since decision 1008 ours lives there too (the Options window's
Keybindings category, KeyBindingsPage.xml). 0997 had carved the 1.13/1.14 seat back
in for its standalone window; that seat left with the window.
· 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
@@ -90,7 +89,6 @@
-- 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"
@@ -203,7 +201,7 @@
<!-- The era layout engine's own numbers (header SCOPE note): 28+144+28 wide, 32-top /
28-bottom padding around nine 21-unit rungs with three 20-unit section gaps (the
ninth rung is the 0997 Key Bindings seat). -->
<Size><AbsDimension x="200" y="309"/></Size>
<Size><AbsDimension x="200" y="288"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Backdrop bgFile="Interface\DialogFrame\UI-DialogBox-Background" edgeFile="Interface\DialogFrame\UI-DialogBox-Border" tile="true">
<BackgroundInsets><AbsInset left="11" right="12" top="12" bottom="11"/></BackgroundInsets>
@@ -255,29 +253,9 @@
<!-- Key Bindings — live (decision 0997; the carved 1.13/1.14 seat, header SCOPE
note): the era menu entry's own body shape — hide the menu, show the window
(whose OnHide brings the menu back, both references' law). -->
<Button name="GameMenuButtonKeybindings" inherits="GameMenuButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonOptions" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-20"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>self:SetText(KEY_BINDINGS)</OnLoad>
<OnClick>
PlaySound("igMainMenuOption")
HideUIPanel(GameMenuFrame)
ShowUIPanel(getglobal("KeyBindingFrame"))
</OnClick>
</Scripts>
</Button>
<!-- The middle section's pending rest (header SCOPE note).
AddOns: the era addon-list dialog — nothing to list yet.
Edit Mode: the era HUD editor — no edit mode exists.
Support: opens the external help site in era — no browser seam.
Macros: ref OnClick when live: PlaySound("igMainMenuOption");
HideUIPanel(GameMenuFrame); ShowMacroFrame() -->
<Button name="GameMenuButtonAddOns" inherits="GameMenuButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonKeybindings" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonOptions" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-20"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>GameMenuButton_Pending(self, ADDONS)</OnLoad></Scripts>
</Button>
@@ -1,798 +0,0 @@
<Ui>
<!--
The KEY BINDINGS window (decision 0997) — the era-shaped standalone KeyBindingFrame the
game menu's Key Bindings button opens, over the engine's binding table
(benilla_ui::script::keybind + crate::bindings — GetNumBindings/GetBinding/SetBinding/
GetBindingKey/GetBindingAction/LoadBindings/SaveBindings/GetCurrentBindingSet, the 1.12
Lua API verbatim). Rows are the HONEST TREE: only commands the engine really dispatches
are registered, so only they (and only their non-empty categories) appear.
PROVENANCE (0985 — the three-source split; OptionsFrame.xml carries the sibling block):
· STRUCTURE & BEHAVIOR — the era Blizzard_BindingUI (wow-ui-source-era @ 1.15.9.68940,
Interface/AddOns/Blizzard_BindingUI — shipped there though 1.15's Settings supersedes
it; per 0985's per-window rule the newest classic-dialog form of THIS window is the
1.13/1.14 one, and that source IS this file's blueprint, the director's own screenshot):
858x660 CENTER dialog, the 175-wide category sidebar replacing 1.12's inline headers
(only non-empty categories appear), the bordered bindings container, Command/Key 1/
Key 2 columns over 25-tall rows, the top-right Character Specific checkbox, and the
Reset To Default / Unbind Key / Okay / Cancel row. Behavior deltas the era added and
this file keeps: Reset To Default confirms first; checkbox-kit click sounds.
· CAPTURE & SET LAW — 1.12's own Blizzard_BindingUI (extracted from patch.MPQ this
session, `_extracted_addons/`), which the era Lua descends from line-for-line: click a
key capsule to arm it; the next key or mouse-3/4/5 press binds IMMEDIATELY (the host's
capture seam hands the canonical ALT-CTRL-SHIFT chord to KeyBindingFrame_OnHostKey);
a stolen key names its victim in red (KEY_UNBOUND_ERROR); left/right clicks select/
deselect, never bind; lone modifiers are ignored; the wheel binds click-commands but
refuses press+release ones (SetBinding returns nil - KEYBINDINGFRAME_MOUSEWHEEL_ERROR);
binds take effect LIVE; Okay saves (SaveBindings), Cancel/ESC reverts
(LoadBindings(GetCurrentBindingSet())); the character-specific set is real 1.12
(DEFAULT/ACCOUNT/CHARACTER = 0/1/2, both confirm popups).
· ART BYTES — the 1.12 MPQ chain only (0978/0985): the same dialog chrome as
OptionsFrame.xml (BACKDROP_DIALOG_32_32 recipe: tiled UI-DialogBox-Background at its
uniform black-60% + the UI-DialogBox-Border rope; UI-DialogBox-Header title plate),
the tooltip-border OUTLINE boxes, the QuestLogTitleHighlight category wash law, the
UI-Panel-Button four-state red buttons, the UI-CheckBox set, the shared scroll kit.
The era's key capsules are UIMenuButtonStretchTemplate over UI-Silver-Button-* —
VERIFIED ABSENT from the 1.12 chain — so the capsules wear what 1.12's OWN binding
window wore: UIPanelButtonTemplate2's panel-button art (the selected state is the
1.12 window's own LockHighlight glow, not the era's silver overlay).
All strings are the 1.12 GlobalStrings globals (the VM executes the real file at boot):
KEY_BINDINGS, COMMAND/KEY1/KEY2, NOT_BOUND, BIND_KEY_TO_COMMAND, KEY_BOUND,
KEY_UNBOUND_ERROR, RESET_TO_DEFAULT, UNBIND, CHARACTER_SPECIFIC_KEYBINDINGS(+TOOLTIP),
CHARACTER_KEY_BINDINGS, CONFIRM_LOSE_BINDING_CHANGES,
CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS, KEYBINDINGFRAME_MOUSEWHEEL_ERROR, the
BINDING_HEADER_*/BINDING_NAME_* row text and the KEY_* key display names — each read
through a `getglobal(...) or raw` fallback (the era's own `_G[category] or category`
posture), so a headless test without GlobalStrings still renders raw tokens.
The era's CONFIRM_RESET_KEYBINDINGS string is 2.x-era text absent from 1.12
GlobalStrings; the fallback literal below carries it (structure kept, string ours).
SCOPE / divergences (noted, not bugs):
- 20 display rows, not the era's 21: the -52 box top (the title plate's clearance, the
options window's own band) plus the column-label band leaves 500 units of row run.
- No push-to-talk row, no gamepad arm, no quick-keybind mode (era furniture with no
benilla mechanism).
- ESC while a capsule is armed BINDS Escape — 1.12's own law (its OnKeyDown has no
ESCAPE filter while selected); ESC with nothing armed cancels and closes through the
shared ladder (UiPanels.xml), reopening the game menu (both references' OnHide).
-->
<!-- The red panel button at this window's sizes (GameMenuFrame.xml l.145 is the shared
original; templates don't cross files — OptionsFrame.xml carries the same copy). -->
<Button name="KeyBindingsRedButtonTemplate" virtual="true">
<Size><AbsDimension x="96" y="22"/></Size>
<ButtonText name="$parentText"/>
<NormalTexture file="Interface\Buttons\UI-Panel-Button-Up"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Panel-Button-Down"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></PushedTexture>
<DisabledTexture file="Interface\Buttons\UI-Panel-Button-Disabled"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></DisabledTexture>
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></HighlightTexture>
<NormalFont inherits="GameFontHighlight"/>
<HighlightFont inherits="GameFontHighlight"/>
<DisabledFont inherits="GameFontDisable"/>
</Button>
<!-- One KEY CAPSULE (1.12 KeyBindingFrameBindingButtonTemplate: UIPanelButtonTemplate2 at
180x22 with the small-highlight font trio; its RegisterForClicks takes all five
buttons so middle/4/5 presses reach the capture law). The SELECTED state is 1.12's
LockHighlight — the ADD highlight held on. -->
<Button name="KeyBindingButtonTemplate" virtual="true">
<Size><AbsDimension x="180" y="22"/></Size>
<ButtonText name="$parentText"/>
<NormalTexture file="Interface\Buttons\UI-Panel-Button-Up"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Panel-Button-Down"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></PushedTexture>
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></HighlightTexture>
<NormalFont inherits="GameFontHighlightSmall"/>
<HighlightFont inherits="GameFontHighlightSmall"/>
<DisabledFont inherits="GameFontDisableSmall"/>
<Scripts>
<OnLoad>this:RegisterForClicks("LeftButtonUp", "RightButtonUp", "MiddleButtonUp", "Button4Up", "Button5Up")</OnLoad>
<OnClick>KeyBindingButton_OnClick(arg1)</OnClick>
</Scripts>
</Button>
<!-- One BINDING ROW (1.12 KeyBindingFrameBindingTemplate at the era row seat: 25 tall,
command name 170 wide at LEFT, the two capsules at 175/355 — no inline header slot:
the sidebar replaced 1.12's in-list headers, and the honest tree has no sub-headers). -->
<Frame name="KeyBindingRowTemplate" virtual="true">
<Size><AbsDimension x="560" y="25"/></Size>
<Layers>
<Layer level="BACKGROUND">
<FontString name="$parentDescription" inherits="GameFontNormalSmall" justifyH="LEFT">
<Size><AbsDimension x="170" y="24"/></Size>
<Anchors><Anchor point="LEFT"/></Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<Button name="$parentKey1Button" inherits="KeyBindingButtonTemplate" id="1">
<Anchors><Anchor point="LEFT"><Offset><AbsDimension x="175" y="0"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentKey2Button" inherits="KeyBindingButtonTemplate" id="2">
<Anchors><Anchor point="LEFT" relativeTo="$parentKey1Button" relativePoint="RIGHT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Button>
</Frames>
</Frame>
<!-- One CATEGORY row — the options window's 1.14 list-button wash law verbatim (0984:
ONE UI-QuestLogTitleHighlight quad in ADD, steel-blue hover / locked gold selection,
white text both ways; the era KeybindingsCategoryListButtonTemplate is the same
OptionsListButtonTemplate family). 175 wide at the era sidebar seat. -->
<Button name="KeyBindingsCategoryRowTemplate" virtual="true" hidden="true">
<Size><AbsDimension x="175" y="20"/></Size>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentBg" file="Interface\QuestFrame\UI-QuestLogTitleHighlight" alphaMode="ADD" hidden="true">
<Size><AbsDimension x="163" y="21"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
</Texture>
</Layer>
</Layers>
<ButtonText name="$parentText" justifyH="LEFT">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="16" y="1"/></Offset></Anchor>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="1"/></Offset></Anchor>
</Anchors>
</ButtonText>
<NormalFont inherits="GameFontNormal"/>
<HighlightFont inherits="GameFontHighlight"/>
<DisabledFont inherits="GameFontDisable"/>
<Scripts>
<OnClick>KeyBindingsCategory_OnClick(this)</OnClick>
<OnEnter>
if KeyBindingFrame.selectedCategory ~= this.categoryIndex then
local bg = getglobal(this:GetName() .. "Bg")
bg:SetVertexColor(0.196, 0.388, 0.8)
bg:Show()
end
</OnEnter>
<OnLeave>
if KeyBindingFrame.selectedCategory ~= this.categoryIndex then
getglobal(this:GetName() .. "Bg"):Hide()
end
</OnLeave>
</Scripts>
</Button>
<Script><![CDATA[
-- ── Strings ── the 1.12 GlobalStrings globals, each with the raw fallback (a headless
-- harness without GlobalStrings still works; the era's own `_G[x] or x` posture).
function KeyBindings_String(name, fallback)
local s = getglobal(name)
if s then return s end
return fallback
end
-- ── The category model (era KeyBindingFrame_LoadCategories, simplified by the honest
-- tree): walk GetBinding(i), group runs of one category token; order = first
-- appearance, which IS 1.12's Bindings.xml order (the registry registers in it).
-- Rebuilt on every show, not load: the app seeds the table at PostStartup, after this
-- file's OnLoads ran.
function KeyBindings_LoadCategories()
local frame = KeyBindingFrame
frame.categories = {}
local byToken = {}
for i = 1, GetNumBindings() do
local _, category = GetBinding(i)
local cat = byToken[category]
if not cat then
cat = { token = category, indices = {} }
byToken[category] = cat
table.insert(frame.categories, cat)
end
table.insert(cat.indices, i)
end
for n = 1, 12 do
local row = getglobal("KeyBindingFrameCategory" .. n)
local cat = frame.categories[n]
if cat then
row.categoryIndex = n
row:SetText(KeyBindings_String(cat.token, cat.token))
row:Show()
else
row:Hide()
end
end
if not frame.categories[frame.selectedCategory or 0] then
frame.selectedCategory = 1
end
KeyBindings_PaintCategories()
end
-- The selection paint (the options window's gold-lock / blue-hover law).
function KeyBindings_PaintCategories()
for n = 1, 12 do
local bg = getglobal("KeyBindingFrameCategory" .. n .. "Bg")
if n == KeyBindingFrame.selectedCategory then
bg:SetVertexColor(1, 1, 0)
bg:Show()
else
bg:Hide()
end
end
end
function KeyBindingsCategory_OnClick(row)
PlaySound("igMainMenuOptionCheckBoxOn")
KeyBindingFrame.selectedCategory = row.categoryIndex
KeyBindings_SetSelected(nil)
KeyBindingFrameOutputText:SetText("")
KeyBindings_PaintCategories()
KeyBindingFrameScrollFrameScrollBar:SetValue(0)
KeyBindingFrame_Update()
end
-- ── Key display ── the 1.12 GetBindingText law by hand: modifier prefixes stay the
-- canonical uppercase tokens, the base key reads its KEY_* global (Spacebar, Up Arrow,
-- Mouse Button 4...) else itself. "CTRL--" renders CTRL + the minus key.
function KeyBindings_KeyText(key)
if not key then
return NORMAL_FONT_COLOR_CODE .. KeyBindings_String("NOT_BOUND", "Not Bound") .. FONT_COLOR_CODE_CLOSE
end
local prefix = ""
local rest = key
while true do
local p = string.sub(rest, 1, 4)
if p == "ALT-" then
prefix = prefix .. "ALT-"
rest = string.sub(rest, 5)
elseif string.sub(rest, 1, 5) == "CTRL-" then
prefix = prefix .. "CTRL-"
rest = string.sub(rest, 6)
elseif string.sub(rest, 1, 6) == "SHIFT-" then
prefix = prefix .. "SHIFT-"
rest = string.sub(rest, 7)
else
break
end
end
return prefix .. KeyBindings_String("KEY_" .. rest, rest)
end
-- ── The list repaint (1.12 KeyBindingFrame_Update over the era category page). -->
KEY_BINDINGS_DISPLAYED = 20
KEY_BINDING_HEIGHT = 25
function KeyBindingFrame_Update()
local frame = KeyBindingFrame
local cat = frame.categories and frame.categories[frame.selectedCategory]
local indices = cat and cat.indices or {}
local numBindings = table.getn(indices)
local offset = BenillaFauxScrollFrame_GetOffset(KeyBindingFrameScrollFrame)
for i = 1, KEY_BINDINGS_DISPLAYED do
local row = getglobal("KeyBindingFrameBinding" .. i)
local index = indices[offset + i]
if index then
local command, _, key1, key2 = GetBinding(index)
local b1 = getglobal("KeyBindingFrameBinding" .. i .. "Key1Button")
local b2 = getglobal("KeyBindingFrameBinding" .. i .. "Key2Button")
b1.commandName = command
b2.commandName = command
b1:SetText(KeyBindings_KeyText(key1))
b2:SetText(KeyBindings_KeyText(key2))
if key1 then b1:SetAlpha(1) else b1:SetAlpha(0.8) end
if key2 then b2:SetAlpha(1) else b2:SetAlpha(0.8) end
getglobal("KeyBindingFrameBinding" .. i .. "Description"):SetText(
KeyBindings_String("BINDING_NAME_" .. command, command))
-- The 1.12 selection highlight: the pressed capsule holds its glow.
if frame.selected == command and frame.keyID == 1 then
b1:LockHighlight()
else
b1:UnlockHighlight()
end
if frame.selected == command and frame.keyID == 2 then
b2:LockHighlight()
else
b2:UnlockHighlight()
end
row:Show()
else
row:Hide()
end
end
BenillaFauxScrollFrame_Update(KeyBindingFrameScrollFrame, numBindings,
KEY_BINDINGS_DISPLAYED, KEY_BINDING_HEIGHT)
KeyBindings_UpdateUnbindKey()
end
-- ── Selection + the capture arm ── selecting a capsule arms the HOST's capture seam
-- (BenillaBindCapture): raw input is swallowed there and comes back canonical through
-- KeyBindingFrame_OnHostKey. Deselecting (either way) disarms.
function KeyBindings_SetSelected(command, keyID)
KeyBindingFrame.selected = command
KeyBindingFrame.keyID = keyID
BenillaBindCapture(command ~= nil)
end
function KeyBindingButton_OnClick(button)
-- The 1.12 law: while something is selected, only LEFT/RIGHT on a capsule
-- re-select or deselect — a middle/4/5 press is a BIND, and the host's armed
-- capture seam (which runs after this click) takes it, so this handler must not
-- touch the selection for those buttons.
if KeyBindingFrame.selected then
if button ~= "LeftButton" and button ~= "RightButton" then
return
end
if KeyBindingFrame.selected == this.commandName and KeyBindingFrame.keyID == this:GetID() then
KeyBindings_SetSelected(nil)
KeyBindingFrameOutputText:SetText("")
else
KeyBindings_SetSelected(this.commandName, this:GetID())
KeyBindingFrameOutputText:SetText(string.format(
KeyBindings_String("BIND_KEY_TO_COMMAND", "Press Key to Bind to Command -> %s"),
KeyBindings_String("BINDING_NAME_" .. this.commandName, this.commandName)))
end
else
KeyBindings_SetSelected(this.commandName, this:GetID())
KeyBindingFrameOutputText:SetText(string.format(
KeyBindings_String("BIND_KEY_TO_COMMAND", "Press Key to Bind to Command -> %s"),
KeyBindings_String("BINDING_NAME_" .. this.commandName, this.commandName)))
end
KeyBindingFrame_Update()
end
-- The host's capture callback: `chord` is canonical (ALT-CTRL-SHIFT-TOKEN). The body is
-- 1.12's OnKeyDown selected-branch verbatim: steal message, clear the command's keys,
-- rebind in slot order so the pressed slot takes the new chord.
function KeyBindingFrame_OnHostKey(chord)
local frame = KeyBindingFrame
if not frame.selected then
return
end
local selected = frame.selected
local oldAction = GetBindingAction(chord)
if oldAction ~= "" and oldAction ~= selected then
local k1, k2 = GetBindingKey(oldAction)
if (not k1 or k1 == chord) and (not k2 or k2 == chord) then
KeyBindingFrameOutputText:SetText(string.format(
KeyBindings_String("KEY_UNBOUND_ERROR", "|cffff0000%s Function is Now Unbound!|r"),
KeyBindings_String("BINDING_NAME_" .. oldAction, oldAction)))
else
KeyBindingFrameOutputText:SetText(KeyBindings_String("KEY_BOUND", "Key Bound Successfully"))
end
else
KeyBindingFrameOutputText:SetText(KeyBindings_String("KEY_BOUND", "Key Bound Successfully"))
end
local key1, key2 = GetBindingKey(selected)
if key1 then SetBinding(key1) end
if key2 then SetBinding(key2) end
if frame.keyID == 1 then
KeyBindings_SetOrRestore(chord, selected, key1)
if key2 then SetBinding(key2, selected) end
else
if key1 then SetBinding(key1, selected) end
KeyBindings_SetOrRestore(chord, selected, key2)
end
frame.bindingsChanged = 1
KeyBindings_SetSelected(nil)
KeyBindingFrame_Update()
end
-- 1.12 KeyBindingFrame_SetBinding: a refused bind (the wheel on a press+release
-- command) restores the slot's old key and says why.
function KeyBindings_SetOrRestore(key, command, oldKey)
if not SetBinding(key, command) then
if oldKey then SetBinding(oldKey, command) end
KeyBindingFrameOutputText:SetText(KeyBindings_String(
"KEYBINDINGFRAME_MOUSEWHEEL_ERROR",
"Can't bind mousewheel to actions with up and down states"))
end
end
function KeyBindings_UpdateUnbindKey()
if KeyBindingFrame.selected then
KeyBindingFrameUnbindButton:Enable()
else
KeyBindingFrameUnbindButton:Disable()
end
end
-- ── The bottom row + checkbox (1.12 bodies, era confirm on Reset To Default) ──
function KeyBindings_Unbind()
local frame = KeyBindingFrame
local key1, key2 = GetBindingKey(frame.selected)
if key1 then SetBinding(key1) end
if key2 then SetBinding(key2) end
if key1 and frame.keyID == 1 then
if key2 then SetBinding(key2, frame.selected) end
else
if key1 then SetBinding(key1, frame.selected) end
end
frame.bindingsChanged = 1
KeyBindings_SetSelected(nil)
KeyBindingFrameOutputText:SetText("")
KeyBindingFrame_Update()
end
function KeyBindings_UpdateHeader()
if KeyBindingFrameCharacterButton:GetChecked() then
KeyBindingFrameTitleText:SetText(string.format(
KeyBindings_String("CHARACTER_KEY_BINDINGS", "Key Bindings for %s"),
UnitName("player") or UNKNOWNOBJECT or "Unknown"))
else
KeyBindingFrameTitleText:SetText(KeyBindings_String("KEY_BINDINGS", "Key Bindings"))
end
end
function KeyBindings_ChangeBindingProfile()
if KeyBindingFrameCharacterButton:GetChecked() then
LoadBindings(2)
else
LoadBindings(1)
end
KeyBindings_UpdateHeader()
KeyBindingFrameOutputText:SetText("")
KeyBindings_SetSelected(nil)
KeyBindingFrame_Update()
end
function KeyBindings_Okay()
local which
if KeyBindingFrameCharacterButton:GetChecked() then
which = 2
else
which = 1
-- 1.12 guards on `GetCurrentBindingSet() == 2` here; in benilla's set model a
-- character set EXISTS exactly while it is the active one (its file's presence
-- IS the state — decision 0997), so the honest guard for "this Okay will
-- permanently delete the character set" is existence, which also catches the
-- uncheck-then-Okay path 1.12's literal guard let slip past its own warning.
if BenillaCharacterBindingsExist() and not CONFIRMED_DELETING_CHARACTER_SPECIFIC_BINDINGS then
StaticPopup_Show("CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS")
return
end
end
SaveBindings(which)
KeyBindingFrameOutputText:SetText("")
KeyBindings_SetSelected(nil)
HideUIPanel(KeyBindingFrame)
end
-- Cancel / the ESC ladder's rung (UiPanels.xml): revert to the saved set and close.
function KeyBindingFrame_Cancel()
LoadBindings(GetCurrentBindingSet())
KeyBindingFrameOutputText:SetText("")
KeyBindings_SetSelected(nil)
HideUIPanel(KeyBindingFrame)
end
StaticPopupDialogs["CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS"] = {
text = KeyBindings_String("CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS",
"Really switch to general key bindings? All key bindings specific to this character will be permanantly deleted."),
button1 = KeyBindings_String("OKAY", "Okay"),
button2 = KeyBindings_String("CANCEL", "Cancel"),
OnAccept = function()
SaveBindings(1)
KeyBindingFrameOutputText:SetText("")
KeyBindings_SetSelected(nil)
CONFIRMED_DELETING_CHARACTER_SPECIFIC_BINDINGS = 1
HideUIPanel(KeyBindingFrame)
end,
}
StaticPopupDialogs["CONFIRM_LOSE_BINDING_CHANGES"] = {
text = KeyBindings_String("CONFIRM_LOSE_BINDING_CHANGES",
"You will lose any unsaved changes if you switch between general and character specific key bindings."),
button1 = KeyBindings_String("OKAY", "Okay"),
button2 = KeyBindings_String("CANCEL", "Cancel"),
OnAccept = function()
KeyBindings_ChangeBindingProfile()
KeyBindingFrame.bindingsChanged = nil
end,
OnCancel = function()
if KeyBindingFrameCharacterButton:GetChecked() then
KeyBindingFrameCharacterButton:SetChecked(nil)
else
KeyBindingFrameCharacterButton:SetChecked(1)
end
end,
}
-- Era addition kept (structure law): Reset To Default confirms first. 1.12 has no
-- string for it; the literal is ours (the era's lives in its client binary).
StaticPopupDialogs["CONFIRM_RESET_KEYBINDINGS"] = {
text = "Are you sure you want to reset all keybindings to their defaults?",
button1 = KeyBindings_String("OKAY", "Okay"),
button2 = KeyBindings_String("CANCEL", "Cancel"),
OnAccept = function()
PlaySound("igMainMenuOptionCheckBoxOn")
LoadBindings(0)
KeyBindingFrameOutputText:SetText("")
KeyBindings_SetSelected(nil)
KeyBindingFrame_Update()
end,
}
]]></Script>
<Frame name="KeyBindingFrame" frameStrata="DIALOG" enableMouse="true" hidden="true">
<Size><AbsDimension x="858" y="660"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<!-- The 1.14 dialog recipe, OptionsFrame.xml's own block (BACKDROP_DIALOG_32_32 —
which is ALSO the era KeyBindingFrame's declared backdrop, a rare exact meet). -->
<Backdrop bgFile="Interface\DialogFrame\UI-DialogBox-Background" edgeFile="Interface\DialogFrame\UI-DialogBox-Border" tile="true">
<TileSize><AbsValue val="32"/></TileSize>
<EdgeSize><AbsValue val="32"/></EdgeSize>
<BackgroundInsets><AbsInset left="11" right="12" top="12" bottom="11"/></BackgroundInsets>
</Backdrop>
<Layers>
<Layer level="ARTWORK">
<Texture name="$parentHeader" file="Interface\DialogFrame\UI-DialogBox-Header">
<Size><AbsDimension x="300" y="68"/></Size>
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="0" y="12"/></Offset></Anchor></Anchors>
</Texture>
<!-- The column labels + their hairline (the options divider idiom: a 1-unit
white fill at 0.3 — what the era's atlas hairline actually is), across the
bindings box's label band. Seats echo the era chain (command at the box
left; Key 1/Key 2 over their capsule columns). -->
<FontString name="$parentCommandLabel" inherits="GameFontNormal" text="Command">
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="227" y="-64"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="$parentKey1Label" inherits="GameFontNormal" text="Key 1">
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="60" y="-64"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="$parentKey2Label" inherits="GameFontNormal" text="Key 2">
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="240" y="-64"/></Offset></Anchor></Anchors>
</FontString>
<Texture name="$parentColumnLine">
<Size><AbsDimension y="1"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="221" y="-82"/></Offset></Anchor>
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-30" y="-82"/></Offset></Anchor>
</Anchors>
<Color r="1" g="1" b="1" a="0.3"/>
</Texture>
</Layer>
<Layer level="OVERLAY">
<FontString name="$parentTitleText" inherits="GameFontNormal" text="Key Bindings">
<Anchors>
<Anchor point="TOP" relativeTo="$parentHeader"><Offset><AbsDimension x="0" y="-14"/></Offset></Anchor>
</Anchors>
</FontString>
<FontString name="$parentOutputText" inherits="GameFontNormal">
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="90" y="46"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- The two outline boxes (the options window's tooltip-border law). -->
<Frame name="$parentCategoryBox">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="14" y="-52"/></Offset></Anchor>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-669" y="52"/></Offset></Anchor>
</Anchors>
<Backdrop bgFile="Interface\Tooltips\UI-Tooltip-Background" edgeFile="Interface\Tooltips\UI-Tooltip-Border" tile="true">
<EdgeSize><AbsValue val="16"/></EdgeSize>
<TileSize><AbsValue val="16"/></TileSize>
<BackgroundInsets><AbsInset left="5" right="5" top="5" bottom="5"/></BackgroundInsets>
</Backdrop>
<Scripts>
<OnLoad>
this:SetBackdropColor(0, 0, 0, 0)
this:SetBackdropBorderColor(0.6, 0.6, 0.6)
</OnLoad>
</Scripts>
</Frame>
<Frame name="$parentBindingsBox">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="215" y="-88"/></Offset></Anchor>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-14" y="52"/></Offset></Anchor>
</Anchors>
<Backdrop bgFile="Interface\Tooltips\UI-Tooltip-Background" edgeFile="Interface\Tooltips\UI-Tooltip-Border" tile="true">
<EdgeSize><AbsValue val="16"/></EdgeSize>
<TileSize><AbsValue val="16"/></TileSize>
<BackgroundInsets><AbsInset left="5" right="5" top="5" bottom="5"/></BackgroundInsets>
</Backdrop>
<Scripts>
<OnLoad>
this:SetBackdropColor(0, 0, 0, 0)
this:SetBackdropBorderColor(0.6, 0.6, 0.6)
</OnLoad>
</Scripts>
</Frame>
<!-- The category rows (12 static slots; the honest tree has 8 categories today —
spares stay hidden, the fill is KeyBindings_LoadCategories). -->
<Button name="$parentCategory1" inherits="KeyBindingsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentCategoryBox"><Offset><AbsDimension x="6" y="-8"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentCategory2" inherits="KeyBindingsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentCategory1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentCategory3" inherits="KeyBindingsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentCategory2" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentCategory4" inherits="KeyBindingsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentCategory3" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentCategory5" inherits="KeyBindingsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentCategory4" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentCategory6" inherits="KeyBindingsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentCategory5" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentCategory7" inherits="KeyBindingsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentCategory6" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentCategory8" inherits="KeyBindingsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentCategory7" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentCategory9" inherits="KeyBindingsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentCategory8" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentCategory10" inherits="KeyBindingsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentCategory9" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentCategory11" inherits="KeyBindingsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentCategory10" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentCategory12" inherits="KeyBindingsCategoryRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentCategory11" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
</Button>
<!-- The 20 binding rows, chained at the era +2 spacing inside the bindings box. -->
<Frame name="$parentBinding1" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBindingsBox"><Offset><AbsDimension x="12" y="-8"/></Offset></Anchor></Anchors>
</Frame>
<Frame name="$parentBinding2" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding1" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding3" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding2" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding4" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding3" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding5" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding4" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding6" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding5" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding7" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding6" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding8" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding7" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding9" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding8" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding10" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding9" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding11" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding10" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding12" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding11" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding13" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding12" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding14" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding13" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding15" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding14" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding16" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding15" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding17" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding16" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding18" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding17" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding19" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding18" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<Frame name="$parentBinding20" inherits="KeyBindingRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBinding19" relativePoint="BOTTOMLEFT"/></Anchors>
</Frame>
<!-- The faux scroll over the rows (the shared kit; the ref's FauxScrollFrame at the
same seam). The wheel guard is the era's own: while a capsule is armed, the
wheel is a BIND (the host arm swallows it) — never a scroll. -->
<Frame name="$parentScrollFrame" inherits="BenillaFauxScrollFrameTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentBindingsBox"><Offset><AbsDimension x="8" y="-8"/></Offset></Anchor>
<Anchor point="BOTTOMRIGHT" relativeTo="$parentBindingsBox"><Offset><AbsDimension x="-26" y="8"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>
BenillaFauxScrollFrame_OnLoad(this)
this.updateFunc = KeyBindingFrame_Update
</OnLoad>
<OnMouseWheel>
if not KeyBindingFrame.selected then
BenillaFauxScrollFrame_OnMouseWheel(this, arg1)
end
</OnMouseWheel>
</Scripts>
</Frame>
<!-- Character Specific Key Bindings — the 1.12 checkbox at the era's top-right seat,
with 1.12's own confirm-on-unsaved-changes law and tooltip. -->
<CheckButton name="$parentCharacterButton">
<Size><AbsDimension x="20" y="20"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-260" y="-24"/></Offset></Anchor></Anchors>
<Layers>
<Layer level="OVERLAY">
<FontString name="$parentLabel" inherits="GameFontHighlightSmall" justifyH="LEFT">
<Anchors><Anchor point="LEFT" relativePoint="RIGHT"><Offset><AbsDimension x="2" y="0"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<NormalTexture file="Interface\Buttons\UI-CheckBox-Up"/>
<PushedTexture file="Interface\Buttons\UI-CheckBox-Down"/>
<CheckedTexture file="Interface\Buttons\UI-CheckBox-Check"/>
<DisabledCheckedTexture file="Interface\Buttons\UI-CheckBox-Check-Disabled"/>
<Scripts>
<OnLoad>
getglobal(this:GetName() .. "Label"):SetText(
KeyBindings_String("CHARACTER_SPECIFIC_KEYBINDINGS", "Character Specific Key Bindings"))
</OnLoad>
<OnClick>
PlaySound("igMainMenuOptionCheckBoxOn")
if KeyBindingFrame.bindingsChanged then
StaticPopup_Show("CONFIRM_LOSE_BINDING_CHANGES")
else
KeyBindings_ChangeBindingProfile()
end
</OnClick>
<OnEnter>
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetText(KeyBindings_String("CHARACTER_SPECIFIC_KEYBINDING_TOOLTIP",
"Click this to toggle between general key bindings and key bindings specific to this character."), nil, nil, nil, nil, 1)
</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</CheckButton>
<!-- The bottom row — the era seats verbatim (defaults left; unbind/okay/cancel right). -->
<Button name="$parentDefaultButton" inherits="KeyBindingsRedButtonTemplate">
<Size><AbsDimension x="130" y="22"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="16" y="16"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>this:SetText(KeyBindings_String("RESET_TO_DEFAULT", "Reset To Default"))</OnLoad>
<OnClick>StaticPopup_Show("CONFIRM_RESET_KEYBINDINGS")</OnClick>
</Scripts>
</Button>
<Button name="$parentUnbindButton" inherits="KeyBindingsRedButtonTemplate">
<Size><AbsDimension x="130" y="22"/></Size>
<Anchors><Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-205" y="16"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>this:SetText(KeyBindings_String("UNBIND", "Unbind Key"))</OnLoad>
<OnClick>
PlaySound("igMainMenuOptionCheckBoxOn")
KeyBindings_Unbind()
</OnClick>
</Scripts>
</Button>
<Button name="$parentOkayButton" inherits="KeyBindingsRedButtonTemplate">
<Anchors><Anchor point="BOTTOMLEFT" relativeTo="$parentUnbindButton" relativePoint="BOTTOMRIGHT"/></Anchors>
<Scripts>
<OnLoad>this:SetText(KeyBindings_String("OKAY", "Okay"))</OnLoad>
<OnClick>KeyBindings_Okay()</OnClick>
</Scripts>
</Button>
<Button name="$parentCancelButton" inherits="KeyBindingsRedButtonTemplate">
<Anchors><Anchor point="BOTTOMLEFT" relativeTo="$parentOkayButton" relativePoint="BOTTOMRIGHT"/></Anchors>
<Scripts>
<OnLoad>this:SetText(KeyBindings_String("CANCEL", "Cancel"))</OnLoad>
<OnClick>KeyBindingFrame_Cancel()</OnClick>
</Scripts>
</Button>
</Frames>
<Scripts>
<OnShow>
KeyBindings_LoadCategories()
KeyBindingFrameCharacterButton:SetChecked(GetCurrentBindingSet() == 2 and 1 or nil)
KeyBindings_UpdateHeader()
KeyBindingFrame.bindingsChanged = nil
KeyBindingFrameScrollFrameScrollBar:SetValue(0)
KeyBindingFrame_Update()
</OnShow>
<OnHide>
-- Both references reopen the game menu (1.12 + era OnHide); a straggling
-- selection disarms the host capture seam.
KeyBindings_SetSelected(nil)
KeyBindingFrameOutputText:SetText("")
ShowUIPanel(GameMenuFrame)
</OnHide>
</Scripts>
</Frame>
</Ui>
@@ -0,0 +1,544 @@
<Ui>
<!--
The KEY BINDINGS page (decision 1008, superseding 0997's standalone window) — the
Options window's Keybindings category, over the engine's binding table
(benilla_ui::script::keybind + crate::bindings — GetNumBindings/GetBinding/SetBinding/
GetBindingKey/GetBindingAction/LoadBindings/SaveBindings/GetCurrentBindingSet, the 1.12
Lua API verbatim). This file is the page MODULE: the templates and the whole script; the
page's static furniture (the rows, the checkbox row, the scroll seam, the search rows)
lives in OptionsFrame.xml as ordinary children of its Keybindings body, inheriting these
templates (manifest order puts this file first). Rows are the HONEST TREE: only commands
the engine really dispatches are registered, so only they (and only their non-empty
categories) appear.
PROVENANCE (0985 — the three-source split; OptionsFrame.xml carries the sibling block):
· STRUCTURE & BEHAVIOR — the era Settings panel's own Keybindings page (wow-ui-source-era
@ 1.15.9.68940: Blizzard_SettingsDefinitions_Frame/Keybindings.lua + Classic/
KeybindingsOverrides.lua + Blizzard_Settings_Shared/Blizzard_Keybindings.*): 1.15 put
keybindings INSIDE the Options window — the director's call this move executes — as
ONE list of EXPANDABLE per-category sections (SettingsKeybindingSectionMixin over
SettingsExpandableSectionMixin; 25-tall rows, sections COLLAPSED by default, categories
in first-appearance order, a bar's section existing only when the bar does — their
PROXY_SHOW_ACTIONBAR_N gate is our honest tree), the Character Specific Keybindings
checkbox as the page's first row (PROXY_CHARACTER_SPECIFIC_BINDINGS, confirm popup on
UNCHECK only), and per-binding search tags whose category REDIRECTS to this page
(Blizzard_SettingsPanel.lua l.578 `category.redirectCategory or category`) with the
matched rows LIVE in the results.
· CAPTURE & SET LAW — 1.12's own Blizzard_BindingUI (extracted from patch.MPQ, 0997),
KEPT over the era's listener where they differ (the game's fidelity truth is 1.12;
the window furniture is the era's): click a key capsule to arm; the next key or
mouse-3/4/5 press binds IMMEDIATELY (the host capture seam hands the canonical
ALT-CTRL-SHIFT chord to KeyBindings_OnHostKey); a stolen key names its victim in red
(KEY_UNBOUND_ERROR) only when the victim goes bare; left/right clicks select/deselect,
never bind; lone modifiers are ignored; the wheel binds click-commands but refuses
press+release ones (SetBinding returns nil → KEYBINDINGFRAME_MOUSEWHEEL_ERROR, old key
restored); ESC while armed BINDS Escape (1.12's OnKeyDown has no ESCAPE filter — the
era's ESC-cancels law is the one era behavior NOT taken, recorded in 1008; the Unbind
Key button is the clearing affordance). ESC with nothing armed never reaches any of
this: the ladder is the TOGGLEGAMEMENU binding, and the armed seam swallows the press.
· ART BYTES — the 1.12 MPQ chain only (0978/0985): the capsules wear 1.12's own
UIPanelButtonTemplate2 panel-button art (era silver VERIFIED ABSENT — 0997), selection
is 1.12's LockHighlight glow; section headers wear the 1.12 collapsible-list dress —
the QuestLog's own UI-PlusButton/UI-MinusButton square and gold label.
COMMIT LAW (1008, the page's one big behavior change from 0997): binds are LIVE-COMMIT.
The options window's whole grammar writes live (SetCVar on click, no Okay/Cancel pair),
and the era Settings panel commits keybinds as they land too — so every successful
bind/steal/unbind/reset saves the current set to disk at once
(SaveBindings(GetCurrentBindingSet())); 0997's Okay/Cancel/revert and the
CONFIRM_LOSE_BINDING_CHANGES popup die with the standalone window. The two destructive
gestures keep their confirms: Reset To Default (the page Defaults button) and leaving
the character-specific set (which permanently deletes it).
All strings are the 1.12 GlobalStrings globals (the VM executes the real file at boot)
through the `getglobal(...) or raw` fallback (the era's own `_G[x] or x` posture), so a
headless test without GlobalStrings still renders raw tokens.
SCOPE / divergences (noted, not bugs — the ledger is 1008):
- No Quick Keybind Mode row (era furniture over an overlay mechanism benilla lacks).
- 1.12's BLANK spacer-headers between multibar blocks are not modeled — both bottom
bars sit under the one MultiActionBar section.
- 1.12's "Key Bindings for <name>" title swap died with the standalone window: the page
title stays the category's, the checkbox is the character-set state (era's own shape).
- Search shows at most KEYBINDINGS_SEARCH_ROWS matched bindings (static row pool; the
era's results list scrolls, ours doesn't yet) — the group head jumps to the full page.
-->
<!-- One KEY CAPSULE (1.12 KeyBindingFrameBindingButtonTemplate: UIPanelButtonTemplate2 at
180x22 with the small-highlight font trio; its RegisterForClicks takes all five
buttons so middle/4/5 presses reach the capture law). The SELECTED state is 1.12's
LockHighlight — the ADD highlight held on. -->
<Button name="KeyBindingButtonTemplate" virtual="true">
<Size><AbsDimension x="180" y="22"/></Size>
<ButtonText name="$parentText"/>
<NormalTexture file="Interface\Buttons\UI-Panel-Button-Up"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Panel-Button-Down"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></PushedTexture>
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></HighlightTexture>
<NormalFont inherits="GameFontHighlightSmall"/>
<HighlightFont inherits="GameFontHighlightSmall"/>
<DisabledFont inherits="GameFontDisableSmall"/>
<Scripts>
<OnLoad>this:RegisterForClicks("LeftButtonUp", "RightButtonUp", "MiddleButtonUp", "Button4Up", "Button5Up")</OnLoad>
<OnClick>KeyBindingButton_OnClick(arg1)</OnClick>
</Scripts>
</Button>
<!-- One LIST ROW, dual-mode (the era list mixes section headers and binding rows in one
25-tall rhythm — SettingsKeybindingSectionInitializer:GetExtent's single bindingHeight).
BIND mode: command name 170 wide at LEFT, the two capsules at 175/355 (0997's seats,
kept). HEADER mode: the whole row is one toggle button wearing 1.12's own collapsible
dress — the QuestLogTitleButton's 16x16 UI-Plus/MinusButton square at LEFT and the gold
label (the era's Options_ListExpand arrows have no 1.12 counterpart). The paint
(KeyBindings_PaintBindRow / _PaintHeaderRow) shows one mode's furniture and hides the
other's. -->
<Frame name="KeyBindingRowTemplate" virtual="true">
<Size><AbsDimension x="560" y="25"/></Size>
<Layers>
<Layer level="BACKGROUND">
<FontString name="$parentDescription" inherits="GameFontNormalSmall" justifyH="LEFT">
<Size><AbsDimension x="170" y="24"/></Size>
<Anchors><Anchor point="LEFT"/></Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<Button name="$parentHeader" hidden="true">
<Anchors>
<Anchor point="TOPLEFT"/>
<Anchor point="BOTTOMRIGHT"/>
</Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture name="$parentIcon">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="LEFT"><Offset><AbsDimension x="2" y="0"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
</Layers>
<ButtonText name="$parentText" justifyH="LEFT">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="24" y="1"/></Offset></Anchor>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="1"/></Offset></Anchor>
</Anchors>
</ButtonText>
<NormalFont inherits="GameFontNormal"/>
<HighlightFont inherits="GameFontHighlight"/>
<Scripts>
<OnClick>KeyBindings_ToggleSection(this.sectionIndex)</OnClick>
</Scripts>
</Button>
<Button name="$parentKey1Button" inherits="KeyBindingButtonTemplate" id="1">
<Anchors><Anchor point="LEFT"><Offset><AbsDimension x="175" y="0"/></Offset></Anchor></Anchors>
</Button>
<Button name="$parentKey2Button" inherits="KeyBindingButtonTemplate" id="2">
<Anchors><Anchor point="LEFT" relativeTo="$parentKey1Button" relativePoint="RIGHT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Button>
</Frames>
</Frame>
<Script><![CDATA[
-- ── Strings ── the 1.12 GlobalStrings globals, each with the raw fallback (a headless
-- harness without GlobalStrings still works; the era's own `_G[x] or x` posture).
function KeyBindings_String(name, fallback)
local s = getglobal(name)
if s then return s end
return fallback
end
-- ── Key display ── the 1.12 GetBindingText law by hand: modifier prefixes stay the
-- canonical uppercase tokens, the base key reads its KEY_* global (Spacebar, Up Arrow,
-- Mouse Button 4...) else itself. "CTRL--" renders CTRL + the minus key. (The action
-- bar's ABBREVIATED labels ride the real GetBindingText transcription in UIParent.xml;
-- this page and the tooltips show the full form, both references' own split.)
function KeyBindings_KeyText(key)
if not key then
return NORMAL_FONT_COLOR_CODE .. KeyBindings_String("NOT_BOUND", "Not Bound") .. FONT_COLOR_CODE_CLOSE
end
local prefix = ""
local rest = key
while true do
local p = string.sub(rest, 1, 4)
if p == "ALT-" then
prefix = prefix .. "ALT-"
rest = string.sub(rest, 5)
elseif string.sub(rest, 1, 5) == "CTRL-" then
prefix = prefix .. "CTRL-"
rest = string.sub(rest, 6)
elseif string.sub(rest, 1, 6) == "SHIFT-" then
prefix = prefix .. "SHIFT-"
rest = string.sub(rest, 7)
else
break
end
end
return prefix .. KeyBindings_String("KEY_" .. rest, rest)
end
-- ── Page state ── no root frame anymore: the page's state lives here. `sections` is the
-- era section model; `selected`/`keyID` the 1.12 selection; `searchPainted` the live
-- search-result rows (rowName → binding index), set by OptionsFrame.xml's search display
-- and repainted after a bind lands on one.
KeyBindingsPage = { sections = {}, items = {} }
KEYBINDINGS_ROW_BASE = "OptionsFrameContainerBodyKeybindingsRow"
KEYBINDINGS_DISPLAYED = 19
KEYBINDINGS_ROW_HEIGHT = 25
KEYBINDINGS_SEARCH_ROWS = 8
-- ── The section model (era CreateKeybindingInitializers, cut to the honest tree): walk
-- GetBinding(i), group runs of one category token; order = first appearance, which IS
-- 1.12's Bindings.xml order (the registry registers in it). Sections default COLLAPSED
-- (the era Classic overrides pass no `expanded`); a rebuild keeps each token's state.
function KeyBindings_BuildSections()
local page = KeyBindingsPage
local was = {}
for _, s in ipairs(page.sections) do
was[s.token] = s.expanded
end
page.sections = {}
local byToken = {}
for i = 1, GetNumBindings() do
local _, category = GetBinding(i)
local sec = byToken[category]
if not sec then
sec = { token = category, indices = {}, expanded = was[category] }
byToken[category] = sec
table.insert(page.sections, sec)
end
table.insert(sec.indices, i)
end
KeyBindings_Flatten()
end
-- The flattened visible list (era EvaluateVisibility): every header, then the expanded
-- sections' rows. The faux scroll runs over this.
function KeyBindings_Flatten()
local page = KeyBindingsPage
page.items = {}
for n, sec in ipairs(page.sections) do
table.insert(page.items, { header = n })
if sec.expanded then
for _, i in ipairs(sec.indices) do
table.insert(page.items, { bind = i })
end
end
end
end
-- The expand/collapse click (era OnExpandedChanged; the sound is the checkbox kit's,
-- the page's one click voice).
function KeyBindings_ToggleSection(n)
local sec = KeyBindingsPage.sections[n]
if not sec then return end
PlaySound("igMainMenuOptionCheckBoxOn")
sec.expanded = not sec.expanded
KeyBindings_Flatten()
KeyBindingsPage_Update()
end
-- Expand/collapse by section index without the click voice — the capture fixture uses
-- it to open a section for the lens.
function KeyBindings_ExpandSection(n, on)
local sec = KeyBindingsPage.sections[n]
if not sec then return end
sec.expanded = on and true or false
KeyBindings_Flatten()
end
-- ── Painters ── one row, one mode. Shared by the list repaint and the search rows
-- (which are the SAME template painted bind-mode — the era reflows live rows into its
-- results, and these capsules run the full capture law in place).
function KeyBindings_PaintBindRow(name, index)
local command, _, key1, key2 = GetBinding(index)
local b1 = getglobal(name .. "Key1Button")
local b2 = getglobal(name .. "Key2Button")
b1.commandName = command
b2.commandName = command
b1:SetText(KeyBindings_KeyText(key1))
b2:SetText(KeyBindings_KeyText(key2))
if key1 then b1:SetAlpha(1) else b1:SetAlpha(0.8) end
if key2 then b2:SetAlpha(1) else b2:SetAlpha(0.8) end
local desc = getglobal(name .. "Description")
desc:SetText(KeyBindings_String("BINDING_NAME_" .. command, command))
desc:Show()
-- The 1.12 selection highlight: the pressed capsule holds its glow.
local page = KeyBindingsPage
if page.selected == command and page.keyID == 1 then
b1:LockHighlight()
else
b1:UnlockHighlight()
end
if page.selected == command and page.keyID == 2 then
b2:LockHighlight()
else
b2:UnlockHighlight()
end
b1:Show()
b2:Show()
getglobal(name .. "Header"):Hide()
end
function KeyBindings_PaintHeaderRow(name, n)
local sec = KeyBindingsPage.sections[n]
local header = getglobal(name .. "Header")
header.sectionIndex = n
header:SetText(KeyBindings_String(sec.token, sec.token))
local icon = getglobal(name .. "HeaderIcon")
if sec.expanded then
icon:SetTexture("Interface\\Buttons\\UI-MinusButton-Up")
else
icon:SetTexture("Interface\\Buttons\\UI-PlusButton-Up")
end
header:Show()
getglobal(name .. "Description"):Hide()
getglobal(name .. "Key1Button"):Hide()
getglobal(name .. "Key2Button"):Hide()
end
-- ── The list repaint (1.12 KeyBindingFrame_Update over the era section list). ──
function KeyBindingsPage_Update()
local page = KeyBindingsPage
local scroll = getglobal("OptionsFrameContainerBodyKeybindingsScrollFrame")
local offset = BenillaFauxScrollFrame_GetOffset(scroll)
for i = 1, KEYBINDINGS_DISPLAYED do
local name = KEYBINDINGS_ROW_BASE .. i
local row = getglobal(name)
local item = page.items[offset + i]
if item and item.header then
KeyBindings_PaintHeaderRow(name, item.header)
row:Show()
elseif item then
KeyBindings_PaintBindRow(name, item.bind)
row:Show()
else
row:Hide()
end
end
BenillaFauxScrollFrame_Update(scroll, table.getn(page.items),
KEYBINDINGS_DISPLAYED, KEYBINDINGS_ROW_HEIGHT)
KeyBindings_UpdateUnbindKey()
end
-- The page entry (OptionsFrame_SelectCategory's Keybindings arm): rebuild the tree —
-- the table may have moved under us (a rebind, a set switch) — sync the checkbox to
-- the live set, park the scroll at the top, repaint.
function KeyBindingsPage_Refresh()
KeyBindings_BuildSections()
local box = getglobal("OptionsFrameContainerBodyKeybindingsCharacterRowCheck")
if GetCurrentBindingSet() == 2 then
box:SetChecked(1)
else
box:SetChecked(nil)
end
getglobal("OptionsFrameContainerBodyKeybindingsScrollFrameScrollBar"):SetValue(0)
KeyBindingsPage_Update()
end
function KeyBindings_Output(text)
getglobal("OptionsFrameContainerBodyKeybindingsOutput"):SetText(text)
end
-- ── Selection + the capture arm ── selecting a capsule arms the HOST's capture seam
-- (BenillaBindCapture): raw input is swallowed there and comes back canonical through
-- KeyBindings_OnHostKey. Deselecting (either way) disarms.
function KeyBindings_SetSelected(command, keyID)
KeyBindingsPage.selected = command
KeyBindingsPage.keyID = keyID
BenillaBindCapture(command ~= nil)
end
function KeyBindings_UpdateUnbindKey()
local unbind = getglobal("OptionsFrameContainerUnbind")
if KeyBindingsPage.selected then
unbind:Enable()
else
unbind:Disable()
end
end
-- Repaint whichever view currently shows binding rows: the page list, or the live
-- search-result rows (a bind landed from one must relabel it — and its victim, which
-- may sit on another visible row).
function KeyBindings_RepaintVisible()
if KeyBindingsPage.searchPainted then
for name, index in pairs(KeyBindingsPage.searchPainted) do
KeyBindings_PaintBindRow(name, index)
end
KeyBindings_UpdateUnbindKey()
else
KeyBindingsPage_Update()
end
end
function KeyBindingButton_OnClick(button)
-- The 1.12 law: while something is selected, only LEFT/RIGHT on a capsule
-- re-select or deselect — a middle/4/5 press is a BIND, and the host's armed
-- capture seam (which runs after this click) takes it, so this handler must not
-- touch the selection for those buttons.
local page = KeyBindingsPage
if page.selected then
if button ~= "LeftButton" and button ~= "RightButton" then
return
end
if page.selected == this.commandName and page.keyID == this:GetID() then
KeyBindings_SetSelected(nil)
KeyBindings_Output("")
else
KeyBindings_SetSelected(this.commandName, this:GetID())
KeyBindings_Output(string.format(
KeyBindings_String("BIND_KEY_TO_COMMAND", "Press Key to Bind to Command -> %s"),
KeyBindings_String("BINDING_NAME_" .. this.commandName, this.commandName)))
end
else
KeyBindings_SetSelected(this.commandName, this:GetID())
KeyBindings_Output(string.format(
KeyBindings_String("BIND_KEY_TO_COMMAND", "Press Key to Bind to Command -> %s"),
KeyBindings_String("BINDING_NAME_" .. this.commandName, this.commandName)))
end
KeyBindings_RepaintVisible()
end
-- The live-commit half of every mutation (1008's commit law — the header block): the
-- table already moved; persist the set it belongs to.
function KeyBindings_Commit()
SaveBindings(GetCurrentBindingSet())
end
-- The host's capture callback: `chord` is canonical (ALT-CTRL-SHIFT-TOKEN). The body is
-- 1.12's OnKeyDown selected-branch verbatim: steal message, clear the command's keys,
-- rebind in slot order so the pressed slot takes the new chord — then the live commit.
function KeyBindings_OnHostKey(chord)
local page = KeyBindingsPage
if not page.selected then
return
end
local selected = page.selected
local oldAction = GetBindingAction(chord)
if oldAction ~= "" and oldAction ~= selected then
local k1, k2 = GetBindingKey(oldAction)
if (not k1 or k1 == chord) and (not k2 or k2 == chord) then
KeyBindings_Output(string.format(
KeyBindings_String("KEY_UNBOUND_ERROR", "|cffff0000%s Function is Now Unbound!|r"),
KeyBindings_String("BINDING_NAME_" .. oldAction, oldAction)))
else
KeyBindings_Output(KeyBindings_String("KEY_BOUND", "Key Bound Successfully"))
end
else
KeyBindings_Output(KeyBindings_String("KEY_BOUND", "Key Bound Successfully"))
end
local key1, key2 = GetBindingKey(selected)
if key1 then SetBinding(key1) end
if key2 then SetBinding(key2) end
if page.keyID == 1 then
KeyBindings_SetOrRestore(chord, selected, key1)
if key2 then SetBinding(key2, selected) end
else
if key1 then SetBinding(key1, selected) end
KeyBindings_SetOrRestore(chord, selected, key2)
end
KeyBindings_Commit()
KeyBindings_SetSelected(nil)
KeyBindings_RepaintVisible()
end
-- 1.12 KeyBindingFrame_SetBinding: a refused bind (the wheel on a press+release
-- command) restores the slot's old key and says why.
function KeyBindings_SetOrRestore(key, command, oldKey)
if not SetBinding(key, command) then
if oldKey then SetBinding(oldKey, command) end
KeyBindings_Output(KeyBindings_String(
"KEYBINDINGFRAME_MOUSEWHEEL_ERROR",
"Can't bind mousewheel to actions with up and down states"))
end
end
-- The Unbind Key button (the Container header band, OptionsFrame.xml): 1.12's body —
-- the selected slot empties, the other slides into slot order — plus the live commit.
function KeyBindings_Unbind()
local page = KeyBindingsPage
local key1, key2 = GetBindingKey(page.selected)
if key1 then SetBinding(key1) end
if key2 then SetBinding(key2) end
if key1 and page.keyID == 1 then
if key2 then SetBinding(key2, page.selected) end
else
if key1 then SetBinding(key1, page.selected) end
end
KeyBindings_Commit()
KeyBindings_SetSelected(nil)
KeyBindings_Output("")
KeyBindings_RepaintVisible()
end
-- ── The character-specific set (1.12's three-set model under the era checkbox row) ──
-- CHECK: switch to the character set and save it at once — it starts as a copy of what
-- you had (LoadBindings(2) falls back to the account snapshot; SaveBindings(2) makes it
-- exist, which IS the checkbox's persisted truth). UNCHECK: destructive — the era
-- confirm-on-uncheck-only law; the box springs back until the popup decides. The accept
-- order is load-then-save: LoadBindings(1) puts the account set live FIRST, so
-- SaveBindings(1) persists it unchanged while dropping the character set (the confirmed
-- permanent delete — saving live-as-account here would smuggle the character binds in).
function KeyBindings_CharacterToggle(box)
PlaySound("igMainMenuOptionCheckBoxOn")
if box:GetChecked() then
LoadBindings(2)
SaveBindings(2)
KeyBindings_SetSelected(nil)
KeyBindings_Output("")
KeyBindingsPage_Refresh()
else
box:SetChecked(1)
StaticPopup_Show("CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS")
end
end
StaticPopupDialogs["CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS"] = {
text = KeyBindings_String("CONFIRM_DELETING_CHARACTER_SPECIFIC_BINDINGS",
"Really switch to general key bindings? All key bindings specific to this character will be permanantly deleted."),
button1 = KeyBindings_String("OKAY", "Okay"),
button2 = KeyBindings_String("CANCEL", "Cancel"),
OnAccept = function()
LoadBindings(1)
SaveBindings(1)
KeyBindings_SetSelected(nil)
KeyBindings_Output("")
KeyBindingsPage_Refresh()
end,
}
-- Reset To Default — the page's Defaults button (era: Defaults confirms; the string is
-- ours, 0997's note: the era's lives in its client binary, 1.12 has none). The reset
-- lands in the CURRENT set and commits live like every other mutation.
StaticPopupDialogs["CONFIRM_RESET_KEYBINDINGS"] = {
text = "Are you sure you want to reset all keybindings to their defaults?",
button1 = KeyBindings_String("OKAY", "Okay"),
button2 = KeyBindings_String("CANCEL", "Cancel"),
OnAccept = function()
PlaySound("igMainMenuOptionCheckBoxOn")
LoadBindings(0)
KeyBindings_Commit()
KeyBindings_SetSelected(nil)
KeyBindings_Output("")
KeyBindingsPage_Refresh()
end,
}
-- ── Search (the era redirect law) ── every binding's search tag is its display name
-- uppercased (era AddSearchTags(GetBindingName(action))); matches score by the shared
-- law (OptionsSearch_Score) and surface under the Keybindings group head, whose click
-- jumps to this page (era Blizzard_SettingsPanel.lua l.578: the fake category's results
-- carry `redirectCategory`). The matched rows are LIVE (era reflows real initializers);
-- OptionsFrame.xml paints them onto its static KeybindSearch row pool.
function KeyBindings_SearchMatches(words)
local out = {}
for i = 1, GetNumBindings() do
local command = GetBinding(i)
local tag = string.upper(KeyBindings_String("BINDING_NAME_" .. command, command))
local s = OptionsSearch_Score(tag, words)
if s then
table.insert(out, { score = s, index = i })
end
end
return out
end
]]></Script>
</Ui>
+52 -26
View File
@@ -19,8 +19,11 @@
joins at OnLoad.
Ref divergences (deliberate, mirroring ActionBar.xml's SCOPE notes):
- No MULTIACTIONBAR* keybinds — the ref ships none by default either; buttons are
mouse-only, so the hotkey corner carries the ref's range dot instead of a label.
- MULTIACTIONBAR1/2BUTTON1-12 are BINDABLE since 1008 (the ref's own commands, registered
in crate::bindings::commands with the ref's runOnUp pair below); they ship UNBOUND like
the ref (no MULTIACTIONBAR* line in any of the install's bindings-cache.wtf files), so
out of the box the hotkey corner still carries the ref's range dot — a bound slot wears
its abbreviated label exactly like the main bar (ActionBar.xml's PaintHotkey law).
- Empty wells hide (the ref's own default) and appear as drop-target rings only while a
cursor payload is held — unlike the main bar's always-visible wells (its art has
sockets; floating rings over bare world would not read as a bar).
@@ -32,6 +35,29 @@
-- base = (page - 1) * 12.
BENILLA_MULTIBAR_BOTTOMLEFT_BASE = 60
BENILLA_MULTIBAR_BOTTOMRIGHT_BASE = 48
-- The MULTIACTIONBAR keybind pair — ref MultiActionBars.lua:24-45 MultiActionButtonDown/
-- Up, transcribed onto our frame names (`bar` is "BottomLeft"/"BottomRight", the ref
-- passes "MultiBarBottomLeft"); the same NORMAL→PUSHED debounce dance as the main bar's
-- BenillaActionButtonDown/Up (ActionBar.xml — the ref's MacroFrame_SaveMacro hook and
-- onSelf argument are not modeled there either). Driven by the binding dispatch
-- (crate::bindings, Kind::EdgeUpDown — decision 1008).
function BenillaMultiActionButtonDown(bar, id)
local button = getglobal("BenillaMultiBar" .. bar .. "Button" .. id)
if ( button:GetButtonState() == "NORMAL" ) then
button:SetButtonState("PUSHED")
end
end
function BenillaMultiActionButtonUp(bar, id)
local button = getglobal("BenillaMultiBar" .. bar .. "Button" .. id)
if ( button:GetButtonState() == "PUSHED" ) then
button:SetButtonState("NORMAL")
UseAction(BenillaActionButton_Action(button))
-- Ref MultiActionButtonUp:39-43: the checked ring reflects the use immediately.
BenillaActionButton_UpdateState(button)
end
end
]]></Script>
<!-- frameStrata HIGH on both bottom bars is the ref's own (`MultiActionBars.xml` l.36 and l.151,
@@ -47,51 +73,51 @@
<Frames>
<CheckButton name="BenillaMultiBarBottomLeftButton1" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 1, BENILLA_MULTIBAR_BOTTOMLEFT_BASE)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 1, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BenillaMultiBarBottomLeftButton2" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaMultiBarBottomLeftButton1" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 2, BENILLA_MULTIBAR_BOTTOMLEFT_BASE)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 2, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BenillaMultiBarBottomLeftButton3" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaMultiBarBottomLeftButton2" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 3, BENILLA_MULTIBAR_BOTTOMLEFT_BASE)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 3, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BenillaMultiBarBottomLeftButton4" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaMultiBarBottomLeftButton3" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 4, BENILLA_MULTIBAR_BOTTOMLEFT_BASE)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 4, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BenillaMultiBarBottomLeftButton5" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaMultiBarBottomLeftButton4" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 5, BENILLA_MULTIBAR_BOTTOMLEFT_BASE)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 5, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BenillaMultiBarBottomLeftButton6" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaMultiBarBottomLeftButton5" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 6, BENILLA_MULTIBAR_BOTTOMLEFT_BASE)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 6, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BenillaMultiBarBottomLeftButton7" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaMultiBarBottomLeftButton6" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 7, BENILLA_MULTIBAR_BOTTOMLEFT_BASE)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 7, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BenillaMultiBarBottomLeftButton8" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaMultiBarBottomLeftButton7" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 8, BENILLA_MULTIBAR_BOTTOMLEFT_BASE)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 8, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BenillaMultiBarBottomLeftButton9" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaMultiBarBottomLeftButton8" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 9, BENILLA_MULTIBAR_BOTTOMLEFT_BASE)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 9, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BenillaMultiBarBottomLeftButton10" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaMultiBarBottomLeftButton9" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 10, BENILLA_MULTIBAR_BOTTOMLEFT_BASE)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 10, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BenillaMultiBarBottomLeftButton11" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaMultiBarBottomLeftButton10" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 11, BENILLA_MULTIBAR_BOTTOMLEFT_BASE)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 11, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BenillaMultiBarBottomLeftButton12" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaMultiBarBottomLeftButton11" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 12, BENILLA_MULTIBAR_BOTTOMLEFT_BASE)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 12, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
</Frames>
</Frame>
@@ -105,51 +131,51 @@
<Frames>
<CheckButton name="BenillaMultiBarBottomRightButton1" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 1, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 1, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BenillaMultiBarBottomRightButton2" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaMultiBarBottomRightButton1" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 2, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 2, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BenillaMultiBarBottomRightButton3" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaMultiBarBottomRightButton2" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 3, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 3, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BenillaMultiBarBottomRightButton4" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaMultiBarBottomRightButton3" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 4, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 4, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BenillaMultiBarBottomRightButton5" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaMultiBarBottomRightButton4" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 5, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 5, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BenillaMultiBarBottomRightButton6" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaMultiBarBottomRightButton5" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 6, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 6, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BenillaMultiBarBottomRightButton7" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaMultiBarBottomRightButton6" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 7, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 7, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BenillaMultiBarBottomRightButton8" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaMultiBarBottomRightButton7" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 8, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 8, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BenillaMultiBarBottomRightButton9" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaMultiBarBottomRightButton8" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 9, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 9, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BenillaMultiBarBottomRightButton10" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaMultiBarBottomRightButton9" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 10, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 10, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BenillaMultiBarBottomRightButton11" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaMultiBarBottomRightButton10" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 11, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 11, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BenillaMultiBarBottomRightButton12" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BenillaMultiBarBottomRightButton11" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 12, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 12, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
</Frames>
</Frame>
+365 -27
View File
@@ -116,9 +116,13 @@
rows, reflowed — values, writes and dependencies all work mid-search. The two
strings ("Search Results", "No results found.") are ours: the era's
SETTINGS_SEARCH_* globals live in the client binary, not the dump.
- **No scroll bars**: the static category tree (9 rows, 2 headers) fits its 199px column
outright, and the deepest page (Audio, 7 rows) fits its area. MinimalScrollBar arrives
when a page outgrows the space.
- **No scroll bars on the CVar pages**: the static category tree (9 rows, 2 headers) fits
its 199px column outright, and the deepest CVar page (Audio, 7 rows) fits its area.
MinimalScrollBar arrives when one outgrows the space. The KEYBINDINGS page (decision
1008 — the 1.15 Settings panel's own Keybindings category, module + provenance in
KeyBindingsPage.xml) is the one page that can't fit and rides the shared faux-scroll
kit; its search results reflow live binding rows under a Keybindings group head whose
click jumps to the page (the era redirect law), capped at the static pool's size.
- **Setting-page cuts (0957 Audio, 0959 Graphics)**: setting-row tooltips (the era
OPTION_TOOLTIP_* hovers) are not wired; Defaults resets the VISIBLE page only (the
era confirm dialog is not modeled); era rows with no 1.12 CVar +
@@ -264,11 +268,28 @@
local body = getglobal("OptionsFrameContainerBody" .. k)
if k == key then body:Show() else body:Hide() end
end
if OPTIONS_PAGE_ROWS[key] then
OptionsPage_Refresh(key)
-- The Keybindings page (KeyBindingsPage.xml, decision 1008) body-swaps like the
-- CVar pages but runs its own machinery: entering rebuilds the section tree;
-- leaving disarms a straggling capture (the host seam must never outlive the page)
-- and clears its message line. Its Defaults is the confirmed Reset To Default (the
-- button's OnClick forks), and the Unbind Key button exists only here.
local kb = getglobal("OptionsFrameContainerBodyKeybindings")
if key == "Keybindings" then
kb:Show()
KeyBindingsPage_Refresh()
OptionsFrameContainerUnbind:Show()
OptionsFrameContainerDefaults:Enable()
else
OptionsFrameContainerDefaults:Disable()
kb:Hide()
KeyBindings_SetSelected(nil)
KeyBindings_Output("")
OptionsFrameContainerUnbind:Hide()
if OPTIONS_PAGE_ROWS[key] then
OptionsPage_Refresh(key)
OptionsFrameContainerDefaults:Enable()
else
OptionsFrameContainerDefaults:Disable()
end
end
end
@@ -329,6 +350,17 @@
end
end
end
-- The keybinding entries join the same buckets (the era's searchable fake category
-- whose results REDIRECT to the Keybindings page — KeyBindingsPage.xml's search
-- note). Their rkey is the BINDING INDEX; the display fork below tells them apart
-- by the group key.
for _, m in ipairs(KeyBindings_SearchMatches(words)) do
if not buckets[m.score] then
buckets[m.score] = {}
table.insert(scores, m.score)
end
table.insert(buckets[m.score], { key = "Keybindings", rkey = m.index })
end
table.sort(scores, function(a, b) return a > b end)
local groups, order = {}, {}
for _, s in ipairs(scores) do
@@ -387,6 +419,17 @@
end
getglobal("OptionsFrameContainerBodySearchHead" .. k):Hide()
end
-- The Keybindings furniture leaves the stage with the rest: its body hides (the
-- search paints matched bindings on the shared pool below, not the page list), a
-- straggling capture disarms, and the pool starts blank.
getglobal("OptionsFrameContainerBodyKeybindings"):Hide()
getglobal("OptionsFrameContainerBodySearchHeadKeybindings"):Hide()
for i = 1, KEYBINDINGS_SEARCH_ROWS do
getglobal("OptionsFrameContainerBodyKeybindSearch" .. i):Hide()
end
KeyBindingsPage.searchPainted = nil
KeyBindings_SetSelected(nil)
OptionsFrameContainerUnbind:Hide()
OptionsFrameContainerTitle:SetText("Search Results")
OptionsFrameContainerDefaults:Hide()
@@ -402,25 +445,44 @@
OptionsSearch_Place(head, prev)
getglobal(head):Show()
prev = head
local placed = {}
for _, rkey in ipairs(groups[key]) do
local row = getglobal("OptionsFrameContainerBody" .. key .. rkey)
if row.parentRow and not placed[row.parentRow] then
placed[row.parentRow] = 1
local pname = "OptionsFrameContainerBody" .. key .. row.parentRow
OptionsSearch_Place(pname, prev)
getglobal(pname):Show()
prev = pname
if key == "Keybindings" then
-- Matched bindings painted LIVE onto the static pool (the era reflows its
-- real rows the same way; the pool caps the count — the SCOPE note in
-- KeyBindingsPage.xml — and the head above jumps to the full page).
KeyBindingsPage.searchPainted = {}
local shown = 0
for _, index in ipairs(groups[key]) do
if shown < KEYBINDINGS_SEARCH_ROWS then
shown = shown + 1
local name = "OptionsFrameContainerBodyKeybindSearch" .. shown
KeyBindings_PaintBindRow(name, index)
OptionsSearch_Place(name, prev)
getglobal(name):Show()
KeyBindingsPage.searchPainted[name] = index
prev = name
end
end
if not placed[rkey] then
placed[rkey] = 1
local name = "OptionsFrameContainerBody" .. key .. rkey
OptionsSearch_Place(name, prev)
row:Show()
prev = name
else
local placed = {}
for _, rkey in ipairs(groups[key]) do
local row = getglobal("OptionsFrameContainerBody" .. key .. rkey)
if row.parentRow and not placed[row.parentRow] then
placed[row.parentRow] = 1
local pname = "OptionsFrameContainerBody" .. key .. row.parentRow
OptionsSearch_Place(pname, prev)
getglobal(pname):Show()
prev = pname
end
if not placed[rkey] then
placed[rkey] = 1
local name = "OptionsFrameContainerBody" .. key .. rkey
OptionsSearch_Place(name, prev)
row:Show()
prev = name
end
end
OptionsPage_Refresh(key)
end
OptionsPage_Refresh(key)
end
end
@@ -432,6 +494,11 @@
OptionsPage_Layout(k)
getglobal("OptionsFrameContainerBodySearchHead" .. k):Hide()
end
for i = 1, KEYBINDINGS_SEARCH_ROWS do
getglobal("OptionsFrameContainerBodyKeybindSearch" .. i):Hide()
end
getglobal("OptionsFrameContainerBodySearchHeadKeybindings"):Hide()
KeyBindingsPage.searchPainted = nil
OptionsFrameContainerBodyNoResults:Hide()
OptionsFrameContainerDefaults:Show()
OptionsFrame_SelectCategory(OptionsFrame.selectedCategory or "Controls")
@@ -1346,13 +1413,37 @@
</Layer>
</Layers>
<Frames>
<!-- Live on any page with rows (OPTIONS_PAGE_ROWS);
OptionsFrame_SelectCategory enables/disables it with the page swap. -->
<!-- Live on any page with rows (OPTIONS_PAGE_ROWS) and on the Keybindings
page, where it is 1.12's Reset To Default behind the era confirm
(KeyBindingsPage.xml's popup); OptionsFrame_SelectCategory
enables/disables it with the page swap. -->
<Button name="$parentDefaults" inherits="OptionsRedButtonTemplate" text="Defaults">
<Anchors><Anchor point="TOPRIGHT"><Offset><AbsDimension x="-36" y="-16"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>self:Disable()</OnLoad>
<OnClick>OptionsPage_Defaults(OptionsFrame.selectedCategory)</OnClick>
<OnClick>
if OptionsFrame.selectedCategory == "Keybindings" then
StaticPopup_Show("CONFIRM_RESET_KEYBINDINGS")
else
OptionsPage_Defaults(OptionsFrame.selectedCategory)
end
</OnClick>
</Scripts>
</Button>
<!-- Unbind Key (1.12's own button, reseated in the header band): exists only
on the Keybindings page, live only while a capsule is selected
(KeyBindings_UpdateUnbindKey drives the enable). -->
<Button name="$parentUnbind" inherits="OptionsRedButtonTemplate" hidden="true">
<Anchors><Anchor point="RIGHT" relativeTo="$parentDefaults" relativePoint="LEFT"><Offset><AbsDimension x="-2" y="0"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>
self:SetText(KeyBindings_String("UNBIND", "Unbind Key"))
self:Disable()
</OnLoad>
<OnClick>
PlaySound("igMainMenuOptionCheckBoxOn")
KeyBindings_Unbind()
</OnClick>
</Scripts>
</Button>
<!-- The page body: one child frame per category with rows, shown by
@@ -1590,6 +1681,229 @@
</Frame>
</Frames>
</Frame>
<!-- The KEYBINDINGS page body (decision 1008 — the era Settings
panel's Keybindings category folded into this window; the module,
provenance and commit law: KeyBindingsPage.xml). First the
Character Specific row (the era page's first setting row, confirm
on uncheck only), then the section list — 19 dual-mode rows under
the shared faux-scroll kit, sections collapsed by default — and
the 1.12 window's message line (KEY_BOUND / the red steal / the
wheel refusal) at the foot. -->
<!-- enableMouse: the page catches the wheel ITSELF on any point no
child claims (hit-testing only sees mouse-enabled frames —
pointer.rs; rows are plain Frames, so a spin over a row's name
would otherwise fall through to the window and die). -->
<Frame name="$parentKeybindings" hidden="true" enableMouse="true">
<Anchors>
<Anchor point="TOPLEFT"/>
<Anchor point="BOTTOMRIGHT"/>
</Anchors>
<Layers>
<Layer level="OVERLAY">
<FontString name="$parentOutput" inherits="GameFontNormal">
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="0" y="8"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- Character Specific Key Bindings — the era checkbox-row
geometry (label left, the UI-CheckBox set at the
control column) over 1.12's set model; the click law
is KeyBindings_CharacterToggle. This bespoke row's
column sits at CENTER-40, not the shared CENTER-80:
1.12's string is longer than the era's and clipped at
the shared split ("…Key Bindin…" — the first capture);
no other control shares this page's column, so the
shift is free. -->
<Frame name="$parentCharacterRow" enableMouse="true">
<Size><AbsDimension y="26"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="10" y="-12"/></Offset></Anchor>
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-20" y="-12"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="OVERLAY">
<FontString name="$parentLabel" inherits="GameFontNormal" justifyH="LEFT">
<Anchors>
<Anchor point="LEFT"><Offset><AbsDimension x="37" y="0"/></Offset></Anchor>
<Anchor point="RIGHT" relativePoint="CENTER"><Offset><AbsDimension x="-45" y="0"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<CheckButton name="$parentCheck">
<Size><AbsDimension x="30" y="29"/></Size>
<Anchors><Anchor point="LEFT" relativePoint="CENTER"><Offset><AbsDimension x="-40" y="0"/></Offset></Anchor></Anchors>
<NormalTexture file="Interface\Buttons\UI-CheckBox-Up"/>
<PushedTexture file="Interface\Buttons\UI-CheckBox-Down"/>
<CheckedTexture file="Interface\Buttons\UI-CheckBox-Check"/>
<DisabledCheckedTexture file="Interface\Buttons\UI-CheckBox-Check-Disabled"/>
<Scripts>
<OnClick>KeyBindings_CharacterToggle(this)</OnClick>
<OnEnter>
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetText(KeyBindings_String("CHARACTER_SPECIFIC_KEYBINDING_TOOLTIP",
"Click this to toggle between general key bindings and key bindings specific to this character."), nil, nil, nil, nil, 1)
</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</CheckButton>
</Frames>
<Scripts>
<OnLoad>
getglobal(this:GetName() .. "Label"):SetText(
KeyBindings_String("CHARACTER_SPECIFIC_KEYBINDINGS", "Character Specific Key Bindings"))
</OnLoad>
</Scripts>
</Frame>
<Frame name="$parentRow1" inherits="KeyBindingRowTemplate">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="10" y="-47"/></Offset></Anchor>
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-32" y="-47"/></Offset></Anchor>
</Anchors>
</Frame>
<Frame name="$parentRow2" inherits="KeyBindingRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRow1" relativePoint="BOTTOMLEFT"/>
<Anchor point="TOPRIGHT" relativeTo="$parentRow1" relativePoint="BOTTOMRIGHT"/>
</Anchors>
</Frame>
<Frame name="$parentRow3" inherits="KeyBindingRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRow2" relativePoint="BOTTOMLEFT"/>
<Anchor point="TOPRIGHT" relativeTo="$parentRow2" relativePoint="BOTTOMRIGHT"/>
</Anchors>
</Frame>
<Frame name="$parentRow4" inherits="KeyBindingRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRow3" relativePoint="BOTTOMLEFT"/>
<Anchor point="TOPRIGHT" relativeTo="$parentRow3" relativePoint="BOTTOMRIGHT"/>
</Anchors>
</Frame>
<Frame name="$parentRow5" inherits="KeyBindingRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRow4" relativePoint="BOTTOMLEFT"/>
<Anchor point="TOPRIGHT" relativeTo="$parentRow4" relativePoint="BOTTOMRIGHT"/>
</Anchors>
</Frame>
<Frame name="$parentRow6" inherits="KeyBindingRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRow5" relativePoint="BOTTOMLEFT"/>
<Anchor point="TOPRIGHT" relativeTo="$parentRow5" relativePoint="BOTTOMRIGHT"/>
</Anchors>
</Frame>
<Frame name="$parentRow7" inherits="KeyBindingRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRow6" relativePoint="BOTTOMLEFT"/>
<Anchor point="TOPRIGHT" relativeTo="$parentRow6" relativePoint="BOTTOMRIGHT"/>
</Anchors>
</Frame>
<Frame name="$parentRow8" inherits="KeyBindingRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRow7" relativePoint="BOTTOMLEFT"/>
<Anchor point="TOPRIGHT" relativeTo="$parentRow7" relativePoint="BOTTOMRIGHT"/>
</Anchors>
</Frame>
<Frame name="$parentRow9" inherits="KeyBindingRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRow8" relativePoint="BOTTOMLEFT"/>
<Anchor point="TOPRIGHT" relativeTo="$parentRow8" relativePoint="BOTTOMRIGHT"/>
</Anchors>
</Frame>
<Frame name="$parentRow10" inherits="KeyBindingRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRow9" relativePoint="BOTTOMLEFT"/>
<Anchor point="TOPRIGHT" relativeTo="$parentRow9" relativePoint="BOTTOMRIGHT"/>
</Anchors>
</Frame>
<Frame name="$parentRow11" inherits="KeyBindingRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRow10" relativePoint="BOTTOMLEFT"/>
<Anchor point="TOPRIGHT" relativeTo="$parentRow10" relativePoint="BOTTOMRIGHT"/>
</Anchors>
</Frame>
<Frame name="$parentRow12" inherits="KeyBindingRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRow11" relativePoint="BOTTOMLEFT"/>
<Anchor point="TOPRIGHT" relativeTo="$parentRow11" relativePoint="BOTTOMRIGHT"/>
</Anchors>
</Frame>
<Frame name="$parentRow13" inherits="KeyBindingRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRow12" relativePoint="BOTTOMLEFT"/>
<Anchor point="TOPRIGHT" relativeTo="$parentRow12" relativePoint="BOTTOMRIGHT"/>
</Anchors>
</Frame>
<Frame name="$parentRow14" inherits="KeyBindingRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRow13" relativePoint="BOTTOMLEFT"/>
<Anchor point="TOPRIGHT" relativeTo="$parentRow13" relativePoint="BOTTOMRIGHT"/>
</Anchors>
</Frame>
<Frame name="$parentRow15" inherits="KeyBindingRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRow14" relativePoint="BOTTOMLEFT"/>
<Anchor point="TOPRIGHT" relativeTo="$parentRow14" relativePoint="BOTTOMRIGHT"/>
</Anchors>
</Frame>
<Frame name="$parentRow16" inherits="KeyBindingRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRow15" relativePoint="BOTTOMLEFT"/>
<Anchor point="TOPRIGHT" relativeTo="$parentRow15" relativePoint="BOTTOMRIGHT"/>
</Anchors>
</Frame>
<Frame name="$parentRow17" inherits="KeyBindingRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRow16" relativePoint="BOTTOMLEFT"/>
<Anchor point="TOPRIGHT" relativeTo="$parentRow16" relativePoint="BOTTOMRIGHT"/>
</Anchors>
</Frame>
<Frame name="$parentRow18" inherits="KeyBindingRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRow17" relativePoint="BOTTOMLEFT"/>
<Anchor point="TOPRIGHT" relativeTo="$parentRow17" relativePoint="BOTTOMRIGHT"/>
</Anchors>
</Frame>
<Frame name="$parentRow19" inherits="KeyBindingRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRow18" relativePoint="BOTTOMLEFT"/>
<Anchor point="TOPRIGHT" relativeTo="$parentRow18" relativePoint="BOTTOMRIGHT"/>
</Anchors>
</Frame>
<!-- The faux scroll over the rows (the shared kit, 0997's own
seam). The kit's bar hangs 6px RIGHT of this frame's edge,
so the frame ends at the rows' own -32 and the 16-wide bar
rides the -26..-10 gutter inside the body (the TrainerFrame
seating). The wheel handler is NOT here: a spin over a row
bubbles up the PARENT chain (pointer.rs), and this frame is
the rows' sibling — it lives on the body below. -->
<Frame name="$parentScrollFrame" inherits="BenillaFauxScrollFrameTemplate">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="10" y="-47"/></Offset></Anchor>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-32" y="28"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>
BenillaFauxScrollFrame_OnLoad(this)
this.updateFunc = KeyBindingsPage_Update
</OnLoad>
</Scripts>
</Frame>
</Frames>
<!-- The page's wheel: rows and the bar all bubble here (the
QuestLog/Trainer lesson — a handler on the sibling catcher
never sees a spin over a row). The guard is 1.12's law:
while a capsule is armed the wheel is a BIND (the host
seam swallows it) — never a scroll. -->
<Scripts>
<OnMouseWheel>
if not KeyBindingsPage.selected then
BenillaFauxScrollFrame_OnMouseWheel(getglobal(this:GetName() .. "ScrollFrame"), arg1)
end
</OnMouseWheel>
</Scripts>
</Frame>
<!-- The search-results group headers — one per page WITH rows
(the OPTIONS_PAGE_ROWS set; a page that gains rows gains its
head here). Unanchored and hidden until a search chains them
@@ -1619,6 +1933,26 @@
getglobal(self:GetName() .. "Label"):SetText("Nameplates")
</OnLoad></Scripts>
</Button>
<Button name="$parentSearchHeadKeybindings" inherits="OptionsSearchHeadTemplate">
<Scripts><OnLoad>
self.categoryKey = "Keybindings"
getglobal(self:GetName() .. "Label"):SetText("Keybindings")
</OnLoad></Scripts>
</Button>
<!-- The keybinding search-result pool (decision 1008): the era
reflows LIVE binding rows into its results; ours is this static
pool of the same template, painted and chained by the search
display (capped at KEYBINDINGS_SEARCH_ROWS — the SCOPE note in
KeyBindingsPage.xml). Unanchored until a search places them;
the capsules run the full capture law in place. -->
<Frame name="$parentKeybindSearch1" inherits="KeyBindingRowTemplate" hidden="true"/>
<Frame name="$parentKeybindSearch2" inherits="KeyBindingRowTemplate" hidden="true"/>
<Frame name="$parentKeybindSearch3" inherits="KeyBindingRowTemplate" hidden="true"/>
<Frame name="$parentKeybindSearch4" inherits="KeyBindingRowTemplate" hidden="true"/>
<Frame name="$parentKeybindSearch5" inherits="KeyBindingRowTemplate" hidden="true"/>
<Frame name="$parentKeybindSearch6" inherits="KeyBindingRowTemplate" hidden="true"/>
<Frame name="$parentKeybindSearch7" inherits="KeyBindingRowTemplate" hidden="true"/>
<Frame name="$parentKeybindSearch8" inherits="KeyBindingRowTemplate" hidden="true"/>
</Frames>
</Frame>
</Frames>
@@ -1753,11 +2087,15 @@
OptionsFrame_SelectCategory(this.selectedCategory or "Controls")
</OnShow>
<!-- Hiding discards pending edits (the era wipes its modified table on hide; its
close-time confirm dialog is cut — SCOPE note) and ends a live search (the
era Flush's ClearSearchBox, SettingsPanel.lua l.187-193). -->
close-time confirm dialog is cut — SCOPE note), ends a live search (the
era Flush's ClearSearchBox, SettingsPanel.lua l.187-193), and disarms a
straggling bind capture — the host seam swallowing all input with no window
on screen would be a lockout (KeyBindingsPage.xml, decision 1008). -->
<OnHide>
OptionsFrameSearchBox:SetText("")
OptionsPage_DiscardPending()
KeyBindings_SetSelected(nil)
KeyBindings_Output("")
</OnHide>
</Scripts>
</Frame>
+61 -6
View File
@@ -34,8 +34,16 @@
change, so the ref's PET_BAR_UPDATE / PET_BAR_UPDATE_COOLDOWN / UNIT_PET / the
UNIT_FLAGS+UNIT_AURA pair it filters for arg1=="pet" collapse into one — the same
collapse StanceBar.xml makes, for the same reason.
- No PET_BAR_SHOWGRID/HIDEGRID. That pair exists for the drag-an-empty-slot state; the
bar's own drag is not built yet, so the grid has nothing to reveal.
- The drag is ONE verb. PickupPetAction serves shift-click, OnDragStart and OnReceiveDrag
alike (decision 1010) — that is the reference's own shape, not a collapse of ours: the
binding itself forks on whether the cursor is already carrying a pet payload, so there is
no "place" counterpart the way PlaceAction answers PickupAction on the main bar. Nothing
else can produce that payload, which is why the pet bar can be rearranged and never
populated: its contents are the server's.
- PET_BAR_SHOWGRID/HIDEGRID are live, and are the PET payload's alone. The engine derives
them off the same cursor transition that drives ACTIONBAR_SHOWGRID/HIDEGRID but on a
disjoint predicate, so a held spell lights the action bar's empty slots and a held pet
action lights this bar's — never both, because neither payload can land on the other bar.
- The autocast sparkle is TEXTURES, not the ref's <Model>. benilla has no Model widget
(the Cooldown and the bag ItemAnim took the same posture): model the mechanism, let our
own renderer draw it. The numbers below are the shipped model's own — see BENILLA_PET_
@@ -186,6 +194,13 @@
-- not boilerplate: it is the whole autocast toggle, and without it a right click is
-- simply never delivered.
button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
-- The drag trio (decision 1010). Both buttons drag, ref-verbatim
-- (PetActionButton_OnLoad l.234) — and unlike the action bar's, ALL THREE gestures here
-- reach the same verb: the reference's OnClick-under-shift, OnDragStart and
-- OnReceiveDrag all call PickupPetAction, which forks on whether the cursor is already
-- carrying. LOCK_ACTIONBAR stays the vanilla default (unlocked), so — as on ActionBar
-- — the ref's cvar guard is not reproduced and the drag is always live.
button:RegisterForDrag("LeftButton", "RightButton")
button.index = index
button.icon = getglobal(button:GetName() .. "Icon")
button.cooldown = getglobal(button:GetName() .. "Cooldown")
@@ -199,7 +214,12 @@
-- the pet is already on the attack, in which case it calls off; right = flip autocast.
function BenillaPetActionButton_OnClick(button, mouseButton)
button:SetChecked(0)
if mouseButton == "RightButton" then
-- Shift outranks the button fork entirely (ref l.253-255): a shift-click on the pet bar
-- is a pick-up whichever mouse button it came from, and it never toggles autocast.
if IsShiftKeyDown() then
PickupPetAction(button.index)
BenillaPetActionBar_Update()
elseif mouseButton == "RightButton" then
TogglePetAutocast(button.index)
elseif IsPetAttackActive(button.index) then
PetStopAttack()
@@ -208,6 +228,20 @@
end
end
-- Both drag ends are the SAME verb (ref l.269-283, byte-verified at `0x4be180`): with an
-- empty cursor it picks the slot up, with a pet payload already held it drops onto this
-- slot. There is no separate "place" binding on this bar, which is also why a spell or an
-- item can never land here — nothing but a pet slot produces the payload it accepts.
function BenillaPetActionButton_OnDragStart(button)
PickupPetAction(button.index)
BenillaPetActionBar_Update()
end
function BenillaPetActionButton_OnReceiveDrag(button)
PickupPetAction(button.index)
BenillaPetActionBar_Update()
end
-- Ref PetActionButton_OnEnter. A token slot has no spell to look up, so its tooltip is
-- built from the two strings the info call already returned (the ref's own `isToken` fork);
-- a spell slot goes through the engine's tooltip channel.
@@ -256,7 +290,14 @@
if autoCastAllowed then button.autocastable:Show() else button.autocastable:Hide() end
button.autocasting = autoCastEnabled
if name then button:Show() else button:Hide() end
-- An unnamed slot hides — UNLESS the drop grid is up, which is the whole point of
-- the grid: while a pet action rides the cursor every slot shows, so you can see
-- where it may land (ref PetActionBar_Update l.122-128 + PetActionBar_ShowGrid).
if name or (BenillaPetActionBarFrame.showgrid or 0) > 0 then
button:Show()
else
button:Hide()
end
if texture then
SetDesaturation(button.icon, not usable and 1 or nil)
@@ -275,6 +316,13 @@
function BenillaPetActionBar_OnLoad(frame)
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
frame:RegisterEvent("PET_BAR_UPDATE")
-- The drop grid (decision 1010). A counter rather than a flag, ref-verbatim
-- (PetActionBar_ShowGrid/_HideGrid): our engine derives the pair off one payload edge
-- so they strictly alternate, but the counter is what the reference keeps and it costs
-- nothing to keep it too.
frame:RegisterEvent("PET_BAR_SHOWGRID")
frame:RegisterEvent("PET_BAR_HIDEGRID")
frame.showgrid = 0
frame.clock = 0
BenillaPetActionBar_OnEvent(frame)
end
@@ -283,7 +331,12 @@
-- bottom-stack pass (UIParent.xml, decision 0272) — the pet term of that stack is what
-- lifts the cast bar and the chat frame clear of it, which is the ref's own
-- PetActionBarFrame.xml:205-210.
function BenillaPetActionBar_OnEvent(frame)
function BenillaPetActionBar_OnEvent(frame, eventName)
if eventName == "PET_BAR_SHOWGRID" then
frame.showgrid = frame.showgrid + 1
elseif eventName == "PET_BAR_HIDEGRID" and frame.showgrid > 0 then
frame.showgrid = frame.showgrid - 1
end
if PetHasActionBar() then
BenillaPetActionBar_Update()
frame:Show()
@@ -347,6 +400,8 @@
<CheckedTexture file="Interface\Buttons\CheckButtonHilight" alphaMode="ADD"/>
<Scripts>
<OnClick>BenillaPetActionButton_OnClick(self, arg1)</OnClick>
<OnDragStart>BenillaPetActionButton_OnDragStart(self)</OnDragStart>
<OnReceiveDrag>BenillaPetActionButton_OnReceiveDrag(self)</OnReceiveDrag>
<OnEnter>BenillaPetActionButton_OnEnter(self)</OnEnter>
<OnLeave>BenillaPetActionButton_OnLeave(self)</OnLeave>
</Scripts>
@@ -426,7 +481,7 @@
</Frames>
<Scripts>
<OnLoad>BenillaPetActionBar_OnLoad(self)</OnLoad>
<OnEvent>BenillaPetActionBar_OnEvent(self)</OnEvent>
<OnEvent>BenillaPetActionBar_OnEvent(self, event)</OnEvent>
<OnUpdate>BenillaPetActionBar_OnUpdate(self, arg1)</OnUpdate>
<OnShow>UIParent_ManageFramePositions()</OnShow>
<OnHide>UIParent_ManageFramePositions()</OnHide>
+67
View File
@@ -274,5 +274,72 @@
BenillaQuestWatchFrame:SetPoint("TOPRIGHT", "MinimapCluster", "BOTTOMRIGHT", -CONTAINER_OFFSET_X, anchorY)
end
end
-- ref UIParent.lua:1819-1878, transcribed 1:1 (decision 1008 — closes 0997's
-- "abbreviates through KEY_ABBR" residue with the REAL mechanism: it never was a
-- KEY_ABBR set; the whole abbreviation is this Lua). The action bar's HotKey corners
-- call it with returnAbbr=1: ONE modifier abbreviates (CTRL→c, SHIFT→s, ALT→a — so
-- SHIFT-2 reads "s-2", the director's ask), TWO OR MORE collapse the whole label to
-- "·" — the ref's own multi-modifier dot. The full form (tooltips, the Keybindings
-- page) keeps the canonical prefixes and reads the base key's KEY_* global.
-- Transcription notes: the ref's dead `gsub(tempName, "CAPSLOCK", "Caps")` line
-- (result discarded — gsub doesn't mutate) is dropped; its GetLocale() deDE
-- CTRL→STRG swap and IsMacClient() KEY_*_MAC lookup ride behind existence guards
-- (this VM defines neither yet, and the getglobal fallback covers the gap).
function GetBindingText(name, prefix, returnAbbr)
if ( not name ) then
return "";
end
local tempName = name;
local i = strfind(name, "-");
local dashIndex = nil;
local count = 0;
while ( i ) do
if ( not dashIndex ) then
dashIndex = i;
else
dashIndex = dashIndex + i;
end
count = count + 1;
tempName = strsub(tempName, i + 1);
i = strfind(tempName, "-");
end
local modKeys = '';
if ( not dashIndex ) then
dashIndex = 0;
else
modKeys = strsub(name, 1, dashIndex);
if ( GetLocale and GetLocale() == "deDE" ) then
modKeys = gsub(modKeys, "CTRL", "STRG");
end
end
if ( returnAbbr ) then
if ( count > 1 ) then
return "·";
else
modKeys = gsub(modKeys, "CTRL", "c");
modKeys = gsub(modKeys, "SHIFT", "s");
modKeys = gsub(modKeys, "ALT", "a");
modKeys = gsub(modKeys, "STRG", "st");
end
end
if ( not prefix ) then
prefix = "";
end
local localizedName = nil;
if ( IsMacClient and IsMacClient() ) then
localizedName = getglobal(prefix..tempName.."_MAC");
end
if ( not localizedName ) then
localizedName = getglobal(prefix..tempName);
end
if ( not localizedName ) then
localizedName = tempName;
end
return modKeys..localizedName;
end
]]></Script>
</Ui>
+7 -16
View File
@@ -101,10 +101,8 @@
-- 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 }
-- (0997's KeyBindingFrame row is gone — decision 1008 folded key bindings into the
-- options window above, the 1.15 layout's own placement; KeyBindingsPage.xml.)
-- 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
@@ -554,22 +552,15 @@
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
-- live, there is no pending-change model for a Cancel to roll back, so putting the
-- window away IS the whole gesture. Looked up like the menu above (a headless panel
-- test loads this file without OptionsFrame.xml behind it).
-- window away IS the whole gesture — and since 1008 that covers the Keybindings
-- page too (live-commit; its OnHide hook disarms a straggling bind capture, and
-- while a capsule IS armed the host's capture seam swallows ESC before this chain
-- ever exists — the press becomes a BIND, 1.12's law). Looked up like the menu
-- above (a headless panel test loads this file without OptionsFrame.xml behind it).
local options = getglobal("OptionsFrame")
if options and options:IsVisible() then
HideUIPanel(options)
+93 -12
View File
@@ -1163,18 +1163,22 @@
when it has none. That is not decoration — a warlock's imp has mana and a hunter's boar has
focus, but a skeleton has neither, and the plain art draws a mana rail with nothing in it.
DEFERRED, each with its reason (all named in 0990):
- HAPPINESS. The ref's PetFrameHappiness icon needs `GetPetHappiness 0x4be900`, whose body
is NOT carved in wow-re (its address is known from the binding table, nothing more). Its
three thresholds, the damage percentage and the loyalty rate are exactly the kind of
derived numbers 0988 was written about: vmangos has a HAPPINESS_LEVEL_SIZE and taking it
would repeat that record's own mistake. So the icon is absent rather than guessed, and
comes back with the carve — together with `GetPetLoyalty 0x4be700`,
`GetPetTrainingPoints 0x4be790` and `GetPetExperience 0x4be840`, its uncarved siblings.
- The PET right-click menu. Its four entries (UnitPopup.xml's PET_PAPERDOLL / PET_RENAME /
PET_ABANDON / PET_DISMISS) each need a binding that is its own build — the pet paper doll
most of all. They stay suppressed there; the frame takes no right click rather than
opening a menu of four dead rows.
HAPPINESS is no longer deferred — decision 1005 built it, off the carve 0990 was waiting
for. `GetPetHappiness 0x4be900` returns a bucket the CLIENT has already computed from
PetPersonality.dbc's own three thresholds, so nothing here is derived from vmangos and
0988's warning was answered rather than dodged. Its three siblings (`GetPetLoyalty`,
`GetPetTrainingPoints`, `GetPetExperience`) landed with it.
STILL DEFERRED, each with its reason:
- The PET right-click menu. The reference opens it from PetFrame_OnClick's else arm
(`ToggleDropDownMenu(1, nil, PetFrameDropDown)` — PetFrame.lua:127-129), and its four
entries (UnitPopup.xml's PET_PAPERDOLL / PET_RENAME / PET_ABANDON / PET_DISMISS) each
need a binding that is its own build — the pet paper doll most of all. Two more are
needed just to decide what the menu SHOWS: `PetCanBeAbandoned` forks it, hiding
paperdoll/rename/abandon for a warlock pet and hiding dismiss for a hunter's, so the
same menu reads "Abandon" (permanent, behind the ABANDON_PET confirm) on a hunter pet
and "Dismiss" on a demon. They stay suppressed in UnitPopup.xml; the frame takes no
right click rather than opening a menu of dead rows.
- PartyMemberBuffTooltip on hover (the ref's OnEnter panel): it belongs to the party
frames' own deferred buff panel, not here.
-->
@@ -1203,6 +1207,7 @@
frame:RegisterEvent("UNIT_NAME_UPDATE")
frame:RegisterEvent("PET_ATTACK_START")
frame:RegisterEvent("PET_ATTACK_STOP")
frame:RegisterEvent("UNIT_HAPPINESS")
frame:EnableMouse(true)
BenillaPetFrame_Update(frame)
end
@@ -1218,6 +1223,10 @@
elseif event == "PET_ATTACK_STOP" then
BenillaPetFrameAttackMode:Hide()
return
elseif event == "UNIT_HAPPINESS" then
-- The ref's own last arm: repaint the icon alone, not the whole frame.
BenillaPetFrame_SetHappiness()
return
elseif event == "UNIT_PET" then
if arg1 ~= "player" then return end
elseif string.sub(event, 1, 5) == "UNIT_" and arg1 ~= "pet" then
@@ -1255,6 +1264,44 @@
end
BenillaPetFrame_UpdateDebuffs()
BenillaPetFrame_SetHappiness()
end
-- ref PetFrame_SetHappiness (PetFrame.lua l.132-156), quoted (decision 1005).
--
-- `GetPetHappiness` hands back a PRE-BUCKETED 1/2/3 — the client does the thresholding
-- against PetPersonality.dbc, not this function — plus the two numbers that bucket
-- selects. Three things here are load-bearing and each is the reference's own:
-- * the hide test is `not happiness or not isHunterPet`, so a bucket of **0** (truthy in
-- Lua) leaves the icon SHOWING with whatever texcoords it had. Testing `happiness == 0`
-- or coercing 0 to nil would hide a frame the reference shows.
-- * the three texcoord cuts run 3/2/1 left-to-right across the strip — happy is at x=0.
-- * the loyalty line is chosen by the SIGN of loyaltyRate, which is why that return is
-- unscaled and allowed to be negative.
function BenillaPetFrame_SetHappiness()
local happiness, damagePercentage, loyaltyRate = GetPetHappiness()
local hasPetUI, isHunterPet = HasPetUI()
if not happiness or not isHunterPet then
BenillaPetFrameHappiness:Hide()
return
end
BenillaPetFrameHappiness:Show()
if happiness == 1 then
BenillaPetFrameHappinessTexture:SetTexCoord(0.375, 0.5625, 0, 0.359375)
elseif happiness == 2 then
BenillaPetFrameHappinessTexture:SetTexCoord(0.1875, 0.375, 0, 0.359375)
elseif happiness == 3 then
BenillaPetFrameHappinessTexture:SetTexCoord(0, 0.1875, 0, 0.359375)
end
BenillaPetFrameHappiness.tooltip = getglobal("PET_HAPPINESS" .. happiness)
BenillaPetFrameHappiness.tooltipDamage = format(PET_DAMAGE_PERCENTAGE, damagePercentage)
if loyaltyRate < 0 then
BenillaPetFrameHappiness.tooltipLoyalty = getglobal("LOSING_LOYALTY")
elseif loyaltyRate > 0 then
BenillaPetFrameHappiness.tooltipLoyalty = getglobal("GAINING_LOYALTY")
else
BenillaPetFrameHappiness.tooltipLoyalty = nil
end
end
-- The four debuff buttons (ref PetFrame.xml's $parentDebuff1..4 over RefreshBuffs). The pet
@@ -1412,6 +1459,40 @@
</Layer>
</Layers>
</Frame>
<!-- HAPPINESS (ref PetFrameHappiness, PetFrame.xml l.233-253; decision 1005). 24×23
anchored LEFT to the frame's RIGHT at (-7,-4), so it sits just inside the plate's
right edge. The file is a three-icon strip and the AUTHORED TexCoords are
happiness 3's — the ref ships the happy face and re-cuts it at update. -->
<Frame name="$parentHappiness" enableMouse="true">
<Size><AbsDimension x="24" y="23"/></Size>
<Anchors><Anchor point="LEFT" relativePoint="RIGHT"><Offset><AbsDimension x="-7" y="-4"/></Offset></Anchor></Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentTexture" file="Interface\PetPaperDollFrame\UI-PetHappiness" setAllPoints="true">
<Size><AbsDimension x="24" y="23"/></Size>
<TexCoords left="0" right="0.1875" top="0" bottom="0.359375"/>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnEnter>
if this.tooltip then
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetText(this.tooltip)
if this.tooltipDamage then
GameTooltip:AddLine(this.tooltipDamage, "", 1, 1, 1)
end
if this.tooltipLoyalty then
GameTooltip:AddLine(this.tooltipLoyalty, "", 1, 1, 1)
end
GameTooltip:Show()
end
</OnEnter>
<OnLeave>
GameTooltip:Hide()
</OnLeave>
</Scripts>
</Frame>
<!-- The four debuffs, ref geometry: the first at (48,-42), then +2 px chained right. -->
<Button name="$parentDebuff1" inherits="BenillaPetDebuffTemplate" id="1">
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="48" y="-42"/></Offset></Anchor></Anchors>
+5 -1
View File
@@ -389,7 +389,11 @@
-- The raid roster (GetRaidRosterInfo) is a later arc — no raid menus open yet.
UnitPopupShown[index] = 0;
elseif ( value == "PET_PAPERDOLL" or value == "PET_RENAME" or value == "PET_ABANDON" or value == "PET_DISMISS" ) then
-- The pet system is a later arc — the PET menu never opens (no "pet" token).
-- The "pet" token exists now (0990), but none of these four rows does: each
-- wants its own binding (paperdoll a whole panel), and `PetCanBeAbandoned`
-- besides, which is what forks abandon-vs-dismiss. So the PET menu stays shut
-- at its opener (UnitFrames.xml's pet frame takes no right click) and these
-- stay suppressed here — the two halves of one deferral, not two.
UnitPopupShown[index] = 0;
elseif ( string.sub(value, 1, 12) == "RAID_TARGET_" ) then
if ( (inParty == 0) or ((isLeader == 0) and (isAssistant == 0)) ) then
+8 -7
View File
@@ -19,10 +19,11 @@
//! - [`Kind::Host`] lands in [`BindingsState::fired`] for engine consumers (chat open, TAB
//! targeting, nameplates, autorun, camera zoom, …).
//!
//! While the Key Bindings window has a capsule selected it arms the **capture seam**
//! (`BenillaBindCapture`): raw input is swallowed here, canonicalized
//! (`ALT-CTRL-SHIFT-<TOKEN>`), and handed back to the window's own capture handler — the 1.12
//! law (lone modifiers ignored, left/right clicks stay UI clicks, ESC binds like any key).
//! While the Keybindings page (the Options window's category since 1008) has a capsule
//! selected it arms the **capture seam** (`BenillaBindCapture`): raw input is swallowed here,
//! canonicalized (`ALT-CTRL-SHIFT-<TOKEN>`), and handed back to the page's own capture handler
//! — the 1.12 law (lone modifiers ignored, left/right clicks stay UI clicks, ESC binds like
//! any key).
//!
//! Persistence: `benilla/bindings/account.txt` + `<Realm>-<Char>.txt` ([`store`], through
//! [`crate::local_state`]); the character file's existence is the character-set state, deleted
@@ -312,8 +313,8 @@ fn latch_and_dispatch(
}
};
// ── 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
// ── The capture seam ── the Keybindings page has a capsule selected: swallow raw input,
// canonicalize, hand the chord string to the page'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 {
@@ -344,7 +345,7 @@ fn latch_and_dispatch(
if let Some(chord_str) = captured {
run_lua(
&mut script,
&format!("KeyBindingFrame_OnHostKey(\"{chord_str}\")"),
&format!("KeyBindings_OnHostKey(\"{chord_str}\")"),
"capture",
);
}
+250 -2
View File
@@ -4,8 +4,9 @@
//!
//! **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
//! action pages, the right multibars' MULTIACTIONBAR3/4, camera views, screenshot, combat log, …)
//! are absent, not stubbed; the page 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:
@@ -616,6 +617,253 @@ pub(crate) static SPECS: &[Spec] = &[
Some("MOUSEWHEELDOWN"),
None
),
// ── MultiActionBar (BINDING_HEADER_MULTIACTIONBAR) ──────────────────────────────────
// The two bottom bars' buttons (MultiBars.xml renders exactly these; 1.12's right bars
// and their MULTIACTIONBAR3/4 commands stay out — honest tree). Ref bodies are the
// MultiActionButtonDown/Up runOnUp pair (Bindings.xml:799-966), transcribed in
// MultiBars.xml; shipped UNBOUND like the ref (no MULTIACTIONBAR* line in any of the
// install's bindings-cache.wtf files). 1.12 files bar 2 under a BLANK spacer-header;
// both bars sit under the one MULTIACTIONBAR header here (1008, recorded).
spec!(
"MULTIACTIONBAR1BUTTON1",
MULTIACTIONBAR,
Kind::EdgeUpDown(
r#"BenillaMultiActionButtonDown("BottomLeft", 1)"#,
r#"BenillaMultiActionButtonUp("BottomLeft", 1)"#
),
None,
None
),
spec!(
"MULTIACTIONBAR1BUTTON2",
MULTIACTIONBAR,
Kind::EdgeUpDown(
r#"BenillaMultiActionButtonDown("BottomLeft", 2)"#,
r#"BenillaMultiActionButtonUp("BottomLeft", 2)"#
),
None,
None
),
spec!(
"MULTIACTIONBAR1BUTTON3",
MULTIACTIONBAR,
Kind::EdgeUpDown(
r#"BenillaMultiActionButtonDown("BottomLeft", 3)"#,
r#"BenillaMultiActionButtonUp("BottomLeft", 3)"#
),
None,
None
),
spec!(
"MULTIACTIONBAR1BUTTON4",
MULTIACTIONBAR,
Kind::EdgeUpDown(
r#"BenillaMultiActionButtonDown("BottomLeft", 4)"#,
r#"BenillaMultiActionButtonUp("BottomLeft", 4)"#
),
None,
None
),
spec!(
"MULTIACTIONBAR1BUTTON5",
MULTIACTIONBAR,
Kind::EdgeUpDown(
r#"BenillaMultiActionButtonDown("BottomLeft", 5)"#,
r#"BenillaMultiActionButtonUp("BottomLeft", 5)"#
),
None,
None
),
spec!(
"MULTIACTIONBAR1BUTTON6",
MULTIACTIONBAR,
Kind::EdgeUpDown(
r#"BenillaMultiActionButtonDown("BottomLeft", 6)"#,
r#"BenillaMultiActionButtonUp("BottomLeft", 6)"#
),
None,
None
),
spec!(
"MULTIACTIONBAR1BUTTON7",
MULTIACTIONBAR,
Kind::EdgeUpDown(
r#"BenillaMultiActionButtonDown("BottomLeft", 7)"#,
r#"BenillaMultiActionButtonUp("BottomLeft", 7)"#
),
None,
None
),
spec!(
"MULTIACTIONBAR1BUTTON8",
MULTIACTIONBAR,
Kind::EdgeUpDown(
r#"BenillaMultiActionButtonDown("BottomLeft", 8)"#,
r#"BenillaMultiActionButtonUp("BottomLeft", 8)"#
),
None,
None
),
spec!(
"MULTIACTIONBAR1BUTTON9",
MULTIACTIONBAR,
Kind::EdgeUpDown(
r#"BenillaMultiActionButtonDown("BottomLeft", 9)"#,
r#"BenillaMultiActionButtonUp("BottomLeft", 9)"#
),
None,
None
),
spec!(
"MULTIACTIONBAR1BUTTON10",
MULTIACTIONBAR,
Kind::EdgeUpDown(
r#"BenillaMultiActionButtonDown("BottomLeft", 10)"#,
r#"BenillaMultiActionButtonUp("BottomLeft", 10)"#
),
None,
None
),
spec!(
"MULTIACTIONBAR1BUTTON11",
MULTIACTIONBAR,
Kind::EdgeUpDown(
r#"BenillaMultiActionButtonDown("BottomLeft", 11)"#,
r#"BenillaMultiActionButtonUp("BottomLeft", 11)"#
),
None,
None
),
spec!(
"MULTIACTIONBAR1BUTTON12",
MULTIACTIONBAR,
Kind::EdgeUpDown(
r#"BenillaMultiActionButtonDown("BottomLeft", 12)"#,
r#"BenillaMultiActionButtonUp("BottomLeft", 12)"#
),
None,
None
),
spec!(
"MULTIACTIONBAR2BUTTON1",
MULTIACTIONBAR,
Kind::EdgeUpDown(
r#"BenillaMultiActionButtonDown("BottomRight", 1)"#,
r#"BenillaMultiActionButtonUp("BottomRight", 1)"#
),
None,
None
),
spec!(
"MULTIACTIONBAR2BUTTON2",
MULTIACTIONBAR,
Kind::EdgeUpDown(
r#"BenillaMultiActionButtonDown("BottomRight", 2)"#,
r#"BenillaMultiActionButtonUp("BottomRight", 2)"#
),
None,
None
),
spec!(
"MULTIACTIONBAR2BUTTON3",
MULTIACTIONBAR,
Kind::EdgeUpDown(
r#"BenillaMultiActionButtonDown("BottomRight", 3)"#,
r#"BenillaMultiActionButtonUp("BottomRight", 3)"#
),
None,
None
),
spec!(
"MULTIACTIONBAR2BUTTON4",
MULTIACTIONBAR,
Kind::EdgeUpDown(
r#"BenillaMultiActionButtonDown("BottomRight", 4)"#,
r#"BenillaMultiActionButtonUp("BottomRight", 4)"#
),
None,
None
),
spec!(
"MULTIACTIONBAR2BUTTON5",
MULTIACTIONBAR,
Kind::EdgeUpDown(
r#"BenillaMultiActionButtonDown("BottomRight", 5)"#,
r#"BenillaMultiActionButtonUp("BottomRight", 5)"#
),
None,
None
),
spec!(
"MULTIACTIONBAR2BUTTON6",
MULTIACTIONBAR,
Kind::EdgeUpDown(
r#"BenillaMultiActionButtonDown("BottomRight", 6)"#,
r#"BenillaMultiActionButtonUp("BottomRight", 6)"#
),
None,
None
),
spec!(
"MULTIACTIONBAR2BUTTON7",
MULTIACTIONBAR,
Kind::EdgeUpDown(
r#"BenillaMultiActionButtonDown("BottomRight", 7)"#,
r#"BenillaMultiActionButtonUp("BottomRight", 7)"#
),
None,
None
),
spec!(
"MULTIACTIONBAR2BUTTON8",
MULTIACTIONBAR,
Kind::EdgeUpDown(
r#"BenillaMultiActionButtonDown("BottomRight", 8)"#,
r#"BenillaMultiActionButtonUp("BottomRight", 8)"#
),
None,
None
),
spec!(
"MULTIACTIONBAR2BUTTON9",
MULTIACTIONBAR,
Kind::EdgeUpDown(
r#"BenillaMultiActionButtonDown("BottomRight", 9)"#,
r#"BenillaMultiActionButtonUp("BottomRight", 9)"#
),
None,
None
),
spec!(
"MULTIACTIONBAR2BUTTON10",
MULTIACTIONBAR,
Kind::EdgeUpDown(
r#"BenillaMultiActionButtonDown("BottomRight", 10)"#,
r#"BenillaMultiActionButtonUp("BottomRight", 10)"#
),
None,
None
),
spec!(
"MULTIACTIONBAR2BUTTON11",
MULTIACTIONBAR,
Kind::EdgeUpDown(
r#"BenillaMultiActionButtonDown("BottomRight", 11)"#,
r#"BenillaMultiActionButtonUp("BottomRight", 11)"#
),
None,
None
),
spec!(
"MULTIACTIONBAR2BUTTON12",
MULTIACTIONBAR,
Kind::EdgeUpDown(
r#"BenillaMultiActionButtonDown("BottomRight", 12)"#,
r#"BenillaMultiActionButtonUp("BottomRight", 12)"#
),
None,
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.
+2 -1
View File
@@ -216,7 +216,8 @@ fn update_shadows(
// until a wow-re shadow-consumer trace pins it.
mount_anims: Query<(&NetEntity, &ModelAnimations), With<crate::entities::mount::MountBody>>,
mut shadows: Query<(&BlobShadow, &mut ShadowKey, &mut ShadowVerts)>,
// Once-a-second census at debug level (`RUST_LOG=benilla::blob_shadow=debug`): how many
// Once-a-second census at debug level (`RUST_LOG=benilla_app::blob_shadow=debug` — the lib
// target is `benilla_app`; a `benilla::` filter silently matches nothing): how many
// shadows exist and why the hidden ones hid — the first question of any "no shadow under X"
// report, answerable from a log instead of a debugger.
mut census_at: Local<f32>,
+11 -5
View File
@@ -977,12 +977,18 @@ pub(super) fn seed_ui_fixture(
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.
// The Keybindings page (1008 — the Options window's category), 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; CVars registered too so the sibling category rows behave. Movement is
// expanded so the lens sees both a header row and the byte-real default capsules.
script.register_cvars(crate::cvars::REGISTERED.iter().copied());
script.register_bindings(&crate::bindings::registry_commands());
if let Err(e) = script.run("ShowUIPanel(KeyBindingFrame)") {
if let Err(e) = script.run(
"ShowUIPanel(OptionsFrame); \
OptionsFrameCategoryListRowKeybindings:Click(); \
KeyBindings_ExpandSection(1, true); KeyBindingsPage_Update()",
) {
warn!("capture: ui-keybindings seed failed: {e}");
}
}
+36 -4
View File
@@ -135,6 +135,20 @@ pub(crate) fn scenario_active() -> bool {
/// WOW_FX_DIST=12 WOW_CAPTURE_OUT=/tmp/vw.png cargo run -q -p benilla
/// ```
///
/// **`WOW_FX_GO=<GameObjectDisplayInfo id>` swaps the subject onto the GAMEOBJECT path** — the
/// third lane, and the one a placed trap/door/chest actually takes: the wire component set a
/// streamed GO gets, so `crate::go_anim`'s §243 state machine — not the effect pool — chooses the
/// sequence. `WOW_FX_GO_STATE` (`GAMEOBJECT_STATE`, default 1 READY) and `WOW_FX_GO_TYPE`
/// (`GAMEOBJECT_TYPE_ID`, default 6 TRAP) select the substate; `benilla-extract goanimscan`
/// *predicts* what that resolves to, and this *shows* it. The lane matters because an absent
/// `GAMEOBJECT_STATE` reads as the wire default `0` = ACTIVE, which on a model with no `Opened`
/// sequence lands somewhere else entirely:
///
/// ```text
/// WOW_DATA=<Data> WOW_CAPTURE=fxview WOW_FX_GO=3073 WOW_FX_GO_STATE=1 WOW_FX_AGE=4 \
/// WOW_FX_DIST=3 WOW_CAPTURE_OUT=/tmp/go.png cargo run -q -p benilla
/// ```
///
/// Knobs: `WOW_FX_MODEL` (required, internal path), `WOW_FX_AGE` (seconds after attach, default
/// 1.0), `WOW_FX_AZ`/`WOW_FX_EL` (camera orbit degrees, default 0/10), `WOW_FX_DIST` (yards,
/// default 5), `WOW_FX_FLY` (yd/s along the model's facing — a missile only trails in motion;
@@ -157,6 +171,17 @@ pub(crate) struct FxViewRequest {
/// knob is the A/B that says whether a defect belongs to the model or to the unit path.
/// `WOW_FX_MODEL` is then optional (the display id names the model).
pub(crate) display: Option<u32>,
/// `WOW_FX_GO=<GameObjectDisplayInfo id>` — spawn the subject as a real **GameObject**, so
/// [`crate::go_anim`]'s state machine picks the sequence instead of the effect pool's
/// "play clip 0". A placed trap renders through this lane and nothing else, so it is the only
/// honest A/B for "does the trap look right".
pub(crate) go: Option<u32>,
/// `WOW_FX_GO_STATE` — the `GAMEOBJECT_STATE` the fixture's descriptor carries (default 1 =
/// READY, what vmangos spawns a trap in). Set it to 0 to see what an omitted field renders as.
pub(crate) go_state: u32,
/// `WOW_FX_GO_TYPE` — the `GAMEOBJECT_TYPE_ID` (default 6 = TRAP). Decides whether
/// [`crate::go_anim::go_animates`] puts the instance on the machine at all.
pub(crate) go_type: u32,
/// `WOW_FX_SCALE` — the unit lane's `NetEntity::scale` (the wire scale a creature carries).
pub(crate) scale: f32,
pub(crate) age: f32,
@@ -404,13 +429,17 @@ impl Plugin for CapturePlugin {
let display: Option<u32> = std::env::var("WOW_FX_DISPLAY")
.ok()
.and_then(|v| v.trim().parse().ok());
let model_path = match (std::env::var("WOW_FX_MODEL"), display) {
let go: Option<u32> = std::env::var("WOW_FX_GO")
.ok()
.and_then(|v| v.trim().parse().ok());
let model_path = match (std::env::var("WOW_FX_MODEL"), display.or(go)) {
(Ok(p), _) => p,
(Err(_), Some(_)) => String::new(), // the unit lane names its model by display id
(Err(_), Some(_)) => String::new(), // the id lanes name their model by display id
(Err(_), None) => {
eprintln!(
"WOW_CAPTURE=fxview needs WOW_FX_MODEL=<internal .mdx/.m2 path> or \
WOW_FX_DISPLAY=<CreatureDisplayInfo id>"
"WOW_CAPTURE=fxview needs WOW_FX_MODEL=<internal .mdx/.m2 path>, \
WOW_FX_DISPLAY=<CreatureDisplayInfo id> or \
WOW_FX_GO=<GameObjectDisplayInfo id>"
);
std::process::exit(2);
}
@@ -424,6 +453,9 @@ impl Plugin for CapturePlugin {
app.insert_resource(FxViewRequest {
model_path,
display,
go,
go_state: knob("WOW_FX_GO_STATE", 1.0) as u32,
go_type: knob("WOW_FX_GO_TYPE", 6.0) as u32,
scale: knob("WOW_FX_SCALE", 1.0),
age: knob("WOW_FX_AGE", 1.0),
az_deg: knob("WOW_FX_AZ", 0.0),
+1 -1
View File
@@ -940,7 +940,7 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 720,
ui: Some(UiFixture::OptionsWorldDetail),
},
// The Key Bindings window on its Movement page (0997). Run with
// The options window's Keybindings page, Movement expanded (1008). Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-keybindings`.
Scenario {
name: "ui-keybindings",
+1
View File
@@ -41,6 +41,7 @@ fn payload_icon(script: &benilla_ui::script::UiScript) -> Option<String> {
CursorPayload::Spell(s) => s.texture,
CursorPayload::Action(a) => a.texture,
CursorPayload::Macro(m) => m.texture,
CursorPayload::PetAction(p) => p.texture,
}
}
+29
View File
@@ -213,6 +213,12 @@ impl Creatures {
self.catalog.collision_height(display_id)
}
/// A display's footprint-decal parameters (yards, pre-scale) — `None` = this model leaves no
/// prints. See [`benilla_formats::CreatureCatalog::footprint`].
pub(crate) fn footprint(&self, display_id: u32) -> Option<benilla_formats::FootprintParams> {
self.catalog.footprint(display_id)
}
/// A display's **base render alpha** (`CreatureDisplayInfo.CreatureModelAlpha / 255`) — the
/// `baseAlpha` factor of the per-unit alpha product the aura CharProc nodes multiply into
/// (`crate::aura_visual`). `None` for an unknown display.
@@ -718,6 +724,29 @@ fn setup_entities(
// No LockType data ⇒ lock-bearing GOs fall back to the Interact gear cursor — degraded, not broken.
Err(e) => warn!("lock-type catalog unavailable, GO cursors fall back to Interact: {e:#}"),
}
match (
benilla_formats::load_pet_personalities(&mut chain),
benilla_formats::load_pet_loyalty_names(&mut chain),
) {
(Ok(personalities), Ok(loyalty)) => {
info!(
"pet stat tables: {} personalities, {} loyalty levels",
personalities.len(),
loyalty.len()
);
commands.insert_resource(crate::ui_pet_stats::PetStatTables {
personalities,
loyalty,
});
}
// No pet tables ⇒ happiness answers its own gate-failure nil (so the icon hides) and the
// loyalty line is blank; the rest of the paper doll — XP, training points — reads straight
// off the descriptor and is unaffected. Degraded, not wrong.
(p, l) => warn!(
"pet stat tables unavailable, happiness/loyalty stay blank: {:#}",
p.err().or(l.err()).expect("at least one of the two failed")
),
}
match CharacterGeosets::load(&mut chain) {
Ok(geosets) => commands.insert_resource(Characters(geosets)),
Err(e) => warn!("character geosets unavailable, players show every geoset: {e:#}"),
@@ -804,16 +804,20 @@ pub(super) fn attach_entity_visuals(
.as_ref()
.and_then(|build| build.anchors.get(&rb.def.bone))
.map_or((entity, false), |&j| (j, true));
// A streamed unit's body trails are always-on (wisp streamers, crystal
// trails); the per-sequence visibility gate is the thrown weapon's InFlight
// keying, which body models don't author — so the running gait is immaterial.
// The `+0xc0` enable gate reads THIS instance's playing sequence, live —
// a GameObject flips state under its own trails (a trap springs, a door
// opens), so there is no spawn-time answer. Passing `None` here (the old
// "body models don't author a gate" shortcut, true of creatures and false of
// GameObjects) drew every trail a model authored in every state: the Frost
// Trap's twelve trigger-only streamers became a permanent spinning column
// over the placed trap (decision 1011).
crate::ribbons::spawn_ribbon(
&mut commands,
rb,
owner,
use_pivot,
placement.scale.max_element(),
None,
crate::ribbons::RibbonSeq::Host(entity),
// The unit's own render alpha gates its trail (0827).
Some(entity),
);
@@ -639,7 +639,9 @@ fn spawn_slot(
root,
false,
ctx.scale,
Some(0),
// A worn item rests in `Stand` and nothing on it plays anything else — the one lane
// where the enable gate genuinely has a fixed answer for the instance's life.
crate::ribbons::RibbonSeq::Fixed(0),
// Its own model instance — chained to the wearer above — so an enchant streamer is
// gone with the avatar in first person and absent until the body is shown (0827/0833).
Some(root),
+51 -10
View File
@@ -78,6 +78,10 @@ pub(super) const FALLBACK_SPAN: f32 = 1.0;
/// like the wire's, in a serverless capture where nothing else claims one.
const FXVIEW_UNIT_GUID: u64 = (0xF130u64 << 48) | 0xFC0FEE;
/// The `fxview` GameObject lane's synthetic guid (`WOW_FX_GO`) — the wire's `0xF110` high word,
/// which is what `crate::net` reads a GO's identity out of.
const FXVIEW_GO_GUID: u64 = (0xF110u64 << 48) | 0xFC0FEE;
/// How long a self-terminating instance may wait unspawned (model/skeleton never loading — the
/// cube-fallback caster) before it is dropped instead of pending forever.
const PENDING_TIMEOUT: f32 = 10.0;
@@ -223,6 +227,43 @@ pub(crate) fn drive_fx_view(
))
.id();
}
// `WOW_FX_GO`: the GAMEOBJECT lane. A placed trap/door/chest reaches the screen through
// `crate::go_anim`'s §243 state machine, never through the effect pool, so this is the
// only lane that reproduces one. The descriptor carries the three fields the machine
// reads — display, TYPE_ID (the `go_animates` gate) and STATE (the substate) — and
// nothing else, so an unset knob renders exactly what an omitted wire field renders.
// Always seated on the terrain: a GO stands on the ground.
if let Some(display) = req.go {
if let Some(hit) = spatial.cast_ray(
pos,
Dir3::NEG_Y,
500.0,
true,
&crate::collision::player_query_filter(),
) {
pos.y -= hit.distance;
}
pos.y += req.up;
return commands
.spawn((
crate::net::Guid(FXVIEW_GO_GUID),
crate::net::NetEntity {
kind: benilla_protocol::EntityKind::GameObject,
display_id: Some(display),
scale: req.scale,
},
crate::net::ObjectStore(benilla_protocol::messages::ObjectFields::from_pairs(
&[
(14, req.go_state), // GAMEOBJECT_STATE
(21, req.go_type), // GAMEOBJECT_TYPE_ID
],
)),
Transform::from_translation(pos)
.with_rotation(Quat::from_rotation_y(req.yaw_deg.to_radians())),
Visibility::default(),
))
.id();
}
// `WOW_FX_GROUND=1`: seat the fixture ON the terrain — a ground-anchored effect's flat
// quads render as projected surface decals and need ground inside their vertical slab.
// The scene settled before arming, so the streamed tile's collider is there to hit.
@@ -262,8 +303,8 @@ pub(crate) fn drive_fx_view(
// instance dies at exactly one pass of sequence 0 and its emitters DRAIN — the fixture
// mirrors it so a capture past the span shows the truth. `WOW_FX_HOLD=1` previews a
// persistent hold instead (reaped by its spell edge in game, so no clock here).
// The one-pass reap is an effect-instance rule; a unit stands there.
if !req.hold && !state.expired && req.display.is_none() {
// The one-pass reap is an effect-instance rule; a unit — or a placed GameObject — stands there.
if !req.hold && !state.expired && req.display.is_none() && req.go.is_none() {
if let Some(at) = state.attached_at {
let span = fx
.models
@@ -277,10 +318,10 @@ pub(crate) fn drive_fx_view(
}
}
if state.attached_at.is_none() {
// The unit lane has no attach step — the unit pipeline builds the model on its own. Its
// "age" is therefore seconds since the unit appeared, which is what the animation phase is
// measured in.
if req.display.is_some() {
// The unit and GameObject lanes have no attach step — the entity pipeline builds the model
// on its own. Their "age" is therefore seconds since the entity appeared, which is what the
// animation phase is measured in.
if req.display.is_some() || req.go.is_some() {
state.attached_at = Some(time.elapsed_secs());
return;
}
@@ -619,9 +660,6 @@ pub(super) fn attach_effect_visuals(
},
);
}
// The same pick, as an `AnimationData` id — the ribbons' per-sequence visibility keys off it,
// so the thrown weapon's trail shows in flight and never in the worn hand.
let played_anim = played.map(|c| c.anim_id);
for rb in &dm.ribbons {
let (owner, use_pivot) = joints
.get(rb.def.bone as usize)
@@ -635,7 +673,10 @@ pub(super) fn attach_effect_visuals(
// `Transform::IDENTITY` placement a few lines up, so both of a model's effect
// families land on the same draw-order rung, which is the property that matters.
1.0,
played_anim,
// The instance's own root carries the clip: an effect model that steps
// Stand -> Hold -> Decay re-answers the gate at each step, instead of freezing the
// birth clip's answer for the whole life.
crate::ribbons::RibbonSeq::Host(root),
// This instance's own model alpha, chained to its host (0827/0833): a standalone
// instance has none above it and draws exactly as before.
Some(root),
+1 -1
View File
@@ -372,7 +372,7 @@ pub(super) fn spawn_wmo_gameobject_props(
owner,
use_pivot,
placement.scale.max_element(),
None,
crate::ribbons::RibbonSeq::Host(entity),
// No model-alpha source: a placed prop / effect instance is always drawn (0827).
None,
)
+69 -6
View File
@@ -42,18 +42,37 @@ use crate::player::WorldCamera;
pub struct FfxGlow {
/// Per-view multiplier on the zone glow weight. See [`Self::WORLD`] / [`Self::UI_PANE`].
pub gain_scale: f32,
/// Per-view multiplier on the haze mix `z` ([`FfxHazeMix`] — the drunk/underwater
/// screen-toward-blur cross-fade). `1.0` only on the true world view: the haze is a
/// *player-state* effect on the viewed scene, and a booth bake that stands in for a UI pane
/// must never inherit it (the reference paints UI model widgets after the FFX pass).
pub haze_scale: f32,
}
impl FfxGlow {
/// The reference's own full-screen glow, at the zone's authored weight — the world camera, and
/// every booth bake that wants world parity.
pub const WORLD: Self = Self { gain_scale: 1.0 };
/// The reference's own full-screen glow, at the zone's authored weight — the world camera:
/// zone glow AND the player-state haze (drunk / camera-eye-submerged blur).
pub const WORLD: Self = Self {
gain_scale: 1.0,
haze_scale: 1.0,
};
/// A portrait/booth bake at world parity for *lighting and glow* (decision 0638) — but never
/// the haze: a bake stands in for a UI model widget, which the reference composites after
/// the WorldFrame's FFX pass, so a drunk player's unit frame stays sharp while the world
/// swims.
pub const BOOTH: Self = Self {
gain_scale: 1.0,
haze_scale: 0.0,
};
/// **Decode only, no glow** — for a bake that stands in for a 1.12 *UI model widget*. The
/// reference applies its FFX pass inside the WorldFrame's own paint (`0x48350e` → the apply
/// hook `0x6cd890`, wow-re `ffxeffects.md`); every UI frame paints afterwards, at its own
/// strata, so a `<PlayerModel>` pane is composited over an already-glowed world and never
/// glows itself. (The give-away in-game: the reference's chat text and buttons don't bloom.)
pub const UI_PANE: Self = Self { gain_scale: 0.0 };
pub const UI_PANE: Self = Self {
gain_scale: 0.0,
haze_scale: 0.0,
};
}
impl Default for FfxGlow {
@@ -74,6 +93,45 @@ pub struct FfxGlowGain(pub f32);
#[derive(Resource, Clone, Default, ExtractResource)]
pub struct FfxDeathFade(pub f32);
/// The haze mix `z` — the combine's screen-toward-blur cross-fade
/// (`out = lerp(screen, blur, z) + w·blur²`, the shipped FFXGlow.bls). The reference's glow
/// render packs it per frame from the **active player's** state (`0x6cb134`/`0x6cb599`, wow-re
/// `ffxeffects/scratch/drunk-blur-z.md`, decision 1009 §A):
/// `z = max(min(drunkByte,100)/100, submerged ? 84/255 : 0)` — fully blurred at 100 inebriation,
/// and a fixed ≈0.329 floor whenever the **camera eye** is in any liquid (the vanilla underwater
/// blur; `0x672470`'s eye-liquid probe, `0xf` = dry). Synced by [`sync_haze`]; uploaded as the
/// combine uniform's `z`, scaled per view by [`FfxGlow::haze_scale`].
#[derive(Resource, Clone, Default, ExtractResource)]
pub struct FfxHazeMix(pub f32);
/// The underwater haze floor: 84/255 (the reference's byte 84 in the COLOR z lane whenever the
/// eye-liquid probe reads non-dry — applied regardless of sobriety).
const HAZE_SUBMERGED_FLOOR: f32 = 84.0 / 255.0;
/// Sync [`FfxHazeMix`] from the two player-state inputs the reference's glow render reads each
/// frame: our own drunk byte (`PLAYER_BYTES_3` byte 1 → `min(b,100)/100`) and the camera-eye
/// submersion claim. Off-world both read empty → 0 (the glue screens never haze).
fn sync_haze(
self_player: Query<&crate::net::ObjectStore, With<crate::net::SelfPlayer>>,
underwater: Option<Res<crate::liquid::Underwater>>,
mut haze: ResMut<FfxHazeMix>,
) {
let drunk = self_player
.single()
.ok()
.and_then(|s| s.0.player_drunk_byte())
.map_or(0.0, |b| f32::from(b.min(100)) / 100.0);
let submerged = if underwater.is_some_and(|u| u.0 != benilla_formats::Submersion::Dry) {
HAZE_SUBMERGED_FLOOR
} else {
0.0
};
let target = drunk.max(submerged);
if haze.0 != target {
haze.0 = target;
}
}
/// Sync the gain from the live zone lighting (the same source `sync_bloom` used). Off-world there
/// is no `Light.dbc` zone — the reference runs its `LightParams` **default 0.5** (wow-re
/// death-pass.md: "the zone/time-of-day ambient glow scalar, default 0.5"): the glue screens'
@@ -288,6 +346,9 @@ impl ViewNode for FfxGlowNode {
// model pane, where the combine runs only for its gamma decode ([`FfxGlow::UI_PANE`]).
let gain = world.resource::<FfxGlowGain>().0 * glow.gain_scale;
let death = world.resource::<FfxDeathFade>().0;
// The haze mix, world-view only ([`FfxGlow::haze_scale`]): a booth bake never inherits
// the drunk/underwater screen blur.
let haze = world.resource::<FfxHazeMix>().0 * glow.haze_scale;
let (Some(downsample), Some(gauss_h), Some(gauss_v), Some(combine)) = (
pipeline_cache.get_render_pipeline(pipelines.downsample),
pipeline_cache.get_render_pipeline(pipelines.gauss_h),
@@ -299,7 +360,7 @@ impl ViewNode for FfxGlowNode {
let render_device = render_context.render_device().clone();
let gain_buf = render_device.create_buffer_with_data(&BufferInitDescriptor {
label: Some("ffx_glow_gain"),
contents: bytemuck::cast_slice(&[gain, death, 0.0, 0.0]),
contents: bytemuck::cast_slice(&[gain, death, haze, 0.0]),
usage: BufferUsages::UNIFORM,
});
let post = view_target.post_process_write();
@@ -391,12 +452,14 @@ impl Plugin for FfxGlowPlugin {
fn build(&self, app: &mut App) {
app.insert_resource(FfxGlowGain(0.647)) // overwritten by sync_gain from zone data
.init_resource::<FfxDeathFade>()
.init_resource::<FfxHazeMix>()
.add_plugins((
ExtractComponentPlugin::<FfxGlow>::default(),
ExtractResourcePlugin::<FfxGlowGain>::default(),
ExtractResourcePlugin::<FfxDeathFade>::default(),
ExtractResourcePlugin::<FfxHazeMix>::default(),
))
.add_systems(Update, (sync_gain, ensure_ffx_glow));
.add_systems(Update, (sync_gain, sync_haze, ensure_ffx_glow));
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
return;
};
+421
View File
@@ -0,0 +1,421 @@
//! **Footprint decals** — the prints a walking unit leaves on snow and sand (B212, decisions
//! 1006/1012): the fourth client of the shared surface-decal projector ([`crate::decal`]), drawn
//! on the shared effect stream like the blob shadow, but **spawn-once**: a print is projected the
//! frame its foot plants and the cached triangles replay every frame until the fade retires it —
//! exactly the reference's own shape (baked once at spawn over collector-gathered ground
//! triangles; the per-frame draw `0x69a3e0` only re-copies with the current alpha).
//!
//! **The mechanism — wow-re §5 cross-checked** (`footprint-decals.md`, folded back in 1012):
//! - **Surface gate**: `TerrainType.Flags & 1` (`0x699eb6`) — set on exactly **Snow** and
//! **Sand** — resolved under the unit through the same ground-effect → TerrainType chain the
//! footstep sounds ride ([`benilla_formats::FootstepCatalog::leaves_footprints`]).
//! - **Trigger**: each per-foot animation event tag (`$xL*`/`$xR*`; the same [`AnimSoundEvent`]
//! stream the sounds read). **`$FSD` is sound-only** — a quadruped whose run authors only
//! `$FSD` leaves no prints, faithfully. Position = the event record's authored offset through
//! the live bone matrix ([`BoneAttach::markers`], `0x7196df`); yaw = the unit's facing.
//! - **Ink + size**: `CreatureModelData.FootprintTextureID` → `FootprintTextures.dbc` →
//! `textures\Footsteps\*` (32×32 pure-black-RGB under soft alpha), sized Length/Width ×(1/36)
//! inches→yards (`0x5fc310`/`0x607a00`) × the unit's wire `OBJECT_FIELD_SCALE_X` **only**
//! (`0x469f10` — no display scale multiplies in). `0xFFFFFFFF` = printless (133 of 430
//! models). A mounted composite prints the MOUNT's ink/dims (the mount model's own event
//! stream) at the RIDER's scale. **The texture is authored as the LEFT foot** — right-foot
//! prints mirror (`0x5fc07f`, scale(−1,1,1) before the yaw).
//! - **Fade**: lifetime **6000 ms**, `t = 1 − age/6000`, `alpha = min(127, ⌊255·t⌋)` — ≈3.0 s
//! hold at ~50 % opacity then a ≈3.0 s linear fade, no fade-in ([`fade`], byte-diffed
//! `PRIMITIVE:footprint_alpha` @`0x69a3e0`).
//! - **Caps**: ring pools of **64 local-player + 512 everyone-else** slots, unconditional
//! rotation (ring-select = GUID == local player, `0xca05f0`).
//! - **Suppressions**: hover (`MOVEFLAG 0x4000_0000`) · stealth (`BYTES_1` byte 3 bit 0x2 —
//! NOT death) · player ghost (`PLAYER_FLAGS & 0x10`) · farther than **50 yd** from the local
//! player · the terrain flag / printless id / no ground triangles. Water does NOT suppress
//! the decal (only the spray branch wades). The reference's `showfootprints` cvar is the
//! master toggle (default on) — we are always-on until a settings page wires the knob (the
//! cvar-policy line, like the blob shadow's `shadowLOD`).
//!
//! Draw state per the RE: src-alpha blend, depth-write off, unlit, white vertex RGB with the
//! fade in vertex alpha — the ink darkness lives in the texture. Still open there (none
//! load-bearing, note §13): the forward-axis sign convention inside the shared UV basis, the
//! uv1 64×8 edge-fade ramp's combine mode (not modeled here — prints are small; the blob
//! shadow's vertical trapezoid is that ramp's other consumer), and the frame order vs the blob
//! shadow (our rung 2048 under its 4096 is a deterministic stand-in).
use std::collections::VecDeque;
use avian3d::prelude::Collider;
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use benilla_assets::AdtTile;
use crate::assets::{AssetSet, LockRecover, WorldAssets};
use crate::blob_shadow::SHADOW_RASTER_BIAS;
use crate::collision::GroundDecalSurface;
use crate::creature_anim::{is_footstep, move_flags, AnimSoundEvent, MovementState};
use crate::decal::{project_decal, DecalFrame};
use crate::entities::{BoneAttach, Creatures};
use crate::net::{NetEntity, ObjectStore, SelfPlayer};
use crate::particles::buffer::{
begin_effect_frame, EffectBlend, EffectDrawSpec, EffectFog, EffectQuads, EffectVertex,
};
use crate::player::WorldCamera;
use crate::schedule::WorldStage;
use crate::sound::footsteps::Footsteps;
use crate::terrain_stream::{ground_effect_under, TerrainStreamer};
/// Print lifetime, spawn to gone — the reference's 6000 ms (byte-verified, wow-re
/// `footprint-decals.md`: `t = 1 − age/6000`, die at `t < 0`).
const LIFETIME: f32 = 6.0;
/// The local player's own ring pool: 64 slots (the reserved head of the reference's 576-slot
/// table @`0xca05f0`), unconditional rotation.
const OWN_CAP: usize = 64;
/// Everyone else shares 512 slots (the table's tail), same rotation.
const SHARED_CAP: usize = 512;
/// Prints farther than this from the local player never spawn (the reference's 2500 yd² gate).
const MAX_DISTANCE_SQ: f32 = 2500.0;
/// The print's sort-ladder rung: below the blob shadow's 4096 — where both decals stack the
/// shadow draws later, deterministically (the reference's frame order is the RE's open §13 item).
const PRINT_SORT_BIAS: f32 = 2048.0;
/// Vertical reach of the projection slab about the planted foot: enough to catch the ground
/// through a slightly-lifted foot bone and drape a step edge, small enough not to paint a
/// terrace below (the blob shadow's slab is the model box; a print has no box to read).
const SLAB_HALF_HEIGHT: f32 = 1.0;
/// One live print: the projected world-space triangles (vertex alpha 1.0 — the fade multiplies
/// at push time), its spawn stamp, and its ink texture.
struct Print {
verts: Vec<EffectVertex>,
spawned: f32,
texture: AssetId<Image>,
/// The draw's sort anchor (the planted-foot spot).
anchor: Vec3,
}
/// The live prints — the reference's two ring pools ([`OWN_CAP`] local player /
/// [`SHARED_CAP`] everyone else), each spawn-ordered (front = oldest; spawn order is expiry
/// order — one shared lifetime). Resources, not per-print entities: prints are inert after
/// spawn — no identity, queries, or despawn wiring needed.
#[derive(Resource, Default)]
struct Footprints {
own: VecDeque<Print>,
shared: VecDeque<Print>,
}
/// The lane's one identity entity — the phase probe's `main_entity` for every print draw (prints
/// have no entity of their own; a probe line still needs a producer to name).
#[derive(Resource)]
struct FootprintLane(Entity);
/// `FootprintTextures.dbc` id → the loaded ink texture. Loaded once at startup (six rows).
#[derive(Resource, Default)]
struct FootprintInk(HashMap<u32, Handle<Image>>);
pub(crate) struct FootprintsPlugin;
impl Plugin for FootprintsPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<Footprints>()
.add_systems(Startup, load_ink.after(AssetSet::Open))
// Present, like the footstep sounds: the same event stream, after the frame's
// animation drive + transforms have settled the foot bones.
.add_systems(Update, spawn_footprints.in_set(WorldStage::Present))
.add_systems(PostUpdate, push_footprints.after(begin_effect_frame));
}
}
/// Read `FootprintTextures.dbc` and start the six ink textures loading.
fn load_ink(
mut commands: Commands,
assets: Option<Res<WorldAssets>>,
asset_server: Res<AssetServer>,
) {
let lane = commands.spawn(Name::new("footprint-lane")).id();
commands.insert_resource(FootprintLane(lane));
let Some(assets) = assets else { return };
let table = {
let mut chain = assets.chain.lock_recover();
benilla_formats::load_footprint_textures(&mut chain)
};
match table {
Ok(table) => {
let ink = table
.into_iter()
.map(|(id, path)| {
// DBC paths are extensionless (`textures\Footsteps\BaseFootprint`); the
// `mpq://` source wants forward slashes + the `.blp`.
let url = format!("mpq://{}.blp", path.replace('\\', "/"));
(id, asset_server.load::<Image>(url))
})
.collect();
commands.insert_resource(FootprintInk(ink));
}
Err(e) => warn!("footprints: FootprintTextures.dbc failed to load: {e:#}"),
}
}
/// The foot side a per-foot tag names (`$FL0` → `L`) — `$FSD` (sound-only, byte-verified) and
/// non-footstep tags are `None`. **Right** prints get the mirrored texture: the shipped art is
/// the left foot (the `0x5fc07f` `side==0` mirror, corrected from 1006's guess in 1012).
fn foot_side(ident: &[u8; 4]) -> Option<u8> {
(is_footstep(ident) && ident != b"$FSD").then_some(ident[2])
}
/// The spawner's ROOT-unit reads (see [`spawn_footprints`]'s `roots` param).
type RootState = (
Has<SelfPlayer>,
Option<&'static NetEntity>,
Option<&'static ObjectStore>,
Option<&'static MovementState>,
);
/// Spawn a print for each per-foot plant on a footprint surface: the state gates (hover /
/// stealth / ghost / distance), the terrain-flags gate, the unit's ink + params, the event
/// marker's live bone position, yaw to the facing, project once, cache the triangles.
#[allow(clippy::too_many_arguments)]
fn spawn_footprints(
mut events: MessageReader<AnimSoundEvent>,
time: Res<Time>,
// GlobalTransform for the same reason as the footstep sounds (0441): a mounted unit's steps
// are the MOUNT child's tags, whose local Transform is the seat-relative ~origin.
units: Query<(&NetEntity, &GlobalTransform, Option<&BoneAttach>)>,
// The spawner's ROOT (the rider for a mount child): the pool select, the print scale (the
// rider's SCALE_X — RE-corrected), and the state gates all read the root.
parents: Query<&ChildOf>,
roots: Query<RootState>,
self_pos: Query<&Transform, With<SelfPlayer>>,
joints: Query<&GlobalTransform>,
footsteps: Option<Res<Footsteps>>,
creatures: Option<Res<Creatures>>,
ink: Option<Res<FootprintInk>>,
streamer: Res<TerrainStreamer>,
adt_tiles: Res<Assets<AdtTile>>,
surfaces: Query<&Collider, With<GroundDecalSurface>>,
mut prints: ResMut<Footprints>,
) {
if events.is_empty() {
return;
}
let (Some(footsteps), Some(creatures), Some(ink)) = (footsteps, creatures, ink) else {
return;
};
let now = time.elapsed_secs();
for ev in events.read() {
let Some(side) = foot_side(&ev.ident) else {
continue;
};
let Ok((net, transform, attach)) = units.get(ev.entity) else {
continue;
};
// The ink + dims come from the EVENT's model (the mount for a mounted composite); the
// scale and every state gate from the ROOT unit (the rider) — the RE's split.
let Some(params) = net.display_id.and_then(|d| creatures.footprint(d)) else {
continue;
};
let Some(texture) = ink.0.get(&params.texture_id) else {
continue;
};
let mut root = ev.entity;
while let Ok(child_of) = parents.get(root) {
root = child_of.parent();
}
let (is_self, root_net, store, movement) = match roots.get(root) {
Ok(r) => r,
Err(_) => continue,
};
// The state gates (each byte-verified, module docs): hover · stealth · player ghost.
if movement.is_some_and(|m| m.flags & move_flags::HOVER != 0) {
continue;
}
if let Some(store) = store {
if store.0.unit_is_stealthed() || store.0.player_is_ghost() {
continue;
}
}
// The planted foot: the event's own marker (bone + offset) through the live joint —
// exactly the shape the missile launch points use. No marker/joint = the unit origin.
let foot = attach
.and_then(|a| {
let (bone, offset) = a.markers.get(&ev.ident).copied()?;
let joint = a.anchor(bone)?;
Some(joints.get(joint).ok()?.transform_point(offset))
})
.unwrap_or_else(|| transform.translation());
// The reference's 50-yd radius about the local player (2500 yd², self always passes).
if !is_self
&& self_pos
.single()
.is_ok_and(|p| p.translation.distance_squared(foot) > MAX_DISTANCE_SQ)
{
continue;
}
// The surface gate, read under the foot itself (a beach edge prints per-foot).
if !footsteps
.0
.leaves_footprints(ground_effect_under(&streamer, &adt_tiles, foot))
{
continue;
}
// Print frame: length along the unit's facing, width across, yawed to the facing.
// With `(sin, cos) = (sin θ, cos θ)` the facing maps to the frame's −z′ axis, so v = 0
// is the toe end (the absolute sign convention is the RE's open §13 item — a
// backwards-pointing print flips one sign here).
let yaw = transform
.to_scale_rotation_translation()
.1
.to_euler(EulerRot::YXZ)
.0;
// The rider's wire SCALE_X for a mounted composite, the unit's own otherwise (RE: the
// print scale is SCALE_X alone — display scales never multiply in).
let scale = root_net.unwrap_or(net).scale.max(0.0);
let (half_len, half_wid) = (params.length * scale * 0.5, params.width * scale * 0.5);
if half_len <= 0.0 || half_wid <= 0.0 {
continue;
}
let frame = DecalFrame {
center: foot,
sin: yaw.sin(),
cos: yaw.cos(),
min_x: -half_wid,
max_x: half_wid,
min_z: -half_len,
max_z: half_len,
min_y: -SLAB_HALF_HEIGHT,
max_y: SLAB_HALF_HEIGHT,
};
let mirror = side == b'R';
let mut verts = Vec::new();
let projected = project_decal(
&mut verts,
&surfaces,
&frame,
|_| 1.0,
|x, z| {
let [u, v] = frame.rect_uv(x, z);
[if mirror { 1.0 - u } else { u }, v]
},
);
if !projected {
continue; // no receiving surface under the foot (the projector's no-ground gate)
}
let n_verts = verts.len();
let (pool, cap) = if is_self {
(&mut prints.own, OWN_CAP)
} else {
(&mut prints.shared, SHARED_CAP)
};
if pool.len() >= cap {
pool.pop_front(); // the reference's unconditional ring rotation
}
pool.push_back(Print {
verts,
spawned: now,
texture: texture.id(),
anchor: foot,
});
// The lane's census line (`RUST_LOG=benilla_app::footprints=debug`): every spawn names
// its tag, ink, spot and triangle count — the first question of any "no prints under X"
// report, answerable from a log (the blob shadow census pattern).
debug!(
"footprint: {} ink {} at ({:.2}, {:.2}, {:.2}), {} verts ({} own + {} shared live)",
ev.ident.map(char::from).iter().collect::<String>(),
params.texture_id,
foot.x,
foot.y,
foot.z,
n_verts,
prints.own.len(),
prints.shared.len(),
);
}
}
/// The reference's fade at age seconds (byte-diffed `PRIMITIVE:footprint_alpha` @`0x69a3e0`):
/// `t = 1 − age/6 s`, `alpha_byte = min(127, ⌊255·t⌋)` — ≈3.0 s hold at 127/255 (~50 %
/// opacity), then linear to 0; no fade-in; dead past [`LIFETIME`].
fn fade(age: f32) -> f32 {
let t = 1.0 - age / LIFETIME;
if t <= 0.0 {
return 0.0;
}
(255.0 * t).floor().min(127.0) / 255.0
}
/// Retire expired prints, then replay every live print's cached triangles onto the effect
/// stream — src-alpha blend of the black-RGB ink, the fade multiplied into the vertex alpha at
/// push time (the cache stays untouched).
fn push_footprints(
time: Res<Time>,
cam: Query<Entity, With<WorldCamera>>,
lane: Option<Res<FootprintLane>>,
mut quads: ResMut<EffectQuads>,
mut prints: ResMut<Footprints>,
) {
let now = time.elapsed_secs();
let prints = &mut *prints;
for pool in [&mut prints.own, &mut prints.shared] {
while pool.front().is_some_and(|p| now - p.spawned >= LIFETIME) {
pool.pop_front();
}
}
if prints.own.is_empty() && prints.shared.is_empty() {
return;
}
let Ok(cam) = cam.single() else { return };
let Some(lane) = lane else { return };
for print in prints.own.iter().chain(prints.shared.iter()) {
let alpha = fade(now - print.spawned);
let start = quads.begin();
quads.verts.extend(print.verts.iter().map(|v| EffectVertex {
color: [v.color[0], v.color[1], v.color[2], v.color[3] * alpha],
..*v
}));
quads.commit_tris(
start,
EffectDrawSpec {
cam,
texture: print.texture,
blend: EffectBlend::Alpha,
fog: EffectFog::Off,
lit: false,
anchor: print.anchor,
bias: PRINT_SORT_BIAS,
raster_bias: SHADOW_RASTER_BIAS,
main_entity: lane.0,
light: None,
},
);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Per-foot tags carry their side letter; `$FSD` (sound-only, byte-verified) and
/// non-footstep tags spawn nothing.
#[test]
fn foot_side_reads_the_side_letter() {
assert_eq!(foot_side(b"$FL0"), Some(b'L'));
assert_eq!(foot_side(b"$FR0"), Some(b'R'));
assert_eq!(foot_side(b"$RL2"), Some(b'L'));
assert_eq!(foot_side(b"$BR0"), Some(b'R'));
assert_eq!(foot_side(b"$FSD"), None);
assert_eq!(foot_side(b"$SND"), None);
assert_eq!(foot_side(b"$CSL"), None);
}
/// The byte-verified ramp: no fade-in — 127/255 from age 0, held while `255·t ≥ 127`
/// (through age = 6·(1 − 127/255) ≈ 3.012 s), then the raw `⌊255·t⌋/255` line to zero at
/// 6 s, dead after.
#[test]
fn fade_matches_the_reference_ramp() {
assert_eq!(fade(0.0), 127.0 / 255.0);
assert_eq!(fade(1.0), 127.0 / 255.0);
assert_eq!(fade(3.0), 127.0 / 255.0);
// Past the hold knee: the raw line. age 4.5 → t = 0.25 → ⌊63.75⌋ = 63.
assert_eq!(fade(4.5), 63.0 / 255.0);
// age 5.988 → t = 0.002 → ⌊0.51⌋ = 0 — the line reaches the floor before death.
assert_eq!(fade(5.988), 0.0);
assert_eq!(fade(6.0), 0.0);
assert_eq!(fade(7.0), 0.0);
}
}
+13 -3
View File
@@ -70,6 +70,11 @@ pub(crate) struct GroundFxDecal {
/// sampled into the vertex tint at push time (was: a per-instance material clone mutated
/// per frame through `FxTintAnims`).
rgb_anim: Option<(std::sync::Arc<benilla_formats::RgbAnim>, f32)>,
/// The part's **static** M2Color tint ([`GroundQuad::tint`]) — the constant colour the mesh
/// path draws through its vertex-colour bake, which this lane has no vertex buffer to carry.
/// White for a batch that authors none, and white whenever [`Self::rgb_anim`] is `Some` (the
/// bake clears one when it emits the other), so the two multiply without ever double-applying.
tint: [f32; 3],
/// The cached projection (world-space effect triangles, white × the vertical-fade alpha).
cache: Vec<EffectVertex>,
/// The posed corners the cache was projected from (NaN-seeded: the first pass always
@@ -119,6 +124,7 @@ pub(crate) fn spawn_ground_fx_decal(
blend,
fog,
rgb_anim,
tint: quad.tint,
cache: Vec::new(),
cached_corners: [Vec3::splat(f32::NAN); 4],
cached_surfaces: 0,
@@ -225,12 +231,16 @@ pub(crate) fn update_ground_fx_decals(
if decal.cache.is_empty() {
continue;
}
// This frame's tint: the part's M2Color RGB loop (instance clock) × the MatAnim alpha
// loop — exactly what the material clone + MeshTag carried on the old path.
let tint = decal
// This frame's tint: the part's M2Color colour — its STATIC constant (the vertex-colour
// bake this lane has no vertex buffer to carry) × its RGB loop where it varies (instance
// clock) — and the MatAnim alpha loop. Exactly what the vertex colours + material clone +
// MeshTag carried on the mesh path. Only one of the two colour terms is ever non-white
// (the bake emits one or the other), so the product is the authored colour, not a square.
let loop_tint = decal
.rgb_anim
.as_ref()
.map_or([1.0, 1.0, 1.0], |(anim, origin)| anim.sample(now - origin));
let tint: [f32; 3] = std::array::from_fn(|i| decal.tint[i] * loop_tint[i]);
let alpha = mat_anim.map_or(1.0, |m| m.current);
let start = quads.begin();
quads.verts.extend(decal.cache.iter().map(|v| EffectVertex {
+10
View File
@@ -53,6 +53,7 @@ mod entities;
mod entity_shade;
mod exterior_cull;
mod ffx_glow;
mod footprints;
mod glue;
mod glue_strings;
mod go_anim;
@@ -129,6 +130,7 @@ mod ui_net;
mod ui_party;
mod ui_pass;
mod ui_pet;
mod ui_pet_stats;
mod ui_quest;
mod ui_quest_log;
mod ui_script;
@@ -170,6 +172,7 @@ use debug_panel::DebugPanelPlugin;
use doodad_anim::DoodadAnimPlugin;
use entities::EntitiesPlugin;
use entity_shade::EntityShadePlugin;
use footprints::FootprintsPlugin;
use interact::InteractPlugin;
use interior::InteriorPlugin;
use lighting::LightingPlugin;
@@ -213,6 +216,7 @@ use ui_net::UiNetPlugin;
use ui_party::UiPartyPlugin;
use ui_pass::PlayerUiPlugin;
use ui_pet::UiPetPlugin;
use ui_pet_stats::UiPetStatsPlugin;
use ui_quest::UiQuestPlugin;
use ui_quest_log::UiQuestLogPlugin;
use ui_script::UiScriptPlugin;
@@ -565,6 +569,9 @@ pub fn run(build: BuildId) -> AppExit {
// animation's box (the byte-verified law — wow-re unit-blob-shadow RE), on the same
// surface-decal projector as the selection ring.
.add_plugins(BlobShadowPlugin)
// Footprint decals (B212, decision 1006): the prints a walking unit leaves on snow/sand,
// spawn-once projections on the same decal projector, fading off the effect stream.
.add_plugins(FootprintsPlugin)
// Doodad animation (decision 0130): placed M2s loop their first sequence + global sequences,
// gated to drawn instances.
.add_plugins(DoodadAnimPlugin)
@@ -708,6 +715,9 @@ pub fn run(build: BuildId) -> AppExit {
// so this renders the ten packed words the last `SMSG_PET_SPELLS` delivered and sends
// intents back. After UiActionPlugin (shares `Spells` and the cooldown triple's clock).
.add_plugins(UiPetPlugin)
// The pet's paper-doll stat block (happiness/loyalty/XP/training points). Its own plugin
// because it runs off descriptor fields and two DBC tables rather than off `SMSG_PET_SPELLS`.
.add_plugins(UiPetStatsPlugin)
// The connection-telemetry feed: the averaged ping RTT behind `GetNetStats()`, which the main
// bar's performance meter polls (decision 0658).
.add_plugins(UiNetPlugin)
+8
View File
@@ -696,6 +696,14 @@ pub(crate) enum ClientCommand {
},
/// Call the pet off its target (`CMSG_PET_STOP_ATTACK`) — the Attack button's second press.
PetStopAttack { pet_guid: u64 },
/// Cancel one of the **pet's** auras (`CMSG_PET_CANCEL_AURA`, decision 1007) — the pet bar's
/// press-again-to-cancel, and the pet-shaped twin of [`Self::CancelAura`].
///
/// It is a separate opcode rather than `CancelAura` with a guid because the server needs to
/// know *whose* aura to drop, and `CMSG_CANCEL_AURA`'s body is a bare spell id. Like its
/// player twin: no answer packet — the removal arrives as a `UNIT_FIELD_AURA` delta on the
/// pet, which is also what puts the slot's icon back.
PetCancelAura { pet_guid: u64, spell_id: u32 },
/// Start melee auto-attack on `guid` (`CMSG_ATTACKSWING`); echoed as `SMSG_ATTACKSTART`.
AttackSwing { guid: u64 },
/// Stop melee auto-attack (`CMSG_ATTACKSTOP`); echoed as `SMSG_ATTACKSTOP`, whose receive path
+1 -1
View File
@@ -109,7 +109,7 @@ pub(super) fn object_create(
// Where and how big, the moment it streams in — the readout that answers "is this prop in
// the wrong place, or the wrong size, or just drawn wrong" without a guess (decision 0637:
// the duel flag read as huge and mislocated, and nothing in the client could say which).
// `RUST_LOG=benilla::net::apply::objects=debug`.
// `RUST_LOG=benilla_app::net::apply::objects=debug`.
debug!(
"gameobject spawn: entry {:?} display {display_id:?} type {go_type:?} \
pos [{:.2}, {:.2}, {:.2}] scale {scale}",
+3
View File
@@ -706,6 +706,9 @@ fn writer_loop(
w.pet_set_action(pet_guid, &entries)
}
ClientCommand::PetStopAttack { pet_guid } => w.pet_stop_attack(pet_guid),
ClientCommand::PetCancelAura { pet_guid, spell_id } => {
w.pet_cancel_aura(pet_guid, spell_id)
}
ClientCommand::AttackSwing { guid } => w.attack_swing(guid),
ClientCommand::AttackStop => w.attack_stop(),
ClientCommand::SetSheathed { state } => w.set_sheathed(state),
+50 -6
View File
@@ -31,6 +31,7 @@ use crate::ui_script::PointerOverUi;
mod arc;
pub(crate) mod camera;
mod drunk;
mod follow;
mod gait;
@@ -448,6 +449,22 @@ fn control(
store.map(|s| s.0.unit_flags() & UNIT_FLAG_STUNNED != 0)
})
.unwrap_or(false);
// Drunkenness (B210): this frame's wobble angle, computed once — the facing veer and the
// swim-pitch porpoise below both read it. Zero while sober (`wobble` early-outs on a 0.0
// fraction), and zero while stunned — the reference's wobble sits behind the same
// input-allowed chain as the turn emitters (`0x60aa47` → `0x5145b0`).
let drunk_wobble = {
let f = self_player
.single()
.ok()
.and_then(|(.., store, _, _, _, _)| store.and_then(|s| s.0.player_drunk_byte()))
.map_or(0.0, drunk::fraction);
if stunned {
0.0
} else {
drunk::wobble(time.elapsed().as_millis() as u32, f)
}
};
run_look_session(
&buttons,
mouse_motion,
@@ -699,6 +716,9 @@ fn control(
// animation a stunned character was still playing was the turn-in-place shuffle, which
// `gait` derives from real yaw change.
let turning = !mouselook && !stunned && (turn_left || turn_right);
// The net translate state — the reference's `flags & 0xf` (its four move bits), read off
// the *net* axes, not the keys: W+S streams no direction bit and doesn't translate.
let translating = fwd_axis != 0 || side_axis != 0;
// This frame's keyboard-turn rotation — `seat_camera` carries the camera by it rigidly
// (char and camera turn as one on the reference; director's call, closing 0050's open
// "camera follow on turn" feel item).
@@ -711,13 +731,25 @@ fn control(
if turn_right {
turn -= 1.0;
}
// 0.75× while also translating — the verified `flags & 0x200f` case, so it reads the
// *net* axis, not the keys: W+S streams no direction bit and turns at the full rate.
let translating = fwd_axis != 0 || side_axis != 0;
// 0.75× while also translating — the verified `flags & 0x200f` case.
let rate = TURN_RATE * if translating { TURN_RATE_MOVING } else { 1.0 };
turn_delta = turn * rate * dt;
player.face_yaw += turn_delta;
}
// The drunk veer (B210): while moving, the facing increments by the wobble angle every
// frame (`0x60aa70–0x60aab7`: `facing + wobble`, 2π-wrapped, committed via the normal
// facing pipeline `0x60de30` — so it streams on the wire like any turn). The slow sign
// oscillation of the pulse is what makes the walk meander. Skipped while a keyboard turn
// is held — the reference's `flags & 0x30` guard (`0x60aa5a`) — so deliberate turning
// stays crisp; both yaw conventions increase turning left, so the add maps sign-for-sign.
// The veer joins `turn_delta` so `seat_camera` carries the camera with it exactly like a
// keyboard turn (char and camera turn as one) — the reference's camera follows the drunk
// meander too (director's ref observation, decision 1018); without the carry the character
// staggers out from under a fixed camera.
if drunk_wobble != 0.0 && translating && !turning {
player.face_yaw += drunk_wobble;
turn_delta = drunk_wobble;
}
let face_rot = Quat::from_rotation_y(player.face_yaw);
let move_fwd = flat(face_rot * Vec3::NEG_Z);
let move_right = flat(face_rot * Vec3::X);
@@ -1012,6 +1044,17 @@ fn control(
.pitch
.clamp(-MOUSELOOK_PITCH_CLAMP, MOUSELOOK_PITCH_CLAMP);
}
// The drunk porpoise (B210): while swimming and moving, the pitch increments by the
// wobble ×4.0 every frame (`0x60aabc–0x60ab0a`: flag `0x200000` → `pitch +
// wobble·[0x80306c]`, clamped, committed via the pitch pipeline `0x60de70`). Same
// wobble as the facing veer above, so the nose and the heading meander together.
// The clamp is the callee `0x60aba0`'s FIXED ±π/2 bounds (`0x808acc`/`0x80c5e4`),
// NOT the mouselook set's ±89° — the reference carries both (decision 1009 §C4).
if drunk_wobble != 0.0 && translating {
player.swim_pitch = (player.swim_pitch
+ drunk_wobble * drunk::SWIM_PITCH_WOBBLE_SCALE)
.clamp(-std::f32::consts::FRAC_PI_2, std::f32::consts::FRAC_PI_2);
}
swim_pitch = player.swim_pitch;
// The travel basis (`0x7c5880`, the client's swim velocity direction): the FORWARD axis
// is the facing pitched by the swim pitch — `(cosP·horiz-fwd + sinP·up)` — so holding W
@@ -1373,9 +1416,10 @@ fn control(
// framing pivot — see `seat_camera`'s doc for why. Computed here (not in `camera`) because it
// depends on the avatar's own capsule constants, which are a movement concern.
let head = player.pos + Vec3::Y * (CAPSULE_HEIGHT - CAPSULE_RADIUS);
// `turn_delta` is the KEYBOARD turn only — the deck's yaw delta was already applied to
// `cam.yaw` at the ride block (frame motion carries the camera unconditionally; only
// input turns respect `seat_camera`'s look-session gate).
// `turn_delta` is the character's own turn this frame (keyboard turn, or the drunk veer)
// — the deck's yaw delta was already applied to `cam.yaw` at the ride block (frame motion
// carries the camera unconditionally; only input turns respect `seat_camera`'s
// look-session gate).
seat_camera(
dt,
turn_delta,
+2 -1
View File
@@ -373,7 +373,8 @@ pub(super) fn apply_zoom_scroll(scroll: f32, dt: f32, rig: &mut CameraControl) {
/// Seat the third-person camera: orient it, orbit it behind the avatar's torso with a collision
/// sweep from the head to the ideal seat (snap-in instantly, ease back out), write the resulting
/// transform, and compute the self-avatar zoom-in fade from the realized camera-to-pivot distance.
/// A **keyboard** turn carries the camera rigidly by `turn_delta` — input turns only: a transport
/// A **keyboard** turn (or the drunk veer, which rides `turn_delta` the same way — decision 1018)
/// carries the camera rigidly — the character's own turns only: a transport
/// deck turning under the rider is frame motion and is applied to `cam.yaw` at the ride block in
/// [`super::control`], bypassing this function's look-session gate (routing it here was the
/// right-drag drift bug — the gate ate the deck's share while a drag was held). A left-drag orbit
+125
View File
@@ -0,0 +1,125 @@
//! Drunkenness — the client-side inebriation math (B210).
//!
//! The server streams a drunk value in `PLAYER_BYTES_3` byte 1
//! ([`benilla_protocol::messages::ObjectFields::player_drunk_byte`]); the reference client turns
//! it into a fraction `min(byte, 100) × 0.01` (`0x5e2a90`, wow-re PRIMITIVE
//! `drunk_fraction_5e2a90`) and, per frame for the **active player while moving**
//! (`flags & 0xf`, the caller band `0x60a984–0x60ab14`), staggers the walk by a wobble angle:
//!
//! - **facing** `+= pulse(now, fraction)` every frame, 2π-wrapped, committed through the normal
//! facing pipeline (`0x60de30`) — the sign of the pulse oscillates slowly, so the character
//! veers left and right as it walks. Skipped while a keyboard turn is held (`flags & 0x30`,
//! non-mouselook) so deliberate turning stays crisp.
//! - **swim pitch** `+= pulse × 4.0` (`_DAT_0080306c`), clamped, while the swim flag is up
//! (`0x200000`) — a drunk swimmer porpoises.
//!
//! The pulse itself (`0x60ab20`, wow-re PRIMITIVE `cosine_pulse`, diffed bit-exact against the
//! binary) is a 3-harmonic cosine blend whose **amplitude and frequencies both scale with the
//! fraction**: `p = now_ms·(π/180)·f`, `pulse = mean(cos(p·0.093), cos(p·0.154), cos(p·0.195))
//! · (π/240)·f`. At full drunk (fraction 1.0) that is a veer rate peaking near ±0.75°/frame with
//! beat periods of a few seconds; tipsy values scale both the swing and how fast it meanders.
//! The three harmonics never phase-lock, so the stagger reads as aimless, not metronomic.
//!
//! Faithful-arithmetic note: the binary computes on the x87 stack at PC_53 (f64) and rounds to
//! f32 only at explicit stores; the port widens each f32 operand to f64 and rounds back only
//! where the binary stores (the mid-pulse `fst` of the phase is reproduced — the first cosine
//! sees the unrounded phase, the second and third the f32-rounded one). Constants carry the
//! binary's exact bit patterns.
/// `_DAT_008029d0` — 0.01 (bits `0x3c23d70a`): the inebriation byte → fraction scale (`0x5e2a90`).
const DRUNK_SCALE: f32 = f32::from_bits(0x3c23_d70a);
/// `_DAT_007ffaac` — π/180 (bits `0x3c8efa35`): the pulse phase's degrees→radians factor.
const DEG2RAD: f32 = f32::from_bits(0x3c8e_fa35);
/// The three harmonic frequency scales of the pulse (`_DAT_0080c5e0/dc/d8`).
const PULSE_F1: f32 = f32::from_bits(0x3dbe_76c9); // 0.093
/// See [`PULSE_F1`].
const PULSE_F2: f32 = f32::from_bits(0x3e1d_b22d); // 0.154
/// See [`PULSE_F1`].
const PULSE_F3: f32 = f32::from_bits(0x3e47_ae14); // 0.195
/// `_DAT_0080c5d4` — 1/3 (bits `0x3eaaaa9f`): the pulse mean. (Not exactly 1.0/3.0 — the
/// binary's constant is one ULP low, and the difftest is bit-exact against *it*.)
const PULSE_MEAN: f32 = f32::from_bits(0x3eaa_aa9f);
/// `_DAT_0080c4bc` — π/240 (bits `0x3c567750`): the pulse amplitude-vs-fraction scale.
const PULSE_AMP: f32 = f32::from_bits(0x3c56_7750);
/// `_DAT_0080306c` — 4.0: the swim-pitch multiplier on the wobble (`0x60aaf1`).
pub(super) const SWIM_PITCH_WOBBLE_SCALE: f32 = 4.0;
/// The drunk fraction the reference stores for a raw inebriation byte: `min(byte, 100) × 0.01`
/// (`0x5e2a90`: `cmp al,0x64; jb; mov al,0x64` then `fild; fmul 0.01; fstp dword`).
pub(super) fn fraction(byte: u8) -> f32 {
let clamped = byte.min(100);
(f64::from(clamped) * f64::from(DRUNK_SCALE)) as f32
}
/// The per-frame wobble angle (radians): the reference's 3-harmonic cosine pulse `0x60ab20`
/// with `n` = the frame's time in milliseconds and `t` = the drunk fraction. Port of wow-re's
/// bit-exact `cosine_pulse` transcription (`crates/object-layer/src/unit_fp.rs`) — see the
/// module doc for the formula and the x87 rounding subtlety.
pub(super) fn wobble(now_ms: u32, fraction: f32) -> f32 {
if fraction == 0.0 {
return 0.0;
}
// `fild qword` of the zero-extended 32-bit time — exact in f64.
let p = f64::from(now_ms) * f64::from(DEG2RAD) * f64::from(fraction);
// The binary's `fst [ebp-4]` rounds the phase to f32 for the 2nd/3rd cosines while the 1st
// consumes the unrounded st0.
let p_f32 = f64::from(p as f32);
let c1 = (p * f64::from(PULSE_F1)).cos();
let c2 = (p_f32 * f64::from(PULSE_F2)).cos();
let c3 = (p_f32 * f64::from(PULSE_F3)).cos();
let mean = ((c1 + c2) + c3) * f64::from(PULSE_MEAN);
(mean * (f64::from(PULSE_AMP) * f64::from(fraction))) as f32
}
// The binary also holds a drunk FOV lane (camera channel `+0x10c`, setter `0x511250`, target
// `(179° − 90°)·fraction` into the projection — wow-re `drunk-camera-fov.md`), which benilla
// briefly shipped as a fisheye. The director's ref observation is that the real client shows NO
// fov change at any drunk value in normal third-person play — the lane exists but is gated dead
// in practice — so benilla renders none either (decision 1018; the gate's identity is wow-re's
// to pin).
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fraction_scales_and_clamps() {
assert_eq!(fraction(0), 0.0);
assert_eq!(fraction(50), 0.5);
// 100 and everything above clamp to the same full-drunk fraction.
assert_eq!(fraction(100), fraction(255));
assert!((fraction(100) - 1.0).abs() < 1e-6);
}
#[test]
fn wobble_zero_when_sober() {
assert_eq!(wobble(123_456, 0.0), 0.0);
}
#[test]
fn wobble_phase_zero_is_full_amplitude() {
// At n = 0 every cosine is 1, so the pulse is exactly mean(1,1,1)·amp·t = amp·t — the
// amplitude anchor: ±(π/240)·fraction radians (≈0.75°/frame at full drunk).
let full = wobble(0, 1.0);
assert!((full - PULSE_AMP).abs() < 1e-7, "got {full}");
let half = wobble(0, 0.5);
assert!((half - PULSE_AMP * 0.5).abs() < 1e-7, "got {half}");
}
#[test]
fn wobble_stays_within_amplitude_and_oscillates() {
// Sweep a minute of frames at full drunk: never beyond the amplitude, and both signs
// visited (the harmonics must actually oscillate).
let (mut lo, mut hi) = (f32::MAX, f32::MIN);
for ms in (0..60_000).step_by(16) {
let w = wobble(ms, 1.0);
assert!(w.abs() <= PULSE_AMP * 1.0001, "|{w}| > amp at {ms}ms");
lo = lo.min(w);
hi = hi.max(w);
}
assert!(
hi > PULSE_AMP * 0.5 && lo < -PULSE_AMP * 0.5,
"range [{lo}, {hi}]"
);
}
}
@@ -398,7 +398,7 @@ pub(super) fn spawn_glue_booth(
..default()
},
bevy::camera::RenderTarget::Image(image.clone().into()),
crate::ffx_glow::FfxGlow::WORLD,
crate::ffx_glow::FfxGlow::BOOTH,
// Placeholder — `sync_glue_booth` overwrites transform + projection from the model's bounds
// on the first bake (the same `body_frame` law as the paper doll).
Projection::from(bevy::camera::PerspectiveProjection {
+2 -2
View File
@@ -625,7 +625,7 @@ pub(crate) fn spawn_warm_booth(
..default()
},
RenderTarget::Image(image.into()),
crate::ffx_glow::FfxGlow::WORLD,
crate::ffx_glow::FfxGlow::BOOTH,
Projection::custom(framing::WowPortraitProjection {
fov: framing::BODY_FOV,
near: 0.02,
@@ -714,7 +714,7 @@ fn setup_booths(
// booth needs the same final node — the FFXGlow combine owns the frame's ONE decode.
// This also keeps the bake at exact world parity (same glow, same transform chain);
// without it the portrait reads one encode too bright.
crate::ffx_glow::FfxGlow::WORLD,
crate::ffx_glow::FfxGlow::BOOTH,
Projection::from(PerspectiveProjection {
fov: PORTRAIT_FOV,
near: 0.02,
+207 -45
View File
@@ -1,13 +1,15 @@
//! Faithful M2 **ribbon trails** — weapon enchant trails, wisp streamers, spell-missile trails.
//!
//! The 1.12 client simulates a ribbon as a ring of **edges**: each frame the emitter's bone-local
//! origin is transformed by the live bone matrix into a node point, a new edge (a vertex pair at
//! `±heightAbove/heightBelow` across the node) is committed at `edgesPerSecond`, old edges age out
//! at `edgeLifetime`, gravity sags the stored verts, and the edge list renders as a triangle strip
//! whose `u` texcoord slides with edge age (the texture's transparent tail fades the trail).
//! Byte-exact spec: wow-5875-re `system/models/scratch/ribbon-emitter-spec.md` (their `9c862186`);
//! the sim below transcribes it with the same simplifications as `particles` (distributions and
//! frames mirrored, not the reference's exact float slots).
//! origin is transformed by the live bone matrix into a node point, `floor(dt·edgesPerSecond +
//! phase)` new edges (a vertex pair at `±heightAbove/heightBelow` across the node) are committed
//! and backdated inside the frame, old edges age out at `edgeLifetime`, **gravity carries the
//! stored verts along world +Z by `g·t²`** — up for the positive majority of the corpus — and the
//! edge list renders as a triangle strip whose `u` texcoord slides with edge age (the texture's
//! transparent tail fades the trail). Byte-exact spec: wow-5875-re
//! `system/models/scratch/ribbon-emitter-spec.md`; the sim below transcribes it with the same
//! simplifications as `particles` (distributions and frames mirrored, not the reference's exact
//! float slots).
//!
//! Like particles, each trail writes its strip into the **shared effect-quad stream**
//! ([`crate::particles::buffer::EffectQuads`], decision 0732 slice P1) — per segment, one quad
@@ -37,6 +39,24 @@ struct Edge {
top: Vec3,
bottom: Vec3,
born: f32,
/// Seconds this edge has been alive, carried explicitly because the reference does
/// ([`simulate_ribbons`]'s gravity note): its per-edge age array at `emitter+0x0c` is the term
/// the gravity increment reads, and an edge born part-way through a frame starts at its
/// **backdated** age, not zero.
age: f32,
}
/// This frame's gravity displacement for one live edge, in world **+Z (up)** yards — the
/// reference's exact per-frame term (`ribbonage 0x7b7e60`, `0x7b8007`..`0x7b800f`):
/// `gravity · ((age + age) + dt) · dt`, applied identically to both of the edge's vertices, with
/// `age` advanced by `dt` straight after.
///
/// `(2a + dt)·dt ≡ (a + dt)² − a²`, so stepping it across an edge's life telescopes exactly to
/// `Δz = gravity · t²` — no ½, no factor of 2, and **upward** for a positive `gravity` (the block
/// is two `fadd`s with no `fchs`, on world-space positions in a +Z-up frame). See
/// [`simulate_ribbons`] for why both halves of that mattered on screen.
fn gravity_step(gravity: f32, age: f32, dt: f32) -> f32 {
gravity * ((age + age) + dt) * dt
}
/// A live ribbon trail riding `owner`. Positions are world-space; the mesh entity's transform is
@@ -58,6 +78,8 @@ pub struct RibbonTrail {
/// active flag before destroy, or hard-cuts at animation end, is the one OPEN half
/// (CEffect-side, flagged in decision 0206).
owner: Option<Entity>,
/// Which clock the `+0xc0` enable gate is sampled against, every frame ([`RibbonSeq`]).
seq: RibbonSeq,
/// The MODEL INSTANCE whose [`crate::model_fade::ModelAlpha`] decides whether this trail is
/// drawn at all (decision 0827). The reference's ribbon render leg reads the owning model's
/// render alpha (`block+0x3c × Model+0x19c`) and **drops the draw** below a threshold (wow-re
@@ -100,14 +122,31 @@ impl RibbonTrail {
}
}
/// What decides a trail's `+0xc0` **enable** gate — the reference's per-ribbon `block+0xbc` byte,
/// which it re-reads every frame (wow-re `ribbon-emitter-spec.md` §6).
#[derive(Clone, Copy)]
pub enum RibbonSeq {
/// Re-read each frame from this entity's [`AnimationPlayer`] + [`ModelAnimations`] — the unit,
/// GameObject, doodad and effect-instance lanes, where the sequence **changes under the
/// trail**: a trap springs, a door opens, an effect model steps Stand → Hold → Decay.
Host(Entity),
/// A fixed `AnimationData.dbc` id for the instance's life — a worn item rests in `Stand`(0),
/// and nothing on it will ever play anything else.
Fixed(u16),
}
/// Spawn a ribbon-trail entity for one [`ModelRibbon`], riding `owner` (a host-bone joint for a
/// skinned model — pass the joint and the def's baked pivot does the rebase — or the model/item
/// root). `current_anim` is the `AnimationData.dbc` id the owner's model is running (a static held
/// item rests in Stand; `None` = the model's first clip / no sequence), which the per-sequence
/// visibility gate keys off. `owner_scale` is the owner placement's largest scale component — the
/// root). `seq` names the clock the per-sequence enable gate is sampled against (see
/// [`RibbonSeq`]). `owner_scale` is the owner placement's largest scale component — the
/// model-local [`ModelRibbon::owner_reach`] takes it to reach world yards, which is what the
/// draw-order rung is measured in. `None` if the trail has no resolved texture, degenerate
/// emission, or is dark in that sequence.
/// draw-order rung is measured in. `None` if the trail has no resolved texture or degenerate
/// emission.
///
/// A gated trail still spawns: the gate is **live**, not a spawn-time decision, because the
/// sequence that answers it changes while the instance lives. Refusing at spawn was correct only
/// for the fixed-sequence lanes it was written for (a held item, a missile) and silently wrong for
/// everything with a state machine — see [`simulate_ribbons`].
#[allow(clippy::too_many_arguments)] // the spawn's full wiring, `alpha_src` included
pub fn spawn_ribbon(
commands: &mut Commands,
@@ -115,29 +154,13 @@ pub fn spawn_ribbon(
owner: Entity,
use_pivot: bool,
owner_scale: f32,
current_anim: Option<u16>,
seq: RibbonSeq,
alpha_src: Option<Entity>,
) -> Option<Entity> {
// Perf-bisect kill-switch: $WOW_NO_PARTICLES also spawns no ribbons (one switch, whole family).
if std::env::var_os("WOW_NO_PARTICLES").is_some() {
return None;
}
// Per-sequence visibility (`+0xc0`): a keyed ribbon shows only in the sequences its author lit
// — the thrown weapon's trail is OFF in Stand (worn in hand) and Impact (landed), ON only in
// InFlight. A model with no gate (`None` — enchant trails, wisps) always shows. Owner and
// missile are separate fixed-sequence entities, so the spawn-time decision holds for the
// instance's life (the shipped keyed ribbons are constant within a sequence).
if let Some(vis) = &ribbon.def.visible_in_anim {
let anim = current_anim.unwrap_or(0); // a static held item rests in Stand (0)
let visible = vis
.get(&anim)
.or_else(|| vis.get(&0))
.copied()
.unwrap_or(true);
if !visible {
return None; // dark this sequence — no trail
}
}
let texture = ribbon.texture.clone()?;
let def = ribbon.def.clone();
// The degenerate gate reads the tracks' PEAKS: a keyed slash (HolySmite) is born at height 0
@@ -167,6 +190,7 @@ pub fn spawn_ribbon(
local_offset: wow_to_bevy(local),
def,
owner: Some(owner),
seq,
alpha_src,
edges: VecDeque::new(),
accumulator: 0.0,
@@ -192,6 +216,8 @@ pub(crate) fn simulate_ribbons(
// Owner reads (joints/roots — never trail entities): disjoint from the trail query's
// `&mut GlobalTransform` below.
transforms: Query<&GlobalTransform, Without<RibbonTrail>>,
// The `+0xc0` enable gate's clock: the sequence a [`RibbonSeq::Host`] instance is playing.
hosts: Query<(&AnimationPlayer, &benilla_assets::ModelAnimations)>,
images: Res<Assets<Image>>,
mut quads: ResMut<EffectQuads>,
// The owning model's render alpha — the trail's draw gate (decision 0827), composed along the
@@ -220,6 +246,7 @@ pub(crate) fn simulate_ribbons(
def,
local_offset,
owner,
seq,
alpha_src,
edges,
accumulator,
@@ -259,31 +286,101 @@ pub(crate) fn simulate_ribbons(
continue;
}
// Expire old edges (front = oldest), sag the rest, commit new ones at cadence.
// The `+0xc0` **enable** gate, sampled LIVE against the sequence the host is playing.
// SETTLED at the bytes (wow-re `ribbon-emitter-spec.md` §7, closed — the dispatch this
// session): the per-ribbon runtime byte `block+0xbc` IS the sampled `visibilityTrack`
// value. Complete writer census — ctor `0x71b34c` = 0, loader default `0x70f80e` = 1,
// then per frame `values[k0]` at `0x7176ee` (step arm) and `0x717714` (non-step arm; a u8
// track is never blended, so both copy the same raw byte) inside `0x714260`. No
// equipment/attach/sheathe writer exists anywhere. `0x718960` only READS it.
//
// Decision 1011 wired this and was right about the mechanism; decision 1013 unwound it on
// the strength of §7's then-open INFERENCE and was wrong. What actually made the trap look
// wrong was gravity (see below) — the low rig sank instead of rising, so the tuft the
// reference shows above the crown vanished and only the gated-off high rig had ever been
// producing anything visible, as a downward column. Two faults, one screenshot.
//
// Clearing the byte KILLS THE WHOLE RIBBON'S DRAW — `0x7080c2` jumps to the collect loop's
// continue at `0x708263`, emitting no record at all. The earlier "committed edges keep
// fading" gloss is withdrawn by the same pass, so this gates the DRAW, not just the
// commit: a trail whose gate clears vanishes on the frame it clears.
let lit = def.visible.as_ref().is_none_or(|vis| match *seq {
RibbonSeq::Fixed(a) => vis.at(a, 0.0),
RibbonSeq::Host(h) => match hosts.get(h) {
// Sequences exist: the playing one answers, and `playing_seq` already degrades to
// the loader-idle clip. A slot with no clip row falls back to `Stand`(0).
Ok((player, anims)) => {
let (anim, t) = crate::doodad_anim::playing_seq(player, anims)
.and_then(|(slot, t)| {
Some((anims.clips.iter().find(|c| c.seq_index == slot)?.anim_id, t))
})
.unwrap_or((0, 0.0));
vis.at(anim, t)
}
// No clock on the host at all — the instance is still being built this frame. The
// reference never has this window (its loader seed arms a sequence the moment the
// M2 goes LIVE, `0x70ebd0`), so answering "enabled" here would invent a state and
// pop one frame of every gated trail. Hold dark until the host can answer.
Err(_) => false,
},
});
let head = lit.then_some(head).flatten();
// Expire old edges (front = oldest), move the rest under gravity, commit new ones at rate.
while edges
.front()
.is_some_and(|e| now - e.born >= def.edge_lifetime)
{
edges.pop_front();
}
if def.gravity != 0.0 {
let sag = 2.0 * def.gravity * dt;
for e in edges.iter_mut() {
e.top.y -= sag;
e.bottom.y -= sag;
}
// GRAVITY — byte-verified (`ribbonage 0x7b7e60`, the loop at `0x7b7fe7..0x7b807a`; wow-re
// `ribbon-emitter-spec.md` §4). Per frame, per live edge, into BOTH vertices' world z:
//
// term = gravity · ((age + age) + dt) · dt ; age += dt
//
// `(2a + dt)·dt` is identically `(a + dt)² − a²`, and the age advance follows immediately,
// so the whole loop telescopes to a closed form — an edge sits at
//
// z(t) = z_at_emit + gravity · t² (t = seconds since commit, yards)
//
// with no ½ and no factor of 2. It is a **position** increment; the vertex is the only
// state besides the per-edge age, there is no velocity, and it is scaled by nothing (the
// interp factor the loop computes just above is never an operand here).
//
// And the sign is `fadd`, twice, with no `fchs` anywhere in the block: positions are world
// space, WoW's +Z is UP, so a **positive gravity makes the trail RISE**. The corpus agrees
// — of 590 ribbon records 86 are positive (totems 5.0, the cleanse family 0.5/1.0) and 16
// negative, including ±0.5 up/down PAIRS inside one model, which a single-signed reading
// cannot explain at all. Our old `pos.y -= 2·g·dt` was wrong twice over: constant velocity
// instead of `g·t²`, and falling instead of rising. On the Frost Trap that turned a
// 0.6–1.3 yd tuft rising off the crown into a 2–3 yd column smeared to the ground.
for e in edges.iter_mut() {
let term = gravity_step(def.gravity, e.age, dt);
e.top.y += term;
e.bottom.y += term;
e.age += dt;
}
// EMISSION — `edgesPerSecond` is a true rate, not a per-frame cadence: the reference
// commits `n = floor(dt·eps + phase)` edges this frame, carries the fraction as the phase,
// and **backdates** each one inside the frame (wow-re §4.2). A one-edge-per-frame cap
// silently thins every trail below `eps` frames per second — the whole trail, at 30 fps
// with the Frost Trap's `eps` 30, is half the edges it should hold.
if let Some((node, axis)) = head {
*accumulator += def.edges_per_second * dt;
if *accumulator >= 1.0 {
*accumulator = accumulator.fract();
if edges.len() < MAX_EDGES {
edges.push_back(Edge {
top: node + axis * h_above,
bottom: node - axis * h_below,
born: now,
});
}
let n = accumulator.floor().max(0.0);
*accumulator -= n;
// Sub-frame backdating: the n-th edge of this frame was emitted `k/n · dt` ago (the
// node itself only has this frame's position, so the sample point is shared — the
// backdate is what the age, and therefore the gravity rise, is measured from).
let n = (n as usize).min(MAX_EDGES.saturating_sub(edges.len()));
for k in 0..n {
let back = dt * (n - 1 - k) as f32 / n as f32;
edges.push_back(Edge {
top: node + axis * h_above,
bottom: node - axis * h_below,
born: now - back,
age: back,
});
}
}
@@ -295,6 +392,13 @@ pub(crate) fn simulate_ribbons(
if !images.contains(&*texture) {
continue;
}
// The gate again, on the DRAW: `0x7080c2` skips the whole record when the byte is 0, so a
// gated-off ribbon shows nothing — not a fading remainder. The sim above still ran (edges
// age and expire exactly as `ribbonage` ages them), which is what makes the trail resume
// mid-strip rather than from empty when the byte comes back.
if !lit {
continue;
}
// An invisible MODEL has no streamer: the reference's ribbon render leg reads the owning
// model's render alpha and drops the draw below a threshold (decision 0827). This is what
// takes your own weapon's enchant trail out of your face in first person, and keeps a
@@ -424,3 +528,61 @@ impl Plugin for RibbonPlugin {
);
}
}
#[cfg(test)]
mod tests {
use super::gravity_step;
/// The per-frame gravity term telescopes to the closed form the bytes imply: stepping
/// `gravity · ((2·age) + dt) · dt` and advancing `age` by `dt` leaves an edge exactly
/// `gravity · t²` from where it was emitted, **whatever the frame rate** — which is the
/// property that makes a trail look the same at 30 and 144 fps. Sign included: positive
/// gravity rises.
#[test]
fn gravity_telescopes_to_g_t_squared_at_any_frame_rate() {
for &g in &[0.5_f32, 1.0, 1.5, 2.0, 5.0, -1.0] {
for &dt in &[1.0 / 144.0_f32, 1.0 / 60.0, 1.0 / 30.0, 1.0 / 15.0] {
let steps = (1.0 / dt).round() as usize; // exactly one second of frames
let (mut z, mut age) = (0.0_f32, 0.0_f32);
for _ in 0..steps {
z += gravity_step(g, age, dt);
age += dt;
}
let t = steps as f32 * dt;
assert!(
(z - g * t * t).abs() < 1e-4,
"g {g} at dt {dt}: got {z}, want {}",
g * t * t
);
}
}
// The sign is the half that was inverted: a positive gravity RISES.
assert!(gravity_step(2.0, 0.0, 0.016) > 0.0);
assert!(gravity_step(-1.0, 0.0, 0.016) < 0.0);
}
/// The Frost Trap's four low streamers, with their authored `gravity` and `edgeLifetime`:
/// each rises `g·L²` = 0.605 / 1.000 / 1.215 / 1.280 yd over an edge's life, from a node at
/// model z 0.129. That is the compact tuft standing off a crown whose own geometry stops at
/// z 0.637 — and it is what the old constant-velocity fall (1.1–3.2 yd DOWNWARD from the
/// twelve upper streamers at z 1.55) turned into a column reaching the ground.
#[test]
fn frost_trap_low_rig_rises_into_a_tuft_over_the_crown() {
for (g, life, want) in [
(0.5_f32, 1.1_f32, 0.605_f32),
(1.0, 1.0, 1.000),
(1.5, 0.9, 1.215),
(2.0, 0.8, 1.280),
] {
let dt = 1.0 / 60.0;
let steps = (life / dt).round() as usize;
let (mut z, mut age) = (0.0_f32, 0.0_f32);
for _ in 0..steps {
z += gravity_step(g, age, dt);
age += dt;
}
assert!((z - want).abs() < 0.01, "g {g} life {life}: {z} vs {want}");
assert!(0.129 + z > 0.637, "clears the crown's own geometry");
}
}
}
+3 -1
View File
@@ -41,8 +41,10 @@ use super::creature::CreatureVoices;
use super::kit::{play_kit, KitRef, SoundCategory, SoundKits};
use super::{AudioListener, SoundConfig, SoundOutput};
/// The loaded terrain-chain catalog. `pub(crate)`: [`crate::footprints`] reads the same chain's
/// `TerrainType.Flags` gate (`leaves_footprints`) — one load, two footfall consumers.
#[derive(Resource)]
struct Footsteps(FootstepCatalog);
pub(crate) struct Footsteps(pub(crate) FootstepCatalog);
fn load_footsteps(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
let Some(assets) = assets else { return };
+1 -1
View File
@@ -17,7 +17,7 @@ mod anim_events;
mod combat;
mod creature;
mod emote;
mod footsteps;
pub(crate) mod footsteps;
mod gameobject;
mod glue;
mod greeting;
+6 -2
View File
@@ -207,8 +207,12 @@ fn play_item_gesture_sounds(
}
// Everything that is not a live item shares the generic grab/drop kit — the reference's
// own `0x494f60`/`0x494f80` grab path for a macro (mode 8) reaches the same
// `INTERFACESOUND_CURSOR*` pair the spell and bar-action modes do.
CursorPayload::Spell(_) | CursorPayload::Action(_) | CursorPayload::Macro(_) => {
// `INTERFACESOUND_CURSOR*` pair the spell and bar-action modes do, and the pet-action
// builder `0x494e20` names `INTERFACESOUND_CURSORGRABOBJECT` outright (wow-re §10.3).
CursorPayload::Spell(_)
| CursorPayload::Action(_)
| CursorPayload::Macro(_)
| CursorPayload::PetAction(_) => {
let kit_id = match gesture {
CursorGesture::Gain => INTERFACESOUND_CURSORGRABOBJECT,
CursorGesture::Loss => INTERFACESOUND_CURSORDROPOBJECT,
@@ -191,6 +191,7 @@ pub(super) fn spawn_ribbons_for(
ribbons: &[benilla_assets::ModelRibbon],
transform: Transform,
joints: Option<&[Entity]>,
arm: Option<Entity>,
fallback_owner: Option<Entity>,
) {
for rb in ribbons {
@@ -201,16 +202,19 @@ pub(super) fn spawn_ribbons_for(
None => continue, // nothing to ride (an entity-less placement)
},
};
// Placed doodads carry only always-on trails (wisp streamers, banner ribbons) — no
// per-sequence visibility gate (that's the thrown weapon's InFlight keying), so the anim
// is immaterial here.
// The `+0xc0` enable gate reads the placement's live sequence, like its emitters one
// function up: an animated doodad re-rolls its variation every play window, so there is
// no arm-once answer. An unanimated placement has no clock and rests in `Stand`.
crate::ribbons::spawn_ribbon(
commands,
rb,
owner,
use_pivot,
transform.scale.max_element(),
None,
arm.map_or(
crate::ribbons::RibbonSeq::Fixed(0),
crate::ribbons::RibbonSeq::Host,
),
// No model-alpha source: a placed prop / effect instance is always drawn (0827).
None,
);
@@ -224,6 +224,7 @@ pub(super) fn spawn_loaded_placements(
&m.ribbons,
p.transform,
host.as_ref().map(|h| h.joints.as_slice()),
host.as_ref().and_then(|h| h.arm),
ents.first().copied(),
);
spawn_lights_for(&mut commands, &m.lights, p.transform, &mut ents);
@@ -610,6 +611,7 @@ pub(super) fn spawn_loaded_placements(
&m.ribbons,
d.transform,
host.as_ref().map(|h| h.joints.as_slice()),
host.as_ref().and_then(|h| h.arm),
ents.first().copied(),
);
spawn_lights_for(&mut commands, &m.lights, d.transform, &mut ents);
+431 -58
View File
@@ -84,9 +84,14 @@ pub(crate) struct PetBar {
/// **"The pet is attacking"** — the client's own `[0xb714b0]`, and a purely local latch with
/// no field behind it anywhere (wow-re §1).
///
/// It has exactly three writers, and reproducing them is the whole of `IsPetAttackActive`:
/// set by an ATTACK command press (`0x4bd42e`), cleared by `PetStopAttack` (`0x4bd6ae`), and
/// cleared on **any** pet-guid change (`0x4bc8ce`) — a new pet is not attacking.
/// It has exactly two writers, and reproducing them is the whole of `IsPetAttackActive`: set
/// by an ATTACK command press (`0x4bd42e`), and cleared by `PetStopAttack`'s core `0x4bd650`
/// (`0x4bd6ae`) plus the pet-guid change (`0x4bc8ce`) — a new pet is not attacking.
///
/// **`0x4bd650` has two call sites, not one**, and missing the second is what made the Attack
/// button read as a mode here when it is an order in the reference: the Lua `PetStopAttack`
/// (the lit button's second press), and the **old-target clear `0x493910` at `0x493a18`**,
/// which every selection writer runs. [`pet_stop_on_old_target_clear`] carries the second.
///
/// benilla read this off the pet's streamed `UNIT_FIELD_TARGET` until the RE landed. That is a
/// different question with a different answer: a defensive pet that retaliates on its own has
@@ -133,7 +138,7 @@ fn actions_usable(bar: &PetBar, pet_flags: Option<u32>) -> bool {
/// (the [`crate::ui_shapeshift`] memory pattern).
#[derive(Default)]
struct PetBarMemory {
pushed: Option<(bool, bool, Vec<PetActionView>)>,
pushed: Option<(bool, bool, bool, Vec<PetActionView>)>,
}
pub(crate) struct UiPetPlugin;
@@ -145,6 +150,11 @@ impl Plugin for UiPetPlugin {
(
// Both feeds ride with the unit feed (before the VM ticks, like the stance bar's);
// the drain runs after the input pass so a click goes out the same frame.
// The old-target clear runs BEFORE the feed so the Attack button goes out in the
// same frame the selection moved, not one behind it.
pet_stop_on_old_target_clear
.in_set(UnitFeed)
.before(feed_pet_bar),
feed_pet_bar.in_set(UnitFeed).before(UiInput),
feed_pet_unit.in_set(UnitFeed).before(UiInput),
drain_pet_actions.after(UiInput),
@@ -185,14 +195,44 @@ fn reaction_token(action: u32) -> Option<(&'static str, &'static str)> {
/// Resolve one packed slot word into what the bar draws.
///
/// `cooldown` is passed in rather than looked up here so the whole function stays a pure
/// (state, word) → view mapping, which is what the tests below exercise.
/// `cooldown` and `showing_active` are passed in rather than looked up here so the whole function
/// stays a pure (state, word) → view mapping, which is what the tests below exercise.
/// `showing_active` is [`active_aura_press`]'s predicate — the *same* answer that decides whether
/// a click cancels, because in the reference it is literally the same call (`0x4bcea0`, reached
/// from `GetPetActionInfo` at `0x4bdd2f` and from `CastPetAction` at `0x4bd24a`). Computing it
/// once and handing it to both is what keeps the icon honest: the button that shows the active art
/// is exactly the button whose next press takes the aura off.
fn slot_view(
entry: PetActionEntry,
bar: &PetSpells,
spell: Option<&benilla_formats::SpellDisplay>,
cooldown: Option<(i64, u32, bool)>,
pet_attacking: bool,
showing_active: bool,
) -> PetActionView {
PetActionView {
// The raw word rides EVERY slot, including the ones that draw as empty — decision 1010's
// drag is word arithmetic and reads it. Zeroing an "empty" slot here would be wrong on the
// wire *and* wrong in the drop core: vmangos fills its unused slots with `ACT_DISABLED` +
// spell id 0, and that shape (type 1, low 16 zero) is precisely the relocation candidate
// the core hunts for.
packed: entry.packed,
// Only a resolved spell can be passive; a token has no record and an unresolvable id has
// no answer, and `false` is the honest reading of both.
passive: spell.is_some_and(|s| s.passive),
..slot_paint(entry, bar, spell, cooldown, pet_attacking, showing_active)
}
}
/// [`slot_view`]'s painted half — everything the button draws, with no wire word in it. Split so
/// the drag's two raw fields are stamped in exactly one place rather than on each of four returns.
fn slot_paint(
entry: PetActionEntry,
bar: &PetSpells,
spell: Option<&benilla_formats::SpellDisplay>,
cooldown: Option<(i64, u32, bool)>,
pet_attacking: bool,
showing_active: bool,
) -> PetActionView {
let kind = entry.kind();
let action = entry.action();
@@ -259,7 +299,17 @@ fn slot_view(
PetActionView {
name: Some(spell.name.clone()),
subtext: spell.rank.clone(),
texture: spell.icon.clone(),
// THE ICON SWAP (decision 1007, wow-re §2.1 `0x4bdd2f`/`0x4bdd38`/`0x4bdd77`): a spell the
// pet is currently running draws its record's `ActiveIconID` instead of its `SpellIconID`.
// Falling back to `icon` here would be wrong — the reference looks up whichever id the
// predicate chose and pushes **nil** if that lookup fails (`0x4bdd50`), so an unresolvable
// active icon hides the button rather than showing the inactive art on an active spell.
// `active_icon` is `None` on exactly that failure, so `.clone()` already says it.
texture: if showing_active {
spell.active_icon.clone()
} else {
spell.icon.clone()
},
is_token: false,
spell_id: Some(action),
// A spell slot NEVER reports isActive — VERIFIED nil on every path (wow-re §2.1, pushed
@@ -267,10 +317,8 @@ fn slot_view(
// concept and `autoCast*` exclusively a spell one; the two halves of the signature never
// overlap (§2.5 quirk 3).
//
// The client does express "the pet is running this spell", but as an ICON swap, not a
// ring: `GetPetActionInfo` substitutes the record's `ActiveIconID` for its `SpellIconID`
// when the spell is a live aura on the pet with its aura-flag bit set (`0x4bcea0`). That
// needs an aura-indexed icon we do not carry yet — named in decision 0988, not faked here.
// "The pet is running this spell" is expressed by the icon above, not by this flag — which
// is why 0988's hole closes without this line changing.
active: false,
// Autocast is bits 31/30 of the word, not the type byte (wow-re §2.1) — and both are
// additionally gated on the spell resolving in `Spell.dbc`, which the early return above
@@ -279,9 +327,38 @@ fn slot_view(
autocast_enabled: entry.autocast_on(),
attack_active: false,
cooldown,
// `packed`/`passive` are [`slot_view`]'s to stamp — this half paints, it does not encode.
..Default::default()
}
}
/// The pet spell slot that is **showing active** — the reference's `0x4bcea0` (wow-re
/// `ui/scratch/pet-action-bar-api.md` §2.1), returning the spell id when it holds so the one
/// answer can drive both of its consumers. Decision 1007.
///
/// It is not a new predicate: `0x4bcea0` is the *pet-side compiled twin* of the player's
/// `0x4e55f0`, which we already carry as [`crate::ui_action::toggle::active_action_toggle`] — same
/// three tests (nonzero raw `ActiveIconID`, the spell's own id in a live `UNIT_FIELD_AURA` slot,
/// that slot's `AURAFLAGS` nibble bit 0), different unit. So this reaches for it rather than
/// restating it, and the pet's store goes in where the player's does.
///
/// **The `ActiveIconID != 0` gate is load-bearing on the send, not just the icon.** Because the
/// binary tests it first (`0x4bcefd`) and `CastPetAction` takes its cancel arm on the whole
/// predicate, a pet spell whose record carries no active icon can never be clicked off — it
/// re-casts instead. That reads like an oversight and isn't ours to fix: the same `0` is what
/// tells the bar there is no "active" art to show, so the two halves are consistent.
fn active_aura_press(
entry: PetActionEntry,
pet: Option<&ObjectStore>,
spell: Option<&benilla_formats::SpellDisplay>,
) -> Option<u32> {
if !entry.is_spell() || entry.is_empty() {
return None;
}
let spell_id = entry.action();
crate::ui_action::toggle::active_action_toggle(spell_id, spell?, pet?).then_some(spell_id)
}
/// Rebuild the ten slot views each frame and diff-push them, firing `PET_BAR_UPDATE` on a change.
///
/// One event where the reference has four (`PET_BAR_UPDATE`, `PET_BAR_UPDATE_COOLDOWN`,
@@ -304,15 +381,20 @@ fn feed_pet_bar(
let now = Instant::now();
let (anchor, ui_now) = (clock.anchor, clock.ui_now);
let has_bar = bar.has_bar();
// The pet's own `UNIT_FIELD_FLAGS`, for the crowd-control half of the usability predicate.
// `None` = we hold a bar for a unit whose descriptor has not arrived (or has left); the
// predicate then rests on bit 27 alone rather than greying a bar on missing data.
let pet_flags = index
// The pet's own descriptor. `None` = we hold a bar for a unit whose descriptor has not arrived
// (or has left); the usability predicate then rests on bit 27 alone rather than greying a bar
// on missing data, and no slot can read as showing-active.
let pet_store = index
.0
.get(&bar.spells.pet_guid)
.and_then(|&e| stores.get(e).ok())
.map(|s| s.0.unit_flags());
.and_then(|&e| stores.get(e).ok());
let pet_flags = pet_store.map(|s| s.0.unit_flags());
let usable = actions_usable(&bar, pet_flags);
// `PickupPetAction`'s own gate, and nobody else's (`0x4be1c1`): a POSSESSED unit's bar cannot
// be rearranged. Deliberately not folded into `usable` — the reference keeps possession out of
// the flags that grey the bar, because a possessed unit is exactly when the buttons must work.
// Absent flags read as not-possessed, matching `usable`'s own missing-data posture.
let pickup_allowed = pet_flags.unwrap_or(0) & UNIT_FLAG_POSSESSED == 0;
let pet_attacking = bar.attacking;
let fresh: Vec<PetActionView> = if has_bar {
@@ -329,22 +411,30 @@ fn feed_pet_bar(
.info(entry.action(), 0, Some(d), now)
.ui_triple(anchor, ui_now)
});
slot_view(entry, &bar.spells, display, cooldown, pet_attacking)
slot_view(
entry,
&bar.spells,
display,
cooldown,
pet_attacking,
active_aura_press(entry, pet_store, display).is_some(),
)
})
.collect()
} else {
Vec::new()
};
if memory.pushed.as_ref() != Some(&(has_bar, usable, fresh.clone())) {
if memory.pushed.as_ref() != Some(&(has_bar, usable, pickup_allowed, fresh.clone())) {
debug!(
"ui_pet: bar {} ({} occupied slot(s), {})",
"ui_pet: bar {} ({} occupied slot(s), {}{})",
if has_bar { "shown" } else { "hidden" },
fresh.iter().filter(|s| s.name.is_some()).count(),
if usable { "usable" } else { "disabled" },
if pickup_allowed { "" } else { ", possessed" },
);
memory.pushed = Some((has_bar, usable, fresh.clone()));
script.set_pet_actions(has_bar, usable, fresh);
memory.pushed = Some((has_bar, usable, pickup_allowed, fresh.clone()));
script.set_pet_actions(has_bar, usable, pickup_allowed, fresh);
script.fire_event("PET_BAR_UPDATE", vec![]);
}
}
@@ -371,6 +461,12 @@ struct PetUnitMemory {
/// the local click latch — is what the reference watches.
const UNIT_FLAG_PET_IN_COMBAT: u32 = 0x0000_0800;
/// `UNIT_FIELD_FLAGS` bit 24 — `UNIT_FLAG_POSSESSED` (vmangos `UnitDefines.h:515`), read by the
/// reference as the descriptor byte `[[pet+0x110]+0xA3] & 1`. Gates the pet bar's **drag** and
/// nothing else (decision 1010): a possessed unit's buttons still work, its layout is just not
/// yours to rearrange.
const UNIT_FLAG_POSSESSED: u32 = 0x0100_0000;
/// **The unit behind [`PetBar`]'s cached guid, plus our own identity** — the one lookup both pet
/// systems need and neither owns.
///
@@ -389,7 +485,7 @@ pub(crate) struct PetUnit<'w, 's> {
impl PetUnit<'_, '_> {
/// The pet's streamed descriptor, or `None` while the bar names a guid whose object has not
/// arrived (or has left) — the case the reference treats as "no pet", never as "a bad pet".
fn store(&self, pet_guid: u64) -> Option<&ObjectStore> {
pub(crate) fn store(&self, pet_guid: u64) -> Option<&ObjectStore> {
let e = *self.index.0.get(&pet_guid)?;
self.stores.get(e).ok()
}
@@ -542,6 +638,7 @@ fn drain_pet_actions(
selection: Res<Selection>,
commands: Res<NetCommands>,
pet: PetUnit,
spells: Option<Res<Spells>>,
mut ui_errors: ResMut<crate::ui_action::UiErrorKeys>,
) {
let Some(mut script) = script else {
@@ -550,7 +647,8 @@ fn drain_pet_actions(
let pressed = script.take_pet_actions();
let toggles = script.take_pet_autocast_toggles();
let stops = script.take_pet_stop_attacks();
if pressed.is_empty() && toggles.is_empty() && stops == 0 {
let writes = script.take_pet_set_actions();
if pressed.is_empty() && toggles.is_empty() && stops == 0 && writes.is_empty() {
return;
}
let pet_guid = bar.spells.pet_guid;
@@ -560,13 +658,30 @@ fn drain_pet_actions(
}
let target_guid = selection.guid.unwrap_or(0);
// The pet's own descriptor — the ATTACK arm's validator reads it, not ours (see below).
// The pet's own descriptor — the ATTACK arm's validator reads it, and so does the spell arm's
// showing-active test. Neither is about the bar; both are about the pet.
let pet_store = pet.store(pet_guid);
for slot in pressed {
let Some(entry) = slot_entry(&bar, slot) else {
continue;
};
// The spell arm's early exit (wow-re §10.1, `0x4bd240`–`0x4bd2ad`): a press on a spell the
// pet is already running takes the aura OFF and **returns** — `CMSG_PET_ACTION` never
// leaves, so it is a cancel, not a re-cast. Nothing is latched locally either: the icon
// goes back when the pet's `UNIT_FIELD_AURA` says the aura is gone, which is the honest
// order (the server can refuse — a dead pet gets `FEEDBACK_PET_DEAD` instead).
let display = entry
.is_spell()
.then(|| spells.as_ref().and_then(|s| s.catalog.get(entry.action())))
.flatten();
if let Some(spell_id) = active_aura_press(entry, pet_store, display) {
debug!("ui_pet: slot {slot} cancels its own aura (spell {spell_id}) — no PetAction");
let _ = commands
.0
.send(ClientCommand::PetCancelAura { pet_guid, spell_id });
continue;
}
// The ATTACK order's validator runs FIRST, and its actor is the pet — see
// [`commit_press`], which owns what a veto actually costs.
let refused = is_attack_order(entry)
@@ -611,14 +726,99 @@ fn drain_pet_actions(
}
}
for _ in 0..stops {
// `PetStopAttack` no-ops entirely when the pet is not attacking (`0x4bd65e`) — no packet,
// no repaint. That gate lives here rather than in the VM because the latch does.
if !bar.attacking {
continue;
if stop_pet_attack(&mut bar, &commands) {
debug!("ui_pet: stop attack");
}
debug!("ui_pet: stop attack");
let _ = commands.0.send(ClientCommand::PetStopAttack { pet_guid });
bar.attacking = false;
}
// The drag's writes (decision 1010). The engine ran the assign core against its own mirror and
// handed back the `(0-based position, word)` pairs; the authoritative ten words live *here*, so
// the app's whole job is to mirror each pair and put the batch on the wire **whole** — the
// server tells the one-pair form from the two-pair form by body size, so a relocation and its
// write must not be split into two sends.
for entries in writes {
for &(position, packed) in &entries {
if let Some(e) = bar.spells.bar.get_mut(position as usize) {
*e = PetActionEntry::from(packed);
}
}
debug!("ui_pet: bar write {entries:?}");
let _ = commands
.0
.send(ClientCommand::PetSetAction { pet_guid, entries });
}
}
/// `PetStopAttack`'s **core**, `0x4bd650` — call the pet off, and the only thing besides a new pet
/// that puts the Attack button out.
///
/// It **no-ops entirely when the latch is down** (`0x4bd65e`): no packet, no repaint. That gate
/// lives here rather than in the VM because the latch does. Returns whether it actually fired,
/// which is what its two callers log.
///
/// Split out of the drain because the drain is *not* its only caller: [`pet_stop_on_old_target_clear`]
/// is the second, exactly as `0x4bd650` has a second call site of its own.
fn stop_pet_attack(bar: &mut PetBar, commands: &NetCommands) -> bool {
let pet_guid = bar.spells.pet_guid;
if !bar.attacking || pet_guid == 0 {
return false;
}
let _ = commands.0.send(ClientCommand::PetStopAttack { pet_guid });
bar.attacking = false;
true
}
/// `0x493910`'s entry gate, as a predicate on the selection transition.
///
/// The old-target clear returns immediately unless there **is** a current selection
/// (`0x493937 or edx,ecx; je epilogue`) and the guid it was handed is either zero or that same
/// selection (`0x493949`/`0x493951`) — and its two callers only ever hand it one of those two.
/// So it runs on exactly the transitions below: a selection that existed is being replaced or
/// dropped. `None → Some` is *not* one of them.
fn old_target_cleared(previous: Option<u64>, now: Option<u64>) -> bool {
previous.is_some() && previous != now
}
/// **Clearing the old target calls `PetStopAttack`'s core**, and that is why the Attack button is
/// not the sticky toggle Stay and Follow are.
///
/// `0x493910` — the old-target clear, run by *both* selection writers (`0x493540`'s switch calls
/// it with the outgoing guid at `ecx = 0`, the explicit clear with `{0,0}` at `ecx = 1`) — does
/// three things past its entry gate, of which benilla previously carried only the first:
///
/// ```text
/// 0x493a08 0x5ecac0(player) ; StopAttack -> CMSG_ATTACKSTOP (target/scan.rs `commit`)
/// 0x493a0f 0x5ee5a0(player) ; MY own pet, or null
/// 0x493a18 0x4bd650() ; PetStopAttack's core, iff there is one <- THIS
/// 0x493a1d if (notifyServer) ... ; CMSG_SET_SELECTION
/// ```
///
/// The pet call sits **above** the notify-server branch, so it runs on the silent switch-clear
/// too, not only on the explicit one. Its effect is `[0xb714b0] = 0` — and `GetPetActionInfo`'s
/// COMMAND branch lights ATTACK on `action == 2 && [0xb714b0] != 0` and nothing else (wow-re §2.3;
/// the command byte is never written for ATTACK, §10.1). So in the reference the light survives
/// exactly until the next time you touch your target, which is what makes an *order* read
/// differently from a *mode*: Stay and Follow persist because they live in the command byte, and
/// Attack does not because it lives in a latch three separate things knock down.
///
/// Modelled as a transition on [`Selection`] rather than a hook in the selection writers because
/// that is what the reference gets for free by routing every writer through one clear: a `Local`
/// mirror sees `/target`, a click, TAB, ESC, the death-teardown clear and the acquire alike. The
/// dedup is the reference's own (`0x493540` bails when the guid is already current, so no clear
/// runs) and falls out of comparing against the mirror.
fn pet_stop_on_old_target_clear(
selection: Res<Selection>,
mut previous: Local<Option<u64>>,
mut bar: ResMut<PetBar>,
commands: Res<NetCommands>,
) {
let now = selection.guid;
if *previous == now {
return;
}
let cleared = old_target_cleared(*previous, now);
*previous = now;
if cleared && stop_pet_attack(&mut bar, &commands) {
debug!("ui_pet: the old-target clear called the pet off (0x493a18)");
}
}
@@ -637,7 +837,8 @@ fn drain_pet_actions(
/// validation chain that ends at `[0xb714b0] = 1` (`0x4bd42e`) — the attack latch, never the
/// command byte. The distinction is the difference between an order and a mode: Stay and Follow
/// are what the pet is *doing until told otherwise*; Attack is a thing you *tell it once*, and its
/// light comes from the latch, which `PetStopAttack` and a new pet are the only things that clear.
/// light comes from the latch — which the lit button's own second press, a new pet, and **every
/// change of your target** ([`pet_stop_on_old_target_clear`]) each knock down.
///
/// Getting this wrong is visible within one click: latching ATTACK into the command byte leaves
/// the Attack button lit forever (`isActive`'s state compare keeps matching), blanks Follow and
@@ -738,6 +939,19 @@ mod tests {
PetActionEntry::from(action | (u32::from(kind) << 24))
}
/// [`slot_view`] for a slot that is **not** showing active — the ordinary case, and the only
/// one the token/spell/cooldown tests care about. The showing-active leg has its own tests,
/// which call `slot_view` directly so the flag is visible at the call.
fn view(
entry: PetActionEntry,
bar: &PetSpells,
spell: Option<&benilla_formats::SpellDisplay>,
cooldown: Option<(i64, u32, bool)>,
pet_attacking: bool,
) -> PetActionView {
slot_view(entry, bar, spell, cooldown, pet_attacking, false)
}
fn spell(name: &str, rank: Option<&str>) -> benilla_formats::SpellDisplay {
benilla_formats::SpellDisplay {
name: name.to_string(),
@@ -762,7 +976,7 @@ mod tests {
fn command_tokens_light_on_the_current_command() {
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
let follow = slot_view(
let follow = view(
packed(PET_COMMAND_FOLLOW, PET_ACT_COMMAND),
&bar,
None,
@@ -774,7 +988,7 @@ mod tests {
assert!(follow.is_token && follow.active);
assert!(!follow.attack_active, "Follow is not the attack fork");
let stay = slot_view(
let stay = view(
packed(PET_COMMAND_STAY, PET_ACT_COMMAND),
&bar,
None,
@@ -792,15 +1006,15 @@ mod tests {
let following = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
let attack = packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND);
let idle = slot_view(attack, &following, None, None, false);
let idle = view(attack, &following, None, None, false);
assert!(!idle.active && !idle.attack_active);
let on = slot_view(attack, &following, None, None, true);
let on = view(attack, &following, None, None, true);
assert!(on.active, "the latch lights it even on a FOLLOW command");
assert!(on.attack_active, "and the next press calls the pet off");
// The latch never reaches another command's button, however busy the pet is.
let follow = slot_view(
let follow = view(
packed(PET_COMMAND_FOLLOW, PET_ACT_COMMAND),
&following,
None,
@@ -816,7 +1030,7 @@ mod tests {
#[test]
fn reaction_tokens_use_the_mode_keys_and_light_on_the_current_react() {
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
let def = slot_view(
let def = view(
packed(PET_REACT_DEFENSIVE, PET_ACT_REACTION),
&bar,
None,
@@ -827,7 +1041,7 @@ mod tests {
assert_eq!(def.texture.as_deref(), Some("PET_DEFENSIVE_TEXTURE"));
assert!(def.is_token && def.active);
assert!(
!slot_view(
!view(
packed(PET_REACT_AGGRESSIVE, PET_ACT_REACTION),
&bar,
None,
@@ -846,7 +1060,7 @@ mod tests {
let mut bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
bar.state |= PET_STATE_BAR_DISABLED;
let passive = slot_view(
let passive = view(
packed(PET_REACT_PASSIVE, PET_ACT_REACTION),
&bar,
None,
@@ -855,7 +1069,7 @@ mod tests {
);
assert!(passive.active, "a bar that cannot be ordered reads Passive");
assert!(
!slot_view(
!view(
packed(PET_REACT_DEFENSIVE, PET_ACT_REACTION),
&bar,
None,
@@ -866,7 +1080,7 @@ mod tests {
);
assert!(
!slot_view(
!view(
packed(PET_COMMAND_FOLLOW, PET_ACT_COMMAND),
&bar,
None,
@@ -909,7 +1123,7 @@ mod tests {
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
let claw = spell("Claw", Some("Rank 3"));
let on = slot_view(
let on = view(
packed(3010, PET_ACT_ENABLED),
&bar,
Some(&claw),
@@ -923,7 +1137,7 @@ mod tests {
assert!(on.autocast_allowed && on.autocast_enabled);
assert!(!on.active, "a SPELL slot never reports isActive");
let off = slot_view(
let off = view(
packed(3010, PET_ACT_DISABLED),
&bar,
Some(&claw),
@@ -933,7 +1147,7 @@ mod tests {
assert!(off.autocast_allowed && !off.autocast_enabled);
// A passive pet spell shows, but can never autocast — no ring, no sparkle.
let passive = slot_view(
let passive = view(
packed(3010, PET_ACT_PASSIVE),
&bar,
Some(&claw),
@@ -953,15 +1167,21 @@ mod tests {
let zero = PetActionEntry::default();
assert!(zero.is_empty());
assert_eq!(
slot_view(zero, &bar, None, None, false),
view(zero, &bar, None, None, false),
PetActionView::default()
);
let filler = packed(0, PET_ACT_DISABLED);
assert!(!filler.is_empty(), "the WORD is not zero");
assert_eq!(
slot_view(filler, &bar, None, None, false),
PetActionView::default(),
view(filler, &bar, None, None, false),
PetActionView {
// Draws nothing and still CARRIES its word (decision 1010) — this is the exact
// slot the drop core hunts for as a relocation candidate (type 1, low 16 zero),
// so zeroing it here would both send the wrong word and lose the candidate.
packed: filler.packed,
..Default::default()
},
"…but spell id 0 resolves to nothing, so the button is still empty"
);
}
@@ -971,8 +1191,15 @@ mod tests {
#[test]
fn an_unresolvable_spell_draws_nothing() {
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
let v = slot_view(packed(999, PET_ACT_ENABLED), &bar, None, None, false);
assert_eq!(v, PetActionView::default());
let entry = packed(999, PET_ACT_ENABLED);
let v = view(entry, &bar, None, None, false);
assert_eq!(
v,
PetActionView {
packed: entry.packed,
..Default::default()
}
);
assert!(!v.autocast_allowed && !v.autocast_enabled);
}
@@ -983,9 +1210,13 @@ mod tests {
fn an_unknown_type_byte_is_inert() {
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
let claw = spell("Claw", None);
let entry = packed(3010, 0x33);
assert_eq!(
slot_view(packed(3010, 0x33), &bar, Some(&claw), None, false),
PetActionView::default()
view(entry, &bar, Some(&claw), None, false),
PetActionView {
packed: entry.packed,
..Default::default()
}
);
}
@@ -1086,15 +1317,15 @@ mod tests {
"an attack order leaves the standing command alone"
);
assert!(
slot_view(follow, &bar.spells, None, None, bar.attacking).active,
view(follow, &bar.spells, None, None, bar.attacking).active,
"so Follow keeps its light while the pet is on something"
);
assert!(slot_view(attack, &bar.spells, None, None, bar.attacking).attack_active);
assert!(view(attack, &bar.spells, None, None, bar.attacking).attack_active);
// The call-off: `PetStopAttack` clears the latch, and the latch was the ONLY thing lighting
// the button — so it goes out, which is what a stuck command byte used to prevent.
bar.attacking = false;
let called_off = slot_view(attack, &bar.spells, None, None, bar.attacking);
let called_off = view(attack, &bar.spells, None, None, bar.attacking);
assert!(
!called_off.active,
"the Attack button goes out with the latch"
@@ -1129,7 +1360,7 @@ mod tests {
);
assert!(!bar.attacking, "and never raises the latch");
assert!(
!slot_view(attack, &bar.spells, None, None, bar.attacking).active,
!view(attack, &bar.spells, None, None, bar.attacking).active,
"so the button stays dark — the bug the director saw was it lighting anyway"
);
assert_eq!(
@@ -1141,7 +1372,53 @@ mod tests {
// The same press with the gate clear is the ordinary attack, unchanged.
assert!(commit_press(&mut bar, attack, false));
assert!(bar.attacking);
assert!(slot_view(attack, &bar.spells, None, None, bar.attacking).active);
assert!(view(attack, &bar.spells, None, None, bar.attacking).active);
}
/// **Touching your target calls the pet off** — `0x493910`'s `0x493a18`, the second call site
/// of `PetStopAttack`'s core and the one benilla was missing.
///
/// This is the mechanism behind the director's report that Attack is not a toggle. Without it
/// the latch had only two ways down (the lit button's own second press and a new pet), so an
/// order lit the button for the rest of the pet's life and read exactly like the Stay/Follow
/// modes it is supposed to contrast with.
#[test]
fn touching_your_target_calls_the_pet_off() {
let (tx, rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
let mut bar = PetBar {
spells: PetSpells {
pet_guid: 0xF14,
..state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE)
},
attacking: true,
..Default::default()
};
// Selecting when nothing was selected is NOT a clear — `0x493937` returns before anything.
assert!(!old_target_cleared(None, Some(7)));
// Replacing one target with another IS, and so is dropping it: the guard is on the OLD
// selection existing, never on the new one being empty.
assert!(old_target_cleared(Some(7), Some(9)));
assert!(old_target_cleared(Some(7), None));
// A no-op re-select never reaches the clear at all (`0x493540`'s own dedup).
assert!(!old_target_cleared(Some(7), Some(7)));
assert!(stop_pet_attack(&mut bar, &commands));
assert!(!bar.attacking, "the latch is down");
assert!(
matches!(rx.try_recv(), Ok(ClientCommand::PetStopAttack { pet_guid }) if pet_guid == 0xF14),
"and the server is told, with the BAR's guid"
);
// The button is dark now, because the latch was the only thing lighting it.
let attack = packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND);
assert!(!view(attack, &bar.spells, None, None, bar.attacking).active);
// Re-clearing is a no-op: `0x4bd65e` returns before the send when the latch is already
// down, so a target change per second does not spray packets.
assert!(!stop_pet_attack(&mut bar, &commands));
assert!(rx.try_recv().is_err());
}
/// `PET_ATTACK_START`/`STOP` read the pet's **server-owned** in-combat flag, and only for a
@@ -1219,4 +1496,100 @@ mod tests {
PET_ACT_REACTION
)));
}
/// A pet with one aura in slot 0. `AURAFLAGS` is nibble-packed 8 slots to the u32, so slot 0's
/// nibble is the low four bits: `0x2` is an effect-index bit (what makes the slot *live*) and
/// `0x1` is `AFLAG_CANCELABLE` — the bit `0x4bcea0` actually tests.
fn pet_running(spell_id: u32, nibble: u32) -> ObjectStore {
const AURA: u16 = 47;
const AURAFLAGS: u16 = 95;
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(AURA, spell_id),
(AURAFLAGS, nibble),
]))
}
/// A spell with an active icon — the shape the predicate needs before it can ever fire.
fn toggle_spell() -> benilla_formats::SpellDisplay {
benilla_formats::SpellDisplay {
active_icon_id: 122,
active_icon: Some("Interface\\Icons\\Ability_Druid_Cower".into()),
..spell("Cower", Some("Rank 1"))
}
}
/// The showing-active predicate, all four of its ways to say no. The pet-side twin of
/// `0x4e55f0` is the *same function* we already had, so what these assert is the wiring: the
/// pet's store goes in, and the three tests are the reference's three.
#[test]
fn a_pet_spell_shows_active_only_while_it_is_a_live_cancelable_aura_on_the_pet() {
let slot = packed(2645, benilla_protocol::messages::PET_TYPE_SPELL_FIRST);
let running = pet_running(2645, 0x3);
let d = toggle_spell();
assert_eq!(
active_aura_press(slot, Some(&running), Some(&d)),
Some(2645)
);
// No ActiveIconID: never a toggle — and that gate is on the SEND too, so this spell
// re-casts rather than cancelling however live its aura is (the reference's own quirk).
let plain = spell("Growl", None);
assert_eq!(active_aura_press(slot, Some(&running), Some(&plain)), None);
// Live but NOT cancelable (effect-index bit only).
let uncancelable = pet_running(2645, 0x2);
assert_eq!(active_aura_press(slot, Some(&uncancelable), Some(&d)), None);
// A different spell's aura, and no pet descriptor at all.
assert_eq!(
active_aura_press(slot, Some(&pet_running(768, 0x3)), Some(&d)),
None
);
assert_eq!(active_aura_press(slot, None, Some(&d)), None);
// A COMMAND slot whose action equals the spell id is a different word entirely — the type
// byte decides here exactly as it does for the ATTACK gate.
assert_eq!(
active_aura_press(packed(2645, PET_ACT_COMMAND), Some(&running), Some(&d)),
None
);
}
/// The icon swap itself: `ActiveIconID`'s texture replaces `SpellIconID`'s while the spell is
/// running, and the button keeps everything else it had.
#[test]
fn an_active_pet_spell_draws_its_active_icon() {
let slot = packed(2645, benilla_protocol::messages::PET_TYPE_SPELL_FIRST);
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
let d = toggle_spell();
let idle = slot_view(slot, &bar, Some(&d), None, false, false);
assert_eq!(idle.texture, d.icon);
let active = slot_view(slot, &bar, Some(&d), None, false, true);
assert_eq!(active.texture, d.active_icon);
assert_eq!(
active.name, idle.name,
"only the icon swaps — the name, rank and autocast flags are untouched"
);
assert!(
!active.active,
"and it is still not `isActive`: a spell slot pushes nil there on every path"
);
}
/// An active spell whose `ActiveIconID` does not resolve in `SpellIcon.dbc` pushes **nil**,
/// not the inactive art. The reference looks up whichever id the predicate chose and gives up
/// if that lookup fails (`0x4bdd50`) — falling back would draw "not running" on a running
/// spell, which is worse than drawing nothing.
#[test]
fn an_unresolvable_active_icon_hides_rather_than_falling_back() {
let slot = packed(2645, benilla_protocol::messages::PET_TYPE_SPELL_FIRST);
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
let d = benilla_formats::SpellDisplay {
active_icon_id: 9999,
active_icon: None,
..spell("Cower", None)
};
assert!(slot_view(slot, &bar, Some(&d), None, false, true)
.texture
.is_none());
}
}
+351
View File
@@ -0,0 +1,351 @@
//! The hunter pet's **paper-doll stat block** — `GetPetHappiness`, `GetPetLoyalty`,
//! `GetPetTrainingPoints`, `GetPetExperience` and `HasPetUI` (decision 1005; wow-re
//! `ui/scratch/pet-action-bar-api.md` §11b + §6).
//!
//! Its own module rather than more of [`crate::ui_pet`] because it is a different concern on a
//! different clock: the action bar's contents arrive whole in `SMSG_PET_SPELLS` and change when
//! the server says so, while these five read plain descriptor fields plus two DBC tables and move
//! continuously — happiness drifts every few seconds while a pet is out. Tying them together would
//! make the bar's diff-and-fire churn on numbers no button draws.
//!
//! **One gate governs all four stat bindings**: `0x6116e0(pet)`, which resolves the pet, requires
//! its `UNIT_FIELD_PETNUMBER`, requires its owner to be us, and finishes
//! `cmp byte [player.fields + 0x79], 3; sete al` — `UNIT_FIELD_BYTES_0` byte 1, the class, against
//! **Hunter**. (The enum is pinned by the combo-point gate's own class test on the same byte:
//! `== 4 || == 0xB` is Rogue and Druid, wow-re `combo-point-gate.md`.) A warlock's imp therefore
//! resolves perfectly and still answers nothing — happiness, loyalty and training points are
//! hunter machinery — and each binding says "nothing" in its own way ([`benilla_ui::script::PetStats`]).
use bevy::prelude::*;
use benilla_ui::script::{PetStats, UiScript};
use crate::net::ObjectStore;
use crate::ui_pet::{PetBar, PetUnit};
use crate::ui_script::UiInput;
use crate::ui_unit::UnitFeed;
/// `UNIT_FIELD_BYTES_0` byte 1 == 3 — **Hunter**, the class the four stat bindings gate on
/// (`0x611752`). Pinned rather than assumed: the same byte's `4`/`0xB` are Rogue and Druid in the
/// already-carved combo-point gate, which fixes the enum this value sits in.
const CLASS_HUNTER: u8 = 3;
/// `UNIT_FIELD_BYTES_0` byte 3 == 4 — the **happiness** power, the field `GetPetHappiness`
/// thresholds. Read by power *index*, not by the unit's active power type: a pet's displayed bar is
/// focus, and happiness is a parallel slot that is always there.
const POWER_HAPPINESS: u8 = 4;
/// The two pet DBC tables, loaded once at startup. Absent client data leaves the stat block empty,
/// which is the same shape as "not a hunter pet" — degraded, never wrong.
#[derive(Resource)]
pub(crate) struct PetStatTables {
pub(crate) personalities: benilla_formats::PetPersonalities,
pub(crate) loyalty: benilla_formats::PetLoyaltyNames,
}
pub(crate) struct UiPetStatsPlugin;
impl Plugin for UiPetStatsPlugin {
fn build(&self, app: &mut App) {
// Rides the unit feed beside the pet bar's own, and before the VM ticks — the pet frame
// repaints out of the same pass that pushes its health.
app.add_systems(Update, feed_pet_stats.in_set(UnitFeed).before(UiInput));
}
}
/// Resolve the whole stat block for the current pet, or [`PetStats::default`] when there is none.
///
/// Split from the system so the composition is testable: every one of these five values passes
/// through the *same* `hunter_pet` gate, and a bug that let one leak past it would show up as a
/// warlock with a loyalty level rather than as a compile error.
fn stats_for(
pet: Option<&ObjectStore>,
self_store: Option<&ObjectStore>,
tables: Option<&PetStatTables>,
) -> (bool, PetStats) {
let Some(fields) = pet.map(|s| &s.0) else {
return (false, PetStats::default());
};
// `HasPetUI`'s FIRST return (`0x4be697`): a pet that resolves AND carries a pet number. Not the
// action bar's gate, which is the cached guid alone — a possessed creature has a bar and no
// pet number, so it gets buttons and no paper doll.
let has_ui = fields.unit_is_pet_or_charm();
// …and the second: the owner's class. We only ever hold a bar for a pet the server named us
// the controller of, so the owner leg is structurally true here; the class is the live test.
let hunter = has_ui
&& self_store.map(|s| ((s.0.unit_bytes_0().unwrap_or(0) >> 8) & 0xff) as u8)
== Some(CLASS_HUNTER);
if !hunter {
return (has_ui, PetStats::default());
}
let (happiness, damage_percentage, loyalty_rate) = tables
.and_then(|t| t.personalities.for_pet(None))
.map(|p| {
let h = p.happiness(fields.unit_power(POWER_HAPPINESS).unwrap_or(0));
(Some(h.bucket), h.damage_percentage, h.loyalty_rate)
})
// No DBC ⇒ the binding's own gate-failure numbers, and nil for the bucket.
.unwrap_or((None, 100.0, 0.0));
(
has_ui,
PetStats {
hunter_pet: true,
happiness,
damage_percentage,
loyalty_rate,
loyalty: tables.and_then(|t| {
t.loyalty
.name(u32::from(fields.unit_loyalty_level()))
.map(str::to_string)
}),
training_points: fields.unit_training_points(),
experience: fields.unit_pet_experience(),
},
)
}
fn feed_pet_stats(
script: Option<NonSendMut<UiScript>>,
bar: Res<PetBar>,
pet: PetUnit,
self_store: Query<&ObjectStore, With<crate::net::SelfPlayer>>,
tables: Option<Res<PetStatTables>>,
mut last: Local<Option<(bool, PetStats)>>,
) {
let Some(mut script) = script else {
return;
};
let fresh = stats_for(
pet.store(bar.spells.pet_guid),
self_store.iter().next(),
tables.as_deref(),
);
// Push only on change. Happiness moves on its own clock and the frame repaints off UNIT_*
// events, so a per-frame push would be pure churn — but the DIFF is what makes this cheap,
// not a timer, so a real change still lands the same frame it arrives.
if last.as_ref() == Some(&fresh) {
return;
}
// `UNIT_HAPPINESS` — the pet frame's repaint wire for the icon (ref `PetFrame_OnEvent`'s last
// arm). The reference fires it off the happiness FIELD changing; we fire it off the three
// values the icon and its tooltip are built from, which is the same event for every consumer
// there is — the icon is bucketed and both tooltip lines are bucket-derived, so movement
// *within* a bucket has nothing to repaint. Named because it is a narrower trigger, not a
// wider one: nothing reads the raw happiness number.
let happiness_moved = last.as_ref().map(|(_, s)| {
(
s.happiness,
s.damage_percentage.to_bits(),
s.loyalty_rate.to_bits(),
)
}) != Some((
fresh.1.happiness,
fresh.1.damage_percentage.to_bits(),
fresh.1.loyalty_rate.to_bits(),
));
if happiness_moved {
debug!(
"ui_pet_stats: happiness {:?} ({}% damage), loyalty {:?}",
fresh.1.happiness, fresh.1.damage_percentage, fresh.1.loyalty
);
}
*last = Some(fresh.clone());
script.set_pet_stats(fresh.0, fresh.1);
if happiness_moved {
script.fire_event("UNIT_HAPPINESS", vec![]);
}
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_protocol::ObjectFields;
const BYTES_0: u16 = 36;
const BYTES_1: u16 = 138;
const POWER5: u16 = 27;
const PETNUMBER: u16 = 139;
const PETXP: u16 = 141;
const PETNEXTXP: u16 = 142;
const TRAINING: u16 = 149;
fn hunter() -> ObjectStore {
// BYTES_0 byte 1 = class. Hunter = 3.
ObjectStore(ObjectFields::from_pairs(&[(
BYTES_0,
u32::from(CLASS_HUNTER) << 8,
)]))
}
fn warlock() -> ObjectStore {
ObjectStore(ObjectFields::from_pairs(&[(BYTES_0, 9 << 8)]))
}
/// A boar: pet number set, loyalty level 6, happy, part-trained, mid-XP.
fn boar() -> ObjectStore {
ObjectStore(ObjectFields::from_pairs(&[
(PETNUMBER, 42),
(BYTES_1, 6 << 8), // loyalty level in byte 1
(POWER5, 1_000_000), // maximum happiness
(TRAINING, (170 << 16) | 130), // total in the HIGH word, spent in the low
(PETXP, 4200),
(PETNEXTXP, 8000),
]))
}
fn tables() -> Option<PetStatTables> {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return None;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
Some(PetStatTables {
personalities: benilla_formats::load_pet_personalities(&mut chain)
.expect("personality"),
loyalty: benilla_formats::load_pet_loyalty_names(&mut chain).expect("loyalty"),
})
}
/// The whole block end to end on the real DBC data — the packed/derived reads are the point:
/// the training-points word order, the loyalty byte, and happiness coming out PRE-BUCKETED
/// with vanilla's own 125% damage for a maxed pet.
#[test]
fn a_hunters_boar_reads_every_field() {
let Some(t) = tables() else { return };
let (has_ui, s) = stats_for(Some(&boar()), Some(&hunter()), Some(&t));
assert!(has_ui && s.hunter_pet);
assert_eq!(s.happiness, Some(3));
assert_eq!(s.damage_percentage, 125.0);
assert_eq!(s.loyalty_rate, 20.0);
assert_eq!(s.loyalty.as_deref(), Some("(Loyalty Level 6) Best Friend"));
assert_eq!(
s.training_points,
(170, 130),
"TOTAL first, from the high word"
);
assert_eq!(s.experience, (4200, 8000));
}
/// The happiness bucket really is derived from the power field, not passed through: three
/// different raw values give three different buckets and three different damage numbers.
#[test]
fn happiness_buckets_the_raw_power() {
let Some(t) = tables() else { return };
let at = |raw: u32| {
let store = ObjectStore(ObjectFields::from_pairs(&[
(PETNUMBER, 42),
(BYTES_1, 0),
(POWER5, raw),
]));
let (_, s) = stats_for(Some(&store), Some(&hunter()), Some(&t));
(s.happiness, s.damage_percentage)
};
assert_eq!(at(0), (Some(1), 75.0), "an unhappy pet deals 75%");
assert_eq!(at(500_000), (Some(2), 100.0));
assert_eq!(at(1_000_000), (Some(3), 125.0));
}
/// **The gate is the class, and it takes everything with it.** A warlock's imp has a pet
/// number — so `HasPetUI`'s first return stays true — and answers no stats at all. Letting one
/// value leak past the gate is the failure this guards: an imp with a loyalty level.
#[test]
fn a_warlocks_minion_has_a_ui_and_no_stats() {
let Some(t) = tables() else { return };
let (has_ui, s) = stats_for(Some(&boar()), Some(&warlock()), Some(&t));
assert!(
has_ui,
"the pet number is what HasPetUI's first return reads"
);
assert!(!s.hunter_pet);
assert_eq!(s.happiness, None);
assert_eq!(s.loyalty, None);
assert_eq!(s.training_points, (0, 0));
assert_eq!(s.experience, (0, 0));
}
/// A possessed creature has an action bar but no pet number, so it gets no paper doll either —
/// the two gates are genuinely different (`PetHasActionBar` is the cached guid alone).
#[test]
fn no_pet_number_means_no_pet_ui() {
let Some(t) = tables() else { return };
let possessed = ObjectStore(ObjectFields::from_pairs(&[(POWER5, 1_000_000)]));
let (has_ui, s) = stats_for(Some(&possessed), Some(&hunter()), Some(&t));
assert!(!has_ui);
assert!(!s.hunter_pet);
}
/// Loyalty level 0 is nil, not level 1 — the client's own bound, and the difference between a
/// fresh pet showing nothing and showing "Rebellious".
#[test]
fn loyalty_level_zero_is_nil() {
let Some(t) = tables() else { return };
let fresh = ObjectStore(ObjectFields::from_pairs(&[
(PETNUMBER, 42),
(BYTES_1, 0),
(POWER5, 1_000_000),
]));
let (_, s) = stats_for(Some(&fresh), Some(&hunter()), Some(&t));
assert_eq!(s.loyalty, None);
assert_eq!(s.happiness, Some(3), "…but happiness still answers");
}
/// Missing client data degrades to the bindings' own gate-failure numbers rather than to a
/// wrong bucket: nil happiness with `(100.0, 0.0)` beside it.
#[test]
fn absent_dbc_data_degrades_to_the_failure_numbers() {
let (has_ui, s) = stats_for(Some(&boar()), Some(&hunter()), None);
assert!(has_ui && s.hunter_pet);
assert_eq!(s.happiness, None);
assert_eq!((s.damage_percentage, s.loyalty_rate), (100.0, 0.0));
// The descriptor-only values still read — they need no table.
assert_eq!(s.training_points, (170, 130));
}
/// The RUNTIME leg on the real data: every GlobalStrings key the happiness tooltip resolves
/// exists in the shipped 1.12 file, with the `%d` the damage line formats into. A typo'd key
/// here is silent — `getglobal` answers nil and the tooltip simply loses a line.
#[test]
fn every_happiness_string_resolves_in_the_real_global_strings() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let src = chain
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
.expect("GlobalStrings.lua in the chain");
let s = benilla_ui::script::UiScript::new().expect("VM");
s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
let g = |key: &str| {
s.lua()
.globals()
.get::<String>(key)
.ok()
.unwrap_or_default()
};
// The three bucket names are resolved by CONCATENATION (`"PET_HAPPINESS"..happiness`), so
// a missing one shows as an empty tooltip for that bucket alone — exactly the failure a
// by-eye check misses.
assert_eq!(g("PET_HAPPINESS1"), "Unhappy");
assert_eq!(g("PET_HAPPINESS2"), "Content");
assert_eq!(g("PET_HAPPINESS3"), "Happy");
assert!(
g("PET_DAMAGE_PERCENTAGE").contains("%d"),
"the damage line formats the percentage in: {:?}",
g("PET_DAMAGE_PERCENTAGE")
);
assert!(!g("LOSING_LOYALTY").is_empty());
assert!(!g("GAINING_LOYALTY").is_empty());
}
/// No pet at all: no UI, no stats, and nothing that could be mistaken for a real answer.
#[test]
fn no_pet_is_no_ui() {
let (has_ui, s) = stats_for(None, Some(&hunter()), None);
assert!(!has_ui);
assert_eq!(s, PetStats::default());
}
}
@@ -172,6 +172,8 @@ fn escape_closes_the_options_window_before_opening_the_menu() {
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml"); // the Keybindings page's faux-scroll kit (1008)
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "GameMenuFrame.xml");
@@ -595,6 +595,7 @@ pub(super) fn drive_script(
CursorPayload::Spell(s) => s.texture,
CursorPayload::Action(a) => a.texture,
CursorPayload::Macro(m) => m.texture,
CursorPayload::PetAction(p) => p.texture,
});
if let (Some(texture), Some(pos)) = (texture, window.cursor_position()) {
if let Some(handle) = assets
@@ -45,13 +45,12 @@ fn harness() -> UiScript {
harness_with(&[])
}
/// The nine buttons, top to bottom — the ERA ladder (the director's call on the 0951 review;
/// GameMenuFrame.xml's header SCOPE note quotes the era source) plus the 0997 Key Bindings
/// seat carved at the middle section's head (the 1.13/1.14 menu's own entry — the window's
/// shape source — returning under 0985's seat-carving rule).
const LADDER: [&str; 9] = [
/// The eight buttons, top to bottom — the ERA ladder, its own shape again (the director's call
/// on the 0951 review; GameMenuFrame.xml's header SCOPE note quotes the era source). 0997's
/// carved Key Bindings seat left with its standalone window — decision 1008 folded key
/// bindings into the Options window, where the era menu always pointed.
const LADDER: [&str; 8] = [
"GameMenuButtonOptions",
"GameMenuButtonKeybindings",
"GameMenuButtonAddOns",
"GameMenuButtonEditMode",
"GameMenuButtonSupport",
@@ -62,10 +61,10 @@ const LADDER: [&str; 9] = [
];
/// The ladder geometry — the era layout engine's own numbers (MainMenuFrameTemplates: padding
/// 32/28/28/28, spacing 0, AddSection gap 20) with the 0997 Key Bindings rung: 200×309, each
/// button 144×21 at x=28, tops at 32/73/94/115/136/157/198/219/260 — three sections split by
/// the 20-unit gaps after Options, after Macros, and before Return to Game. First thing to
/// break if the era shape is ever "tidied".
/// 32/28/28/28, spacing 0, AddSection gap 20): 200×288, each button 144×21 at x=28, tops at
/// 32/73/94/115/136/177/198/239 — three sections split by the 20-unit gaps after Options,
/// after Macros, and before Return to Game. First thing to break if the era shape is ever
/// "tidied".
#[test]
fn the_menu_has_the_era_frame_and_button_ladder() {
let mut s = harness();
@@ -75,11 +74,11 @@ fn the_menu_has_the_era_frame_and_button_ladder() {
let (w, h) = s
.eval::<(f64, f64)>("return GameMenuFrame:GetWidth(), GameMenuFrame:GetHeight()")
.unwrap();
assert_eq!((w, h), (200.0, 309.0), "the era frame size");
assert_eq!((w, h), (200.0, 288.0), "the era frame size");
let top = s.eval::<f64>("return GameMenuFrame:GetTop()").unwrap();
let left = s.eval::<f64>("return GameMenuFrame:GetLeft()").unwrap();
const TOPS: [f64; 9] = [32.0, 73.0, 94.0, 115.0, 136.0, 157.0, 198.0, 219.0, 260.0];
const TOPS: [f64; 8] = [32.0, 73.0, 94.0, 115.0, 136.0, 177.0, 198.0, 239.0];
for (name, down) in LADDER.iter().zip(TOPS) {
let (bw, bh, btop, bleft) = s
.eval::<(f64, f64, f64, f64)>(&format!(
@@ -724,7 +723,13 @@ fn backpack() -> ContainerState {
/// real load order; the bare-menu harness elsewhere exercises the `or 1` guard implicitly.
#[test]
fn the_menu_rides_the_shared_era_window_scale() {
let mut s = harness_with(&["GameTooltip.xml", "UIDropDownMenu.xml", "OptionsFrame.xml"]);
let mut s = harness_with(&[
"GameTooltip.xml",
"UIDropDownMenu.xml",
"ScrollTemplates.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
]);
s.run("ShowUIPanel(GameMenuFrame)").unwrap();
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -1,28 +1,31 @@
//! The shipped `assets/ui/KeyBindingsFrame.xml` — the era-shaped, 1.12-skinned Key Bindings
//! window over the engine's binding table (decision 0997; the provenance block in the XML).
//! The shipped `assets/ui/KeyBindingsPage.xml` + OptionsFrame.xml's Keybindings body — the
//! Options window's Keybindings category over the engine's binding table (decision 1008,
//! superseding 0997's standalone window; the provenance block in the XML).
//!
//! What these guard: the file loads clean inside its real neighbourhood; the game menu's Key
//! Bindings button is the door in and the window's OnHide brings the menu back (both
//! references' law); the sidebar shows exactly the honest tree's non-empty categories in 1.12
//! `Bindings.xml` order; the rows read the live table (defaults byte-real from the client's own
//! `bindings-cache.wtf`); the capture flow — select a capsule → the host arm arms → the
//! canonical chord lands through `KeyBindingFrame_OnHostKey` — binds live, steals with the red
//! 1.12 message only when the victim goes bare, refuses the wheel on press+release commands,
//! and restores the old key on refusal; Unbind/Okay/Cancel/Reset and the character-specific
//! checkbox run the 1.12 set model (Save queues the host persist; Cancel/ESC reverts through
//! `LoadBindings(GetCurrentBindingSet())`; the ESC ladder rung is that exact Cancel).
//! What these guard: the module + the options window load clean together; the page is an
//! ordinary category (body-swapped, Defaults live, the Unbind button only here); the section
//! tree is the honest tree's non-empty categories in 1.12 `Bindings.xml` order, COLLAPSED by
//! default (the era's own state) and toggling like the era's expandable sections; the capture
//! flow — select a capsule → the host arm arms → the canonical chord lands through
//! `KeyBindings_OnHostKey` — binds LIVE-COMMIT (every mutation queues the host persist,
//! 1008's law; closing the window keeps everything), steals with the red 1.12 message only
//! when the victim goes bare, refuses the wheel on press+release commands and restores the
//! old key; the character-specific checkbox runs 1.12's set model with the era's
//! confirm-on-uncheck-only; search surfaces binding matches as LIVE rows under the
//! Keybindings redirect head; and the action bar's abbreviation law (`GetBindingText`
//! transcribed in UIParent.xml) reads `s-2`, the ref's own Lua.
//!
//! Labels here are the RAW tokens (`BINDING_HEADER_MOVEMENT`, `BUTTON3`): the harness loads no
//! GlobalStrings, exercising the window's `getglobal(...) or raw` fallback — the app's VM
//! GlobalStrings, exercising the page's `getglobal(...) or raw` fallback — the app's VM
//! executes the real 1.12 GlobalStrings.lua at boot, which turns them into "Movement Keys" /
//! "Middle Mouse". One test seeds a handful of the real strings to pin that path too.
use benilla_ui::script::keybind::{KeybindCommand, KeybindRequest};
use benilla_ui::script::UiScript;
use benilla_ui::script::{QuadContent, UiScript};
use crate::bindings::commands::SPECS;
/// The window's real neighbourhood, in the manifest's own order, with the registry seeded the
/// The page's real neighbourhood, in the manifest's own order, with the registry seeded the
/// way the app seeds it (`crate::bindings::seed_bindings` — registration before any show).
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
@@ -42,8 +45,11 @@ fn harness() -> UiScript {
"Fonts.xml",
"UiPanels.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"ScrollTemplates.xml",
"KeyBindingsFrame.xml",
"UIParent.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"GameMenuFrame.xml",
] {
let text = std::fs::read_to_string(
@@ -59,7 +65,7 @@ fn harness() -> UiScript {
"{file}: loader errors: {:?}",
report.errors
);
if file == "KeyBindingsFrame.xml" {
if file == "KeyBindingsPage.xml" || file == "OptionsFrame.xml" {
assert!(
report.warnings.is_empty(),
"{file}: loader warnings (dropped subtrees?): {:?}",
@@ -71,41 +77,37 @@ fn harness() -> UiScript {
s
}
fn shown(s: &mut UiScript) {
s.run("ShowUIPanel(KeyBindingFrame)").unwrap();
assert!(s.errors().is_empty(), "on show: {:?}", s.errors());
/// Open the options window on the Keybindings page.
fn on_page(s: &mut UiScript) {
s.run(r#"ShowUIPanel(OptionsFrame); OptionsFrame_SelectCategory("Keybindings")"#)
.unwrap();
assert!(s.errors().is_empty(), "on page: {:?}", s.errors());
}
const ROW: &str = "OptionsFrameContainerBodyKeybindingsRow";
#[test]
fn the_menu_button_is_the_door_in_and_the_window_hands_the_menu_back() {
let s = harness();
s.run("ShowUIPanel(GameMenuFrame)").unwrap();
s.run("GameMenuButtonKeybindings:Click()").unwrap();
assert!(
s.eval::<bool>("return KeyBindingFrame:IsVisible()")
.unwrap(),
"the window opened"
);
assert!(
!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap(),
"…and the menu went away first"
);
// Cancel closes AND reopens the menu — both references' OnHide.
s.run("KeyBindingFrameCancelButton:Click()").unwrap();
assert!(!s
.eval::<bool>("return KeyBindingFrame:IsVisible()")
fn the_page_is_an_options_category_with_the_collapsed_honest_tree() {
let mut s = harness();
on_page(&mut s);
assert!(s
.eval::<bool>("return OptionsFrameContainerBodyKeybindings:IsVisible()")
.unwrap());
assert!(
s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap(),
"closing the window brings the game menu back"
s.eval::<bool>("return OptionsFrameContainerUnbind:IsVisible()")
.unwrap(),
"Unbind Key exists on this page"
);
}
#[test]
fn the_sidebar_is_the_honest_tree_in_bindings_xml_order() {
let mut s = harness();
shown(&mut s);
// The registry's category tokens, first-appearance order — exactly 1.12's file order.
assert!(
!s.eval::<bool>("return OptionsFrameContainerUnbind:IsEnabled()")
.unwrap(),
"…disabled until a capsule is selected"
);
assert!(s
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled()")
.unwrap());
// The section tree: the registry's category tokens, first-appearance order — exactly
// 1.12's file order — every section a COLLAPSED header row (the era default).
let mut expected: Vec<&str> = Vec::new();
for spec in SPECS {
if !expected.contains(&spec.category) {
@@ -114,81 +116,92 @@ fn the_sidebar_is_the_honest_tree_in_bindings_xml_order() {
}
for (i, token) in expected.iter().enumerate() {
let text = s
.eval::<String>(&format!(
"return KeyBindingFrameCategory{}:GetText()",
i + 1
))
.eval::<String>(&format!("return {ROW}{}HeaderText:GetText()", i + 1))
.unwrap();
assert_eq!(&text, token, "category {} (raw-token fallback)", i + 1);
assert_eq!(&text, token, "section {} (raw-token fallback)", i + 1);
}
assert!(
!s.eval::<bool>(&format!(
"return KeyBindingFrameCategory{}:IsVisible()",
expected.len() + 1
))
.unwrap(),
"no spare category rows show"
!s.eval::<bool>(&format!("return {ROW}{}:IsVisible()", expected.len() + 1))
.unwrap(),
"all sections collapsed: nothing past the headers"
);
// Movement is selected by default and wears the locked-gold wash.
assert!(s
.eval::<bool>("return KeyBindingFrameCategory1Bg:IsVisible()")
.unwrap());
// Row 1 is MOVEANDSTEER with its byte-real default (raw token without GlobalStrings).
// The multibar section is real (1008): its header token sits in the tree.
assert!(expected.contains(&"BINDING_HEADER_MULTIACTIONBAR"));
// Expanding Movement puts its rows under the header, byte-real defaults on the capsules.
s.run(&format!("{ROW}1Header:Click()")).unwrap();
assert_eq!(
s.eval::<String>("return KeyBindingFrameBinding1Description:GetText()")
s.eval::<String>(&format!("return {ROW}2Description:GetText()"))
.unwrap(),
"MOVEANDSTEER"
);
assert_eq!(
s.eval::<String>("return KeyBindingFrameBinding1Key1ButtonText:GetText()")
s.eval::<String>(&format!("return {ROW}2Key1ButtonText:GetText()"))
.unwrap(),
"BUTTON3"
);
// With the real strings present (the app's GlobalStrings), the same row reads 1.12's text.
// With the real strings present (the app's GlobalStrings), the same rows read 1.12 text.
s.run(
r#"BINDING_HEADER_MOVEMENT = "Movement Keys"
BINDING_NAME_MOVEANDSTEER = "Move and Steer"
KEY_BUTTON3 = "Middle Mouse""#,
KEY_BUTTON3 = "Middle Mouse"
KeyBindingsPage_Update()"#,
)
.unwrap();
s.run("KeyBindings_LoadCategories(); KeyBindingFrame_Update()")
.unwrap();
assert_eq!(
s.eval::<String>("return KeyBindingFrameCategory1:GetText()")
s.eval::<String>(&format!("return {ROW}1HeaderText:GetText()"))
.unwrap(),
"Movement Keys"
);
assert_eq!(
s.eval::<String>("return KeyBindingFrameBinding1Description:GetText()")
s.eval::<String>(&format!("return {ROW}2Description:GetText()"))
.unwrap(),
"Move and Steer"
);
assert_eq!(
s.eval::<String>("return KeyBindingFrameBinding1Key1ButtonText:GetText()")
s.eval::<String>(&format!("return {ROW}2Key1ButtonText:GetText()"))
.unwrap(),
"Middle Mouse"
);
// Collapse again: the next header slides back up under the first.
s.run(&format!("{ROW}1Header:Click()")).unwrap();
assert_eq!(
s.eval::<String>(&format!("return {ROW}2HeaderText:GetText()"))
.unwrap(),
"BINDING_HEADER_CHAT"
);
// Leaving the page hides its body and the Unbind button.
s.run(r#"OptionsFrame_SelectCategory("Controls")"#).unwrap();
assert!(!s
.eval::<bool>("return OptionsFrameContainerBodyKeybindings:IsVisible()")
.unwrap());
assert!(!s
.eval::<bool>("return OptionsFrameContainerUnbind:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn the_capture_flow_binds_steals_and_refuses_like_112() {
let mut s = harness();
shown(&mut s);
// Row 2 is MOVEFORWARD (W, UP). Selecting its Key 1 capsule arms the host seam.
on_page(&mut s);
s.run(&format!("{ROW}1Header:Click()")).unwrap(); // expand Movement
s.take_keybind_requests(); // drop any noise before the flow under test
// Row 3 is MOVEFORWARD (W, UP). Selecting its Key 1 capsule arms the host seam.
assert!(!s.bind_capture_armed());
s.run("KeyBindingFrameBinding2Key1Button:Click()").unwrap();
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
assert!(
s.bind_capture_armed(),
"a selected capsule arms the capture"
);
assert!(
s.eval::<bool>("return KeyBindingFrameUnbindButton:IsEnabled() ~= nil and KeyBindingFrameUnbindButton:IsEnabled() ~= 0")
s.eval::<bool>("return OptionsFrameContainerUnbind:IsEnabled()")
.unwrap(),
"Unbind arms with the selection"
);
// The host hands back a canonical chord: F binds into slot 1, W's old seat; UP survives
// in slot 2; the capture disarms; the table is LIVE (GetBindingAction).
s.run(r#"KeyBindingFrame_OnHostKey("F")"#).unwrap();
// in slot 2; the capture disarms; the table is LIVE and the bind COMMITTED (Save queued —
// 1008's live-commit law, where 0997 waited for Okay).
s.run(r#"KeyBindings_OnHostKey("F")"#).unwrap();
assert!(!s.bind_capture_armed(), "a completed bind disarms");
assert!(s
.eval::<bool>(
@@ -200,154 +213,353 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
"",
"the old key is free"
);
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
assert_eq!(
s.eval::<String>("return KeyBindingFrameOutputText:GetText()")
s.eval::<String>("return OptionsFrameContainerBodyKeybindingsOutput:GetText()")
.unwrap(),
"Key Bound Successfully"
);
// Stealing the LAST key of another command names the victim in red (1.12's
// KEY_UNBOUND_ERROR). T is ATTACKTARGET's only key.
s.run("KeyBindingFrameBinding2Key2Button:Click()").unwrap();
s.run(r#"KeyBindingFrame_OnHostKey("T")"#).unwrap();
s.run(&format!("{ROW}3Key2Button:Click()")).unwrap();
s.run(r#"KeyBindings_OnHostKey("T")"#).unwrap();
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("T")"#).unwrap(),
"MOVEFORWARD"
);
assert!(
s.eval::<String>("return KeyBindingFrameOutputText:GetText()")
s.eval::<String>("return OptionsFrameContainerBodyKeybindingsOutput:GetText()")
.unwrap()
.contains("ATTACKTARGET"),
"the newly-bare victim is named"
);
// The wheel refusal: MOVEFORWARD has press+release state — SetBinding refuses the wheel
// and the slot's old key is restored (1.12's KeyBindingFrame_SetBinding).
s.run("KeyBindingFrameBinding2Key1Button:Click()").unwrap();
s.run(r#"KeyBindingFrame_OnHostKey("MOUSEWHEELUP")"#)
.unwrap();
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
s.run(r#"KeyBindings_OnHostKey("MOUSEWHEELUP")"#).unwrap();
assert!(
s.eval::<bool>(r#"local k1 = GetBindingKey("MOVEFORWARD"); return k1 == "F""#)
.unwrap(),
"the refused slot restored its key"
);
assert_eq!(
s.eval::<String>("return KeyBindingFrameOutputText:GetText()")
s.eval::<String>("return OptionsFrameContainerBodyKeybindingsOutput:GetText()")
.unwrap(),
"Can't bind mousewheel to actions with up and down states"
);
// A right-click on the armed capsule deselects without binding.
s.run("KeyBindingFrameBinding2Key1Button:Click()").unwrap();
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
assert!(s.bind_capture_armed());
s.run(r#"KeyBindingFrameBinding2Key1Button:Click("RightButton")"#)
s.run(&format!(r#"{ROW}3Key1Button:Click("RightButton")"#))
.unwrap();
assert!(!s.bind_capture_armed(), "right-click deselects");
// Hiding the window disarms a straggling capture (the OnHide hook — a locked-out client
// otherwise: the armed seam swallows all input with no window on screen).
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
assert!(s.bind_capture_armed());
s.run("HideUIPanel(OptionsFrame)").unwrap();
assert!(!s.bind_capture_armed(), "OnHide disarms");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn unbind_okay_cancel_and_reset_run_the_112_set_model() {
fn unbind_reset_and_the_live_commit_replace_okay_cancel() {
let mut s = harness();
shown(&mut s);
// Unbind: select JUMP's Key 1 (row 8), unbind — both defaults gone from slot 1, slot 2
// survives (the 1.12 slot dance).
on_page(&mut s);
s.run(&format!("{ROW}1Header:Click()")).unwrap(); // expand Movement
s.take_keybind_requests();
// JUMP is Movement's 8th command → row 9 under the header. Unbind its Key 1: SPACE goes,
// NUMPAD0 slides into slot 1 (the 1.12 slot dance), and the change commits at once.
assert_eq!(
s.eval::<String>("return KeyBindingFrameBinding8Description:GetText()")
s.eval::<String>(&format!("return {ROW}9Description:GetText()"))
.unwrap(),
"JUMP"
);
s.run("KeyBindingFrameBinding8Key1Button:Click()").unwrap();
s.run("KeyBindingFrameUnbindButton:Click()").unwrap();
s.run(&format!("{ROW}9Key1Button:Click()")).unwrap();
s.run("OptionsFrameContainerUnbind:Click()").unwrap();
assert!(s
.eval::<bool>(
r#"local k1, k2 = GetBindingKey("JUMP"); return k1 == "NUMPAD0" and k2 == nil"#
)
.unwrap());
// Okay persists the account set: the host request queue carries Save(1), window closes.
s.run("KeyBindingFrameOkayButton:Click()").unwrap();
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
assert!(!s
.eval::<bool>("return KeyBindingFrame:IsVisible()")
.unwrap());
// Reopen; rebind something and CANCEL — the live table reverts to the saved set.
shown(&mut s);
s.run("KeyBindingFrameBinding8Key1Button:Click()").unwrap();
s.run(r#"KeyBindingFrame_OnHostKey("G")"#).unwrap();
// LIVE-COMMIT is the law now (1008): bind G, close the window — the bind KEEPS (0997's
// Cancel/ESC revert died with the standalone window).
s.run(&format!("{ROW}9Key1Button:Click()")).unwrap();
s.run(r#"KeyBindings_OnHostKey("G")"#).unwrap();
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
s.run("OptionsFrameCloseButton:Click()").unwrap();
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("G")"#).unwrap(),
"JUMP"
"JUMP",
"closing keeps the live-committed bind"
);
s.run("KeyBindingFrameCancelButton:Click()").unwrap();
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("G")"#).unwrap(),
"",
"Cancel reverted the unsaved bind"
);
assert!(
s.take_keybind_requests().is_empty(),
"Cancel persists nothing"
);
// Reset To Default: era confirm popup, then LoadBindings(0) — JUMP's defaults return
// (Okay earlier saved it as NUMPAD0-only, so this really is the DEFAULT set, not the
// saved one).
shown(&mut s);
s.run("KeyBindingFrameDefaultButton:Click()").unwrap();
// Reset To Default: the page's Defaults button behind the era confirm — JUMP's real
// defaults return and the reset itself commits.
on_page(&mut s);
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
s.run("StaticPopup1Button1:Click()").unwrap();
assert!(s
.eval::<bool>(
r#"local k1, k2 = GetBindingKey("JUMP"); return k1 == "SPACE" and k2 == "NUMPAD0""#
)
.unwrap());
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn the_esc_ladder_rung_cancels_and_the_character_checkbox_switches_sets() {
fn the_esc_ladder_closes_the_window_and_the_checkbox_switches_sets() {
let mut s = harness();
shown(&mut s);
// An unsaved bind, then the ladder (the ESC binding's own body): reverted + closed +
// menu back.
s.run("KeyBindingFrameBinding8Key1Button:Click()").unwrap();
s.run(r#"KeyBindingFrame_OnHostKey("G")"#).unwrap();
on_page(&mut s);
// The ladder (the ESC binding's own body): the options rung hides the window — since 1008
// that IS the whole gesture for keybinds too (live-commit; nothing to revert).
s.run("ToggleGameMenu()").unwrap();
assert!(!s
.eval::<bool>("return KeyBindingFrame:IsVisible()")
.unwrap());
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("G")"#).unwrap(),
"",
"the ladder's rung is the Cancel gesture"
);
assert!(s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap());
s.run("HideUIPanel(GameMenuFrame)").unwrap();
assert!(!s.eval::<bool>("return OptionsFrame:IsVisible()").unwrap());
// The character-specific checkbox: no unsaved changes → straight profile switch; the
// title takes 1.12's per-character form; Okay saves set 2.
shown(&mut s);
s.run("KeyBindingFrameCharacterButton:Click()").unwrap();
// The character-specific checkbox (1.12's set model, era confirm-on-uncheck-only law):
// CHECK switches to the character set and saves it into existence at once.
on_page(&mut s);
s.take_keybind_requests();
s.run("OptionsFrameContainerBodyKeybindingsCharacterRowCheck:Click()")
.unwrap();
assert_eq!(s.current_binding_set(), 2);
assert!(s
.eval::<String>("return KeyBindingFrameTitleText:GetText()")
.unwrap()
.starts_with("Key Bindings for"));
s.run("KeyBindingFrameOkayButton:Click()").unwrap();
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(2)]);
assert!(s.character_bindings_exist());
// Back to general: Okay from the character set asks the 1.12 confirm, whose accept
// saves set 1 and drops the character set.
shown(&mut s);
s.run("KeyBindingFrameCharacterButton:Click()").unwrap();
assert_eq!(s.current_binding_set(), 1);
s.run("KeyBindingFrameOkayButton:Click()").unwrap();
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(2)]);
// UNCHECK is destructive: the box springs back and the 1.12 confirm decides. Cancel
// first — still on the character set.
s.run("OptionsFrameContainerBodyKeybindingsCharacterRowCheck:Click()")
.unwrap();
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"leaving the character set confirms the permanent delete"
);
assert!(
s.eval::<bool>(
"return OptionsFrameContainerBodyKeybindingsCharacterRowCheck:GetChecked() ~= nil"
)
.unwrap(),
"the box springs back until the popup decides"
);
s.run("StaticPopup1Button2:Click()").unwrap();
assert_eq!(s.current_binding_set(), 2);
assert!(s.character_bindings_exist());
// Accept: back to the account set, the character set dropped (load-then-save order — the
// account file must not inherit the character binds).
s.run("OptionsFrameContainerBodyKeybindingsCharacterRowCheck:Click()")
.unwrap();
s.run("StaticPopup1Button1:Click()").unwrap();
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
assert_eq!(s.current_binding_set(), 1);
assert!(
!s.character_bindings_exist(),
"the confirmed delete dropped set 2"
);
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn search_surfaces_bindings_as_live_rows_under_the_redirect_head() {
let mut s = harness();
s.run(r#"BINDING_NAME_JUMP = "Jump""#).unwrap();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.take_keybind_requests();
// A query that matches one binding and no CVar row: the Keybindings group head shows,
// with the match painted LIVE on the search pool (the era reflows its real rows the
// same way).
s.run(r#"OptionsFrameSearchBox:SetText("jump")"#).unwrap();
assert!(s
.eval::<bool>("return OptionsFrameContainerBodySearchHeadKeybindings:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return OptionsFrameContainerBodyKeybindSearch1:IsVisible()")
.unwrap());
assert_eq!(
s.eval::<String>("return OptionsFrameContainerBodyKeybindSearch1Description:GetText()")
.unwrap(),
"Jump"
);
assert!(!s
.eval::<bool>("return KeyBindingFrame:IsVisible()")
.eval::<bool>("return OptionsFrameContainerBodyKeybindSearch2:IsVisible()")
.unwrap());
// The result row is LIVE: its capsule arms, the bind lands and commits, the row relabels.
s.run("OptionsFrameContainerBodyKeybindSearch1Key1Button:Click()")
.unwrap();
assert!(s.bind_capture_armed());
s.run(r#"KeyBindings_OnHostKey("H")"#).unwrap();
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("H")"#).unwrap(),
"JUMP"
);
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
assert_eq!(
s.eval::<String>("return OptionsFrameContainerBodyKeybindSearch1Key1ButtonText:GetText()")
.unwrap(),
"H"
);
// The head is the era redirect: clicking it ends the search on the Keybindings page.
s.run("OptionsFrameContainerBodySearchHeadKeybindings:Click()")
.unwrap();
assert_eq!(
s.eval::<String>("return OptionsFrame.selectedCategory")
.unwrap(),
"Keybindings"
);
assert!(s
.eval::<bool>("return OptionsFrameContainerBodyKeybindings:IsVisible()")
.unwrap());
assert!(!s
.eval::<bool>("return OptionsFrameContainerBodyKeybindSearch1:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn the_action_bar_abbreviation_is_the_refs_own_getbindingtext() {
let s = harness();
// ref UIParent.lua:1819 transcribed (UIParent.xml): one modifier abbreviates…
assert_eq!(
s.eval::<String>(r#"return GetBindingText("SHIFT-2", "KEY_", 1)"#)
.unwrap(),
"s-2",
"the director's SHIF… truncation reads s-2 now"
);
assert_eq!(
s.eval::<String>(r#"return GetBindingText("ALT-Z", "KEY_", 1)"#)
.unwrap(),
"a-Z"
);
assert_eq!(
s.eval::<String>(r#"return GetBindingText("W", "KEY_", 1)"#)
.unwrap(),
"W"
);
// …two or more collapse to the ref's dot — including the CTRL-- oddity, whose second
// dash the ref's dash-counting loop counts as a second modifier (their quirk, pinned).
assert_eq!(
s.eval::<String>(r#"return GetBindingText("CTRL-SHIFT-2", "KEY_", 1)"#)
.unwrap(),
"·"
);
assert_eq!(
s.eval::<String>(r#"return GetBindingText("CTRL--", "KEY_", 1)"#)
.unwrap(),
"·"
);
// The full form keeps the prefixes and reads the KEY_* global when it exists.
assert_eq!(
s.eval::<String>(r#"return GetBindingText("SHIFT-2", "KEY_")"#)
.unwrap(),
"SHIFT-2"
);
s.run(r#"KEY_SPACE = "Spacebar""#).unwrap();
assert_eq!(
s.eval::<String>(r#"return GetBindingText("SPACE", "KEY_")"#)
.unwrap(),
"Spacebar"
);
assert!(s
.eval::<bool>(r#"return GetBindingText(nil) == """#)
.unwrap());
}
/// The wheel and the bar's seat (the director's scuff report, same day 1008 landed). The wheel
/// is the QuestLog/Trainer lesson relearned: a spin bubbles up the PARENT chain (pointer.rs),
/// so a handler on the sibling faux frame never sees it — it lives on the page body, which is
/// mouse-enabled so a spin over a row's NAME (a plain-Frame area no child claims) is caught
/// too, not just the bubbles from capsule/header Buttons. The bar rides the gutter INSIDE the
/// body: the kit hangs it 6px right of the faux frame's edge, so the frame ends at the rows'
/// own -32 — anchoring it to body-right hung the bar on the window border.
#[test]
fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
let mut s = harness();
on_page(&mut s);
// Every section open: 100+ flat rows — the list overflows its 19 slots and the bar shows.
s.run(
r#"for i = 1, table.getn(KeyBindingsPage.sections) do KeyBindings_ExpandSection(i, true) end
KeyBindingsPage_Update()"#,
)
.unwrap();
const SF: &str = "OptionsFrameContainerBodyKeybindingsScrollFrame";
assert!(s
.eval::<bool>(&format!("return {SF}ScrollBar:IsVisible()"))
.unwrap());
s.resolve();
let body_right = s
.eval::<f64>("return OptionsFrameContainerBodyKeybindings:GetRight()")
.unwrap();
let rows_right = s.eval::<f64>(&format!("return {ROW}1:GetRight()")).unwrap();
let bar_left = s
.eval::<f64>(&format!("return {SF}ScrollBar:GetLeft()"))
.unwrap();
let bar_right = s
.eval::<f64>(&format!("return {SF}ScrollBar:GetRight()"))
.unwrap();
assert!(
bar_left >= rows_right,
"bar starts right of the rows ({bar_left} vs {rows_right})"
);
assert!(
bar_right <= body_right - 8.0,
"bar inset from the body edge ({bar_right} vs body {body_right})"
);
// A spin over a row NAME — the dead zone when the handler lived on the sibling.
let quads = s.extract();
let (wx, wy) = quads
.iter()
.find_map(|q| match &q.content {
QuadContent::Text { text: Some(t), .. } if t == "MOVEANDSTEER" => q
.rect
.map(|r| ((r.left + r.right) * 0.5, (r.bottom + r.top) * 0.5)),
_ => None,
})
.expect("a MOVEANDSTEER description quad");
s.mouse_wheel(wx, wy, -1.0);
assert!(s.errors().is_empty(), "wheel over a name: {:?}", s.errors());
assert_eq!(
s.eval::<f64>(&format!("return BenillaFauxScrollFrame_GetOffset({SF})"))
.unwrap(),
1.0
);
// …and over a CAPSULE (a Button: the bubble path, capsule → row → body). GetLeft-family
// reads are scale-LOCAL (the 1.12 contract; this window wears ERA_WINDOW_SCALE) — the
// pointer lives in screen px, so the aim converts through GetEffectiveScale.
let (cx, cy) = {
let k = s
.eval::<f64>(&format!("return {ROW}2Key1Button:GetEffectiveScale()"))
.unwrap();
let l = s
.eval::<f64>(&format!("return {ROW}2Key1Button:GetLeft()"))
.unwrap();
let r = s
.eval::<f64>(&format!("return {ROW}2Key1Button:GetRight()"))
.unwrap();
let t = s
.eval::<f64>(&format!("return {ROW}2Key1Button:GetTop()"))
.unwrap();
let b = s
.eval::<f64>(&format!("return {ROW}2Key1Button:GetBottom()"))
.unwrap();
((((l + r) * 0.5 * k) as f32), (((t + b) * 0.5 * k) as f32))
};
s.mouse_wheel(cx, cy, -1.0);
assert_eq!(
s.eval::<f64>(&format!("return BenillaFauxScrollFrame_GetOffset({SF})"))
.unwrap(),
2.0
);
// While a capsule is armed the wheel is a BIND, never a scroll (1.12's law) — in the app
// the host seam swallows the spin before the UI sees one; the Lua guard is the belt for
// a spin that reaches the page anyway.
s.run(&format!("{ROW}2Key1Button:Click()")).unwrap();
s.mouse_wheel(wx, wy, -1.0);
assert_eq!(
s.eval::<f64>(&format!("return BenillaFauxScrollFrame_GetOffset({SF})"))
.unwrap(),
2.0,
"armed: the wheel must not scroll"
);
s.run("KeyBindings_SetSelected(nil)").unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
+7 -7
View File
@@ -618,13 +618,13 @@ pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
// show/hide), and it must come AFTER SpellBookFrame, whose shift-click reaches
// `BenillaMacroFrame_AddMacroLine`. Before GameMenuFrame, whose Macros button opens it.
"MacroFrame.xml",
// The Key Bindings window (decision 0997): the era-shaped standalone KeyBindingFrame
// over the engine's binding table (benilla_ui::script::keybind + crate::bindings — the
// 1.12 GetBinding/SetBinding Lua API). Needs Fonts, UiPanels (panel manager + popup
// engine + the ESC ladder rung that reverts it), ScrollTemplates (the faux kit), and
// GameTooltip (the character-specific checkbox's hover); before GameMenuFrame, whose
// Key Bindings button reaches it by name at click time.
"KeyBindingsFrame.xml",
// The Keybindings page MODULE (decision 1008, superseding 0997's standalone window):
// the templates + script of the Options window's Keybindings category, over the
// engine's binding table (benilla_ui::script::keybind + crate::bindings — the 1.12
// GetBinding/SetBinding Lua API). Needs UiPanels (popup engine) and GameTooltip (the
// character-specific checkbox's hover); MUST load before OptionsFrame.xml, whose
// Keybindings body inherits these templates and calls this script at OnLoad.
"KeyBindingsPage.xml",
// The options window (the 0985 provenance split: 0950's era structure, 0978's
// 1.12-native art, 0981/0984's 1.14 chrome + working era search, 0989's directed
// cuts — bare fully-live sliders, no corner X; 0992's dropdown rows + the Nameplates
@@ -40,6 +40,8 @@ fn harness_on(mut s: UiScript) -> UiScript {
"UiPanels.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"ScrollTemplates.xml", // the Keybindings page's faux-scroll kit
"KeyBindingsPage.xml", // the Keybindings body's templates + script (1008)
"OptionsFrame.xml",
"GameMenuFrame.xml",
] {
@@ -47,6 +47,11 @@ fn declare_token_strings(s: &UiScript) {
.unwrap();
}
/// The words the drag moves — `ACT_COMMAND`/Attack and `ACT_ENABLED`/Claw as the server packs
/// them. Carried on the views because the drag is word arithmetic (decision 1010).
const ATTACK_WORD: u32 = 0x0700_0002;
const CLAW_WORD: u32 = 0xC100_0BC2;
/// A hunter's bar as the server actually sends it: Attack (a lit command token), an empty spell
/// slot, Claw (a spell with autocast running), and Defensive (a lit reaction token).
fn hunter_slots() -> Vec<PetActionView> {
@@ -57,6 +62,7 @@ fn hunter_slots() -> Vec<PetActionView> {
is_token: true,
active: true,
attack_active: true,
packed: ATTACK_WORD,
..Default::default()
};
slots[3] = PetActionView {
@@ -66,6 +72,7 @@ fn hunter_slots() -> Vec<PetActionView> {
spell_id: Some(3010),
autocast_allowed: true,
autocast_enabled: true,
packed: CLAW_WORD,
..Default::default()
};
slots[8] = PetActionView {
@@ -73,6 +80,7 @@ fn hunter_slots() -> Vec<PetActionView> {
texture: Some("PET_DEFENSIVE_TEXTURE".into()),
is_token: true,
active: true,
packed: 0x0600_0001,
..Default::default()
};
slots
@@ -121,7 +129,7 @@ fn the_shipped_pet_bar_drives_end_to_end() {
// A pet appears. The tick is what runs the bar's OnUpdate, i.e. the sparkle trail — it is an
// animation, so it exists from the first frame after the repaint, not from the repaint.
s.set_pet_actions(true, true, hunter_slots());
s.set_pet_actions(true, true, true, hunter_slots());
s.fire_event("PET_BAR_UPDATE", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.tick(0.05);
@@ -194,7 +202,7 @@ fn the_shipped_pet_bar_drives_end_to_end() {
);
// The pet goes: the bar hides again, sparkles and all.
s.set_pet_actions(false, true, Vec::new());
s.set_pet_actions(false, true, true, Vec::new());
s.fire_event("PET_BAR_UPDATE", vec![]);
s.tick(0.05);
s.resolve();
@@ -213,7 +221,7 @@ fn clicks_route_through_the_attack_toggle_fork() {
s.set_screen_size(1024.0, 768.0);
load_pet_bar(&s);
declare_token_strings(&s);
s.set_pet_actions(true, true, hunter_slots());
s.set_pet_actions(true, true, true, hunter_slots());
s.fire_event("PET_BAR_UPDATE", vec![]);
s.resolve();
@@ -236,7 +244,7 @@ fn clicks_route_through_the_attack_toggle_fork() {
let mut slots = hunter_slots();
slots[0].active = false;
slots[0].attack_active = false;
s.set_pet_actions(true, true, slots);
s.set_pet_actions(true, true, true, slots);
s.fire_event("PET_BAR_UPDATE", vec![]);
s.resolve();
click(&mut s, 87.0, 71.0, "LeftButton");
@@ -265,7 +273,7 @@ fn a_disabled_bar_greys_rather_than_hides() {
load_pet_bar(&s);
declare_token_strings(&s);
s.set_pet_actions(true, false, hunter_slots());
s.set_pet_actions(true, false, true, hunter_slots());
s.fire_event("PET_BAR_UPDATE", vec![]);
s.resolve();
let quads = s.extract();
@@ -315,7 +323,7 @@ fn pet_bar_row(with_multibar: bool) -> (usize, f32) {
load_xml(&s, "PetActionBar.xml");
declare_token_strings(&s);
s.set_pet_actions(true, true, hunter_slots());
s.set_pet_actions(true, true, true, hunter_slots());
s.fire_event("PET_BAR_UPDATE", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.tick(0.05);
@@ -360,3 +368,92 @@ fn the_pet_bar_rises_and_sheds_its_shelf_over_the_bottom_left_bar() {
"the bar must rise by the ref's 43 px (moved {risen})"
);
}
/// **The drag, through the shipped XML** (decision 1010). One verb serves both ends — the button's
/// `OnDragStart` and its `OnReceiveDrag` both call `PickupPetAction` — so what makes this a move
/// rather than two pick-ups is the binding's own fork on whether the cursor is already carrying.
///
/// Also locks the grid: while a pet action rides the cursor every slot shows, including the empty
/// ones, so there is somewhere visible to drop it. That is the whole reason `PET_BAR_SHOWGRID`
/// exists, and the reason an unnamed button's `Hide()` is conditional.
#[test]
fn dragging_a_pet_spell_between_slots_moves_it_through_the_shipped_handlers() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_pet_bar(&s);
declare_token_strings(&s);
s.set_pet_actions(true, true, true, hunter_slots());
s.fire_event("PET_BAR_UPDATE", vec![]);
s.resolve();
// Slot 5 is empty, so its button is hidden — until the grid comes up.
assert!(!s
.eval::<bool>("return BenillaPetActionButton5:IsShown()")
.unwrap());
s.run("BenillaPetActionButton_OnDragStart(BenillaPetActionButton4)")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.take_pet_set_actions(),
vec![vec![(3, CLAW_WORD & 0xFFFF_0000)]],
"the pickup blanks the slot it came from and tells the server"
);
s.tick(0.01);
s.resolve();
assert_eq!(
s.eval::<i64>("return BenillaPetActionBarFrame.showgrid")
.unwrap(),
1
);
assert!(
s.eval::<bool>("return BenillaPetActionButton5:IsShown()")
.unwrap(),
"the grid reveals the empty slots to drop onto"
);
s.run("BenillaPetActionButton_OnReceiveDrag(BenillaPetActionButton5)")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.take_pet_set_actions(),
vec![vec![(4, CLAW_WORD)]],
"and the drop writes the word verbatim into slot 5 (0-based 4)"
);
s.tick(0.01);
assert_eq!(
s.eval::<i64>("return BenillaPetActionBarFrame.showgrid")
.unwrap(),
0,
"the grid goes down with the payload"
);
assert!(s.eval::<bool>("return GetCursorInfo() == nil").unwrap());
}
/// A shift-click is the same pick-up, whichever mouse button carried it — the reference's own fork
/// puts shift above the left/right split, so it never toggles autocast by accident.
#[test]
fn shift_clicking_a_pet_button_picks_it_up_rather_than_casting() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_pet_bar(&s);
declare_token_strings(&s);
s.set_pet_actions(true, true, true, hunter_slots());
s.fire_event("PET_BAR_UPDATE", vec![]);
s.resolve();
s.run(
"IsShiftKeyDown = function() return 1 end \
BenillaPetActionButton_OnClick(BenillaPetActionButton4, 'RightButton')",
)
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.take_pet_actions().is_empty() && s.take_pet_autocast_toggles().is_empty(),
"shift outranks both click arms — no cast, no autocast flip"
);
assert_eq!(
s.take_pet_set_actions(),
vec![vec![(3, CLAW_WORD & 0xFFFF_0000)]]
);
}
@@ -310,6 +310,136 @@ fn left_clicking_the_pet_frame_targets_it() {
assert!(s.take_target_requests().is_empty());
}
/// The GlobalStrings the happiness tooltip resolves by key. Loaded from the MPQ in production; the
/// test declares the ones it reads, the pet-bar tests' convention. (That the real keys exist in the
/// shipped file is asserted separately, by name, in `ui_pet_stats`.)
fn declare_happiness_strings(s: &UiScript) {
s.run(
"PET_HAPPINESS1 = 'Unhappy' PET_HAPPINESS2 = 'Content' PET_HAPPINESS3 = 'Happy' \
PET_DAMAGE_PERCENTAGE = 'Damage: %d%%' \
LOSING_LOYALTY = 'Losing Loyalty' GAINING_LOYALTY = 'Gaining Loyalty'",
)
.unwrap();
}
fn stats(
hunter: bool,
happiness: Option<u32>,
damage: f32,
rate: f32,
) -> benilla_ui::script::PetStats {
benilla_ui::script::PetStats {
hunter_pet: hunter,
happiness,
damage_percentage: damage,
loyalty_rate: rate,
loyalty: Some("(Loyalty Level 6) Best Friend".into()),
training_points: (170, 130),
experience: (4200, 8000),
}
}
/// The happiness icon end to end through the real XML (decision 1005): shown for a hunter pet,
/// re-cut per bucket, hidden for anything that is not one.
///
/// The tooltip key is the assertion rather than the texcoords because it is what proves **which
/// branch ran** — `PET_HAPPINESS2` can only come from the `happiness == 2` arm.
#[test]
fn the_happiness_icon_shows_per_bucket_and_hides_for_a_non_hunter_pet() {
let mut s = load_pet_frame();
declare_happiness_strings(&s);
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 2)));
for (bucket, tip, rate, loyalty_line) in [
(3u32, "Happy", 20.0f32, Some("Gaining Loyalty")),
(2, "Content", 0.0, None),
(1, "Unhappy", -10.0, Some("Losing Loyalty")),
] {
s.set_pet_stats(true, stats(true, Some(bucket), 125.0, rate));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert!(
s.eval::<bool>("return BenillaPetFrameHappiness:IsVisible()")
.unwrap(),
"bucket {bucket} must show the icon"
);
assert_eq!(
s.eval::<String>("return BenillaPetFrameHappiness.tooltip")
.unwrap(),
tip,
"bucket {bucket} took the wrong texcoord branch"
);
// The loyalty line is chosen by the SIGN of the rate — and is absent at exactly zero.
let line = s
.eval::<Option<String>>("return BenillaPetFrameHappiness.tooltipLoyalty")
.unwrap();
assert_eq!(
line.as_deref(),
loyalty_line,
"bucket {bucket} loyalty line"
);
}
// A warlock's imp: `HasPetUI`'s second return is nil, so the icon hides however happy the
// first return looks.
s.set_pet_stats(true, stats(false, Some(3), 125.0, 20.0));
s.fire_event("UNIT_HAPPINESS", vec![]);
assert!(!s
.eval::<bool>("return BenillaPetFrameHappiness:IsVisible()")
.unwrap());
}
/// **Bucket 0 keeps the icon up.** The reference hides on `not happiness`, and `0` is truthy in
/// Lua — so a client that folded bucket 0 into nil would hide a frame the reference shows. This is
/// the trap wow-re calls out by name, tested where it would actually bite: in the frame.
#[test]
fn happiness_bucket_zero_keeps_the_icon_showing() {
let mut s = load_pet_frame();
declare_happiness_strings(&s);
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 2)));
s.set_pet_stats(true, stats(true, Some(0), 100.0, 0.0));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert!(
s.eval::<bool>("return BenillaPetFrameHappiness:IsVisible()")
.unwrap(),
"bucket 0 is a number, not the hide case"
);
// …whereas a genuine nil — the gate failure — does hide it.
s.set_pet_stats(true, stats(true, None, 100.0, 0.0));
s.fire_event("UNIT_HAPPINESS", vec![]);
assert!(!s
.eval::<bool>("return BenillaPetFrameHappiness:IsVisible()")
.unwrap());
}
/// `UNIT_HAPPINESS` repaints the icon **alone** — the reference's own arm returns before the
/// frame-wide update, so a happiness tick must not be a full repaint.
#[test]
fn unit_happiness_repaints_only_the_icon() {
let mut s = load_pet_frame();
declare_happiness_strings(&s);
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 2)));
s.set_pet_stats(true, stats(true, Some(1), 75.0, -10.0));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert_eq!(
s.eval::<String>("return BenillaPetFrameHappiness.tooltip")
.unwrap(),
"Unhappy"
);
// Feed it up a bucket and fire ONLY the happiness event.
s.set_pet_stats(true, stats(true, Some(3), 125.0, 20.0));
s.fire_event("UNIT_HAPPINESS", vec![]);
assert_eq!(
s.eval::<String>("return BenillaPetFrameHappiness.tooltip")
.unwrap(),
"Happy",
"UNIT_HAPPINESS must re-cut the icon on its own"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The layering law (decision 0884), the pet frame's copy of the player/target/party tests: the
/// frame art must draw OVER the bar fills, which only the frame LEVEL can hold — the draw layer is
/// bucket-wide. A "simplification" back to declaration order puts the bars on top as pasted slabs.
@@ -398,6 +398,10 @@ pub fn groundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
// (any height — the shape test is the renderer's own, the ceiling is lifted), the population
// that decides where the decal lane's hover ceiling (`GROUND_HOVER_MAX`) can sit.
let mut hovers: Vec<(f32, String)> = Vec::new();
// The TINT census: ground quads whose whole colour is a CONSTANT M2Color
// (`GroundQuad::tint`) — the population a decal consumer draws white unless it carries the
// vertex-colour bake across (the Flare's two washes on the neutral `GENERICGLOW*` radials).
let (mut tinted_total, mut tinted_models) = (0u32, 0u32);
for name in names {
let Ok(bytes) = chain.read_file(&name) else {
continue;
@@ -411,6 +415,7 @@ pub fn groundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
let (mut flat_count, mut quad_count, mut other_count) = (0u32, 0u32, 0u32);
let mut blend_modes: Vec<String> = Vec::new();
let mut model_hovers: Vec<f32> = Vec::new();
let mut tints: Vec<[f32; 3]> = Vec::new();
for s in &subs {
if s.positions.is_empty() {
continue;
@@ -432,8 +437,11 @@ pub fn groundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
// The RENDERER's own detector, so this report is exactly what the ground-fx
// decal lane will do with each batch — the instrument can't drift from the
// mechanism it measures.
if s.ground_quad().is_some() {
if let Some(q) = s.ground_quad() {
quad_count += 1;
if q.tint != [1.0; 3] && !tints.contains(&q.tint) {
tints.push(q.tint);
}
} else {
other_count += 1;
}
@@ -449,6 +457,8 @@ pub fn groundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
} else {
mixed += 1;
}
tinted_total += tints.len() as u32;
tinted_models += u32::from(!tints.is_empty());
blend_modes.sort();
let hover_note = if model_hovers.is_empty() {
String::new()
@@ -462,14 +472,29 @@ pub fn groundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
.join(" ")
)
};
let tint_note = if tints.is_empty() {
String::new()
} else {
format!(
" TINT[{}]",
tints
.iter()
.map(|t| format!("{:.2},{:.2},{:.2}", t[0], t[1], t[2]))
.collect::<Vec<_>>()
.join(" ")
)
};
println!(
"{total_batches:>3} batches {flat_count:>3} flat ({quad_count:>2} quad-1bone, {other_count:>2} other-flat){hover_note} blend[{}] {name}",
"{total_batches:>3} batches {flat_count:>3} flat ({quad_count:>2} quad-1bone, {other_count:>2} other-flat){hover_note}{tint_note} blend[{}] {name}",
blend_modes.join(" ")
);
}
eprintln!(
"{scanned} models scanned, {hits} with flat batches ({all_flat} all-flat, {mixed} mixed); flat batches: {quad_total} QUAD-1BONE, {other_total} OTHER-FLAT"
);
eprintln!(
"static M2Color TINT on ground quads: {tinted_models} models carry {tinted_total} distinct non-white constants — the colour a decal lane loses unless it carries the vertex bake"
);
if !hovers.is_empty() {
hovers.sort_by(|a, b| a.0.total_cmp(&b.0));
eprintln!(
@@ -12,22 +12,65 @@ use benilla_formats::Chain;
use crate::model_key;
/// Sweep every `.m2` in the chain and list the models carrying RIBBON emitters.
/// Sweep every `.m2` in the chain and list the models carrying RIBBON emitters, with each
/// model's `+0xc0` **enable-gate** census beside the count — the population instrument for
/// "which trails are state-dependent, and where does the gate actually key".
///
/// A `GATED` line names the sequences a trail is dark in; `MID-SEQ` marks the models whose gate
/// flips *inside* a band rather than only at its start, which a band-start-only reader cannot
/// express (decision 1011 — `G_FrostTrap`'s streamers light 200 ms into the trigger and nowhere
/// else, so a band-start read says "never" and an ungated consumer says "always").
pub fn ribbonscan(chain: &mut Chain) -> Result<()> {
let names = super::m2_names(chain, None)?;
let (mut scanned, mut hits) = (0u32, 0u32);
let (mut scanned, mut hits, mut gated, mut mid_seq) = (0u32, 0u32, 0u32, 0u32);
for name in names {
let Ok(bytes) = chain.read_file(&name) else {
continue;
};
scanned += 1;
let n = benilla_formats::m2_ribbon_emitter_count(&bytes);
if n > 0 {
hits += 1;
println!("{n:>2} {name}");
if n == 0 {
continue;
}
hits += 1;
let defs = benilla_formats::parse_m2_ribbon_emitters(&bytes).unwrap_or_default();
// Per ribbon: the sequences it can be dark in, and whether the gate keys mid-band.
let mut notes: Vec<String> = Vec::new();
let (mut any_gate, mut any_mid) = (false, false);
for (i, d) in defs.iter().enumerate() {
let Some(vis) = &d.visible else { continue };
any_gate = true;
let mut per: Vec<String> = vis
.per_anim()
.map(|(anim, keys)| {
any_mid |= keys.len() > 1;
let states: Vec<String> = keys
.iter()
.map(|&(t, on)| format!("{t:.3}s{}", if on { "+" } else { "-" }))
.collect();
format!("{anim}:{}", states.join(","))
})
.collect();
per.sort();
notes.push(format!(" r{i} {}", per.join(" ")));
}
gated += u32::from(any_gate);
mid_seq += u32::from(any_mid);
let tag = match (any_gate, any_mid) {
(true, true) => " [GATED MID-SEQ]",
(true, false) => " [GATED]",
_ => "",
};
println!("{n:>2} {name}{tag}");
for line in notes {
println!("{line}");
}
}
eprintln!("{scanned} models scanned, {hits} with ribbons");
eprintln!(
"{scanned} models scanned, {hits} with ribbons — {gated} carry an enable gate, \
{mid_seq} of those key it MID-SEQUENCE (a band-start-only read misses their whole \
ON window)"
);
Ok(())
}
+82
View File
@@ -116,10 +116,27 @@ struct ModelRow {
scale: f32,
/// `BloodID` — see [`CreatureModel::blood`]. Reads signed: `−1` marks a bloodless model.
blood: i32,
/// `FootprintTextureID` (field 6) — the `FootprintTextures.dbc` key. Reads signed: `−1`
/// (133 of 430 shipped rows) marks a model that leaves no prints.
footprint_texture: i32,
/// `FootprintTextureLength`/`Width` (fields 7/8), authored in **inches** — the client caches
/// them ×(1/36) into yards (byte-verified at `0x607a00`, wow-re mount-composition.md).
footprint_length: f32,
footprint_width: f32,
/// `collisionHeight` (field 15), raw model units — see [`CreatureCatalog::collision_height`].
collision_height: f32,
}
/// A display's footprint-decal parameters (see [`CreatureCatalog::footprint`]): the
/// `FootprintTextures.dbc` key + the print rectangle in **yards** (length along the facing,
/// width across), pre-scale.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FootprintParams {
pub texture_id: u32,
pub length: f32,
pub width: f32,
}
/// Display/model tables loaded from the DBCs, resolving `displayId` → [`CreatureModel`].
///
/// `Default` is the **empty** catalog — the same thing "the DBC failed to load" already means to
@@ -170,6 +187,24 @@ impl CreatureCatalog {
Some(self.models.get(&row.model_id)?.collision_height)
}
/// A display's **footprint decal** parameters — `CreatureModelData` fields 6..=8 through the
/// display→model chain, sizes converted to **yards** (the client's own ×(1/36) inches→yards
/// cache, byte-verified at `0x607a00` for the mounted getter `0x607920`). `None` when either
/// lookup misses, the model authors `FootprintTextureID = −1` (no prints — 133 of 430 shipped
/// rows), or the print rectangle is degenerate (40 rows carry an id over a 0×0 size). World
/// yards = these × the unit's render scale — the caller multiplies, like `collision_height`.
pub fn footprint(&self, display_id: u32) -> Option<FootprintParams> {
let row = self.display.get(&display_id)?;
let model = self.models.get(&row.model_id)?;
let texture_id = u32::try_from(model.footprint_texture).ok()?;
let (length, width) = (model.footprint_length / 36.0, model.footprint_width / 36.0);
(length > 0.0 && width > 0.0).then_some(FootprintParams {
texture_id,
length,
width,
})
}
/// Resolve an NPC display id to its model, or `None` if either DBC lookup misses.
pub fn model(&self, display_id: u32) -> Option<CreatureModel> {
let row = self.display.get(&display_id)?;
@@ -301,6 +336,10 @@ pub fn load_creature_catalog(chain: &mut Chain) -> Result<CreatureCatalog> {
scale: f32_at(r, 4).unwrap_or(1.0),
// BloodID (field 5) reads signed: −1 marks a bloodless model in the real data.
blood: u32_at(r, 5).map_or(0, |v| v as i32),
// FootprintTextureID reads signed too: −1 = no prints (see ModelRow docs).
footprint_texture: u32_at(r, 6).map_or(-1, |v| v as i32),
footprint_length: f32_at(r, 7).unwrap_or(0.0),
footprint_width: f32_at(r, 8).unwrap_or(0.0),
collision_height: f32_at(r, 15).unwrap_or(0.0),
},
);
@@ -394,6 +433,49 @@ mod tests {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data")
}
/// The footprint accessor on the **real** build-5875 DBCs: a display wearing the HumanMale
/// body resolves the Base boot print (`FootprintTextures` id 1) at the authored 12×10 inches
/// → 1/3 × 5/18 yards (the client's ×1/36 cache conversion, byte-verified at `0x607a00`);
/// a display whose model authors `FootprintTextureID = −1` resolves `None`. Guards the
/// schema columns (a shifted field would misread every print) and the inches→yards space.
#[test]
fn footprints_resolve_on_real_data() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_creature_catalog(&mut chain).expect("load creature catalog");
let human = cat
.display
.iter()
.find(|(_, r)| {
cat.models.get(&r.model_id).is_some_and(|m| {
m.path
.eq_ignore_ascii_case(r"Character\Human\Male\HumanMale.mdx")
})
})
.map(|(&d, _)| d)
.expect("some display wears the HumanMale body");
let p = cat.footprint(human).expect("HumanMale leaves prints");
assert_eq!(p.texture_id, 1, "the Base boot print");
assert!((p.length - 12.0 / 36.0).abs() < 1e-6, "length {}", p.length);
assert!((p.width - 10.0 / 36.0).abs() < 1e-6, "width {}", p.width);
// A −1 model (133 shipped rows) yields no print params through any display over it.
let printless = cat
.display
.iter()
.find(|(_, r)| {
cat.models
.get(&r.model_id)
.is_some_and(|m| m.footprint_texture == -1)
})
.map(|(&d, _)| d)
.expect("some display over a printless model");
assert_eq!(cat.footprint(printless), None);
}
/// End-to-end on the **real** build-5875 DBCs: the `ExtendedDisplayInfoID` chain resolves
/// character-model NPCs (guards/townsfolk) to a CreatureDisplayInfoExtra appearance whose body is a
/// `Character\` M2 skinned by a pre-baked atlas that actually ships under `Textures\BakedNpcTextures\`.
+92 -1
View File
@@ -29,7 +29,7 @@ use crate::Chain;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{parse, u32_at};
use crate::dbc::{parse, str_at, u32_at};
/// The joined footstep tables, resolvable from `(footstep class, ground effect id)`.
pub struct FootstepCatalog {
@@ -37,6 +37,9 @@ pub struct FootstepCatalog {
effect_terrain: HashMap<u32, u32>,
/// `TerrainType` id → its `SoundID` class (the lookup's `TerrainSoundID` axis).
terrain_sound: HashMap<u32, u32>,
/// `TerrainType` id → its `Flags` word. In the shipped 5875 data bit 0 is set on exactly
/// Snow (3) and Sand (7) — the leaves-footprints surfaces (the reporter-visible pair).
terrain_flags: HashMap<u32, u32>,
/// `(CreatureFootstepID, TerrainSoundID)` → `(dry kit, splash kit)`.
lookup: HashMap<(u32, u32), (u32, u32)>,
}
@@ -51,6 +54,17 @@ impl FootstepCatalog {
self.lookup.get(&(footstep_class, sound_class)).copied()
}
/// Does the ground under this effect layer take footprint decals? `TerrainType.Flags` bit 0
/// through the same effect→terrain chain the sounds ride (INTERIM reading, decision 1006:
/// bit 0 fits the shipped data — set on exactly Snow and Sand — pending the wow-re byte
/// verdict on the client's own gate). No/unknown effect layer = no prints.
pub fn leaves_footprints(&self, effect_id: Option<u32>) -> bool {
effect_id
.and_then(|e| self.effect_terrain.get(&e))
.and_then(|t| self.terrain_flags.get(t))
.is_some_and(|flags| flags & 1 != 0)
}
pub fn len(&self) -> usize {
self.lookup.len()
}
@@ -95,10 +109,14 @@ pub fn load_footstep_catalog(chain: &mut Chain) -> Result<FootstepCatalog> {
.context("reading TerrainType.dbc")?;
let rs = parse(&bytes, n_u32_schema("TerrainType", 6, &[1]), "TerrainType")?;
let mut terrain_sound = HashMap::with_capacity(rs.records().len());
let mut terrain_flags = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
if let (Some(id), Some(class)) = (u32_at(r, 0), u32_at(r, 4)) {
terrain_sound.insert(id, class);
}
if let (Some(id), Some(flags)) = (u32_at(r, 0), u32_at(r, 5)) {
terrain_flags.insert(id, flags);
}
}
let bytes = chain
@@ -123,10 +141,33 @@ pub fn load_footstep_catalog(chain: &mut Chain) -> Result<FootstepCatalog> {
Ok(FootstepCatalog {
effect_terrain,
terrain_sound,
terrain_flags,
lookup,
})
}
/// `FootprintTextures.dbc` — id → texture path (extensionless, e.g.
/// `textures\Footsteps\BaseFootprint`), the table `CreatureModelData.FootprintTextureID`
/// indexes. Shipped 5875 data: 6 rows (1 Base, 3 Cloven, 4 Bare, 5 Claw, 6 Hoof, 7 Paw), all
/// 32×32 pure-black-RGB BLPs under a soft alpha — the print decal's ink.
pub fn load_footprint_textures(chain: &mut Chain) -> Result<HashMap<u32, String>> {
let bytes = chain
.read_file("DBFilesClient\\FootprintTextures.dbc")
.context("reading FootprintTextures.dbc")?;
let rs = parse(
&bytes,
n_u32_schema("FootprintTextures", 2, &[1]),
"FootprintTextures",
)?;
let mut map = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
if let (Some(id), Some(path)) = (u32_at(r, 0), str_at(&rs, r, 1)) {
map.insert(id, path);
}
}
Ok(map)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -189,4 +230,54 @@ mod tests {
assert_eq!(cat.resolve(7, None), None);
assert_eq!(cat.resolve(9999, None), None);
}
/// The footprint gate on the real data: `TerrainType.Flags` bit 0 is set on exactly Snow (3)
/// and Sand (7) — an effect layer over either takes prints, every other terrain (and the
/// no-layer sentinel) doesn't. And `FootprintTextures.dbc` decodes its six shipped rows to
/// the `textures\Footsteps\*` ink paths.
#[test]
fn footprint_gate_and_textures_on_real_data() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_footstep_catalog(&mut chain).expect("load footstep catalog");
let printing: std::collections::BTreeSet<u32> = cat
.terrain_flags
.iter()
.filter(|(_, &f)| f & 1 != 0)
.map(|(&t, _)| t)
.collect();
assert_eq!(
printing,
[3, 7].into(),
"exactly Snow and Sand carry the flag"
);
let effect_on = |terrain: u32| {
cat.effect_terrain
.iter()
.find(|(_, &tt)| tt == terrain)
.map(|(&e, _)| e)
};
if let Some(e) = effect_on(3) {
assert!(cat.leaves_footprints(Some(e)), "snow effect layer prints");
}
if let Some(e) = effect_on(5) {
assert!(!cat.leaves_footprints(Some(e)), "grass doesn't");
}
assert!(!cat.leaves_footprints(None), "no layer, no prints");
let inks = load_footprint_textures(&mut chain).expect("load FootprintTextures.dbc");
assert_eq!(inks.len(), 6);
assert_eq!(
inks.get(&1).map(String::as_str),
Some(r"textures\Footsteps\BaseFootprint")
);
assert_eq!(
inks.get(&7).map(String::as_str),
Some(r"textures\Footsteps\PawFootprint")
);
}
}
+10 -3
View File
@@ -23,7 +23,9 @@ pub use characters::{
mod creatures;
mod dbc;
mod unit_blood;
pub use creatures::{load_creature_catalog, CreatureCatalog, CreatureModel, NpcAppearance};
pub use creatures::{
load_creature_catalog, CreatureCatalog, CreatureModel, FootprintParams, NpcAppearance,
};
pub use dbc::load_macro_icons;
pub use unit_blood::{load_blood_catalog, BloodCatalog};
mod itemsets;
@@ -56,6 +58,11 @@ pub use lock::{
};
mod lock_type;
pub use lock_type::{load_lock_type_catalog, LockTypeCatalog};
mod pet_stats;
pub use pet_stats::{
load_pet_loyalty_names, load_pet_personalities, PetHappiness, PetLoyaltyNames,
PetPersonalities, PetPersonality, FALLBACK_PERSONALITY,
};
mod items;
pub use items::{load_item_display_catalog, ItemDisplay, ItemDisplayCatalog};
mod item_sounds;
@@ -99,7 +106,7 @@ pub use emotes::{load_emote_sound_catalog, EmoteSoundCatalog};
mod environmental_damage;
pub use environmental_damage::{load_environmental_damage, EnvironmentalDamageTable};
mod footsteps;
pub use footsteps::{load_footstep_catalog, FootstepCatalog};
pub use footsteps::{load_footprint_textures, load_footstep_catalog, FootstepCatalog};
mod sound_entries;
pub use sound_entries::{load_sound_kit_catalog, sound_kit_flags, SoundKit, SoundKitCatalog};
mod sound_provider;
@@ -146,7 +153,7 @@ pub use particles::{
ParticleEmitterDef, ParticleShape, SplineData,
};
mod ribbons;
pub use ribbons::{parse_m2_ribbon_emitters, RibbonEmitterDef};
pub use ribbons::{parse_m2_ribbon_emitters, RibbonEmitterDef, RibbonVisibility};
mod value_track;
pub use value_track::{TrackValue, ValueTrack};
mod models;
+27 -1
View File
@@ -417,6 +417,18 @@ pub struct GroundQuad {
pub corners: [[f32; 3]; 4],
/// The authored UV at each corner, parallel to [`Self::corners`].
pub uvs: [[f32; 2]; 4],
/// The batch's **static M2Color tint** — the constant colour-track bake that rides
/// [`RenderSubmesh::vertex_colors`] on the mesh path — or white when the batch authors none.
///
/// A decal consumer re-renders this quad from the corners and never touches its vertex buffer,
/// so without carrying the tint here the batch's whole colour is lost. That is load-bearing
/// exactly where `m2_batches` says it is: spell ground art is authored on the NEUTRAL
/// `GENERICGLOW*` radials, whose warmth lives entirely in this constant — `Flare_State_Base`'s
/// two 13.89-yd washes are white sheets tinted `(0.992, 0.467, 0.0)`, and an untinted additive
/// draw of them is a blown-white pool where the reference lays a dim orange one. A
/// *time-varying* track instead rides [`RenderSubmesh::rgb_anim`] and clears the vertex bake,
/// so this is white there and the two never double-apply.
pub tint: [f32; 3],
}
/// Vertices further than this from the quad's own plane disqualify a batch as ground-plane flat
@@ -614,6 +626,20 @@ impl RenderSubmesh {
corners[slot] = *p;
uvs[slot] = *uv;
}
Some((GroundQuad { bone, corners, uvs }, hover))
// The batch's constant M2Color, straight off the vertex bake it would have drawn with
// (all four are the same colour — see [`GroundQuad::tint`]); white when it authors none.
let tint = self
.vertex_colors
.first()
.map_or([1.0; 3], |c| [c[0], c[1], c[2]]);
Some((
GroundQuad {
bone,
corners,
uvs,
tint,
},
hover,
))
}
}
+340
View File
@@ -0,0 +1,340 @@
//! `PetPersonality.dbc` + `PetLoyalty.dbc` — the two tables behind a hunter pet's happiness and
//! loyalty readouts (decision 1005; wow-re `ui/scratch/pet-action-bar-api.md` §11b).
//!
//! **`GetPetHappiness` does its own thresholding.** The client does not hand Lua a raw happiness
//! number for the UI to bucket — it returns a **pre-bucketed 1/2/3** plus the two numbers that
//! bucket implies, from a three-column-triple row (`0x4be947`–`0x4be9c3`):
//!
//! ```text
//! rec = <table>[personalityId] ; missing/out-of-range -> FALLBACK rec = <table>[1]
//! raw = UNIT_FIELD_POWER5 ; the happiness power
//! esi = 0; while (esi < 3 && raw >= rec[0x28 + 4*esi]) esi++ ; the three thresholds
//! ret1 = esi ; 0..3
//! ret2 = rec[0x30 + 4*esi] * 100.0f ; damage percentage (esi >= 1)
//! ret3 = rec[0x3c + 4*esi] ; loyalty rate (esi >= 1, may be NEGATIVE)
//! ```
//!
//! **Which `.dbc` backs it was NOT carved** — wow-re records `[0xc0d9e0]`/`[0xc0d9e4]` only as an
//! anonymous BSS `{indexTable, maxId}` pair, and the dispatch that produced the carve glossed the
//! index as a *creature family*. That gloss is wrong, and the file settles it: `CreatureFamily.dbc`
//! has a **0x48-byte** record — the read at `rec+0x48` would run off the end — and every dword from
//! `0x28` up is zero in all 23 rows. `PetPersonality.dbc` is a 0x4c-byte record whose last nine
//! columns are exactly the three triples, at exactly those offsets:
//!
//! | id | thresholds `0x28` | damage `0x34` | loyalty rate `0x40` |
//! |---|---|---|---|
//! | 1 | 0 / 333000 / 666000 | 0.75 / 1.00 / 1.25 | −10 / 5 / 20 |
//! | 3 | 0 / 250000 / 750000 | 0.00 / 1.00 / 1.25 | −1 / 0 / 2 |
//!
//! Row 1 is vanilla's documented pet-happiness behaviour on the nose — unhappy pets deal 75%
//! damage, content 100%, happy 125%, against a happiness power that runs 0…1,000,000 in thirds —
//! which is the independent corroboration that this is the table and row 1 is the row live pets
//! use. (See [`PetPersonalities::for_pet`] for what is still open: *which field* selects the row.)
//!
//! `PetLoyalty.dbc` is the plain half: 8 rows, `ID` + the localized `Name` block, read at
//! `[[0xc0d9f4][lvl] + 4*locale + 4]` — i.e. `Name` enUS is field 1, the same shape every other
//! localized table here uses.
use std::collections::HashMap;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::chain::Chain;
use crate::dbc::{f32_at, parse, str_at, u32_at};
const PET_PERSONALITY: &str = "DBFilesClient\\PetPersonality.dbc";
const PET_LOYALTY: &str = "DBFilesClient\\PetLoyalty.dbc";
/// `PetPersonality.dbc`'s column count (the DBC header's `field_count`; `benilla-dbc` enforces it).
const PERSONALITY_FIELDS: usize = 19;
/// `PetLoyalty.dbc`'s column count.
const LOYALTY_FIELDS: usize = 10;
/// The localized `Name` block's enUS column — field 1 in both files, and the `+4` in the client's
/// own `[row + 4*locale + 4]`.
const NAME_FIELD: usize = 1;
/// The first threshold column: byte `0x28` = field 10.
const THRESHOLD_FIELD: usize = 0x28 / 4;
/// The first damage-percentage column: byte `0x34` = field 13.
const DAMAGE_FIELD: usize = 0x34 / 4;
/// The first loyalty-rate column: byte `0x40` = field 16.
const LOYALTY_RATE_FIELD: usize = 0x40 / 4;
/// The personality id the client falls back to when it cannot resolve one (`0x4be96c`: `rec` null
/// or the index out of range ⇒ `rec = indexTable[1]`). Not a benilla convention — the reference's
/// own second chance, and the row every live pet is observed to use.
pub const FALLBACK_PERSONALITY: u32 = 1;
/// One `PetPersonality` row's three parallel triples, indexed by the happiness bucket.
///
/// The triples are **1-based against the bucket**: bucket 1 reads slot 0. Bucket 0 never indexes
/// them at all — the client jumps straight to the shared `(100.0, 0.0)` tail — which is why
/// [`PetHappiness`] carries the numbers rather than the caller doing the offset arithmetic.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PetPersonality {
/// The three ascending happiness thresholds the raw power is counted against.
pub thresholds: [u32; 3],
/// Damage multiplier per bucket, as stored (`0.75` = 75%). The binding scales by 100.
pub damage: [f32; 3],
/// Loyalty gain rate per bucket. **May be negative** — an unhappy pet loses loyalty.
pub loyalty_rate: [f32; 3],
}
/// What `GetPetHappiness` answers: the bucket plus the two numbers it selects.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PetHappiness {
/// `0..=3`. **`0` is not the failure case** — the client pushes it as the number `0` and takes
/// the same tail as a gate failure, and the shipped `PetFrame.lua` has no branch for it, so the
/// icon keeps whatever texcoords it had. Keep it distinct from "no answer at all".
pub bucket: u32,
/// Return 2 — the damage percentage, already scaled by the client's own `100.0f`
/// (`[0x806b10]`). `100.0` for bucket 0.
pub damage_percentage: f32,
/// Return 3 — the loyalty rate, unscaled and possibly negative. `0.0` for bucket 0.
pub loyalty_rate: f32,
}
impl PetPersonality {
/// Bucket a raw happiness power and pick the row's two numbers — `0x4be981`'s count loop and
/// the two indexed reads after it, transcribed.
pub fn happiness(&self, raw: u32) -> PetHappiness {
let bucket = self.thresholds.iter().take_while(|&&t| raw >= t).count();
// Bucket 0 shares the gate-failure tail (`0x4be9a8 je 0x4be9e9`) rather than indexing.
let Some(i) = bucket.checked_sub(1) else {
return PetHappiness {
bucket: 0,
damage_percentage: 100.0,
loyalty_rate: 0.0,
};
};
PetHappiness {
bucket: bucket as u32,
damage_percentage: self.damage[i] * 100.0,
loyalty_rate: self.loyalty_rate[i],
}
}
}
/// `PetPersonality.dbc`, by id.
pub struct PetPersonalities(HashMap<u32, PetPersonality>);
impl PetPersonalities {
/// The row for a pet whose personality id is `id`, with the client's own fallback applied.
///
/// **`id` is `None` today, always, and that is a recorded gap rather than an oversight.** The
/// client selects the row with `0x605600(pet)` = `[[pet+0xb30]+0x24]` — a field of its cached
/// creature template — and wow-re has carved neither which template field `+0x24` is nor which
/// table it indexes. Passing `None` takes the reference's *own* out-of-range path
/// ([`FALLBACK_PERSONALITY`]), which is what every live pet is observed to land on: the file
/// ships two rows, ids 1 and 3, and row 1 alone reproduces vanilla's 75/100/125% damage.
///
/// So the shape is right and the numbers are right; when the selector is carved, this call
/// gains an argument and nothing else moves.
pub fn for_pet(&self, id: Option<u32>) -> Option<&PetPersonality> {
id.and_then(|i| self.0.get(&i))
.or_else(|| self.0.get(&FALLBACK_PERSONALITY))
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
/// `PetLoyalty.dbc`: loyalty level → its localized name.
pub struct PetLoyaltyNames(HashMap<u32, String>);
impl PetLoyaltyNames {
/// The name for a loyalty level, or `None` — which is `GetPetLoyalty`'s **nil**, and it is nil
/// for level `0` as well as for anything past the table (`0x4be700`'s bound against
/// `[0xc0d9f8]`, plus `lua_pushstring`'s own NULL→nil).
pub fn name(&self, level: u32) -> Option<&str> {
(level != 0).then(|| self.0.get(&level).map(String::as_str))?
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
fn personality_schema() -> Schema {
let mut s = Schema::new("PetPersonality");
for i in 0..PERSONALITY_FIELDS {
let ty = match i {
NAME_FIELD => FieldType::String,
i if (DAMAGE_FIELD..DAMAGE_FIELD + 3).contains(&i) => FieldType::Float32,
i if (LOYALTY_RATE_FIELD..LOYALTY_RATE_FIELD + 3).contains(&i) => FieldType::Float32,
// ID, the rest of the localization block, and the three thresholds (which the client
// compares as integers against an integer power field).
_ => FieldType::UInt32,
};
s.add_field(SchemaField::new(format!("F{i}"), ty));
}
s
}
fn loyalty_schema() -> Schema {
let mut s = Schema::new("PetLoyalty");
for i in 0..LOYALTY_FIELDS {
let ty = if i == NAME_FIELD {
FieldType::String
} else {
FieldType::UInt32
};
s.add_field(SchemaField::new(format!("F{i}"), ty));
}
s
}
/// Load `PetPersonality.dbc` from the patch chain.
pub fn load_pet_personalities(chain: &mut Chain) -> Result<PetPersonalities> {
let bytes = chain
.read_file(PET_PERSONALITY)
.with_context(|| format!("reading {PET_PERSONALITY}"))?;
let rs = parse(&bytes, personality_schema(), "PetPersonality.dbc")?;
let mut by_id = HashMap::new();
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
let triple_u32 = |base: usize| [0, 1, 2].map(|i| u32_at(r, base + i).unwrap_or(0));
let triple_f32 = |base: usize| [0, 1, 2].map(|i| f32_at(r, base + i).unwrap_or(0.0));
by_id.insert(
id,
PetPersonality {
thresholds: triple_u32(THRESHOLD_FIELD),
damage: triple_f32(DAMAGE_FIELD),
loyalty_rate: triple_f32(LOYALTY_RATE_FIELD),
},
);
}
Ok(PetPersonalities(by_id))
}
/// Load `PetLoyalty.dbc` from the patch chain.
pub fn load_pet_loyalty_names(chain: &mut Chain) -> Result<PetLoyaltyNames> {
let bytes = chain
.read_file(PET_LOYALTY)
.with_context(|| format!("reading {PET_LOYALTY}"))?;
let rs = parse(&bytes, loyalty_schema(), "PetLoyalty.dbc")?;
let mut by_id = HashMap::new();
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
if let Some(name) = str_at(&rs, r, NAME_FIELD) {
by_id.insert(id, name);
}
}
Ok(PetLoyaltyNames(by_id))
}
#[cfg(test)]
mod tests {
use super::*;
fn chain() -> Option<crate::chain::Chain> {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return None;
}
Some(crate::open_chain(&data).expect("open chain"))
}
/// The real 5875 `PetPersonality.dbc`, byte-anchored. A column slip here is silent and
/// catastrophic — the thresholds and the damage triple are adjacent, so reading one for the
/// other yields plausible-looking numbers and a pet that is permanently "unhappy".
#[test]
fn the_real_personality_rows_carry_vanillas_own_happiness_numbers() {
let Some(mut chain) = chain() else { return };
let t = load_pet_personalities(&mut chain).expect("load PetPersonality.dbc");
assert_eq!(
t.len(),
2,
"5875 ships exactly two personalities, ids 1 and 3"
);
let one = t.for_pet(Some(1)).expect("id 1");
assert_eq!(one.thresholds, [0, 333_000, 666_000]);
assert_eq!(one.damage, [0.75, 1.0, 1.25]);
assert_eq!(one.loyalty_rate, [-10.0, 5.0, 20.0]);
let three = t.for_pet(Some(3)).expect("id 3");
assert_eq!(three.thresholds, [0, 250_000, 750_000]);
// The client's own fallback, and the reason it is load-bearing for us: an unresolved
// personality is the ONLY path we take today, and it must land on row 1.
assert_eq!(t.for_pet(None), t.for_pet(Some(1)));
assert_eq!(t.for_pet(Some(999)), t.for_pet(Some(1)));
}
/// The bucket loop against the shipped row 1, at the boundaries — and the ×100 scaling, which
/// is the client's and not the UI's.
#[test]
fn the_bucket_loop_counts_thresholds_met() {
let Some(mut chain) = chain() else { return };
let t = load_pet_personalities(&mut chain).expect("load");
let p = *t.for_pet(None).expect("fallback row");
// Happiness runs 0..1_000_000 in thirds. Every reachable raw value buckets 1..3.
for (raw, bucket, dmg) in [
(0, 1, 75.0),
(332_999, 1, 75.0),
(333_000, 2, 100.0),
(665_999, 2, 100.0),
(666_000, 3, 125.0),
(1_000_000, 3, 125.0),
] {
let h = p.happiness(raw);
assert_eq!((h.bucket, h.damage_percentage), (bucket, dmg), "raw {raw}");
}
assert_eq!(
p.happiness(0).loyalty_rate,
-10.0,
"an unhappy pet LOSES loyalty"
);
assert_eq!(p.happiness(1_000_000).loyalty_rate, 20.0);
}
/// Bucket 0 is structurally reachable (a row whose first threshold is above the raw value) and
/// is NOT the failure case: it answers the number `0` with the same `(100.0, 0.0)` tail a gate
/// failure uses. A re-implementation that folded it into nil would hide the pet frame.
#[test]
fn bucket_zero_is_a_number_not_a_failure() {
let p = PetPersonality {
thresholds: [10, 20, 30],
damage: [0.75, 1.0, 1.25],
loyalty_rate: [-10.0, 5.0, 20.0],
};
let h = p.happiness(9);
assert_eq!(h.bucket, 0);
assert_eq!((h.damage_percentage, h.loyalty_rate), (100.0, 0.0));
}
/// The eight real loyalty names, verbatim, and the two nil cases the binding must reproduce.
///
/// **The shipped strings carry a `"(Loyalty Level N) "` prefix** — developer annotation left in
/// the data, and the client pushes the column with no stripping whatsoever
/// (`lua_pushstring` of `[row + 4*locale + 4]`). So that prefix is what the pet paper doll
/// shows, and trimming it here would be a "tidier than the reference" divergence. Levels 7 and
/// 8 are `"Loyalty Cap"` and `"Unused"`: the ladder players actually climb is 1–6.
#[test]
fn the_real_loyalty_levels_are_named_verbatim() {
let Some(mut chain) = chain() else { return };
let n = load_pet_loyalty_names(&mut chain).expect("load PetLoyalty.dbc");
assert_eq!(n.len(), 8);
assert_eq!(n.name(1), Some("(Loyalty Level 1) Rebellious"));
assert_eq!(n.name(3), Some("(Loyalty Level 3) Submissive"));
assert_eq!(n.name(6), Some("(Loyalty Level 6) Best Friend"));
assert_eq!(n.name(7), Some("(Loyalty Level 7) Loyalty Cap"));
assert_eq!(n.name(8), Some("(Loyalty Level 8) Unused"));
// Level 0 is "no loyalty yet" and the client answers nil, not the first row.
assert_eq!(n.name(0), None);
assert_eq!(n.name(9), None);
}
}
+170 -43
View File
@@ -22,12 +22,33 @@
//! one-shot spell effects key them: `HolySmite_Low_Chest.m2` flares its slash ribbons' height
//! `0 → 0.167 → 0` over 267 ms and fades their alpha `1 → 0`, so the old value[0] bake read a
//! permanent zero height and Smite's impact slash never drew (the 0141 lesson, surfaced).
//! `texSlot` stays value[0]-baked (constant in the shipped corpus). The `visibility` gate track
//! (`+0xc0`) IS parsed — **per sequence** ([`RibbonEmitterDef::visible_in_anim`]): the thrown
//! weapon keys its flight trail OFF in Stand (worn in the hand) and Impact (landed), ON only in
//! InFlight (flying), so the spawn site lights it on the missile and never in the hand. This is
//! the multi-sequence gating that was a named seam — the thrown weapon is its driving model. Ramp
//! within a sequence isn't modeled (no shipped ribbon keys it): the band-start value stands.
//! `texSlot` stays value[0]-baked (constant in the shipped corpus).
//!
//! The **visibility** gate track (`+0xc0`) parses as [`RibbonVisibility`]: the keys of every
//! sequence, band-local, sampled **step** (a `u8` track is never blended, so both of the
//! reference's sampler arms copy the same raw `values[k0]`). It is the source of the enable byte
//! `block+0xbc` — ctor `0x71b34c` = 0, loader default `0x70f80e` = 1, then written every frame
//! from the sampled value at `0x7176ee` / `0x717714` inside `0x714260`; `0x718960` only reads it,
//! and no equipment/attach/sheathe writer exists anywhere in the binary. Clearing it **kills the
//! whole ribbon's draw** (`0x7080c2` → the collect loop's continue at `0x708263`). wow-re
//! `ribbon-emitter-spec.md` §6/§7, settled by the dispatch behind decision 1017: §7 previously
//! left the writer OPEN and *guessed* the equip/attach route, and decision 1013 unwound the whole
//! mechanism on the strength of that guess.
//!
//! The thrown weapon keys its flight trail OFF in Stand (worn in the hand) and Impact (landed),
//! ON only in InFlight. Keeping the keys — rather than the old band-start bool per sequence — is
//! not tidiness: shipped ribbons **do** key the gate mid-sequence. `G_FrostTrap.m2` lights its
//! low rig 534 ms into Spawn (`867` inside the `333..1067` band) and its twelve upper streamers
//! 200 ms into `Custom0` (`4200` inside `4000..5400`) — so a placed trap shows the low rig alone
//! and the upper twelve fire only when it springs.
//!
//! **NOT transcribed**, both narrow, both recorded so the next reader knows they are open: the
//! reference gates re-sampling on `timestamps.count > Model+0x8c`, which is 0 on an instance's
//! first frame and 1 after — so a **single-key** track samples once and then latches, where we
//! re-answer every frame (same result for a constant track, which is that whole population). And
//! its key window is `interpolationRanges[rangeIdx]` with `rangeIdx` the **emitter's own bone's**
//! active slot, not the model's, `lo >= hi` returning `lo` unsearched; we window by the playing
//! sequence's band, which agrees wherever the ribbon's bone plays what the model plays.
use std::io::Cursor;
@@ -108,31 +129,56 @@ pub struct RibbonEmitterDef {
pub tile_rows: u16,
pub tile_cols: u16,
pub tex_slot: u16,
/// Per-sequence **visibility** from the `+0xc0` gate track (`M2Track<u8>`): `anim_id → whether
/// the trail shows during that sequence`, sampled at the sequence's band start. `None` = no
/// gate — keyless, global-seq-clocked, or ON in every sequence (the always-on majority: enchant
/// trails, wisps). `Some(map)` = the trail is dark in some sequence: the thrown weapon keys it
/// OFF in Stand (worn in hand) and Impact (landed), ON only in InFlight — so the spawn site
/// gates on the sequence its owner runs ([`crate::RibbonEmitterDef`]). The shipped keyed ribbons
/// are constant-per-sequence; ramping mid-sequence isn't modeled (no shipped ribbon does it).
pub visible_in_anim: Option<std::collections::HashMap<u16, bool>>,
/// The `+0xc0` **enable** track, per sequence and keyed within it (see the module doc).
/// `None` = no gate — keyless, global-seq-clocked, or ON everywhere (the always-on majority:
/// enchant trails, wisps). `Some(v)` = the trail goes dark somewhere, and the consumer must
/// sample [`RibbonVisibility::at`] against the sequence its host is playing, every frame.
pub visible: Option<RibbonVisibility>,
}
/// Per-sequence visibility from a ribbon's `+0xc0` gate track (`M2Track<u8>`, sequence-timeline).
/// Returns `anim_id → whether the trail shows during that sequence`, sampled nearest-previous at
/// each sequence's absolute band start (the shipped keyed ribbons are constant-per-sequence, so
/// the band-start value stands for the whole sequence). `None` when there is nothing to gate:
/// A ribbon's `+0xc0` enable track, resolved per sequence: for each `AnimationData.dbc` id the
/// model authors, the gate's step keys inside that sequence's band, rebased to **band-local
/// seconds**. Entry 0 of each list is the value the band opens on (the nearest-previous key at
/// the band start), so a sample never has to look outside the sequence.
#[derive(Debug, Clone, PartialEq)]
pub struct RibbonVisibility {
by_anim: std::collections::HashMap<u16, Vec<(f32, bool)>>,
}
impl RibbonVisibility {
/// Whether the trail emits during `anim` at `t` seconds into the clip. Step
/// (nearest-previous) — the track's own `interp == 0`, which is what every shipped gate
/// carries. A sequence this model doesn't author falls back to `Stand`(0)'s answer, then to
/// the reference's load default: **enabled** (`0x70f80e` writes `block+0xbc = 1`).
pub fn at(&self, anim: u16, t: f32) -> bool {
let Some(keys) = self.by_anim.get(&anim).or_else(|| self.by_anim.get(&0)) else {
return true;
};
keys.iter()
.take_while(|&&(kt, _)| kt <= t)
.last()
.or(keys.first())
.is_some_and(|&(_, on)| on)
}
/// Every `(anim id, keys)` pair, for instruments that census the gate.
pub fn per_anim(&self) -> impl Iterator<Item = (u16, &[(f32, bool)])> {
self.by_anim.iter().map(|(&a, k)| (a, k.as_slice()))
}
}
/// Parse a ribbon's `+0xc0` gate track (`M2Track<u8>`, sequence-timeline) into a
/// [`RibbonVisibility`]: per `anim_id`, the keys that fall inside that sequence's band, rebased to
/// band-local seconds, preceded by the nearest-previous value at the band start. `None` when there
/// is nothing to gate:
///
/// - a keyless / out-of-range track (the always-on loader default), or
/// - a keyless / out-of-range track (the reference's enabled-at-load default), or
/// - a **global-sequence** clock (`gseq != 0xffff`) — a free-running loop, not per-sequence, or
/// - a track that resolves ON in *every* sequence (a keyed-but-always-on author).
/// - a track that resolves ON at every key of every sequence (a keyed-but-always-on author).
///
/// Sequences: MD20 count @ `0x1c`, offset @ `0x20`, stride `0x44` — `anim_id` @ +0, band `[start,
/// end]` @ +4/+8 (the same walk as [`crate::value_track::seq0_band`], one sequence wider).
fn visibility_by_anim(
bytes: &[u8],
vis_track: usize,
) -> Option<std::collections::HashMap<u16, bool>> {
fn visibility_by_anim(bytes: &[u8], vis_track: usize) -> Option<RibbonVisibility> {
if vis_track + 0x1c > bytes.len() {
return None;
}
@@ -154,7 +200,8 @@ fn visibility_by_anim(
let nseq = le_u32(bytes, 0x1c) as usize;
let oseq = le_u32(bytes, 0x20) as usize;
let mut map = std::collections::HashMap::new();
let mut by_anim: std::collections::HashMap<u16, Vec<(f32, bool)>> =
std::collections::HashMap::new();
let mut any_off = false;
for i in 0..nseq {
let s = oseq + i * 0x44;
@@ -162,22 +209,29 @@ fn visibility_by_anim(
break;
}
let anim = le_u16(bytes, s);
let start = le_u32(bytes, s + 4);
// Nearest-previous at the band start (M2 step interpolation); before the first key, the
// first key's value; keyless-safe default ON.
let mut on = keys.first().map(|&(_, v)| v).unwrap_or(true);
for &(t, v) in &keys {
if t <= start {
on = v;
} else {
break;
let (start, end) = (le_u32(bytes, s + 4), le_u32(bytes, s + 8));
// The band opens on the nearest-previous key (M2 step interpolation); before the first
// key, the first key's value; keyless-safe default ON.
let opening = keys
.iter()
.take_while(|&&(t, _)| t <= start)
.last()
.or(keys.first())
.is_some_and(|&(_, v)| v);
let mut band: Vec<(f32, bool)> = vec![(0.0, opening)];
// …then every key strictly inside the band, band-local. `G_FrostTrap`'s streamers live
// entirely here — their whole ON window opens 200 ms into the trigger sequence.
for &(t, v) in keys.iter().filter(|&&(t, _)| t > start && t <= end) {
let local = (t - start) as f32 / 1000.0;
if band.last().is_some_and(|&(_, prev)| prev != v) {
band.push((local, v));
}
}
any_off |= !on;
map.entry(anim).or_insert(on); // variations share an id — the head sequence wins
any_off |= band.iter().any(|&(_, v)| !v);
by_anim.entry(anim).or_insert(band); // variations share an id — the head sequence wins
}
// Nothing dark anywhere ⇒ effectively always-on: no gate to carry.
any_off.then_some(map)
any_off.then_some(RibbonVisibility { by_anim })
}
/// Parse an M2's ribbon emitters (see the module doc). Empty when the model has none or isn't a
@@ -262,7 +316,7 @@ pub fn parse_m2_ribbon_emitters(bytes: &[u8]) -> Result<Vec<RibbonEmitterDef>> {
tile_rows: le_u16(bytes, e + 0xa0).max(1),
tile_cols: le_u16(bytes, e + 0xa2).max(1),
tex_slot: track_first(bytes, e + 0xa4, 2, 0, le_u16),
visible_in_anim: visibility_by_anim(bytes, e + 0xc0),
visible: visibility_by_anim(bytes, e + 0xc0),
});
}
Ok(out)
@@ -277,6 +331,35 @@ mod tests {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data")
}
/// [`RibbonVisibility::at`]'s sampling law, on a hand-built gate: STEP (nearest-previous, the
/// track's own `interp == 0`), the band-opening entry answers before the first in-band key,
/// and a sequence the model doesn't author falls back to `Stand`(0) and then to the
/// reference's enabled-at-load default.
#[test]
fn visibility_samples_step_and_falls_back_to_stand() {
let vis = RibbonVisibility {
by_anim: [
(0u16, vec![(0.0, false)]),
(153u16, vec![(0.0, false), (0.2, true), (1.4, false)]),
]
.into_iter()
.collect(),
};
// Step within the band: dark, lit, dark — and lit right ON the key, not after it.
assert!(!vis.at(153, 0.0));
assert!(!vis.at(153, 0.199));
assert!(vis.at(153, 0.2), "step: the key's value takes effect AT it");
assert!(vis.at(153, 1.399));
assert!(!vis.at(153, 1.4));
// A sequence this gate doesn't list borrows Stand(0)'s answer…
assert!(!vis.at(147, 0.0), "unlisted sequence borrows Stand");
// …and with no Stand either, the load default stands: enabled.
let no_stand = RibbonVisibility {
by_anim: [(153u16, vec![(0.0, false)])].into_iter().collect(),
};
assert!(no_stand.at(147, 0.0), "no answer at all ⇒ the load default");
}
/// The real `HolySmite_Low_Chest.m2` (Smite's impact): its yellow slash IS its two ribbon
/// emitters, whose look tracks are KEYED inside the seq band [3300, 4433] — height flares
/// `0 → 0.167 → 0` over the first 267 ms and alpha fades `1 → 0` by 467 ms. The old
@@ -313,7 +396,7 @@ mod tests {
// ON wherever it plays (its alpha, above, does the fade). So it must carry NO gate,
// or the spawn site would wrongly darken it against a Stand default.
assert_eq!(
r.visible_in_anim, None,
r.visible, None,
"an always-on slash carries no visibility gate"
);
}
@@ -337,11 +420,55 @@ mod tests {
let defs = parse_m2_ribbon_emitters(&bytes).expect("parse ribbons");
assert_eq!(defs.len(), 1, "the dagger authors one trail ribbon");
let vis = defs[0]
.visible_in_anim
.visible
.as_ref()
.expect("the thrown dagger's trail IS visibility-gated");
assert_eq!(vis.get(&0), Some(&false), "Stand (worn in hand): dark");
assert_eq!(vis.get(&144), Some(&true), "InFlight (flying): lit");
assert_eq!(vis.get(&191), Some(&false), "Impact (landed): dark");
assert!(!vis.at(0, 0.0), "Stand (worn in hand): dark");
assert!(vis.at(144, 0.0), "InFlight (flying): lit");
assert!(!vis.at(191, 0.0), "Impact (landed): dark");
}
/// The placed **Frost Trap** — the model that proved the gate is keyed *inside* a sequence,
/// not constant across it (decisions 1011/1017). Its sixteen ribbons split in two: four low
/// ones on bones 45–48 at model z 0.129 that light 534 ms into Spawn and stay lit through
/// Closed — the tuft a placed trap shows, riding up off the crown on the verified `+g·t²` —
/// and twelve upper ones on bones 33–44 at z ≈ 1.55 that are dark in **every** rest state and
/// light only 200 ms into `Custom0`, when the trap springs.
///
/// A band-start-only read answers "dark" for `Custom0` too, so the upper rig would never fire;
/// an ungated consumer draws all sixteen for ever, which is half of the tall column the
/// director reported (the other half was gravity's sign — see `benilla::ribbons`).
#[test]
fn real_frost_trap_upper_streamers_light_only_inside_the_trigger() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
let bytes = chain
.read_file("World\\Goober\\G_FrostTrap.m2")
.expect("read G_FrostTrap.m2");
let defs = parse_m2_ribbon_emitters(&bytes).expect("parse ribbons");
assert_eq!(defs.len(), 16, "the frost trap authors sixteen trails");
// Ribbons 0..4: the low swirl at the trap's base. Dark as Spawn opens, lit 534 ms in
// (key 867 inside the 333..1067 band), and lit for all of Closed — what a placed trap shows.
for r in &defs[0..4] {
let v = r.visible.as_ref().expect("the low swirl IS gated");
assert!(!v.at(145, 0.0), "Spawn opens dark");
assert!(v.at(145, 0.6), "…and lights 534 ms in");
assert!(v.at(147, 0.0), "Closed: lit — the placed trap's swirl");
}
// Ribbons 4..16: the upper streamers. Dark in every rest state; lit only 200 ms into the
// trigger (key 4200 inside the 4000..5400 band) — the column, and only when sprung.
for r in &defs[4..16] {
let v = r.visible.as_ref().expect("the upper streamers ARE gated");
assert!(!v.at(147, 0.0), "Closed: dark — no column on a placed trap");
assert!(!v.at(148, 0.0), "Open: dark");
assert!(!v.at(153, 0.0), "the trigger OPENS dark");
assert!(v.at(153, 0.5), "…and lights 200 ms in");
assert!(!v.at(153, 1.45), "…then goes dark again at the band end");
}
}
}
@@ -104,3 +104,47 @@ fn character_model_detects_no_ground_quads() {
"no body batch reads as a ground quad"
);
}
/// The quad's **static M2Color tint** ([`benilla_formats::GroundQuad::tint`]) — the constant the
/// mesh path draws through its vertex-colour bake, which a decal consumer has no vertex buffer to
/// carry. The Flare's ground wash is the case that named it: two 13.89-yd quads on the NEUTRAL
/// `GENERICGLOW*` radials, whose entire colour is the constant `(0.992, 0.467, 0.0)` — drawn
/// untinted, an additive pool of them blows white instead of laying down dim orange. Battle
/// Shout's crescents are the other side of the same gate: their colour VARIES (white→red over the
/// clip), so it rides `rgb_anim`, the vertex bake is cleared, and this must read white — that is
/// what keeps the two from double-applying.
#[test]
fn ground_quads_carry_their_static_m2color_tint() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = open_chain(&data).expect("open chain");
let bytes = chain
.read_file("SPELLS\\Flare_State_Base.m2")
.expect("Flare state-base model");
let subs = parse_m2_render_submeshes(&bytes, "", &[]).expect("submeshes");
let quads: Vec<_> = subs.iter().filter_map(|s| s.ground_quad()).collect();
assert_eq!(quads.len(), 2, "both washes are ground quads");
for q in &quads {
assert!((q.tint[0] - 0.992).abs() < 1e-3, "warm red: {:?}", q.tint);
assert!((q.tint[1] - 0.467).abs() < 1e-3, "warm green: {:?}", q.tint);
assert!(q.tint[2] < 1e-3, "no blue at all: {:?}", q.tint);
// 13.89 yd across — the wash the tint has to colour.
let span = q.corners[3][0] - q.corners[0][0];
assert!((span - 13.89).abs() < 0.02, "wash span {span}");
}
// The animated twin: colour varies, so it rides `rgb_anim` and the vertex bake is cleared.
let bytes = chain
.read_file("Spells\\BattleShout_Cast_Base.m2")
.expect("Battle Shout cast-base model");
let subs = parse_m2_render_submeshes(&bytes, "", &[]).expect("submeshes");
for sub in &subs {
let q = sub.ground_quad().expect("crescent");
assert!(sub.rgb_anim.is_some(), "the crescent's colour is a loop");
assert_eq!(q.tint, [1.0; 3], "…so the static tint stays white");
}
}
+6 -6
View File
@@ -130,12 +130,12 @@ pub use opcode_names::opcode_name;
pub use packet::{CreatureQueryInfo, MonsterMoveFacing, ServerPacket};
pub use parse::parse_server;
pub use pet::{
pet_action, pet_set_action, pet_stop_attack, PetActionEntry, PetMode, PetSpellCooldown,
PetSpells, PET_ACTION_SLOTS, PET_ACT_COMMAND, PET_ACT_DISABLED, PET_ACT_ENABLED,
PET_ACT_PASSIVE, PET_ACT_REACTION, PET_AUTOCAST_ALLOWED, PET_AUTOCAST_ON, PET_COMMAND_ATTACK,
PET_COMMAND_DISMISS, PET_COMMAND_FOLLOW, PET_COMMAND_STAY, PET_COOLDOWN_PERMANENT,
PET_REACT_AGGRESSIVE, PET_REACT_DEFENSIVE, PET_REACT_PASSIVE, PET_STATE_BAR_DISABLED,
PET_TYPE_SPELL_FIRST, PET_TYPE_SPELL_LAST, PET_UNUSABLE_UNIT_FLAGS,
pet_action, pet_cancel_aura, pet_set_action, pet_stop_attack, PetActionEntry, PetMode,
PetSpellCooldown, PetSpells, PET_ACTION_SLOTS, PET_ACT_COMMAND, PET_ACT_DISABLED,
PET_ACT_ENABLED, PET_ACT_PASSIVE, PET_ACT_REACTION, PET_AUTOCAST_ALLOWED, PET_AUTOCAST_ON,
PET_COMMAND_ATTACK, PET_COMMAND_DISMISS, PET_COMMAND_FOLLOW, PET_COMMAND_STAY,
PET_COOLDOWN_PERMANENT, PET_REACT_AGGRESSIVE, PET_REACT_DEFENSIVE, PET_REACT_PASSIVE,
PET_STATE_BAR_DISABLED, PET_TYPE_SPELL_FIRST, PET_TYPE_SPELL_LAST, PET_UNUSABLE_UNIT_FLAGS,
};
pub use pose::{set_sheathed, stand_state_change};
pub use progression::{learn_talent, ExplorationXp, LevelUpInfo, XpGain};
@@ -251,6 +251,10 @@ pub const CMSG_PET_SET_ACTION: u16 = 0x0174; // 372
pub const CMSG_PET_SPELL_AUTOCAST: u16 = 0x02F3; // 755
/// Call the pet off — the Attack button's second press.
pub const CMSG_PET_STOP_ATTACK: u16 = 0x02EA; // 746
/// Take an aura off the **pet** — a pet bar spell click on a spell already running on it. The
/// player's own `CMSG_CANCEL_AURA` cannot serve: its body is a bare spell id with no room to name
/// whose aura to drop.
pub const CMSG_PET_CANCEL_AURA: u16 = 0x026B; // 619
/// VERIFIED vmangos `Opcodes_1_12_1.h`: 94 (decision 0236). Body in
/// [`super::gameobject::gameobject_query`] — the ask-once GO template lookup, identical shape to
@@ -417,6 +417,25 @@ pub fn pet_stop_attack(pet_guid: u64) -> Vec<u8> {
pet_guid.to_le_bytes().to_vec()
}
/// Body of `CMSG_PET_CANCEL_AURA` (opcode 619): `u64 petGuid` then `u32 spellId`.
///
/// Both ends agree independently. wow-re read the send off the client
/// (`ui/scratch/pet-action-bar-api.md` §10.1, `0x4bd25f`–`0x4bd2ad`: `push 0x26b`, the guid pair,
/// the id); vmangos reads the same two fields in the same order
/// (`PetCancelAura::ReadFromWorldPacket`, `Server/Packets/Pet.cpp:27-31`).
///
/// The guid is what distinguishes it from the player's `CMSG_CANCEL_AURA` — which carries a bare
/// spell id and so could only ever mean "mine". The handler re-checks that the guid is our pet or
/// our charm and drops the packet otherwise, and answers a **dead** pet with
/// `SendPetActionFeedback(FEEDBACK_PET_DEAD)` rather than silence (`HandlePetCancelAuraOpcode`,
/// `Handlers/SpellHandler.cpp:407-432`) — the same feedback channel the bar's other refusals use.
pub fn pet_cancel_aura(pet_guid: u64, spell_id: u32) -> Vec<u8> {
let mut body = Vec::with_capacity(12);
body.extend_from_slice(&pet_guid.to_le_bytes());
body.extend_from_slice(&spell_id.to_le_bytes());
body
}
/// Body of `CMSG_PET_SET_ACTION` (opcode 372): `u64 petGuid` then one or two
/// `(u32 position, u32 packedData)` pairs. The server distinguishes the forms **by body size
/// alone** — 24 bytes ⇒ two entries, anything else ⇒ one (`PetSetAction::ReadFromWorldPacket`,
@@ -614,6 +633,18 @@ mod tests {
// CMSG_PET_STOP_ATTACK: the lone guid.
assert_eq!(pet_stop_attack(0x2A), 0x2Au64.to_le_bytes());
// CMSG_PET_CANCEL_AURA: guid then spell id, 12 bytes — the guid is the whole difference
// from the player's own CMSG_CANCEL_AURA, which is a bare u32.
assert_eq!(
pet_cancel_aura(0x2A, 2645),
[
0x2Au64.to_le_bytes().to_vec(),
2645u32.to_le_bytes().to_vec()
]
.concat()
);
assert_eq!(pet_cancel_aura(0x2A, 2645).len(), 12);
// CMSG_PET_SET_ACTION: the server tells one entry from two BY BODY SIZE, so the one-entry
// form is 16 bytes and the two-entry form is exactly 24.
assert_eq!(pet_set_action(0x2A, &[(3, packed)]).len(), 16);
@@ -112,6 +112,17 @@ const FIELD_UNIT_MOUNTDISPLAYID: u16 = 133;
/// client decides a creature has **no** classification (rank forced to 0), so no elite dragon, no
/// ELITE/BOSS tooltip word and no world-boss skull on an enslaved mob.
const FIELD_UNIT_PETNUMBER: u16 = 139;
/// `UNIT_FIELD_PETEXPERIENCE` (idx 141) / `UNIT_FIELD_PETNEXTLEVELEXP` (142) — a hunter pet's XP
/// bar, the `(currXP, nextXP)` pair `GetPetExperience 0x4be840` returns in that order.
/// Byte-verified client-side at `[unit+0x110]+0x21c` / `+0x220` (wow-re
/// `ui/scratch/pet-action-bar-api.md` §11b); the client converts each with `fild qword` off a
/// **zero-extended** dword, so neither can read negative however large it gets.
const FIELD_UNIT_PETEXPERIENCE: u16 = 141;
const FIELD_UNIT_PETNEXTLEVELEXP: u16 = 142;
/// `UNIT_TRAINING_POINTS` (idx 149) — **two `u16`s packed into one dword**, byte-verified at
/// `[unit+0x110]+0x23c` / `+0x23e`: `GetPetTrainingPoints 0x4be790` pushes the **high** word first
/// and the low word second, which `PetPaperDollFrame.lua` names `totalPoints, spent`.
const FIELD_UNIT_TRAINING_POINTS: u16 = 149;
const FIELD_UNIT_DYNAMIC_FLAGS: u16 = 143;
/// `UNIT_CHANNEL_SPELL` (idx 144, `OBJECT_END(6) + 0x8A`, PUBLIC — VERIFIED vmangos
/// `UpdateFields_1_12_1.h:89`) — the spell id a unit is channeling (`0` = not channeling).
@@ -227,6 +238,10 @@ impl UnitAuraSlot {
// appearance decode `0x5fb200` + corpse cross-check). The PLAYER block starts at field 188.
const FIELD_PLAYER_BYTES: u16 = 193;
const FIELD_PLAYER_BYTES_2: u16 = 194;
// PLAYER_BYTES_3 = UNIT_END(188) + 0x7: the low u16 packs `gender | (drunk & 0xFFFE)` (vmangos
// `Player::SetDrunkValue`), so byte 1 is the drunk value's high byte — exactly the byte the
// reference client reads at `[[unit+0xe68]+0x1d]` (wow-re `drunk_fraction_5e2a90`).
const FIELD_PLAYER_BYTES_3: u16 = 195;
// PLAYER_QUEST_LOG_1_1 = UNIT_END(188) + 0xA = 198 (vmangos `UpdateFields_1_12_1.h:128`; this block
// precedes the CONTAINER-onward hex-comment drift — enum arithmetic and hex comment agree here, and
// decision 0088 §4 live-verified an accepted quest id landing on 198 + 3·slot). 20 slots
@@ -345,6 +345,15 @@ impl ObjectFields {
pub fn player_bytes_2(&self) -> Option<u32> {
self.get_u32(FIELD_PLAYER_BYTES_2)
}
/// `PLAYER_BYTES_3` **byte 1** — the inebriation byte (0..255) drunkenness renders from. The
/// server packs `gender | (drunk & 0xFFFE)` into the field's low u16 and decays it 256 per
/// 10 s (vmangos `Player::SetDrunkValue` / `HandleDrunkReduce`), so this byte is
/// `drunk_u16 >> 8` — the value the reference clamps to 100 and scales ×0.01 into its drunk
/// fraction (`[[unit+0xe68]+0x1d]`, wow-re `drunk_fraction_5e2a90`). `None` when never sent
/// on a bare delta; a create snapshot reads absent as `Some(0)` (sober).
pub fn player_drunk_byte(&self) -> Option<u8> {
self.get_u32(FIELD_PLAYER_BYTES_3).map(|v| (v >> 8) as u8)
}
/// `PLAYER_FLAGS` (field 190) — the player state flags. `0` when absent (the descriptor's
/// zero default — a create block skips zero fields).
pub fn player_flags(&self) -> u32 {
@@ -171,6 +171,30 @@ impl ObjectFields {
pub fn unit_stand_state(&self) -> u8 {
(self.get_u32(FIELD_UNIT_BYTES_1).unwrap_or(0) & 0xff) as u8
}
/// `UNIT_FIELD_BYTES_1` byte 1 — a hunter pet's **loyalty level**, `1..=8`, and `0` for a unit
/// that has none (VERIFIED vmangos `UNIT_BYTES_1_OFFSET_PET_LOYALTY = 1`; byte-verified
/// client-side as `[unit+0x110]+0x211`, the byte `GetPetLoyalty 0x4be700` indexes
/// `PetLoyalty.dbc` with). `0` is the binding's **nil**, not level 1.
pub fn unit_loyalty_level(&self) -> u8 {
((self.get_u32(FIELD_UNIT_BYTES_1).unwrap_or(0) >> 8) & 0xff) as u8
}
/// `UNIT_FIELD_PETEXPERIENCE` / `UNIT_FIELD_PETNEXTLEVELEXP` — `GetPetExperience`'s
/// `(currXP, nextXP)`, in that order. Absent fields read `0`, which is the binding's own
/// gate-failure value: this pair is numbers on every path, never nil.
pub fn unit_pet_experience(&self) -> (u32, u32) {
(
self.get_u32(FIELD_UNIT_PETEXPERIENCE).unwrap_or(0),
self.get_u32(FIELD_UNIT_PETNEXTLEVELEXP).unwrap_or(0),
)
}
/// `UNIT_TRAINING_POINTS` split as the client splits it — `(total, spent)`, **high word
/// first**. The order is the load-bearing half: both are small numbers of the same magnitude,
/// so swapping them yields a pet that looks like it has spent everything or nothing, with no
/// error anywhere.
pub fn unit_training_points(&self) -> (u16, u16) {
let packed = self.get_u32(FIELD_UNIT_TRAINING_POINTS).unwrap_or(0);
((packed >> 16) as u16, (packed & 0xffff) as u16)
}
/// `UNIT_FIELD_BYTES_1` byte 3's `UNIT_VIS_FLAGS_CREEP (0x2)` — the sneaking visual, set by
/// `SPELL_AURA_MOD_STEALTH` (vmangos `SpellAuras.cpp:3610`,
/// `UNIT_BYTES_1_OFFSET_VIS_FLAG = 3`). The usable walk's only-stealthed leg tests exactly
@@ -1,12 +1,17 @@
//! The pet bar's `WorldWriter` sends — mirroring [`crate::messages::pet`] (decisions 0982, 0988).
//!
//! Three verbs, and the thing to know about them is that **the server answers none of them**.
//! Every one is an intent the client has already applied to its own state before sending
//! Four verbs, and the thing to know about them is that **no reply packet answers any of them**.
//! The first three are intents the client has already applied to its own state before sending
//! (wow-re §10.1/§10.2: the reaction, command and autocast paths all write `[0xb71468]` or the
//! slot word and fire `PET_BAR_UPDATE` *before* the packet leaves). The bar's contents change only
//! when a fresh `SMSG_PET_SPELLS` arrives — a summon, a swap, a learned spell — never as a reply
//! to one of these.
//!
//! [`WorldWriter::pet_cancel_aura`] is the exception worth naming: it too gets no reply, but it is
//! the one verb the client does *not* pre-apply, because what it changes is the pet's own
//! descriptor. The aura leaves in a `UNIT_FIELD_AURA` delta, and the slot's icon follows from
//! that — so the round trip is visible, just not as an answer.
//!
//! Every one carries the pet's guid: the server re-checks ownership on each
//! (`PetHandler.cpp`'s `GetCharmerOrOwnerGuid` gate) and silently drops anything naming a unit we
//! don't control.
@@ -38,6 +43,20 @@ impl WorldWriter {
)
}
/// Cancel one of the **pet's** auras (`CMSG_PET_CANCEL_AURA`, layout in
/// [`messages::pet_cancel_aura`]) — a pet bar spell click that lands on a spell already running
/// on the pet (wow-re §10.1, `0x4bd25f`; the predicate is `0x4bcea0`).
///
/// The click that sends this sends **nothing else**: the reference returns straight after,
/// so the ordinary `CMSG_PET_ACTION` never leaves. That "and return" is the whole behaviour —
/// clicking a lit pet buff takes it off rather than re-casting it.
pub fn pet_cancel_aura(&mut self, pet_guid: u64, spell_id: u32) -> Result<()> {
self.send(
opcode::CMSG_PET_CANCEL_AURA,
&messages::pet_cancel_aura(pet_guid, spell_id),
)
}
/// Write one or two pet bar slots (`CMSG_PET_SET_ACTION`, layout — and the reason this one
/// opcode carries two different verbs — in [`messages::pet_set_action`]).
///
+4 -1
View File
@@ -290,7 +290,10 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) -> bool
}
}
Some(
other @ (CursorPayload::Spell(_) | CursorPayload::Action(_) | CursorPayload::Macro(_)),
other @ (CursorPayload::Spell(_)
| CursorPayload::Action(_)
| CursorPayload::Macro(_)
| CursorPayload::PetAction(_)),
) => {
model.cursor = Some(other);
false
+76 -10
View File
@@ -18,6 +18,7 @@ use super::{Model, ScriptValue};
mod bar;
mod doll;
mod drag;
mod pet;
pub(crate) use bar::place_action;
pub(crate) use drag::{arm_drag, maybe_start_drag, take_drag, DragGesture, DragRelease};
@@ -40,16 +41,17 @@ pub const EQUIPMENT_BAG: i64 = -100;
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// What the cursor carries — the client's payload-mode global [0xb4d900] as a typed enum
/// (wow-re cursor-dragdrop-payload.md §1: 1 = live item, 3 = spell, **8 = macro**; our Action arm
/// is the client's bar-slot pickup; the money/preview arms stay unbuilt). One transition seam for
/// every surface, so sounds, CURSOR_UPDATE, and lock display can't drift apart per window
/// (decision 0216).
/// (wow-re cursor-dragdrop-payload.md §1: 1 = live item, 3 = spell, **4 = pet action**, **8 =
/// macro**; our Action arm is the client's bar-slot pickup; the money/preview arms stay unbuilt).
/// One transition seam for every surface, so sounds, CURSOR_UPDATE, and lock display can't drift
/// apart per window (decision 0216).
#[derive(Clone, Debug, PartialEq)]
pub enum CursorPayload {
Item(CursorItem),
Spell(CursorSpell),
Action(CursorAction),
Macro(CursorMacro),
PetAction(CursorPetAction),
}
/// The item currently held on the cursor (`PickupContainerItem`/`SplitContainerItem`/
@@ -111,6 +113,31 @@ pub struct CursorAction {
pub texture: Option<String>,
}
/// A pet-bar payload ([`pet`]'s `PickupPetAction` produces it) — the client's cursor mode **4**,
/// whose payload global `[0xb4e2f8]` is the picked slot's **packed word, copied verbatim** (its
/// sole writer `0x494f0c` is a plain `mov edx,[edi]; mov [0xb4e2f8],edx`; decision 1010).
///
/// The word is the payload, and that is the whole design: the drop trampoline `0x4bce00` forwards
/// this dword into the assign core without reading a field of it, so a slot's contents after any
/// accepted drop is a word that already existed elsewhere in client state. Nothing is encoded at
/// drop time and nothing here needs decoding.
///
/// This payload is **pet-bar-only**. `PlaceAction` refuses it (wow-re `action-item-slot.md`'s
/// payload table: pet actions and class abilities are the refused modes, macros are not), and
/// nothing else produces it — which is also why a pet bar can only ever be *rearranged*, never
/// populated from the spellbook: its contents are the server's.
#[derive(Clone, Debug, PartialEq)]
pub struct CursorPetAction {
/// The 1-based pet bar slot it was picked from.
pub src_slot: u32,
/// The slot's packed word, verbatim.
pub packed: u32,
/// `Attributes & 0x40` for a spell word — carried because the assign core's one source filter
/// tests it at DROP time, not at pickup (`0x4bc9f8`).
pub passive: bool,
pub texture: Option<String>,
}
/// A macro payload ([`super::macros`]'s `PickupMacro` produces it) — the client's cursor mode
/// **8**, whose payload global is the bare macro id (`[0xb4e2fc]`, wow-re `action-item-slot.md`'s
/// payload table, where mode 8 is the ONE non-item/non-spell payload `PlaceAction` accepts).
@@ -141,16 +168,35 @@ pub(crate) fn queue_cursor_update(model: &mut Model) {
model
.pending_events
.push(("CURSOR_UPDATE".to_string(), Vec::new()));
let now_held = model.cursor.is_some();
if now_held != model.cursor_grid_shown {
model.cursor_grid_shown = now_held;
let event = if now_held {
// The two grids are derived off DIFFERENT predicates, because the reference fires them from
// different places and the payload spaces do not overlap (decision 1010):
//
// - the ACTION bar's grid follows "is anything held that could land there" — every arm except
// the pet one, which `PlaceAction` refuses outright;
// - the PET bar's grid follows the pet payload alone. The reference fires `PET_BAR_SHOWGRID`
// from inside the pet-action pickup builder itself (`0x494f28`), not from a shared cursor
// transition — so a spell on the cursor lights the action bar's empty slots and leaves the
// pet bar alone, which is also the only honest answer: you cannot drop it there.
let pet_held = matches!(model.cursor, Some(CursorPayload::PetAction(_)));
let bar_held = model.cursor.is_some() && !pet_held;
if bar_held != model.cursor_grid_shown {
model.cursor_grid_shown = bar_held;
let event = if bar_held {
"ACTIONBAR_SHOWGRID"
} else {
"ACTIONBAR_HIDEGRID"
};
model.pending_events.push((event.to_string(), Vec::new()));
}
if pet_held != model.pet_grid_shown {
model.pet_grid_shown = pet_held;
let event = if pet_held {
"PET_BAR_SHOWGRID"
} else {
"PET_BAR_HIDEGRID"
};
model.pending_events.push((event.to_string(), Vec::new()));
}
}
/// Queue `ITEM_LOCK_CHANGED(bag, slot)` — a pickup locking a source slot, or a place/cancel/
@@ -194,7 +240,12 @@ pub(crate) fn clear_cursor(model: &mut Model) {
queue_cursor_update(model);
queue_lock_changed(model, item.bag, item.slot);
}
Some(CursorPayload::Spell(_) | CursorPayload::Action(_) | CursorPayload::Macro(_)) => {
Some(
CursorPayload::Spell(_)
| CursorPayload::Action(_)
| CursorPayload::Macro(_)
| CursorPayload::PetAction(_),
) => {
queue_cursor_update(model);
}
None => {}
@@ -250,7 +301,12 @@ pub(crate) fn world_drop_click(model: &mut Model) -> bool {
true
}
(
Some(CursorPayload::Spell(_) | CursorPayload::Action(_) | CursorPayload::Macro(_)),
Some(
CursorPayload::Spell(_)
| CursorPayload::Action(_)
| CursorPayload::Macro(_)
| CursorPayload::PetAction(_),
),
WorldPick::Terrain | WorldPick::Nothing,
) => {
clear_cursor(model);
@@ -463,6 +519,15 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Value::Nil,
Value::Nil,
)),
// The pet arm follows the Action arm's shape — kind word + the slot it came from.
// Nothing in the shipped 1.12 UI reads it (the pet bar's own drag never asks what
// it is carrying); it is here so the ONE payload space stays fully describable.
Some(CursorPayload::PetAction(p)) => Ok((
Value::String(lua.create_string("petaction")?),
Value::Integer(i64::from(p.src_slot)),
Value::Nil,
Value::Nil,
)),
None => Ok((Value::Nil, Value::Nil, Value::Nil, Value::Nil)),
}
})?,
@@ -509,6 +574,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
doll::install(lua)?;
bar::install(lua)?;
pet::install(lua)?;
Ok(())
}
+13 -5
View File
@@ -110,15 +110,23 @@ pub(crate) fn place_action(model: &mut Model, id: u32) -> bool {
let Some(held) = model.cursor.take() else {
return false;
};
let (kind, action, texture) = match &held {
CursorPayload::Action(a) => (a.kind, a.action, a.texture.clone()),
CursorPayload::Item(i) => (ACTION_KIND_ITEM, i.item_id, i.texture.clone()),
CursorPayload::Spell(s) => (ACTION_KIND_SPELL, s.spell_id, s.texture.clone()),
let placeable = match &held {
CursorPayload::Action(a) => Some((a.kind, a.action, a.texture.clone())),
CursorPayload::Item(i) => Some((ACTION_KIND_ITEM, i.item_id, i.texture.clone())),
CursorPayload::Spell(s) => Some((ACTION_KIND_SPELL, s.spell_id, s.texture.clone())),
// Mode 8 — the one non-item/non-spell payload the reference's `PlaceAction` accepts
// (`action-item-slot.md` §5's payload table: pet actions and class abilities are refused,
// macros are not). It packs the bare macro id under the MACRO tag, exactly as the SPELL
// and ITEM arms pack theirs.
CursorPayload::Macro(m) => (ACTION_KIND_MACRO, m.index, m.texture.clone()),
CursorPayload::Macro(m) => Some((ACTION_KIND_MACRO, m.index, m.texture.clone())),
// Mode 4 — the other half of that same table's refusal (decision 1010). A pet action has
// no `CMSG_SET_ACTION_BUTTON` encoding at all, so there is nothing to pack: the payload
// goes straight back on the cursor, where the pet bar can still take it.
CursorPayload::PetAction(_) => None,
};
let Some((kind, action, texture)) = placeable else {
model.cursor = Some(held);
return false;
};
let displaced = model.actions.insert(
id,
+4 -1
View File
@@ -102,7 +102,10 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
true
}
Some(
other @ (CursorPayload::Spell(_) | CursorPayload::Action(_) | CursorPayload::Macro(_)),
other @ (CursorPayload::Spell(_)
| CursorPayload::Action(_)
| CursorPayload::Macro(_)
| CursorPayload::PetAction(_)),
) => {
model.cursor = Some(other);
false
+641
View File
@@ -0,0 +1,641 @@
//! The pet bar's drag (decision 1010; wow-re `ui/scratch/pet-action-bar-api.md` §10.3/§10.4/§10.7)
//! — **one** Lua verb, `PickupPetAction`, which forks on whether the cursor is already carrying a
//! pet payload. The reference's own bar calls it from all three gestures (`PetActionButton_OnClick`
//! under shift, `OnDragStart`, and `OnReceiveDrag` — `PetActionBarFrame.lua:252-283`), so "pick up"
//! and "drop" are the same binding seen from either side of an empty cursor.
//!
//! **Three things set this surface apart from [`super::bar`]'s**, and each is why this is its own
//! module rather than an arm of that one:
//!
//! - **The payload is a WORD, forwarded verbatim.** `0x4bce00` hands the cursor's dword straight to
//! the assign core without reading a field of it, and the core's only write is
//! `bar[target] = *source`. There is no drop-time encoding — see [`CursorPetAction`].
//! - **The bar's contents are the SERVER's.** Nothing but a pet slot can produce this payload and
//! nothing but a pet slot accepts it, so the drag can only ever *rearrange* ten words the last
//! `SMSG_PET_SPELLS` delivered. That is the opposite of the action bar, where the 120-slot table
//! is ours and a drag populates it.
//! - **A displaced occupant is RELOCATED inside the bar before it is handed back.** The action bar
//! hops the displaced action onto the cursor unconditionally; here the core first tries to keep
//! it on the bar (a duplicate's slot, or an empty non-token slot), and only a target that was
//! *not* a token hands its occupant to the cursor.
//!
//! **A drop always empties the cursor first** (`0x495190 ClearCursor(1,1)`, run by all three of
//! `0x4bce00`'s callers *before* the accept decision), and only a write that relocated nothing
//! re-fills it, with the displaced occupant (`0x4bce38 test eax,eax; je`). So every refusal loses
//! the payload — read at the bytes, all six of `0x4bc9a0`'s exits: the no-op on an equal dword
//! (`0x4bc9ed xor eax,eax`), the passive-source reject (`0x4bca28`), the token abort, and both
//! never-blocking front gates all return `EAX = 0`. 1010 shipped the inverse of this as a named
//! divergence — a refused drop kept its payload — and the director retired it (1016): the case it
//! was protecting against is a drop the reference simply does not make, and dropping a pet spell
//! *away* from the bar is how you take it off the bar in the first place.
//!
//! (The **clamp** on slot 11 is still ours, and still deliberate — see [`PET_SLOTS`].)
use mlua::Lua;
use crate::script::Model;
use super::{queue_cursor_update, CursorPayload, CursorPetAction};
/// Pet bar slots — the reference's `NUM_PET_ACTION_SLOTS` and vmangos's
/// `MAX_UNIT_ACTION_BAR_INDEX`, the same 10.
///
/// **The clamp lives here**, and it is a deliberate divergence from the reference's own arithmetic:
/// its callers gate `cmp esi,0xa; jbe`, which admits index 10 (Lua argument 11), and neither
/// `0x4bce00` nor `0x4bc9a0` bounds-checks again — so a real client writes one dword *past* the
/// ten-slot array. Not reproduced (wow-re §10.7 says so in as many words).
const PET_SLOTS: u32 = 10;
/// The client's slot type, masked as it masks it (`(packed >> 24) & 0x3F`).
fn kind(packed: u32) -> u8 {
((packed >> 24) & 0x3F) as u8
}
/// A **token** slot — a command (7) or a reaction (6). The core treats the two identically
/// everywhere it asks the question: a token occupant is what forces the relocation search, and a
/// token is what disqualifies a slot from being a relocation candidate.
fn is_token(packed: u32) -> bool {
matches!(kind(packed), 6 | 7)
}
/// Can this word ride the cursor at all? `0x494e20`'s own jump table (`0x494f40`) routes types
/// 1–5 / 6 / 7 and **bails on type 0 or ≥ 8** — so an empty or nonsense word produces no payload,
/// which is what makes "displaced an empty slot" hand back nothing rather than a blank carry.
fn payload_word(packed: u32) -> Option<u32> {
(1..=7).contains(&kind(packed)).then_some(packed)
}
/// The duplicate scan's comparison key (`0x4bca44`/`0x4bca57`, mask `0x3FFFFFFF`) — everything but
/// the two autocast bits. **Not** type-1-only: the note's earlier prose said the duplicate clear
/// was, and the bytes say the mask covers the whole type+action field for any source type.
fn same_action(a: u32, b: u32) -> bool {
a & 0x3FFF_FFFF == b & 0x3FFF_FFFF
}
/// The blanked-spell shape `PickupPetAction` writes back into a picked-from slot: type 1 with a
/// zero id. The duplicate scan is skipped for exactly this source (`0x4bca34`–`0x4bca3e`) — it
/// would otherwise match every other blanked slot on the bar.
fn is_blanked_spell(packed: u32) -> bool {
kind(packed) == 1 && packed & 0xFFFF == 0
}
/// A relocation **candidate** (`0x4bca8b`–`0x4bcab0`): not a token, and its low 16 bits zero. So an
/// all-zero slot qualifies and so does a blanked type-1 slot — the note's earlier "token-or-empty"
/// reading was wrong in both directions.
fn is_relocation_candidate(packed: u32) -> bool {
!is_token(packed) && packed & 0xFFFF == 0
}
/// What one accepted assignment did.
struct Assigned {
/// The slot the target's previous occupant was moved to, if it was moved.
reloc: Option<usize>,
/// The occupant left with nowhere to go — the cursor's new load. `None` when it was relocated,
/// and `None` when the target was empty (nothing to carry).
displaced: Option<u32>,
}
/// The assign core `0x4bc9a0(target, source, sendFlag = 1)` — "set a pet action", and the whole of
/// what a drop does. `None` = nothing was written and nothing should be sent.
///
/// The gates, in the binary's order:
///
/// 1. **no-op** when the source equals the target's current word, compared as the **full unmasked
/// dword** (`0x4bc9e2`) — autocast bits included, so re-dropping a slot on itself is inert.
/// 2. **silent reject** of a passive spell source (`0x4bc9f8`–`0x4bca2e`). No error id, unlike the
/// player bar's `PlaceAction`, which raises `0x9e` for the same test.
/// 3. **duplicate scan**, slots ascending: the first slot that carries the same action (and is not
/// the target) becomes the relocation slot, and the token search is **short-circuited entirely**
/// (`0x4bca78`). This is what makes the ordinary drag a swap.
/// 4. otherwise, if the target's occupant **is a token**, search for a candidate slot to move it
/// to; **no candidate ⇒ abort**, nothing written and nothing sent.
/// 5. write `bar[reloc] = old occupant` (when there is one) and `bar[target] = source`.
///
/// The reference's return value is `EAX = 1` iff **no** relocation happened (`0x4bcb92 setl`) —
/// so an abort and a successful relocation are indistinguishable at the call. That is the one thing
/// a re-implementation must not read as "did the write happen", which is why this returns
/// [`Assigned`] and never a bool.
fn assign(slots: &mut [u32], target: usize, source: u32, passive: bool) -> Option<Assigned> {
let occupant = *slots.get(target)?;
if occupant == source {
return None;
}
if kind(source) == 1 && passive {
return None;
}
let mut reloc = None;
if !is_blanked_spell(source) {
reloc = (0..slots.len()).find(|&i| i != target && same_action(slots[i], source));
}
if reloc.is_none() && is_token(occupant) {
// No `j != target` guard, and none is needed: this branch only runs when the target's own
// occupant IS a token, which disqualifies it as a candidate. The bytes have no guard here
// either.
reloc = (0..slots.len()).find(|&j| is_relocation_candidate(slots[j]));
reloc?; // the abort — a token occupant with nowhere to go writes and sends nothing
}
if let Some(j) = reloc {
slots[j] = occupant;
}
slots[target] = source;
Some(Assigned {
reloc,
displaced: reloc.is_none().then(|| payload_word(occupant)).flatten(),
})
}
/// `PickupPetAction(slot)` — the pet bar's one drag verb, 1-based (ref
/// `PetActionBarFrame.lua:252-283`; the binding `0x4be180`).
///
/// - **`UNIT_FLAG_POSSESSED` set** ⇒ nothing at all. The gate sits above the cursor fork
/// (`0x4be1c1`), so it blocks the drop as well as the pick-up.
/// - **carrying a pet payload** ⇒ [`place_pet_action`].
/// - **carrying anything else** ⇒ nothing. The reference only enters its drop trampoline when the
/// *pet* payload global is non-empty; a held spell or item is not that global and simply stays
/// held. (Which is the same fact as "a pet bar cannot be populated, only rearranged".)
/// - **empty cursor** ⇒ pick the slot up: the payload is its word verbatim, and — **for a spell
/// slot only** (`0x4be268 cmp cl,1`) — the slot is written back with its id zeroed, through the
/// same assign core and with the same send. A token slot keeps its contents while its word rides
/// the cursor, which is how a command ends up *copied* onto another slot rather than moved.
///
/// Returns whether the caller should repaint.
pub(super) fn pickup_pet_action(model: &mut Model, slot: u32) -> bool {
if !model.pet_bar.pickup_allowed {
return false;
}
match &model.cursor {
Some(CursorPayload::PetAction(_)) => return place_pet_action(model, slot),
Some(_) => return false,
None => {}
}
let Some(index) = slot_index(model, slot) else {
return false;
};
let view = model.pet_bar.slots[index].view.clone();
let Some(packed) = payload_word(view.packed) else {
return false;
};
model.cursor = Some(CursorPayload::PetAction(CursorPetAction {
src_slot: slot,
packed,
passive: view.passive,
texture: view.texture.clone(),
}));
// A SPELL slot empties as it is picked up — id zeroed, type and autocast bits kept — and the
// server is told. Everything else keeps its slot.
//
// `passive: false` is not a shortcut: the assign core's passive gate reads the record of the
// **source word's own spell id**, and the blanked word's id is 0, which resolves to no record
// at all. So a passive spell still empties its slot when picked up — the refusal comes later,
// when the payload is dropped somewhere.
if kind(packed) == 1 {
write_slot(model, index, packed & 0xFFFF_0000, false);
}
queue_cursor_update(model);
true
}
/// The drop half. Runs the assign core against the engine's optimistic mirror of the ten words,
/// queues the resulting `(0-based position, word)` pairs for the app to send as one
/// `CMSG_PET_SET_ACTION`, and disposes of the cursor:
///
/// - **write, occupant homeless** ⇒ the occupant goes onto the cursor (the trampoline's
/// `0x4bce3d`, reached only on the core's `EAX = 1`) — the familiar swap-in-hand.
/// - **write, occupant relocated** ⇒ the cursor empties; the displaced word is still on the bar.
/// - **no write** (self-drop, passive source, or the token abort) ⇒ the cursor empties too, and
/// the payload is gone. `ClearCursor` ran before the decision and `EAX = 0` never re-fills.
///
/// The cursor is therefore emptied on **every** path out of here, including the ones that return
/// `false` — `false` is "nothing was written", not "nothing happened".
fn place_pet_action(model: &mut Model, slot: u32) -> bool {
let Some(CursorPayload::PetAction(held)) = model.cursor.clone() else {
return false;
};
// `ClearCursor(1,1)` — unconditional, and *before* anything is decided. Past the possessed
// gate in `PickupPetAction` there is no way back out of here still holding the payload.
model.cursor = None;
let Some(index) = slot_index(model, slot) else {
queue_cursor_update(model);
return false;
};
// The target's own view, read BEFORE the write — a displaced word's passive bit and icon are
// its slot's, not the payload's we are about to consume.
let occupant_view = model.pet_bar.slots[index].view.clone();
let Some(assigned) = write_slot(model, index, held.packed, held.passive) else {
queue_cursor_update(model);
return false;
};
model.cursor = assigned.displaced.map(|packed| {
CursorPayload::PetAction(CursorPetAction {
src_slot: slot,
packed,
passive: occupant_view.passive,
texture: occupant_view.texture.clone(),
})
});
queue_cursor_update(model);
true
}
/// The 1-based button index → an in-range slot of a bar that exists.
fn slot_index(model: &Model, slot: u32) -> Option<usize> {
if !model.pet_bar.has_bar || slot == 0 || slot > PET_SLOTS {
return None;
}
let index = slot as usize - 1;
(index < model.pet_bar.slots.len()).then_some(index)
}
/// Run [`assign`] against the engine's mirror and queue what changed. Split out because the pickup
/// path needs it too — the reference routes its own slot-blanking through the same core, with the
/// same send (`0x4be268`+).
///
/// The mirror is optimistic in exactly [`super::bar`]'s sense: the app owns the authoritative ten
/// words and re-pushes them, but a drag must read right in the same frame it happens.
fn write_slot(model: &mut Model, target: usize, source: u32, passive: bool) -> Option<Assigned> {
let mut words: Vec<u32> = model.pet_bar.slots.iter().map(|s| s.view.packed).collect();
let assigned = assign(&mut words, target, source, passive)?;
// One send carrying one or two `(position, data)` pairs — the optional relocation pair FIRST,
// then the mandatory target pair, exactly the order `0x4bcad4`+ builds them and exactly what
// `CMSG_PET_SET_ACTION` accepts. The relocation pair goes out whenever there was a relocation,
// without re-testing whether the word changed: that is the binary's own `if (relocSlot >= 0)`.
let mut entries: Vec<(u32, u32)> = Vec::with_capacity(2);
if let Some(j) = assigned.reloc {
entries.push((j as u32, words[j]));
}
entries.push((target as u32, words[target]));
model.pet_set_actions.push(entries);
for (i, word) in words.into_iter().enumerate() {
model.pet_bar.slots[i].view.packed = word;
}
Some(assigned)
}
/// A **token** word's `texture` is the NAME of a global, not a path — `GetPetActionInfo`'s own
/// convention (§2.4: the two `char[32]` tables hand back `PET_ATTACK_TEXTURE`, and the bar paints
/// with `getglobal(texture)`). The cursor takes a PATH, so a command or reaction word that reaches
/// the cursor has to carry one, or the drag is invisible — you pick Attack up and nothing sticks
/// to the pointer. Resolved here, the one place a slot's view becomes a cursor payload, exactly as
/// `PetActionBar.xml` resolves it in the one place a slot's view becomes a button.
///
/// A spell word already carries a real `Interface\Icons\…` path and is left alone.
fn resolve_token_icon(lua: &Lua) {
let name = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
match &model.cursor {
Some(CursorPayload::PetAction(p)) if is_token(p.packed) => p.texture.clone(),
_ => None,
}
};
let Some(name) = name else {
return;
};
let path: Option<String> = lua.globals().get(name.as_str()).unwrap_or_default();
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if let Some(CursorPayload::PetAction(p)) = &mut model.cursor {
p.texture = path;
}
}
/// Register the pet bar's one cursor global.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.globals().set(
"PickupPetAction",
lua.create_function(|lua, slot: u32| {
let took = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
pickup_pet_action(&mut model, slot)
};
// Both halves can leave a TOKEN on the cursor — the pick-up directly, the drop through
// its displaced occupant — so the resolution hangs off the binding, not off either arm.
resolve_token_icon(lua);
Ok(took)
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::script::{PetActionView, UiScript};
/// The words a hunter's bar actually carries. `FILLER` is vmangos's own unused-slot shape
/// (`ACT_DISABLED` + spell id 0) — masked type 1 with a zero low word, which is exactly the
/// relocation candidate the core hunts for, and the reason it must not be flattened to 0.
const ATTACK: u32 = 0x0700_0002;
const FOLLOW: u32 = 0x0700_0001;
const DEFENSIVE: u32 = 0x0600_0001;
const CLAW: u32 = 0xC100_0BC2;
const GROWL: u32 = 0x8100_0EC0;
const BITE: u32 = 0x8100_0EC1;
const FILLER: u32 = 0x8100_0000;
/// A source that is **still on the bar** swaps with its destination: the duplicate scan finds
/// the copy, that slot takes the occupant, and the cursor comes away empty. This is the path a
/// TOKEN drag always takes, because a token pickup does not blank its source slot.
#[test]
fn a_source_still_on_the_bar_swaps_with_its_destination() {
let mut bar = [CLAW, GROWL, FILLER, ATTACK];
let a = assign(&mut bar, 1, CLAW, false).unwrap();
assert_eq!(bar[1], CLAW, "the source word landed");
assert_eq!(a.reloc, Some(0), "and its own old slot took the occupant");
assert_eq!(bar[0], GROWL);
assert_eq!(a.displaced, None, "nothing left over to carry");
}
/// A SPELL drag is the other shape, and the pickup's blank is what makes it so: a blanked
/// source is the one thing the duplicate scan skips (`0x4bca34`), so nothing matches and the
/// occupant is displaced onto the cursor instead.
#[test]
fn a_blanked_source_displaces_the_occupant_instead_of_swapping() {
let mut bar = [CLAW, GROWL, FILLER, ATTACK];
assign(&mut bar, 0, CLAW & 0xFFFF_0000, false).unwrap();
assert_eq!(bar[0], CLAW & 0xFFFF_0000, "the pickup blanked slot 0");
let a = assign(&mut bar, 1, CLAW, false).unwrap();
assert_eq!(bar[1], CLAW);
assert_eq!(a.reloc, None);
assert_eq!(a.displaced, Some(GROWL));
}
/// A TOKEN occupant is never displaced onto the cursor: the core relocates it to the first
/// candidate (not a token, low 16 zero) and the cursor comes away empty.
#[test]
fn a_token_occupant_is_relocated_not_displaced() {
let mut bar = [CLAW, ATTACK, FILLER, DEFENSIVE];
let a = assign(&mut bar, 1, BITE, false).unwrap();
assert_eq!(bar[1], BITE);
assert_eq!(a.reloc, Some(2), "ATTACK moved to the filler slot");
assert_eq!(bar[2], ATTACK);
assert_eq!(a.displaced, None, "nothing is left over to carry");
}
/// …and with nowhere to put it, the whole assignment ABORTS: no write, no send. The reference
/// reports this identically to a successful relocation (`EAX = 0`), which is why the outcome
/// here is an `Option` and never a bool.
#[test]
fn a_token_occupant_with_no_candidate_aborts_entirely() {
let mut bar = [CLAW, ATTACK, GROWL, DEFENSIVE];
let before = bar;
assert!(assign(&mut bar, 1, BITE, false).is_none());
assert_eq!(bar, before, "nothing written");
}
/// The two silent refusals: dropping a word onto the slot it already occupies (compared as the
/// FULL dword, autocast bits and all), and a passive spell source.
#[test]
fn the_self_drop_and_the_passive_source_are_no_ops() {
let mut bar = [CLAW, GROWL, FILLER, ATTACK];
assert!(assign(&mut bar, 0, CLAW, false).is_none());
assert!(assign(&mut bar, 1, CLAW, true).is_none(), "passive source");
// The autocast bit is part of the compare — the same spell with its ring flipped is a
// different word and DOES write.
assert!(assign(&mut bar, 0, CLAW & !0x4000_0000, false).is_some());
}
fn view(packed: u32, name: &str) -> PetActionView {
PetActionView {
name: (packed != 0).then(|| name.to_string()),
packed,
..Default::default()
}
}
/// A token slot as the feed really pushes it: `name` and `texture` are both the NAMES OF
/// GLOBALS, which is `GetPetActionInfo`'s own convention for types 6 and 7.
fn token_view(packed: u32, name: &str, texture: &str) -> PetActionView {
PetActionView {
is_token: true,
texture: Some(texture.to_string()),
..view(packed, name)
}
}
/// A hunter's default bar: three commands, four spell slots (two known, two filler), three
/// reactions — the arrangement `CharmInfo::InitPetActionBar` writes.
fn hunter_bar() -> Vec<PetActionView> {
vec![
token_view(ATTACK, "PET_ACTION_ATTACK", "PET_ATTACK_TEXTURE"),
token_view(FOLLOW, "PET_ACTION_FOLLOW", "PET_FOLLOW_TEXTURE"),
token_view(0x0700_0003, "PET_ACTION_WAIT", "PET_WAIT_TEXTURE"),
view(CLAW, "Claw"),
view(GROWL, "Growl"),
view(FILLER, ""),
view(FILLER, ""),
token_view(0x0600_0002, "PET_MODE_AGGRESSIVE", "PET_AGGRESSIVE_TEXTURE"),
token_view(DEFENSIVE, "PET_MODE_DEFENSIVE", "PET_DEFENSIVE_TEXTURE"),
token_view(0x0600_0000, "PET_MODE_PASSIVE", "PET_PASSIVE_TEXTURE"),
]
}
fn bar_script() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_pet_actions(true, true, true, hunter_bar());
s
}
/// Picking a SPELL slot up empties it — id zeroed, type and autocast bits kept — and tells the
/// server in the same breath. The payload is the word verbatim.
#[test]
fn picking_up_a_spell_blanks_its_slot_and_sends() {
let mut s = bar_script();
assert!(s.eval::<bool>("return PickupPetAction(4)").unwrap());
assert_eq!(
s.eval::<(String, i64)>("local k, slot = GetCursorInfo() return k, slot")
.unwrap(),
("petaction".to_string(), 4)
);
assert_eq!(
s.take_pet_set_actions(),
vec![vec![(3, CLAW & 0xFFFF_0000)]],
"one pair, 0-based, the id zeroed and everything else kept"
);
}
/// A dragged TOKEN carries a real icon PATH, not the name of the global that holds one.
///
/// `GetPetActionInfo` hands a token slot `("PET_ACTION_ATTACK", …, "PET_ATTACK_TEXTURE")` —
/// two global names — and the bar paints with `getglobal`. The cursor cannot: it needs a path
/// to decode, so an unresolved name means dragging Attack, Follow or Stay sticks *nothing* to
/// the pointer, which is what the director saw. A spell slot's path is left exactly as it is.
#[test]
fn a_dragged_token_carries_a_resolved_icon_path() {
let mut s = UiScript::new().unwrap();
s.set_pet_actions(true, true, true, hunter_bar());
s.run(r#"PET_ATTACK_TEXTURE = "Interface\\Icons\\Ability_GhoulFrenzy""#)
.unwrap();
s.run("PickupPetAction(1)").unwrap();
let Some(CursorPayload::PetAction(p)) = s.cursor_payload() else {
panic!("the command word is on the cursor")
};
assert_eq!(
p.texture.as_deref(),
Some("Interface\\Icons\\Ability_GhoulFrenzy"),
"the global was resolved, not carried as a name"
);
// An unset global resolves to nothing rather than to its own name — better a missing icon
// than a path that cannot exist.
s.run("ClearCursor() PET_ATTACK_TEXTURE = nil PickupPetAction(1)")
.unwrap();
let Some(CursorPayload::PetAction(p)) = s.cursor_payload() else {
panic!("still picked up")
};
assert_eq!(p.texture, None);
}
/// A TOKEN slot keeps its contents while its word rides the cursor (`0x4be268 cmp cl,1` — the
/// blank is type-1-only), so a command ends up COPIED onto its destination rather than moved.
#[test]
fn picking_up_a_token_leaves_the_slot_alone() {
let mut s = bar_script();
assert!(s.eval::<bool>("return PickupPetAction(1)").unwrap());
assert!(
s.take_pet_set_actions().is_empty(),
"nothing was written, so nothing is sent"
);
}
/// The whole gesture, end to end: pick a spell up and drop it two slots along. The pickup's
/// blank and the drop's write are two separate sends, and the displaced spell comes back on
/// the cursor.
#[test]
fn a_drag_between_two_spell_slots_sends_twice_and_carries_the_occupant() {
let mut s = bar_script();
s.run("PickupPetAction(4) PickupPetAction(5)").unwrap();
assert_eq!(
s.take_pet_set_actions(),
vec![vec![(3, CLAW & 0xFFFF_0000)], vec![(4, CLAW)]]
);
assert_eq!(
s.eval::<(String, i64)>("local k, slot = GetCursorInfo() return k, slot")
.unwrap(),
("petaction".to_string(), 5),
"Growl was displaced and is now held, addressed as the slot it came from"
);
}
/// Dropping onto a COMMAND relocates that command to a filler slot instead of handing it back
/// — the cursor comes away empty, and ONE send carries both pairs (the server tells the two
/// forms apart by body size, so they must not be split).
#[test]
fn dropping_onto_a_token_relocates_it_in_one_send() {
let mut s = bar_script();
s.run("PickupPetAction(4) PickupPetAction(1)").unwrap();
let sends = s.take_pet_set_actions();
assert_eq!(sends.len(), 2, "the pickup's blank, then the drop");
assert_eq!(
sends[1],
vec![(3, ATTACK), (0, CLAW)],
"the relocation pair FIRST, then the write — the binary's own order. And the slot the \
spell was just picked OUT of is the first candidate, so Attack lands there."
);
assert!(s.eval::<bool>("return GetCursorInfo() == nil").unwrap());
}
/// A refused drop **eats** the payload — `ClearCursor` ran before the decision and `EAX = 0`
/// never re-fills. 1010 shipped the inverse as a named divergence; 1016 retired it.
///
/// The reachable refusal is the passive-spell source: the pickup has no passive gate, so a
/// passive slot picks up fine and is then refused everywhere it is dropped. Losing it is not
/// the data loss it looks like — the pickup's blank already went to the server, which is
/// exactly how a pet spell is taken off the bar.
#[test]
fn a_refused_drop_eats_the_payload() {
let mut s = UiScript::new().unwrap();
let mut bar = hunter_bar();
bar[3] = PetActionView {
passive: true,
..view(CLAW, "Claw")
};
s.set_pet_actions(true, true, true, bar);
s.run("PickupPetAction(4) PickupPetAction(5)").unwrap();
assert_eq!(
s.take_pet_set_actions(),
vec![vec![(3, CLAW & 0xFFFF_0000)]],
"the pickup's own blank went through; the drop wrote and sent nothing"
);
assert!(
s.eval::<bool>("return GetCursorInfo() == nil").unwrap(),
"and the cursor is empty — the reference cleared it before it ever decided"
);
}
/// The clamp. The reference's callers admit Lua argument 11 and its assign core never bounds
/// again, so a real client writes one dword past the array; we refuse instead.
#[test]
fn slot_eleven_is_refused_rather_than_written_past_the_array() {
let mut s = bar_script();
assert!(!s.eval::<bool>("return PickupPetAction(11)").unwrap());
assert!(!s.eval::<bool>("return PickupPetAction(0)").unwrap());
assert!(s.take_pet_set_actions().is_empty());
assert!(s.eval::<bool>("return GetCursorInfo() == nil").unwrap());
}
/// `UNIT_FLAG_POSSESSED` blocks BOTH ends, because the reference's gate sits above the cursor
/// fork: you cannot pick a possessed unit's slot up, and you cannot drop onto its bar either.
#[test]
fn a_possessed_bar_takes_no_drag_at_all() {
let mut s = UiScript::new().unwrap();
s.set_pet_actions(true, true, false, hunter_bar());
assert!(!s.eval::<bool>("return PickupPetAction(4)").unwrap());
assert!(s.take_pet_set_actions().is_empty());
}
/// The two payload spaces do not overlap in either direction, and the two grids follow them:
/// a held spell lights the ACTION bar's grid and cannot land on a pet slot; a held pet action
/// lights the PET bar's and cannot land on an action slot.
#[test]
fn the_pet_payload_and_the_action_bar_refuse_each_other() {
let mut s = bar_script();
s.set_action(
1,
Some(crate::script::ActionSlot {
texture: Some("Interface\\Icons\\Spell_A".into()),
kind: 0,
action: 133,
count: 0,
}),
);
s.run(
r#"
petshows, barshows = 0, 0
local f = CreateFrame("Frame", "PetGridListener")
f:RegisterEvent("PET_BAR_SHOWGRID")
f:RegisterEvent("ACTIONBAR_SHOWGRID")
f:SetScript("OnEvent", function()
if event == "PET_BAR_SHOWGRID" then petshows = petshows + 1 end
if event == "ACTIONBAR_SHOWGRID" then barshows = barshows + 1 end
end)
"#,
)
.unwrap();
s.run("PickupPetAction(4)").unwrap();
s.tick(0.01);
assert_eq!(s.eval::<i64>("return petshows").unwrap(), 1);
assert_eq!(
s.eval::<i64>("return barshows").unwrap(),
0,
"the action bar's grid stays down — nothing can land there"
);
// PlaceAction refuses it outright and leaves it held.
assert!(!s.eval::<bool>("return PlaceAction(1)").unwrap());
assert!(s
.eval::<bool>("local k = GetCursorInfo() return k == 'petaction'")
.unwrap());
assert!(
s.take_action_sets().is_empty(),
"and writes nothing to the action bar"
);
}
}
+4 -4
View File
@@ -71,8 +71,8 @@ pub(crate) struct KeybindState {
/// Bumped by every live-table mutation and every seed — the app's re-derive-dispatch signal.
generation: u64,
requests: Vec<KeybindRequest>,
/// The Key Bindings window's capture arm (`BenillaBindCapture(armed)`): while set, the host
/// swallows raw input and calls the window back with the canonical chord string.
/// The Keybindings page's capture arm (`BenillaBindCapture(armed)`): while set, the host
/// swallows raw input and calls the page back with the canonical chord string.
capture_armed: bool,
}
@@ -279,8 +279,8 @@ impl super::UiScript {
self.model_mut().keybinds.stored[1].is_some()
}
/// The Key Bindings window's capture arm — while true, the host swallows raw input and
/// calls `KeyBindingFrame_OnHostKey("<chord>")` instead of dispatching it.
/// The Keybindings page's capture arm — while true, the host swallows raw input and
/// calls `KeyBindings_OnHostKey("<chord>")` instead of dispatching it.
pub fn bind_capture_armed(&self) -> bool {
self.model_mut().keybinds.capture_armed
}
+3 -3
View File
@@ -114,8 +114,8 @@ pub use char_stats::{
pub use container::{ContainerMove, ContainerSlot, ContainerState, EnchantView, UiCursorMode};
pub use craft::{CraftReagent, CraftRecipe, CraftState};
pub use cursor::{
CursorAction, CursorItem, CursorMacro, CursorPayload, CursorSpell, EnchantConfirm, WorldPick,
EQUIPMENT_BAG,
CursorAction, CursorItem, CursorMacro, CursorPayload, CursorPetAction, CursorSpell,
EnchantConfirm, WorldPick, EQUIPMENT_BAG,
};
pub use death::{DeathAction, DeathUiState};
pub use duel::DuelRequest;
@@ -131,7 +131,7 @@ pub use mail::{MailInboxRow, MailSendRequest, MailState};
pub use merchant::{ItemStatsHead, MerchantItem, MerchantState};
pub(crate) use model::Model;
pub use party::{PartyMemberInfo, PartyRequest, PartyState};
pub use pet::PetActionView;
pub use pet::{PetActionView, PetStats};
pub use quest::{QuestAction, QuestItemView, QuestPanel, QuestSelect, QuestState};
pub use quest_log::{QuestLogDetail, QuestLogEntryView, QuestLogObjectiveView, QuestLogState};
pub use session::SessionRequest;
+17
View File
@@ -306,6 +306,12 @@ pub(crate) struct Model {
pub(crate) pet_autocast_toggles: Vec<u32>,
/// `PetStopAttack()` calls queued — a count, since the verb takes no argument.
pub(crate) pet_stop_attacks: u32,
/// Pet bar writes queued by the drag ([`cursor::pet`], decision 1010) — **one entry per
/// `CMSG_PET_SET_ACTION`**, each holding the one or two `(0-based position, packed word)` pairs
/// that send names. The nesting is the point: the server tells the one-pair form from the
/// two-pair form **by body size**, so a relocation and its write must travel together and must
/// not be flattened into a stream of singles.
pub(crate) pet_set_actions: Vec<Vec<(u32, u32)>>,
/// The per-bag container snapshot (keyed by live-API bag id, 0 = backpack) the app pushes,
/// and the `UseContainerItem` intents it drains — the container seam ([`container`]).
@@ -332,7 +338,16 @@ pub(crate) struct Model {
/// arm, any surface (bags/doll/actions alike — the reference's own "any placeable payload
/// shows the bar's drop grid"). A Some→Some transition (the action hop) updates nothing here,
/// so no spurious HIDE+SHOW churns out of one gesture.
///
/// The **pet** payload arm is excluded (decision 1010): `PlaceAction` refuses it, so lighting
/// the action bar's empty slots for a payload that cannot land there would be an invitation to
/// a no-op. It drives [`Self::pet_grid_shown`] instead.
pub(crate) cursor_grid_shown: bool,
/// The same mirror for the PET bar's grid — `PET_BAR_SHOWGRID`/`PET_BAR_HIDEGRID`, which the
/// reference fires from inside the pet-action pickup builder itself (`0x494f28`) rather than
/// from a shared cursor transition. So the two grids are disjoint: a spell lights the action
/// bar's, a pet action lights the pet bar's, and neither lights both.
pub(crate) pet_grid_shown: bool,
/// The app's world pick under the cursor — the reference's click-time pick state
/// (`[this+0x350]`: nothing / terrain / object), fed once per frame
/// ([`super::UiScript::set_world_pick`]; stays `Nothing` in tests/captures). Routes
@@ -792,12 +807,14 @@ impl Model {
pet_actions_pressed: Vec::new(),
pet_autocast_toggles: Vec::new(),
pet_stop_attacks: 0,
pet_set_actions: Vec::new(),
containers: HashMap::new(),
container_uses: Vec::new(),
container_cooldowns: HashMap::new(),
has_key: false,
cursor: None,
cursor_grid_shown: false,
pet_grid_shown: false,
world_pick: cursor::WorldPick::default(),
container_moves: Vec::new(),
container_repairs: Vec::new(),
+297 -6
View File
@@ -51,6 +51,22 @@ pub struct PetActionView {
/// `(start_ms on the GetTime clock, duration_ms, enabled)` — [`super::action::ActionState`]'s
/// exact shape; `None` = no cooldown.
pub cooldown: Option<(i64, u32, bool)>,
/// **The slot's packed word, verbatim** — the one place this seam's "the engine holds no pet
/// knowledge" rule bends, and deliberately (decision 1010).
///
/// The drag ([`super::cursor::pet`]) is word arithmetic in the reference and cannot be
/// anything else: `0x4bc9a0` compares occupants under `& 0x3FFFFFFF`, tests a candidate's low
/// 16 bits, and **writes the source word through unchanged** — `0x4bce00` forwards the cursor's
/// payload dword without reading a field of it, and the payload itself is only ever a verbatim
/// copy of a word that already existed in a slot. There is no drop-time encoding to model, so
/// re-deriving a word from `(kind, action, bits)` at the engine boundary would be inventing one.
///
/// `0` is the empty slot, which is also the reference's own test (the zero dword).
pub packed: u32,
/// `Attributes & 0x40` (`SPELL_ATTR_PASSIVE`) for a spell slot — the drop core's one source
/// filter (`0x4bc9f8`–`0x4bca2e`: a type-1 source whose `SpellRec+0x18 & 0x40` is set is
/// refused, silently). False for a token and for an empty slot.
pub passive: bool,
}
/// [`PetActionView`] as stored: the cooldown converted to the `GetTime` clock at push time (the
@@ -62,6 +78,39 @@ pub(crate) struct StoredPetAction {
pub(crate) cooldown: Option<(f64, f64, bool)>,
}
/// The hunter-pet stat block behind `GetPetHappiness`/`GetPetLoyalty`/`GetPetTrainingPoints`/
/// `GetPetExperience` and `HasPetUI`'s second return (decision 1005; wow-re §11b).
///
/// **All four bindings share one gate** — `0x6116e0(pet)`, "is this a hunter's pet" — which is why
/// they share one pushed struct: a warlock's imp resolves perfectly well and still answers nothing,
/// because happiness, loyalty and training points are hunter machinery.
///
/// **Their failure conventions differ, and the difference is the API.** `GetPetLoyalty` fails to
/// **nil**; the two pairs fail to **`(0, 0)` — numbers, not nil**; `GetPetHappiness` fails to
/// **`(nil, 100.0, 0.0)`**, nil in the first slot and numbers in the other two. `PetFrame.lua`
/// hides the happiness icon on `not happiness` alone, so collapsing any of these into a uniform
/// nil-everything would hide the frame in cases the reference shows it.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PetStats {
/// `HasPetUI`'s second return — the hunter-pet discriminator. False makes every field below
/// answer its own failure convention regardless of what is in it.
pub hunter_pet: bool,
/// `GetPetHappiness`'s first return: the **pre-bucketed** `0..=3`. `None` is the gate
/// failure's nil; `Some(0)` is a real answer and must stay distinguishable from it.
pub happiness: Option<u32>,
/// Return 2 — the damage percentage, already scaled by the client's own `100.0f`.
pub damage_percentage: f32,
/// Return 3 — the loyalty rate, unscaled and possibly negative.
pub loyalty_rate: f32,
/// `GetPetLoyalty`'s single return — the `PetLoyalty.dbc` name, verbatim including the shipped
/// `"(Loyalty Level N) "` prefix. `None` = nil (level 0, or off the table).
pub loyalty: Option<String>,
/// `GetPetTrainingPoints` → `(totalPoints, spent)`, high word first.
pub training_points: (u16, u16),
/// `GetPetExperience` → `(currXP, nextXP)`.
pub experience: (u32, u32),
}
/// The pet bar's pushed state: the slots plus the two bar-wide bits the reference exposes
/// separately from them.
#[derive(Clone, Debug, Default, PartialEq)]
@@ -72,6 +121,21 @@ pub(crate) struct PetBarState {
/// `GetPetActionsUsable()` — false desaturates every icon on the bar at once.
pub(crate) actions_usable: bool,
pub(crate) slots: Vec<StoredPetAction>,
/// `HasPetUI`'s FIRST return — "there is a pet with a UI at all", which the reference derives
/// from the pet resolving plus its `UNIT_FIELD_PETNUMBER` being nonzero (`0x4be697`). Separate
/// from `has_bar`: the action bar's gate is the cached guid alone, with no pet-number test.
pub(crate) has_ui: bool,
pub(crate) stats: PetStats,
/// May the bar be **rearranged** — `PickupPetAction`'s own gate, and nobody else's
/// (`0x4be1c1`: `[[pet+0x110]+0xA3] & 1 == 0`, i.e. `UNIT_FLAG_POSSESSED` clear).
///
/// A third bar-wide bit rather than a fold into `actions_usable`, because the reference is
/// deliberate about keeping them apart: possession is expressly **not** among the flags that
/// grey the bar (a possessed unit is exactly the case where the buttons must still work), and
/// the crowd-control flags that do grey it do **not** block a drag. Two gates, two questions.
///
/// It sits before the cursor fork, so it blocks the DROP as well as the pick-up.
pub(crate) pickup_allowed: bool,
}
impl super::UiScript {
@@ -81,12 +145,14 @@ impl super::UiScript {
&mut self,
has_bar: bool,
actions_usable: bool,
pickup_allowed: bool,
slots: Vec<PetActionView>,
) {
let mut model = self.model_mut();
model.pet_bar = PetBarState {
has_bar,
actions_usable,
pickup_allowed,
slots: slots
.into_iter()
.map(|view| {
@@ -102,9 +168,22 @@ impl super::UiScript {
StoredPetAction { view, cooldown }
})
.collect(),
// The stat block rides its own setter: `SMSG_PET_SPELLS` replaces the bar wholesale,
// but happiness moves every few seconds off a plain descriptor field, so tying the two
// together would make the bar's diff-and-fire churn on a number no button draws.
has_ui: model.pet_bar.has_ui,
stats: std::mem::take(&mut model.pet_bar.stats),
};
}
/// Push the hunter-pet stat block ([`PetStats`]) — the four paper-doll bindings plus
/// `HasPetUI`. Separate from [`Self::set_pet_actions`] because it changes on a different clock.
pub fn set_pet_stats(&mut self, has_ui: bool, stats: PetStats) {
let bar = &mut self.model_mut().pet_bar;
bar.has_ui = has_ui;
bar.stats = stats;
}
/// Drain the 1-based slot indices `CastPetAction` queued since the last call. What each index
/// *means* on the wire (a command, a reaction, a cast) is the app's to decide at drain time
/// from the slot it still owns.
@@ -121,6 +200,16 @@ impl super::UiScript {
pub fn take_pet_stop_attacks(&mut self) -> u32 {
std::mem::replace(&mut self.model_mut().pet_stop_attacks, 0)
}
/// Drain the pet bar writes the drag queued (decision 1010) — **one `Vec` per
/// `CMSG_PET_SET_ACTION`**, each of one or two `(0-based position, packed word)` pairs.
///
/// Already-applied on the engine's side: the app's job is to mirror each pair into its own
/// authoritative ten words and put the batch on the wire whole. Flattening the batches would
/// break the server's body-size fork between the one- and two-entry forms.
pub fn take_pet_set_actions(&mut self) -> Vec<Vec<(u32, u32)>> {
std::mem::take(&mut self.model_mut().pet_set_actions)
}
}
/// The 1-based button index → stored slot, the reference's own indexing.
@@ -241,6 +330,89 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// HasPetUI() → hasUI, isHunterPet — ALWAYS exactly two returns (`EAX=2` on every path of
// `0x4be670`), with `(nil, nil)` when there is no pet with a UI. The second return is the
// gate every stat binding below shares.
g.set(
"HasPetUI",
lua.create_function(move |lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let bar = &model.pet_bar;
Ok((flag(bar.has_ui), flag(bar.has_ui && bar.stats.hunter_pet)))
})?,
)?;
// GetPetHappiness() → happiness, damagePercentage, loyaltyRate.
//
// The client thresholds; Lua does not. Return 1 is a PRE-BUCKETED 0..3 read off
// PetPersonality.dbc's three columns, and `0` is a real bucket that answers the NUMBER 0 —
// `PetFrame.lua` hides the icon on `not happiness`, and a 0 leaves it showing with whatever
// texcoords it had. Only the gate failure is nil, and even then returns 2 and 3 are the
// numbers (100.0, 0.0), never nil.
g.set(
"GetPetHappiness",
lua.create_function(move |lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let s = &model.pet_bar.stats;
let happiness = match s.happiness.filter(|_| s.hunter_pet) {
Some(b) => Value::Integer(i64::from(b)),
None => Value::Nil,
};
let (dmg, rate) = if happiness == Value::Nil {
(100.0, 0.0)
} else {
(s.damage_percentage, s.loyalty_rate)
};
Ok(MultiValue::from_vec(vec![
happiness,
Value::Number(f64::from(dmg)),
Value::Number(f64::from(rate)),
]))
})?,
)?;
// GetPetLoyalty() → the localized level name, or NIL. The one stat binding whose failure is
// nil rather than a number — level 0 (no loyalty yet) included.
g.set(
"GetPetLoyalty",
lua.create_function(move |lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let s = &model.pet_bar.stats;
Ok(match s.loyalty.as_deref().filter(|_| s.hunter_pet) {
Some(name) => Value::String(lua.create_string(name)?),
None => Value::Nil,
})
})?,
)?;
// GetPetTrainingPoints() → totalPoints, spent — the two halves of one packed dword, HIGH word
// first. Numbers on every path: a gate failure is (0, 0), not nil.
g.set(
"GetPetTrainingPoints",
lua.create_function(move |lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let s = &model.pet_bar.stats;
let (total, spent) = if s.hunter_pet {
s.training_points
} else {
(0, 0)
};
Ok((f64::from(total), f64::from(spent)))
})?,
)?;
// GetPetExperience() → currXP, nextXP. Numbers on every path, like the pair above; the client
// converts both `fild qword` off a zero-extended dword, so neither can arrive negative.
g.set(
"GetPetExperience",
lua.create_function(move |lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let s = &model.pet_bar.stats;
let (cur, next) = if s.hunter_pet { s.experience } else { (0, 0) };
Ok((f64::from(cur), f64::from(next)))
})?,
)?;
// PetStopAttack() — queue the call-off. No argument: the wire carries only the pet's guid.
g.set(
"PetStopAttack",
@@ -256,7 +428,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
#[cfg(test)]
mod tests {
use super::PetActionView;
use super::{PetActionView, PetStats};
use crate::script::UiScript;
/// A hunter's bar, cut down to the three slot classes that matter: the Attack command (a
@@ -291,7 +463,7 @@ mod tests {
assert!(s.eval::<bool>("return PetHasActionBar() == nil").unwrap());
assert!(s.eval::<bool>("return GetPetActionInfo(1) == nil").unwrap());
s.set_pet_actions(true, true, slots());
s.set_pet_actions(true, true, true, slots());
assert_eq!(s.eval::<i64>("return PetHasActionBar()").unwrap(), 1);
assert_eq!(s.eval::<i64>("return GetPetActionsUsable()").unwrap(), 1);
@@ -349,7 +521,7 @@ mod tests {
fn cooldown_triple_stamps_to_the_vm_clock_and_goes_cold() {
let mut s = UiScript::new().unwrap();
s.tick(10.0); // GetTime == 10
s.set_pet_actions(true, true, slots());
s.set_pet_actions(true, true, true, slots());
assert_eq!(
s.eval::<(f64, f64, i32)>("return GetPetActionCooldown(1)")
@@ -378,7 +550,7 @@ mod tests {
#[test]
fn intents_queue_and_the_meaningless_ones_are_dropped() {
let mut s = UiScript::new().unwrap();
s.set_pet_actions(true, true, slots());
s.set_pet_actions(true, true, true, slots());
s.run("CastPetAction(1) CastPetAction(3) CastPetAction(9)")
.unwrap();
@@ -410,7 +582,7 @@ mod tests {
#[test]
fn attack_active_is_a_per_slot_boolean() {
let mut s = UiScript::new().unwrap();
s.set_pet_actions(true, true, slots());
s.set_pet_actions(true, true, true, slots());
assert!(s.eval::<bool>("return IsPetAttackActive(1)").unwrap());
assert!(!s.eval::<bool>("return IsPetAttackActive(2)").unwrap());
assert!(s
@@ -423,10 +595,129 @@ mod tests {
#[test]
fn a_disabled_bar_is_still_a_bar() {
let mut s = UiScript::new().unwrap();
s.set_pet_actions(true, false, slots());
s.set_pet_actions(true, false, true, slots());
assert_eq!(s.eval::<i64>("return PetHasActionBar()").unwrap(), 1);
assert!(s
.eval::<bool>("return GetPetActionsUsable() == nil")
.unwrap());
}
fn hunter_stats() -> PetStats {
PetStats {
hunter_pet: true,
happiness: Some(3),
damage_percentage: 125.0,
loyalty_rate: 20.0,
loyalty: Some("(Loyalty Level 6) Best Friend".into()),
training_points: (170, 130),
experience: (4200, 8000),
}
}
/// The four stat bindings on a hunter's pet — arities and values, including the pair ORDER
/// (`total` before `spent`, `curr` before `next`), which is the half a swap makes invisible.
#[test]
fn a_hunters_pet_answers_every_stat_binding() {
let mut s = UiScript::new().unwrap();
s.set_pet_stats(true, hunter_stats());
assert!(s
.eval::<bool>(
"local h, dmg, rate = GetPetHappiness() \
return h == 3 and dmg == 125 and rate == 20"
)
.unwrap());
assert_eq!(
s.eval::<String>("return GetPetLoyalty()").unwrap(),
"(Loyalty Level 6) Best Friend",
"the shipped prefix is pushed verbatim — the client does no stripping"
);
assert!(s
.eval::<bool>("local t, sp = GetPetTrainingPoints() return t == 170 and sp == 130")
.unwrap());
assert!(s
.eval::<bool>("local c, n = GetPetExperience() return c == 4200 and n == 8000")
.unwrap());
assert!(s
.eval::<bool>("local ui, hunter = HasPetUI() return ui == 1 and hunter == 1")
.unwrap());
}
/// **The failure conventions are three different shapes and the API is those shapes.** A
/// warlock's imp has a pet UI and is not a hunter pet: loyalty goes nil, both pairs go to
/// `(0, 0)` as NUMBERS, and happiness goes `(nil, 100, 0)` — nil in the first slot only.
///
/// Flattening any of these to nil-everything breaks a different consumer: `PetFrame.lua` hides
/// its happiness icon on `not happiness`, but `PetPaperDollFrame` does arithmetic on the pairs
/// and would error on a nil.
#[test]
fn a_non_hunter_pet_fails_three_different_ways() {
let mut s = UiScript::new().unwrap();
s.set_pet_stats(
true,
PetStats {
hunter_pet: false,
..hunter_stats()
},
);
assert!(
s.eval::<bool>(
"local h, dmg, rate = GetPetHappiness() \
return h == nil and dmg == 100 and rate == 0"
)
.unwrap(),
"happiness is nil but its two numbers are still numbers"
);
assert!(s.eval::<bool>("return GetPetLoyalty() == nil").unwrap());
assert!(s
.eval::<bool>("local t, sp = GetPetTrainingPoints() return t == 0 and sp == 0")
.unwrap());
assert!(s
.eval::<bool>("local c, n = GetPetExperience() return c == 0 and n == 0")
.unwrap());
// HasPetUI still reports the UI — it is the SECOND return that discriminates.
assert!(s
.eval::<bool>("local ui, hunter = HasPetUI() return ui == 1 and hunter == nil")
.unwrap());
}
/// **Bucket 0 is a number, not nil**, and this is the trap the RE calls out by name: the
/// shipped `PetFrame.lua` branches on 1/2/3 and hides only on `not happiness`, so a 0 must
/// leave the icon up. Collapsing it into the failure path hides a frame the reference shows.
#[test]
fn happiness_bucket_zero_is_not_the_failure_case() {
let mut s = UiScript::new().unwrap();
s.set_pet_stats(
true,
PetStats {
happiness: Some(0),
damage_percentage: 100.0,
loyalty_rate: 0.0,
..hunter_stats()
},
);
assert!(s
.eval::<bool>("local h = GetPetHappiness() return h == 0 and h ~= nil")
.unwrap());
assert!(
s.eval::<bool>("return not GetPetHappiness() == false")
.unwrap(),
"0 is truthy in Lua, so the reference's `not happiness` hide-test does NOT fire"
);
}
/// With no pet at all every binding takes its failure path and `HasPetUI` answers `(nil, nil)`
/// — two returns on every path, never zero.
#[test]
fn no_pet_still_answers_two_values_from_has_pet_ui() {
let s = UiScript::new().unwrap();
assert!(s
.eval::<bool>("local ui, hunter = HasPetUI() return ui == nil and hunter == nil")
.unwrap());
assert!(s.eval::<bool>("return GetPetLoyalty() == nil").unwrap());
assert!(s
.eval::<bool>("local h, dmg = GetPetHappiness() return h == nil and dmg == 100")
.unwrap());
}
}