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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(¶ms.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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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\`.
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`]).
|
||||
///
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user