spells shake the camera, and the sound keeps playing in the background
- spells shake the camera: `SpellEffectCameraShakes.dbc` loads beside `CameraShakes.dbc` as the group table, `SpellVisualKit` field 14 is the shake column (58 of 1772 kits carry one — War Stomp, Thunder Clap, Kodo Stomp, the ~50 Knockdown/Slam/Charge impacts, every summon, Sapper Charge, Earthquake, Sapphiron's death), and the kit shake fires exactly once per play from the first effect node's one-time arm pass (`0x620e11`), the same leg that plays the kit sound one field over — the older "fires only if nothing else did" reading would have silenced every kit with a populated effect slot; `$SHK` markers fire from GameObject and DynamicObject hosts only (the titan chest, the bomb wagon); one deliberate deviation — the reference reads a bone-attached node's position twelve instructions before it is written, so 26 of its 58 shake kits shake at the world origin and are culled by the 80 yd cut, which is an uninitialised field, not a design; 21 of 24 presets now have a live producer, up from 6 - the sound keeps playing when the window loses focus, if you want it to: 1.12 mutes everything on `WM_ACTIVATE` and offers no way out, so benilla ships that as the default (`Sound_EnableSoundWhenGameIsInBG` "0") and puts an Audio-page checkbox on it — a gate appended LAST in the main-track chain, downstream of every tap, so an unattended capture still records; wow-re's round settled that music is not exempt and that it is a mute, not a pause - a building's far side no longer draws through its own walls: the camera-inside leg has three passes and we had one and a half — Pass 2 admits an exterior-shell group only against a frustum clipped to ONE deferred window (we marked every such group visible against the full view, under a comment calling it a benign over-draw), and Pass 3, which we had none of, draws the 24 `0x10000` groups (14 in Stormwind, 6 across Dire Maul, Razorfen's four 450–520 yd shells) from anywhere, frustum-tested on both legs by byte-identical setup; there is no antiportal branch in 5875 and no such flag in its data (815/815 roots and 5220/5220 groups censused, no bit above 18 ever set) - the macro window is the reference's own `Blizzard_MacroUI`, and `GlobalStrings.lua` — line 1 of the reference's own toc — was missing from our manifest entirely (the app loaded it out of band at startup, so the client was right and every harness that loaded the manifest was not); exactly one `<OnLoad>` runs, the most-derived, because `SetScript` releases the slot before it looks at the new body — which is why the reference re-states the template's width line in tab 1 and not tab 2 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1,972 +0,0 @@
|
||||
<Ui>
|
||||
<!--
|
||||
benilla's MACRO window (decision 0983) — the 384x512 macro editor + its name/icon popup,
|
||||
re-expressed from the real 1.12 `Interface\AddOns\Blizzard_MacroUI\Blizzard_MacroUI.xml`
|
||||
and `.lua` (extracted from the MPQ chain to session scratch; director-local reference
|
||||
artifacts, never committed — the 0068/0138 pattern. Line cites below are into that
|
||||
extraction). All the art it names is shipped: `Interface\MacroFrame\MacroFrame-Icon`,
|
||||
`-BotLeft`, `-BotRight`, `MacroPopup-{Top,Bot}{Left,Right}` (verified present in
|
||||
interface.MPQ), plus the PaperDollInfoFrame top halves and the ClassTrainerFrame bar the
|
||||
ref itself borrows.
|
||||
|
||||
Docks the left UIPanel slot with the ref's own registration row
|
||||
(`UIPanelWindows["MacroFrame"] = { area = "left", pushable = 5, whileDead = 1 }`,
|
||||
Blizzard_MacroUI.lua:7 — mirrored in UiPanels.xml). `whileDead` is the reference's, and it
|
||||
matters: writing a macro while you run back as a ghost is a real thing people do.
|
||||
|
||||
The usual house deviation, applied consistently: every frame here is Benilla-prefixed. The
|
||||
ref's global entry point `ShowMacroFrame()` keeps its BARE name (UIParent.lua:198 — the
|
||||
game menu's Macros button and `SlashCmdList["MACRO"]` both call it), matching this
|
||||
codebase's own `ToggleCharacter`/`ToggleQuestLog`/`ToggleSpellBook` precedent.
|
||||
|
||||
THE MODEL IS THE ENGINE'S. Unlike every other window in this folder, the macro table is not
|
||||
an app feed: 1.12 macros have no server side at all, so `benilla_ui::script::macros` owns
|
||||
the table and this file drives it through the reference's own API —
|
||||
`GetNumMacros`/`GetMacroInfo`/`GetMacroIndexByName`/`CreateMacro`/`EditMacro`/
|
||||
`DeleteMacro`/`PickupMacro`/`GetNumMacroIcons`/`GetMacroIconInfo`. `UPDATE_MACROS` (the
|
||||
ref's own event) fires on every change, including the load at world entry, and is what
|
||||
redraws this window.
|
||||
|
||||
DELIBERATE DEVIATIONS, each re-cited at its site:
|
||||
- THE BODY BOX SIZES ITS OWN SCROLL CHILD. The ref's `MacroFrameScrollFrame` inherits
|
||||
`UIPanelScrollFrameTemplate`, whose C-side `UpdateScrollChildRect` auto-grows the child
|
||||
to the text — the engine gap TalentFrame.xml and QuestLogFrame.xml both document. Here
|
||||
the child is an EditBox whose height is recomputed from the text on every change
|
||||
(`BenillaMacroFrameText_Resize`), over-estimating wrapped lines rather than clipping.
|
||||
- `MacroFrameTextButton` (the ref's invisible click-to-focus plate over the body) is kept
|
||||
— this engine's EditBox does not self-focus on a click through a ScrollFrame.
|
||||
- The ref's `PanelTemplates_TabResize(-15, ...)` width tuning is KEPT, but expressed as
|
||||
two fields on the tab (`benillaTabPadding`/`benillaTabMaxWidth`) rather than as a call:
|
||||
`TabButtonTemplate` (UiPanels.xml) owns the one text-fit call site, and an instance
|
||||
calling TabResize itself would just be overwritten on the next settle. Dropping the
|
||||
tuning (0983's original call) is what let the character tab run off the window's right
|
||||
edge — 384 units does not hold two +0-fit tabs. The ref's manual
|
||||
`HighlightTexture:SetWidth(GetTextWidth() + 31)` stays folded into that same settle.
|
||||
- `PlaySound("igCharacterInfoOpen"/"Close"/"gsTitleOptionOK")` are kept — this engine has
|
||||
PlaySound and those kit names resolve.
|
||||
-->
|
||||
|
||||
<Script><![CDATA[
|
||||
-- ref Blizzard_MacroUI.lua:1-5, verbatim.
|
||||
MAX_MACROS = 18
|
||||
NUM_MACRO_ICONS_SHOWN = 20
|
||||
NUM_ICONS_PER_ROW = 5
|
||||
NUM_ICON_ROWS = 4
|
||||
MACRO_ICON_ROW_HEIGHT = 36
|
||||
|
||||
-- The body box's own scroll sizing (this file's header comment). `letters="255"` caps the
|
||||
-- body, and the box is ~286 wide at GameFontHighlightSmall, so ~48 characters fit a line.
|
||||
-- The child height is `(explicit lines + wrap allowance) * line height`, which can only
|
||||
-- OVER-estimate — an over-estimate costs a little empty scroll room, an under-estimate
|
||||
-- clips text the player typed.
|
||||
BENILLA_MACRO_LINE_H = 14
|
||||
BENILLA_MACRO_CHARS_PER_LINE = 48
|
||||
BENILLA_MACRO_BODY_H = 85 -- the ref's own MacroFrameScrollFrame height (ref l.396)
|
||||
|
||||
function BenillaMacroFrameText_Resize()
|
||||
local box = getglobal("BenillaMacroFrameText")
|
||||
local text = box:GetText() or ""
|
||||
local lines, pos = 1, 1
|
||||
while true do
|
||||
local at = string.find(text, "\n", pos, true)
|
||||
if not at then break end
|
||||
lines, pos = lines + 1, at + 1
|
||||
end
|
||||
local wrap = math.ceil(string.len(text) / BENILLA_MACRO_CHARS_PER_LINE)
|
||||
local h = (lines + wrap) * BENILLA_MACRO_LINE_H
|
||||
if h < BENILLA_MACRO_BODY_H then h = BENILLA_MACRO_BODY_H end
|
||||
box:SetHeight(h)
|
||||
local scroll = getglobal("BenillaMacroFrameScrollFrame")
|
||||
scroll:UpdateScrollChildRect()
|
||||
BenillaScroll_SyncBar(scroll)
|
||||
end
|
||||
|
||||
function BenillaMacroFrameScrollFrame_OnLoad(scrollFrame)
|
||||
scrollFrame:SetScrollChild(getglobal("BenillaMacroFrameText"))
|
||||
BenillaScroll_SyncBar(scrollFrame)
|
||||
end
|
||||
|
||||
-- Tab 2's label is the ref's own `format(CHARACTER_SPECIFIC_MACROS, UnitName("player"))`
|
||||
-- (ref MacroFrame.xml l.509), re-derived on every show because the player name is not known
|
||||
-- at load time here. Guarded on the global string itself: a VM without GlobalStrings (the
|
||||
-- shipped-XML load test) must not die in an OnLoad.
|
||||
function BenillaMacroFrame_TabTwoLabel()
|
||||
if not CHARACTER_SPECIFIC_MACROS then return "Character" end
|
||||
return format(CHARACTER_SPECIFIC_MACROS, UnitName("player") or "")
|
||||
end
|
||||
|
||||
-- ref UIParent.lua:198-203. No LoadAddOn here: benilla has no load-on-demand addon layer,
|
||||
-- so the window is always loaded and this is a plain Show.
|
||||
function ShowMacroFrame()
|
||||
ShowUIPanel(getglobal("BenillaMacroFrame"))
|
||||
end
|
||||
|
||||
-- ref Blizzard_MacroUI.lua:13-17.
|
||||
function BenillaMacroFrame_OnLoad(frame)
|
||||
BenillaMacroFrame_SetAccountMacros()
|
||||
PanelTemplates_SetNumTabs(frame, 2)
|
||||
PanelTemplates_SetTab(frame, 1)
|
||||
end
|
||||
|
||||
-- ref l.19-29. The OnHide save is the reference's own "closing the window commits the
|
||||
-- body you were typing" — the single most load-bearing line in this file.
|
||||
function BenillaMacroFrame_OnShow()
|
||||
BenillaMacroFrame_Update()
|
||||
PlaySound("igCharacterInfoOpen")
|
||||
end
|
||||
|
||||
function BenillaMacroFrame_OnHide()
|
||||
getglobal("BenillaMacroPopupFrame"):Hide()
|
||||
BenillaMacroFrame_SaveMacro()
|
||||
PlaySound("igCharacterInfoClose")
|
||||
end
|
||||
|
||||
-- ref l.31-49.
|
||||
function BenillaMacroFrame_SetAccountMacros()
|
||||
local frame = getglobal("BenillaMacroFrame")
|
||||
frame.macroBase = 0
|
||||
local numAccountMacros = GetNumMacros()
|
||||
if numAccountMacros > 0 then
|
||||
BenillaMacroFrame_SelectMacro(frame.macroBase + 1)
|
||||
else
|
||||
BenillaMacroFrame_SelectMacro(nil)
|
||||
end
|
||||
end
|
||||
|
||||
function BenillaMacroFrame_SetCharacterMacros()
|
||||
local frame = getglobal("BenillaMacroFrame")
|
||||
frame.macroBase = MAX_MACROS
|
||||
local numAccountMacros, numCharacterMacros = GetNumMacros()
|
||||
if numCharacterMacros > 0 then
|
||||
BenillaMacroFrame_SelectMacro(frame.macroBase + 1)
|
||||
else
|
||||
BenillaMacroFrame_SelectMacro(nil)
|
||||
end
|
||||
end
|
||||
|
||||
-- DEVIATION (this window is always loaded; the ref's is LoadOnDemand). The ref's
|
||||
-- `MacroFrame_OnLoad` — which picks the first macro — runs the instant the addon loads,
|
||||
-- i.e. immediately before the first open, so it always sees the real macro table. Ours
|
||||
-- runs at boot, before the app has loaded a single macro from disk, so the selection has
|
||||
-- to be re-established whenever the table can have moved. One place: the top of _Update,
|
||||
-- which every show, every UPDATE_MACROS, every tab switch and every delete already runs
|
||||
-- through. A still-valid selection is left alone (reopening the window keeps your place,
|
||||
-- exactly as the ref's does).
|
||||
function BenillaMacroFrame_EnsureSelection()
|
||||
local frame = getglobal("BenillaMacroFrame")
|
||||
local numAccount, numChar = GetNumMacros()
|
||||
local numMacros = numAccount
|
||||
if frame.macroBase ~= 0 then numMacros = numChar end
|
||||
local sel = frame.selectedMacro
|
||||
if sel and sel > frame.macroBase and sel <= (frame.macroBase + numMacros) then
|
||||
return
|
||||
end
|
||||
if numMacros > 0 then
|
||||
BenillaMacroFrame_SelectMacro(frame.macroBase + 1)
|
||||
else
|
||||
BenillaMacroFrame_SelectMacro(nil)
|
||||
end
|
||||
end
|
||||
|
||||
-- ref l.51-120, re-expressed.
|
||||
function BenillaMacroFrame_Update()
|
||||
BenillaMacroFrame_EnsureSelection()
|
||||
local frame = getglobal("BenillaMacroFrame")
|
||||
local numAccountMacros, numCharacterMacros = GetNumMacros()
|
||||
local numMacros
|
||||
if frame.macroBase == 0 then
|
||||
numMacros = numAccountMacros
|
||||
else
|
||||
numMacros = numCharacterMacros
|
||||
end
|
||||
|
||||
for i = 1, MAX_MACROS do
|
||||
local macroButton = getglobal("BenillaMacroButton" .. i)
|
||||
local macroIcon = getglobal("BenillaMacroButton" .. i .. "Icon")
|
||||
local macroName = getglobal("BenillaMacroButton" .. i .. "Name")
|
||||
if i <= numMacros then
|
||||
local name, texture, body = GetMacroInfo(frame.macroBase + i)
|
||||
macroIcon:SetTexture(texture)
|
||||
macroName:SetText(name)
|
||||
macroButton:Enable()
|
||||
if frame.selectedMacro and (i == (frame.selectedMacro - frame.macroBase)) then
|
||||
macroButton:SetChecked(1)
|
||||
getglobal("BenillaMacroFrameSelectedMacroName"):SetText(name)
|
||||
getglobal("BenillaMacroFrameText"):SetText(body)
|
||||
getglobal("BenillaMacroFrameSelectedMacroButton"):SetID(i)
|
||||
getglobal("BenillaMacroFrameSelectedMacroButtonIcon"):SetTexture(texture)
|
||||
else
|
||||
macroButton:SetChecked(0)
|
||||
end
|
||||
else
|
||||
macroButton:SetChecked(0)
|
||||
macroIcon:SetTexture("")
|
||||
macroName:SetText("")
|
||||
macroButton:Disable()
|
||||
end
|
||||
end
|
||||
|
||||
if frame.selectedMacro ~= nil then
|
||||
BenillaMacroFrame_ShowDetails()
|
||||
else
|
||||
BenillaMacroFrame_HideDetails()
|
||||
end
|
||||
|
||||
if numMacros == MAX_MACROS then
|
||||
getglobal("BenillaMacroNewButton"):Disable()
|
||||
else
|
||||
getglobal("BenillaMacroNewButton"):Enable()
|
||||
end
|
||||
|
||||
if getglobal("BenillaMacroPopupFrame"):IsVisible() then
|
||||
getglobal("BenillaMacroEditButton"):Disable()
|
||||
getglobal("BenillaMacroDeleteButton"):Disable()
|
||||
else
|
||||
getglobal("BenillaMacroEditButton"):Enable()
|
||||
getglobal("BenillaMacroDeleteButton"):Enable()
|
||||
end
|
||||
|
||||
if not frame.selectedMacro then
|
||||
getglobal("BenillaMacroDeleteButton"):Disable()
|
||||
end
|
||||
-- The body's scroll range follows whatever SetText just put in the box.
|
||||
BenillaMacroFrameText_Resize()
|
||||
end
|
||||
|
||||
-- ref MacroFrame.lua:93-97. The engine calls nothing by this name; the spellbook's
|
||||
-- SHIFT-click does (SpellBookFrame.xml, ref SpellBookFrame.lua:271-283) — the "Shift click
|
||||
-- a spell in your spellbook with the macro UI open" line MACRO_HELP_TEXT_LINE5 advertises.
|
||||
--
|
||||
-- Three things here are the reference's, not oversights (B248 reported the first as a bug):
|
||||
-- the line arrives WHOLE (`/cast <name>`, never the bare name — that insert is a later
|
||||
-- client's `ChatEdit_InsertLink`), it is appended with NO separator, and the visibility
|
||||
-- gate is the BODY BOX's, so a shift-click with the name/icon popup up writes nothing.
|
||||
-- The dirty flag and the scroll refit are the box's own OnTextChanged, exactly as they are
|
||||
-- for a typed character — this function is the ref's three lines and stays them.
|
||||
function BenillaMacroFrame_AddMacroLine(line)
|
||||
local box = getglobal("BenillaMacroFrameText")
|
||||
if box:IsVisible() then
|
||||
box:SetText((box:GetText() or "") .. line)
|
||||
end
|
||||
end
|
||||
|
||||
-- ref l.128-138.
|
||||
function BenillaMacroButton_OnClick(button)
|
||||
local frame = getglobal("BenillaMacroFrame")
|
||||
BenillaMacroFrame_SaveMacro()
|
||||
BenillaMacroFrame_SelectMacro(frame.macroBase + button:GetID())
|
||||
BenillaMacroFrame_Update()
|
||||
getglobal("BenillaMacroPopupFrame"):Hide()
|
||||
getglobal("BenillaMacroFrameText"):ClearFocus()
|
||||
end
|
||||
|
||||
function BenillaMacroFrame_SelectMacro(id)
|
||||
getglobal("BenillaMacroFrame").selectedMacro = id
|
||||
end
|
||||
|
||||
-- ref l.140-150.
|
||||
function BenillaMacroNewButton_OnClick()
|
||||
BenillaMacroFrame_SaveMacro()
|
||||
local popup = getglobal("BenillaMacroPopupFrame")
|
||||
popup.mode = "new"
|
||||
popup:Show()
|
||||
end
|
||||
|
||||
function BenillaMacroEditButton_OnClick()
|
||||
BenillaMacroFrame_SaveMacro()
|
||||
local popup = getglobal("BenillaMacroPopupFrame")
|
||||
popup.mode = "edit"
|
||||
popup:Show()
|
||||
end
|
||||
|
||||
-- ref l.152-169.
|
||||
function BenillaMacroFrame_HideDetails()
|
||||
getglobal("BenillaMacroEditButton"):Hide()
|
||||
getglobal("BenillaMacroFrameCharLimitText"):Hide()
|
||||
getglobal("BenillaMacroFrameEnterMacroText"):Hide()
|
||||
getglobal("BenillaMacroFrameText"):Hide()
|
||||
getglobal("BenillaMacroFrameSelectedMacroName"):Hide()
|
||||
getglobal("BenillaMacroFrameSelectedMacroBackground"):Hide()
|
||||
getglobal("BenillaMacroFrameSelectedMacroButton"):Hide()
|
||||
end
|
||||
|
||||
function BenillaMacroFrame_ShowDetails()
|
||||
getglobal("BenillaMacroEditButton"):Show()
|
||||
getglobal("BenillaMacroFrameCharLimitText"):Show()
|
||||
getglobal("BenillaMacroFrameEnterMacroText"):Show()
|
||||
getglobal("BenillaMacroFrameText"):Show()
|
||||
getglobal("BenillaMacroFrameSelectedMacroName"):Show()
|
||||
getglobal("BenillaMacroFrameSelectedMacroBackground"):Show()
|
||||
getglobal("BenillaMacroFrameSelectedMacroButton"):Show()
|
||||
end
|
||||
|
||||
-- ref l.171-213.
|
||||
function BenillaMacroPopupFrame_OnShow(popup)
|
||||
if popup.mode == "new" then
|
||||
getglobal("BenillaMacroFrameText"):Hide()
|
||||
getglobal("BenillaMacroFrameSelectedMacroButtonIcon"):SetTexture("")
|
||||
popup.selectedIcon = nil
|
||||
end
|
||||
getglobal("BenillaMacroFrameText"):ClearFocus()
|
||||
getglobal("BenillaMacroPopupEditBox"):SetFocus()
|
||||
|
||||
PlaySound("igCharacterInfoOpen")
|
||||
BenillaMacroPopupFrame_Update(true) -- the ONE caller that resets the name box
|
||||
BenillaMacroPopupOkayButton_Update()
|
||||
|
||||
getglobal("BenillaMacroEditButton"):Disable()
|
||||
getglobal("BenillaMacroDeleteButton"):Disable()
|
||||
getglobal("BenillaMacroNewButton"):Disable()
|
||||
getglobal("BenillaMacroFrameTab1"):Disable()
|
||||
getglobal("BenillaMacroFrameTab2"):Disable()
|
||||
end
|
||||
|
||||
function BenillaMacroPopupFrame_OnHide(popup)
|
||||
local frame = getglobal("BenillaMacroFrame")
|
||||
if popup.mode == "new" then
|
||||
getglobal("BenillaMacroFrameText"):Show()
|
||||
getglobal("BenillaMacroFrameText"):SetFocus()
|
||||
end
|
||||
|
||||
getglobal("BenillaMacroEditButton"):Enable()
|
||||
getglobal("BenillaMacroDeleteButton"):Enable()
|
||||
local numAccountMacros, numCharacterMacros = GetNumMacros()
|
||||
local numMacros
|
||||
if frame.macroBase == 0 then
|
||||
numMacros = numAccountMacros
|
||||
else
|
||||
numMacros = numCharacterMacros
|
||||
end
|
||||
if numMacros < MAX_MACROS then
|
||||
getglobal("BenillaMacroNewButton"):Enable()
|
||||
end
|
||||
PanelTemplates_UpdateTabs(frame)
|
||||
end
|
||||
|
||||
-- ref l.215-250, with ONE faithfulness note that is easy to get wrong. The ref's body
|
||||
-- opens `if ( this.mode == "new" ) then MacroPopupEditBox:SetText("")` — and `this` there
|
||||
-- is the FrameScript caller, not the popup: from `MacroPopupFrame_OnShow` it IS the popup
|
||||
-- (so a fresh "new" clears the name box), but from `MacroPopupButton_OnClick` it is the
|
||||
-- clicked BUTTON, whose `.mode` is nil, so neither branch runs and the name you already
|
||||
-- typed survives the icon click. Substituting the popup for `this` would blank the name on
|
||||
-- every icon click — the reference does not do that. Modelled explicitly instead: only the
|
||||
-- OnShow path asks for the name reset.
|
||||
function BenillaMacroPopupFrame_Update(resetName)
|
||||
local popup = getglobal("BenillaMacroPopupFrame")
|
||||
local scrollFrame = getglobal("BenillaMacroPopupScrollFrame")
|
||||
local numMacroIcons = GetNumMacroIcons()
|
||||
local macroPopupOffset = FauxScrollFrame_GetOffset(scrollFrame)
|
||||
|
||||
if resetName then
|
||||
if popup.mode == "new" then
|
||||
getglobal("BenillaMacroPopupEditBox"):SetText("")
|
||||
elseif popup.mode == "edit" then
|
||||
local name = GetMacroInfo(getglobal("BenillaMacroFrame").selectedMacro)
|
||||
getglobal("BenillaMacroPopupEditBox"):SetText(name or "")
|
||||
end
|
||||
end
|
||||
|
||||
for i = 1, NUM_MACRO_ICONS_SHOWN do
|
||||
local icon = getglobal("BenillaMacroPopupButton" .. i .. "Icon")
|
||||
local button = getglobal("BenillaMacroPopupButton" .. i)
|
||||
local index = (macroPopupOffset * NUM_ICONS_PER_ROW) + i
|
||||
if index <= numMacroIcons then
|
||||
icon:SetTexture(GetMacroIconInfo(index))
|
||||
button:Show()
|
||||
else
|
||||
icon:SetTexture("")
|
||||
button:Hide()
|
||||
end
|
||||
if index == popup.selectedIcon then
|
||||
button:SetChecked(1)
|
||||
else
|
||||
button:SetChecked(nil)
|
||||
end
|
||||
end
|
||||
|
||||
FauxScrollFrame_Update(scrollFrame,
|
||||
math.ceil(numMacroIcons / NUM_ICONS_PER_ROW), NUM_ICON_ROWS, MACRO_ICON_ROW_HEIGHT)
|
||||
end
|
||||
|
||||
-- ref l.252-261. The "edit" arm's second Enable is the ref's own: renaming an existing
|
||||
-- macro does not require re-picking an icon.
|
||||
function BenillaMacroPopupOkayButton_Update()
|
||||
local popup = getglobal("BenillaMacroPopupFrame")
|
||||
local okay = getglobal("BenillaMacroPopupOkayButton")
|
||||
local text = getglobal("BenillaMacroPopupEditBox"):GetText() or ""
|
||||
if string.len(text) > 0 and popup.selectedIcon then
|
||||
okay:Enable()
|
||||
else
|
||||
okay:Disable()
|
||||
end
|
||||
if popup.mode == "edit" and string.len(text) > 0 then
|
||||
okay:Enable()
|
||||
end
|
||||
end
|
||||
|
||||
-- ref l.263-268.
|
||||
function BenillaMacroPopupButton_OnClick(button)
|
||||
local popup = getglobal("BenillaMacroPopupFrame")
|
||||
local scrollFrame = getglobal("BenillaMacroPopupScrollFrame")
|
||||
popup.selectedIcon = button:GetID()
|
||||
+ (FauxScrollFrame_GetOffset(scrollFrame) * NUM_ICONS_PER_ROW)
|
||||
getglobal("BenillaMacroFrameSelectedMacroButtonIcon")
|
||||
:SetTexture(GetMacroIconInfo(popup.selectedIcon))
|
||||
BenillaMacroPopupOkayButton_Update()
|
||||
BenillaMacroPopupFrame_Update()
|
||||
end
|
||||
|
||||
-- ref l.270-280. `perCharacter` is the ref's own `(MacroFrame.macroBase > 0)`.
|
||||
function BenillaMacroPopupOkayButton_OnClick()
|
||||
local frame = getglobal("BenillaMacroFrame")
|
||||
local popup = getglobal("BenillaMacroPopupFrame")
|
||||
local name = getglobal("BenillaMacroPopupEditBox"):GetText()
|
||||
local index = 1
|
||||
if popup.mode == "new" then
|
||||
index = CreateMacro(name, popup.selectedIcon, nil, nil, (frame.macroBase > 0))
|
||||
elseif popup.mode == "edit" then
|
||||
index = EditMacro(frame.selectedMacro, name, popup.selectedIcon)
|
||||
end
|
||||
popup:Hide()
|
||||
BenillaMacroFrame_SelectMacro(index)
|
||||
BenillaMacroFrame_Update()
|
||||
end
|
||||
|
||||
-- ref l.282-287.
|
||||
function BenillaMacroFrame_SaveMacro()
|
||||
local frame = getglobal("BenillaMacroFrame")
|
||||
if frame.textChanged and frame.selectedMacro then
|
||||
EditMacro(frame.selectedMacro, nil, nil, getglobal("BenillaMacroFrameText"):GetText())
|
||||
frame.textChanged = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- The body box's OnTextChanged (ref MacroFrame.xml l.411-427): mark dirty, close a "new"
|
||||
-- popup, refresh the character counter, and keep the view pinned to the bottom while
|
||||
-- typing (the ref's own scrollBar:SetValue(max) tail).
|
||||
function BenillaMacroFrameText_OnTextChanged(box)
|
||||
local frame = getglobal("BenillaMacroFrame")
|
||||
frame.textChanged = 1
|
||||
local popup = getglobal("BenillaMacroPopupFrame")
|
||||
if popup.mode == "new" then
|
||||
popup:Hide()
|
||||
end
|
||||
getglobal("BenillaMacroFrameCharLimitText")
|
||||
:SetText(format(MACROFRAME_CHAR_LIMIT, box:GetNumLetters()))
|
||||
BenillaMacroFrameText_Resize()
|
||||
local bar = getglobal("BenillaMacroFrameScrollFrameBar")
|
||||
local _, maxValue = bar:GetMinMaxValues()
|
||||
if maxValue > 0 and box.maxScroll ~= maxValue then
|
||||
box.maxScroll = maxValue
|
||||
bar:SetValue(maxValue)
|
||||
end
|
||||
end
|
||||
|
||||
-- The window's own event driver: UPDATE_MACROS is the ref's event for "the macro table
|
||||
-- changed" and covers BOTH an edit made here and the app's load at world entry, so a
|
||||
-- freshly-entered world shows the saved macros without this file knowing about files.
|
||||
function BenillaMacroFrame_OnEvent(frame, event)
|
||||
if event == "UPDATE_MACROS" then
|
||||
BenillaMacroFrame_EnsureSelection()
|
||||
if frame:IsVisible() then
|
||||
BenillaMacroFrame_Update()
|
||||
end
|
||||
end
|
||||
end
|
||||
]]></Script>
|
||||
|
||||
<!--
|
||||
ref MacroFrameButtonTemplate (l.4-52): a 36x36 CheckButton whose NormalTexture IS the icon
|
||||
($parentIcon), over the empty-slot plate, with the name outlined along the bottom.
|
||||
-->
|
||||
<CheckButton name="BenillaMacroFrameButtonTemplate" virtual="true">
|
||||
<Size><AbsDimension x="36" y="36"/></Size>
|
||||
<Layers>
|
||||
<Layer level="OVERLAY">
|
||||
<FontString name="$parentName" inherits="GameFontHighlightSmallOutline">
|
||||
<Size><AbsDimension x="36" y="10"/></Size>
|
||||
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="0" y="2"/></Offset></Anchor></Anchors>
|
||||
</FontString>
|
||||
</Layer>
|
||||
<Layer level="BACKGROUND">
|
||||
<Texture file="Interface\Buttons\UI-EmptySlot-Disabled">
|
||||
<Size><AbsDimension x="64" y="64"/></Size>
|
||||
<Anchors><Anchor point="CENTER"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<NormalTexture name="$parentIcon">
|
||||
<Size><AbsDimension x="36" y="36"/></Size>
|
||||
<Anchors><Anchor point="CENTER"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
|
||||
</NormalTexture>
|
||||
<HighlightTexture alphaMode="ADD" file="Interface\Buttons\ButtonHilight-Square"/>
|
||||
<CheckedTexture alphaMode="ADD" file="Interface\Buttons\CheckButtonHilight"/>
|
||||
</CheckButton>
|
||||
|
||||
<!-- ref MacroButtonTemplate (l.53-65): the list button — click selects, drag picks up. -->
|
||||
<CheckButton name="BenillaMacroButtonTemplate" inherits="BenillaMacroFrameButtonTemplate" virtual="true">
|
||||
<Scripts>
|
||||
<OnLoad>self:RegisterForDrag("LeftButton")</OnLoad>
|
||||
<OnClick>BenillaMacroButton_OnClick(self)</OnClick>
|
||||
<OnDragStart>PickupMacro(getglobal("BenillaMacroFrame").macroBase + self:GetID())</OnDragStart>
|
||||
</Scripts>
|
||||
</CheckButton>
|
||||
|
||||
<!-- ref MacroPopupButtonTemplate (l.66-72). -->
|
||||
<CheckButton name="BenillaMacroPopupButtonTemplate" inherits="BenillaMacroFrameButtonTemplate" virtual="true">
|
||||
<Scripts>
|
||||
<OnClick>BenillaMacroPopupButton_OnClick(self)</OnClick>
|
||||
</Scripts>
|
||||
</CheckButton>
|
||||
|
||||
<!-- The panel button face (the TrainerFrame precedent — this engine ships no
|
||||
UIPanelButtonTemplate/UIPanelButtonGrayTemplate). -->
|
||||
<Button name="BenillaMacroPanelButtonTemplate" virtual="true">
|
||||
<Size><AbsDimension x="80" y="22"/></Size>
|
||||
<NormalFont inherits="GameFontNormal"/>
|
||||
<HighlightFont inherits="GameFontHighlight"/>
|
||||
<DisabledFont inherits="GameFontDisable"/>
|
||||
<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>
|
||||
</Button>
|
||||
|
||||
<!-- ref MacroFrame (l.73-618). No <Anchors>: ShowUIPanel SetPoints this into the left slot. -->
|
||||
<Frame name="BenillaMacroFrame" hidden="true">
|
||||
<Size><AbsDimension x="384" y="512"/></Size>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
<Texture name="BenillaMacroFramePortrait" file="Interface\MacroFrame\MacroFrame-Icon">
|
||||
<Size><AbsDimension x="60" y="60"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="7" y="-6"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
</Layer>
|
||||
<!-- The four quadrant slabs: the ref borrows PaperDollInfoFrame's two top halves and
|
||||
uses MacroFrame's own two bottoms (ref l.103-134). -->
|
||||
<Layer level="BORDER">
|
||||
<Texture file="Interface\PaperDollInfoFrame\UI-Character-General-TopLeft">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"/></Anchors>
|
||||
</Texture>
|
||||
<Texture file="Interface\PaperDollInfoFrame\UI-Character-General-TopRight">
|
||||
<Size><AbsDimension x="128" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPRIGHT"/></Anchors>
|
||||
</Texture>
|
||||
<Texture file="Interface\MacroFrame\MacroFrame-BotLeft">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="BOTTOMLEFT"/></Anchors>
|
||||
</Texture>
|
||||
<Texture file="Interface\MacroFrame\MacroFrame-BotRight">
|
||||
<Size><AbsDimension x="128" y="256"/></Size>
|
||||
<Anchors><Anchor point="BOTTOMRIGHT"/></Anchors>
|
||||
</Texture>
|
||||
<FontString inherits="GameFontNormal" text="CREATE_MACROS">
|
||||
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="0" y="-17"/></Offset></Anchor></Anchors>
|
||||
</FontString>
|
||||
</Layer>
|
||||
<Layer level="ARTWORK">
|
||||
<!-- The divider between the 18-button grid and the detail pane (ref l.146-171). -->
|
||||
<Texture name="BenillaMacroHorizontalBarLeft" file="Interface\ClassTrainerFrame\UI-ClassTrainer-HorizontalBar">
|
||||
<Size><AbsDimension x="256" y="16"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="15" y="-220"/></Offset></Anchor></Anchors>
|
||||
<TexCoords left="0" right="1.0" top="0" bottom="0.25"/>
|
||||
</Texture>
|
||||
<Texture file="Interface\ClassTrainerFrame\UI-ClassTrainer-HorizontalBar">
|
||||
<Size><AbsDimension x="75" y="16"/></Size>
|
||||
<Anchors><Anchor point="LEFT" relativeTo="BenillaMacroHorizontalBarLeft" relativePoint="RIGHT"/></Anchors>
|
||||
<TexCoords left="0" right="0.29296875" top="0.25" bottom="0.5"/>
|
||||
</Texture>
|
||||
<Texture name="BenillaMacroFrameSelectedMacroBackground" file="Interface\Buttons\UI-EmptySlot">
|
||||
<Size><AbsDimension x="64" y="64"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="16" y="-228"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
<FontString name="BenillaMacroFrameSelectedMacroName" inherits="GameFontNormalLarge" justifyH="LEFT">
|
||||
<Size><AbsDimension x="256" y="16"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="BenillaMacroFrameSelectedMacroBackground" relativePoint="TOPRIGHT">
|
||||
<Offset><AbsDimension x="-4" y="-15"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
</FontString>
|
||||
<FontString name="BenillaMacroFrameEnterMacroText" inherits="GameFontHighlightSmall" text="ENTER_MACRO_LABEL">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="BenillaMacroFrameSelectedMacroBackground" relativePoint="BOTTOMLEFT">
|
||||
<Offset><AbsDimension x="8" y="0"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
</FontString>
|
||||
<FontString name="BenillaMacroFrameCharLimitText" inherits="GameFontHighlightSmall">
|
||||
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="-15" y="105"/></Offset></Anchor></Anchors>
|
||||
</FontString>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Frames>
|
||||
<!-- The detail pane's own big button: a drag source for the selected macro (ref
|
||||
l.217-231 — its OnClick unchecks itself and picks up, never re-selects). -->
|
||||
<CheckButton name="BenillaMacroFrameSelectedMacroButton" inherits="BenillaMacroButtonTemplate" id="0">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="BenillaMacroFrameSelectedMacroBackground">
|
||||
<Offset><AbsDimension x="14" y="-14"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<Scripts>
|
||||
<OnClick>
|
||||
self:SetChecked(nil)
|
||||
PickupMacro(getglobal("BenillaMacroFrame").selectedMacro)
|
||||
</OnClick>
|
||||
</Scripts>
|
||||
</CheckButton>
|
||||
|
||||
<!-- The 18-button grid: 3 rows of 6 (ref l.232-393; the ref's own 13px column gap and
|
||||
10px row gap). -->
|
||||
<CheckButton name="BenillaMacroButton1" inherits="BenillaMacroButtonTemplate" id="1">
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="42" y="-83"/></Offset></Anchor></Anchors>
|
||||
</CheckButton>
|
||||
<CheckButton name="BenillaMacroButton2" inherits="BenillaMacroButtonTemplate" id="2"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton1" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton3" inherits="BenillaMacroButtonTemplate" id="3"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton2" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton4" inherits="BenillaMacroButtonTemplate" id="4"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton3" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton5" inherits="BenillaMacroButtonTemplate" id="5"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton4" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton6" inherits="BenillaMacroButtonTemplate" id="6"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton5" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton7" inherits="BenillaMacroButtonTemplate" id="7"><Anchors><Anchor point="TOP" relativeTo="BenillaMacroButton1" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton8" inherits="BenillaMacroButtonTemplate" id="8"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton7" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton9" inherits="BenillaMacroButtonTemplate" id="9"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton8" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton10" inherits="BenillaMacroButtonTemplate" id="10"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton9" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton11" inherits="BenillaMacroButtonTemplate" id="11"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton10" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton12" inherits="BenillaMacroButtonTemplate" id="12"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton11" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton13" inherits="BenillaMacroButtonTemplate" id="13"><Anchors><Anchor point="TOP" relativeTo="BenillaMacroButton7" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton14" inherits="BenillaMacroButtonTemplate" id="14"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton13" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton15" inherits="BenillaMacroButtonTemplate" id="15"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton14" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton16" inherits="BenillaMacroButtonTemplate" id="16"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton15" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton17" inherits="BenillaMacroButtonTemplate" id="17"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton16" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton18" inherits="BenillaMacroButtonTemplate" id="18"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton17" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
|
||||
<!-- The body's tooltip-backdrop plate (ref MacroFrameTextBackground l.449-477), drawn
|
||||
before the scroll frame so the text sits on it. -->
|
||||
<Frame name="BenillaMacroFrameTextBackground">
|
||||
<Size><AbsDimension x="322" y="95"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="18" y="-305"/></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>
|
||||
self:SetBackdropBorderColor(TOOLTIP_DEFAULT_COLOR.r, TOOLTIP_DEFAULT_COLOR.g, TOOLTIP_DEFAULT_COLOR.b)
|
||||
self:SetBackdropColor(TOOLTIP_DEFAULT_BACKGROUND_COLOR.r, TOOLTIP_DEFAULT_BACKGROUND_COLOR.g, TOOLTIP_DEFAULT_BACKGROUND_COLOR.b)
|
||||
</OnLoad>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
|
||||
<!-- The body editor (ref MacroFrameScrollFrame l.394-435). A real ScrollFrame with a
|
||||
self-sizing EditBox child + a real draggable Slider — this file's header comment. -->
|
||||
<ScrollFrame name="BenillaMacroFrameScrollFrame">
|
||||
<Size><AbsDimension x="286" y="85"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="BenillaMacroFrameSelectedMacroBackground" relativePoint="BOTTOMLEFT">
|
||||
<Offset><AbsDimension x="11" y="-18"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<Frames>
|
||||
<!-- `$parentBar` is the real-scroll kit's own naming contract
|
||||
(ScrollTemplates.xml: `BenillaScroll_SyncBar` derives the bar from the
|
||||
frame's name). Its OnValueChanged REPLACES the faux handler the shared
|
||||
Slider template carries — the TalentFrame precedent. -->
|
||||
<Slider name="$parentBar" inherits="BenillaScrollBarTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="6" y="-16"/></Offset></Anchor>
|
||||
<Anchor point="BOTTOMLEFT" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="6" y="16"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts>
|
||||
<OnValueChanged>BenillaScrollBar_OnValueChanged(self, arg1)</OnValueChanged>
|
||||
</Scripts>
|
||||
</Slider>
|
||||
<EditBox name="BenillaMacroFrameText" multiLine="true" letters="255" autoFocus="false">
|
||||
<Size><AbsDimension x="286" y="85"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"/></Anchors>
|
||||
<FontString inherits="GameFontHighlightSmall"/>
|
||||
<Scripts>
|
||||
<OnTextChanged>BenillaMacroFrameText_OnTextChanged(self)</OnTextChanged>
|
||||
<OnEscapePressed>self:ClearFocus()</OnEscapePressed>
|
||||
</Scripts>
|
||||
</EditBox>
|
||||
</Frames>
|
||||
<Scripts>
|
||||
<OnLoad>BenillaMacroFrameScrollFrame_OnLoad(self)</OnLoad>
|
||||
</Scripts>
|
||||
</ScrollFrame>
|
||||
|
||||
<!-- ref MacroFrameTextButton (l.436-448): the click-to-focus plate over the body. -->
|
||||
<Button name="BenillaMacroFrameTextButton">
|
||||
<Size><AbsDimension x="286" y="85"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaMacroFrameScrollFrame" relativePoint="TOPLEFT"/></Anchors>
|
||||
<Scripts>
|
||||
<OnClick>getglobal("BenillaMacroFrameText"):SetFocus()</OnClick>
|
||||
</Scripts>
|
||||
</Button>
|
||||
|
||||
<!-- The two tabs (ref l.478-520). Tab 2's label is the ref's own
|
||||
`format(CHARACTER_SPECIFIC_MACROS, UnitName("player"))`, re-set on every show
|
||||
because the player name is not known at load time in this client. -->
|
||||
<Button name="BenillaMacroFrameTab1" inherits="TabButtonTemplate" text="GENERAL_MACROS" id="1">
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="65" y="-39"/></Offset></Anchor></Anchors>
|
||||
<Scripts>
|
||||
<!-- The ref's own `PanelTemplates_TabResize(-15)` (l.488) as a fit parameter —
|
||||
the template's settle loop owns the one call site (UiPanels.xml). -15 is
|
||||
what keeps a two-tab row inside a 384-wide window. -->
|
||||
<OnLoad>self.benillaTabPadding = -15</OnLoad>
|
||||
<OnClick>
|
||||
PanelTemplates_SetTab(getglobal("BenillaMacroFrame"), self:GetID())
|
||||
BenillaMacroFrame_SaveMacro()
|
||||
BenillaMacroFrame_SetAccountMacros()
|
||||
BenillaMacroFrame_Update()
|
||||
</OnClick>
|
||||
</Scripts>
|
||||
</Button>
|
||||
<Button name="BenillaMacroFrameTab2" inherits="TabButtonTemplate" id="2">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="BenillaMacroFrameTab1" relativePoint="RIGHT"/></Anchors>
|
||||
<Scripts>
|
||||
<!-- ref l.511: `PanelTemplates_TabResize(-15, nil, nil, 150)`. The cap is
|
||||
load-bearing here and nowhere else — this label carries a player name, so
|
||||
it is the one tab whose width is not known when the window is written.
|
||||
|
||||
`benillaTabRightInset` is what makes it hold: **this window is narrower
|
||||
than its frame says.** The rect is 384 wide, but the art is
|
||||
`UI-Character-General-TopLeft` (256) + `-TopRight` (128 — VERIFIED 128 in
|
||||
the BLP header, so no squash), and that second slice carries a transparent
|
||||
tail: decoding its DXT3 alpha, the last opaque column is 92 of 128, so the
|
||||
**drawn plate ends at unit 349**. 40 is that boundary with a few units of
|
||||
breathing room, so a clamped tab's end cap doesn't sit flush on the border.
|
||||
Clamping to the frame rect — the first attempt (0997) — clamped to an edge
|
||||
the player cannot see, and the tab still walked out over the world. -->
|
||||
<OnLoad>
|
||||
self.benillaTabPadding = -15
|
||||
self.benillaTabMaxWidth = 150
|
||||
self.benillaTabRightInset = 40
|
||||
self:SetText(BenillaMacroFrame_TabTwoLabel())
|
||||
</OnLoad>
|
||||
<OnClick>
|
||||
PanelTemplates_SetTab(getglobal("BenillaMacroFrame"), self:GetID())
|
||||
BenillaMacroFrame_SaveMacro()
|
||||
BenillaMacroFrame_SetCharacterMacros()
|
||||
BenillaMacroFrame_Update()
|
||||
</OnClick>
|
||||
</Scripts>
|
||||
</Button>
|
||||
|
||||
<Button name="BenillaMacroEditButton" inherits="BenillaMacroPanelButtonTemplate" text="CHANGE_MACRO_NAME_ICON">
|
||||
<Size><AbsDimension x="170" y="22"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="BenillaMacroFrameSelectedMacroBackground">
|
||||
<Offset><AbsDimension x="51" y="-30"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnClick>BenillaMacroEditButton_OnClick()</OnClick></Scripts>
|
||||
</Button>
|
||||
<Button name="BenillaMacroDeleteButton" inherits="BenillaMacroPanelButtonTemplate" text="DELETE">
|
||||
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="17" y="79"/></Offset></Anchor></Anchors>
|
||||
<Scripts>
|
||||
<OnClick>
|
||||
-- The ref relies on the button being Disabled (MacroFrame_Update) to keep
|
||||
-- `selectedMacro` non-nil here; guard anyway — a script Click() bypasses
|
||||
-- the disabled state and DeleteMacro(nil) is a hard error, not a no-op.
|
||||
if not getglobal("BenillaMacroFrame").selectedMacro then return end
|
||||
DeleteMacro(getglobal("BenillaMacroFrame").selectedMacro)
|
||||
BenillaMacroFrame_SetAccountMacros()
|
||||
if getglobal("BenillaMacroFrame").macroBase ~= 0 then
|
||||
BenillaMacroFrame_SetCharacterMacros()
|
||||
end
|
||||
BenillaMacroFrame_Update()
|
||||
getglobal("BenillaMacroFrameText"):ClearFocus()
|
||||
</OnClick>
|
||||
</Scripts>
|
||||
</Button>
|
||||
<Button name="BenillaMacroNewButton" inherits="BenillaMacroPanelButtonTemplate" text="NEW">
|
||||
<Anchors><Anchor point="CENTER" relativePoint="TOPLEFT"><Offset><AbsDimension x="222" y="-422"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnClick>BenillaMacroNewButton_OnClick()</OnClick></Scripts>
|
||||
</Button>
|
||||
<Button name="BenillaMacroExitButton" inherits="BenillaMacroPanelButtonTemplate" text="EXIT">
|
||||
<Anchors><Anchor point="CENTER" relativePoint="TOPLEFT"><Offset><AbsDimension x="303" y="-422"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnClick>HideUIPanel(getglobal("BenillaMacroFrame"))</OnClick></Scripts>
|
||||
</Button>
|
||||
<!-- The top-right X (ref a UIPanelCloseButton at TOPRIGHT (-29,-8); the minimize-button
|
||||
art stands in — this engine ships no UIPanelCloseButton, TrainerFrame's precedent). -->
|
||||
<Button name="BenillaMacroFrameCloseButton">
|
||||
<Size><AbsDimension x="32" y="32"/></Size>
|
||||
<Anchors><Anchor point="TOPRIGHT"><Offset><AbsDimension x="-29" y="-8"/></Offset></Anchor></Anchors>
|
||||
<NormalTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Up"/>
|
||||
<PushedTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Down"/>
|
||||
<HighlightTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Highlight" alphaMode="ADD"/>
|
||||
<Scripts><OnClick>HideUIPanel(getglobal("BenillaMacroFrame"))</OnClick></Scripts>
|
||||
</Button>
|
||||
</Frames>
|
||||
<Scripts>
|
||||
<OnLoad>
|
||||
BenillaMacroFrame_OnLoad(self)
|
||||
self:RegisterEvent("UPDATE_MACROS")
|
||||
</OnLoad>
|
||||
<OnEvent>BenillaMacroFrame_OnEvent(self, event)</OnEvent>
|
||||
<OnShow>
|
||||
getglobal("BenillaMacroFrameTab2"):SetText(BenillaMacroFrame_TabTwoLabel())
|
||||
BenillaMacroFrame_OnShow()
|
||||
UpdateMicroButtons()
|
||||
</OnShow>
|
||||
<OnHide>
|
||||
BenillaMacroFrame_OnHide()
|
||||
UpdateMicroButtons()
|
||||
</OnHide>
|
||||
<OnMouseWheel>BenillaScroll_OnMouseWheel(getglobal("BenillaMacroFrameScrollFrame"), arg1)</OnMouseWheel>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
|
||||
<!-- ref MacroPopupFrame (l.619-1005): the name + icon chooser, hung off the window's right edge. -->
|
||||
<Frame name="BenillaMacroPopupFrame" toplevel="true" hidden="true">
|
||||
<Size><AbsDimension x="297" y="298"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="BenillaMacroFrame" relativePoint="TOPRIGHT">
|
||||
<Offset><AbsDimension x="-40" y="-40"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
<Texture file="Interface\MacroFrame\MacroPopup-TopLeft">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"/></Anchors>
|
||||
</Texture>
|
||||
<Texture file="Interface\MacroFrame\MacroPopup-TopRight">
|
||||
<Size><AbsDimension x="64" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="256" y="0"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
<Texture file="Interface\MacroFrame\MacroPopup-BotLeft">
|
||||
<Size><AbsDimension x="256" y="64"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="0" y="-256"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
<Texture file="Interface\MacroFrame\MacroPopup-BotRight">
|
||||
<Size><AbsDimension x="64" y="64"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="256" y="-256"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
<FontString inherits="GameFontHighlightSmall" text="MACRO_POPUP_TEXT">
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="24" y="-21"/></Offset></Anchor></Anchors>
|
||||
</FontString>
|
||||
<FontString inherits="GameFontHighlightSmall" text="MACRO_POPUP_CHOOSE_ICON">
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="24" y="-69"/></Offset></Anchor></Anchors>
|
||||
</FontString>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Frames>
|
||||
<EditBox name="BenillaMacroPopupEditBox" letters="16" historyLines="0">
|
||||
<Size><AbsDimension x="200" y="20"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="29" y="-35"/></Offset></Anchor></Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
<Texture name="BenillaMacroPopupNameLeft" file="Interface\ClassTrainerFrame\UI-ClassTrainer-FilterBorder">
|
||||
<Size><AbsDimension x="12" y="29"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="-11" y="0"/></Offset></Anchor></Anchors>
|
||||
<TexCoords left="0" right="0.09375" top="0" bottom="1.0"/>
|
||||
</Texture>
|
||||
<Texture name="BenillaMacroPopupNameMiddle" file="Interface\ClassTrainerFrame\UI-ClassTrainer-FilterBorder">
|
||||
<Size><AbsDimension x="175" y="29"/></Size>
|
||||
<Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupNameLeft" relativePoint="RIGHT"/></Anchors>
|
||||
<TexCoords left="0.09375" right="0.90625" top="0" bottom="1.0"/>
|
||||
</Texture>
|
||||
<Texture name="BenillaMacroPopupNameRight" file="Interface\ClassTrainerFrame\UI-ClassTrainer-FilterBorder">
|
||||
<Size><AbsDimension x="12" y="29"/></Size>
|
||||
<Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupNameMiddle" relativePoint="RIGHT"/></Anchors>
|
||||
<TexCoords left="0.90625" right="1.0" top="0" bottom="1.0"/>
|
||||
</Texture>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<FontString inherits="ChatFontNormal"/>
|
||||
<Scripts>
|
||||
<OnTextChanged>
|
||||
BenillaMacroPopupOkayButton_Update()
|
||||
getglobal("BenillaMacroFrameSelectedMacroName"):SetText(self:GetText())
|
||||
</OnTextChanged>
|
||||
<OnEscapePressed>getglobal("BenillaMacroPopupFrame"):Hide()</OnEscapePressed>
|
||||
<OnEnterPressed>
|
||||
-- The ref's own spelling, and now literally ours: IsEnabled answers the
|
||||
-- NUMBER 1 or 0 (button.rs, wow-re button-enabled-state.md). Plain
|
||||
-- truthiness would be WRONG here — 0 is truthy in Lua.
|
||||
if getglobal("BenillaMacroPopupOkayButton"):IsEnabled() ~= 0 then
|
||||
BenillaMacroPopupOkayButton_OnClick()
|
||||
end
|
||||
</OnEnterPressed>
|
||||
</Scripts>
|
||||
</EditBox>
|
||||
|
||||
<!-- The icon grid's faux scroll (ref a ClassTrainerListScrollFrameTemplate, l.759-775).
|
||||
The frame itself is invisible; its ONE visible consequence is where the bar seats
|
||||
(FauxScrollFrameTemplate hangs it off the frame's right edge +6, inset 16
|
||||
top and bottom). So its rect is the reference's VERBATIM — 296x195 at TOPRIGHT
|
||||
(-39,-67) — which puts the bar at popup x 264..280, on the plate's groove and
|
||||
bracketing the 4-row grid. 0983 sized it to the grid instead (252x152 at TOPLEFT
|
||||
24,-85) and the bar came out at x 282..298: hanging half off the plate's right
|
||||
edge, over the world. -->
|
||||
<ScrollFrame name="BenillaMacroPopupScrollFrame" inherits="FauxScrollFrameTemplate">
|
||||
<Size><AbsDimension x="296" y="195"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-39" y="-67"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts>
|
||||
<!-- The faux kit's contract (ScrollTemplates.xml): the owner sets updateFunc,
|
||||
the shared Slider handler calls it whenever the row offset moves. -->
|
||||
<OnLoad>
|
||||
ScrollFrame_OnLoad()
|
||||
self.updateFunc = BenillaMacroPopupFrame_Update
|
||||
</OnLoad>
|
||||
</Scripts>
|
||||
</ScrollFrame>
|
||||
|
||||
<CheckButton name="BenillaMacroPopupButton1" inherits="BenillaMacroPopupButtonTemplate" id="1">
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="24" y="-85"/></Offset></Anchor></Anchors>
|
||||
</CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton2" inherits="BenillaMacroPopupButtonTemplate" id="2"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton1" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton3" inherits="BenillaMacroPopupButtonTemplate" id="3"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton2" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton4" inherits="BenillaMacroPopupButtonTemplate" id="4"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton3" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton5" inherits="BenillaMacroPopupButtonTemplate" id="5"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton4" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton6" inherits="BenillaMacroPopupButtonTemplate" id="6"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaMacroPopupButton1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-8"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton7" inherits="BenillaMacroPopupButtonTemplate" id="7"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton6" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton8" inherits="BenillaMacroPopupButtonTemplate" id="8"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton7" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton9" inherits="BenillaMacroPopupButtonTemplate" id="9"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton8" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton10" inherits="BenillaMacroPopupButtonTemplate" id="10"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton9" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton11" inherits="BenillaMacroPopupButtonTemplate" id="11"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaMacroPopupButton6" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-8"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton12" inherits="BenillaMacroPopupButtonTemplate" id="12"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton11" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton13" inherits="BenillaMacroPopupButtonTemplate" id="13"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton12" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton14" inherits="BenillaMacroPopupButtonTemplate" id="14"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton13" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton15" inherits="BenillaMacroPopupButtonTemplate" id="15"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton14" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton16" inherits="BenillaMacroPopupButtonTemplate" id="16"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaMacroPopupButton11" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-8"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton17" inherits="BenillaMacroPopupButtonTemplate" id="17"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton16" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton18" inherits="BenillaMacroPopupButtonTemplate" id="18"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton17" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton19" inherits="BenillaMacroPopupButtonTemplate" id="19"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton18" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton20" inherits="BenillaMacroPopupButtonTemplate" id="20"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton19" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
|
||||
<Button name="BenillaMacroPopupCancelButton" inherits="BenillaMacroPanelButtonTemplate" text="CANCEL">
|
||||
<Size><AbsDimension x="78" y="22"/></Size>
|
||||
<Anchors><Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-11" y="13"/></Offset></Anchor></Anchors>
|
||||
<Scripts>
|
||||
<OnClick>
|
||||
getglobal("BenillaMacroPopupFrame"):Hide()
|
||||
BenillaMacroFrame_Update()
|
||||
PlaySound("gsTitleOptionOK")
|
||||
</OnClick>
|
||||
</Scripts>
|
||||
</Button>
|
||||
<Button name="BenillaMacroPopupOkayButton" inherits="BenillaMacroPanelButtonTemplate" text="OKAY">
|
||||
<Size><AbsDimension x="78" y="22"/></Size>
|
||||
<Anchors><Anchor point="RIGHT" relativeTo="BenillaMacroPopupCancelButton" relativePoint="LEFT"><Offset><AbsDimension x="-2" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts>
|
||||
<OnClick>
|
||||
BenillaMacroPopupOkayButton_OnClick()
|
||||
PlaySound("gsTitleOptionOK")
|
||||
</OnClick>
|
||||
</Scripts>
|
||||
</Button>
|
||||
</Frames>
|
||||
<Scripts>
|
||||
<OnShow>BenillaMacroPopupFrame_OnShow(self)</OnShow>
|
||||
<OnHide>BenillaMacroPopupFrame_OnHide(self)</OnHide>
|
||||
<OnMouseWheel>BenillaFauxScrollFrame_OnMouseWheel(getglobal("BenillaMacroPopupScrollFrame"), arg1)</OnMouseWheel>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
</Ui>
|
||||
@@ -655,6 +655,17 @@
|
||||
.. "Below 100% trades sharpness for frame rate; above 100% is supersampling, which "
|
||||
.. "smooths edges at the cost of frame rate. Applies when you click Apply."
|
||||
|
||||
-- Enable Sound in Background (1847). An era row: 1.12 hardcodes the behaviour and has no
|
||||
-- checkbox, so there is no OPTION_TOOLTIP_* to resolve and the BENILLA_ prefix applies
|
||||
-- for the same reason Render Scale's does -- the reference's namespace stays the
|
||||
-- reference's. The last line is not padding: mute is not pause on either client, and a
|
||||
-- player who expects a paused fight to resume where it stopped is owed the difference.
|
||||
BENILLA_TOOLTIP_BACKGROUND_SOUND =
|
||||
"Keeps the game's sound playing while you are working in another window.\n\n"
|
||||
.. "With this off, switching away silences the game and switching back restores it. "
|
||||
.. "Sounds keep running while they are silenced, so a long one may have finished by "
|
||||
.. "the time you return."
|
||||
|
||||
-- Display Mode (1650). 1.12's own OPTION_TOOLTIP_WINDOWED_MODE cannot ride this row: it
|
||||
-- is written for the checkbox it belonged to ("Check to play in a non-fullscreen
|
||||
-- window..."), and its second half describes a desktop-gamma consequence of an exclusive
|
||||
@@ -674,6 +685,7 @@
|
||||
Audio = {
|
||||
"RowEnableAll", "RowMaster", "RowSound", "RowMusic", "RowAmbience",
|
||||
"RowEnableMusic", "RowEnableAmbience", "RowEnableErrorSpeech",
|
||||
"RowBackgroundSound",
|
||||
},
|
||||
Graphics = {
|
||||
"RowUiScale", "RowFarclip", "RowWorldDetail",
|
||||
@@ -2356,6 +2368,26 @@
|
||||
self.soundQuirk = 1
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<!-- Sound in the background (decision 1847). The one
|
||||
Audio row 1.12 has no checkbox for: it mutes on its
|
||||
window-activation event unconditionally, so this row
|
||||
offers the way out the reference never did, shipped
|
||||
OFF at the reference's own behaviour. Era's label and
|
||||
era's CVar spelling, the autoLootDefault posture.
|
||||
soundQuirk like every box on this page - the mapping
|
||||
belongs to the panel, not to the row's provenance. -->
|
||||
<Frame name="$parentRowBackgroundSound" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowEnableErrorSpeech" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowEnableErrorSpeech" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "Sound_EnableSoundWhenGameIsInBG",
|
||||
"Enable Sound in Background",
|
||||
"BENILLA_TOOLTIP_BACKGROUND_SOUND")
|
||||
self.soundQuirk = 1
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
</Frames>
|
||||
</Frame>
|
||||
<!-- The Graphics page (0959): the 1.12 OptionsFrameSliders rows
|
||||
|
||||
@@ -351,7 +351,7 @@
|
||||
-- remember is a LATER client's (`ChatEdit_InsertLink`). And the gate is SHIFT,
|
||||
-- not the mouse button: a plain right click on this page casts, same as a left one
|
||||
-- — pinned both ways in `ui_script::spellbook_tests`.
|
||||
local macroFrame = getglobal("BenillaMacroFrame")
|
||||
local macroFrame = getglobal("MacroFrame")
|
||||
if macroFrame and macroFrame:IsVisible() then
|
||||
local spellName, subSpellName = GetSpellName(id, SpellBookFrame.bookType)
|
||||
if spellName and not IsSpellPassive(id, SpellBookFrame.bookType) then
|
||||
@@ -359,7 +359,7 @@
|
||||
if subSpellName and string.len(subSpellName) > 0 then
|
||||
line = line .. "(" .. subSpellName .. ")"
|
||||
end
|
||||
BenillaMacroFrame_AddMacroLine(line)
|
||||
MacroFrame_AddMacroLine(line)
|
||||
end
|
||||
else
|
||||
PickupSpell(id, SpellBookFrame.bookType)
|
||||
|
||||
@@ -250,6 +250,19 @@
|
||||
end
|
||||
end
|
||||
|
||||
-- ref UIParent.lua's own `ShowMacroFrame`, moved here from our MacroFrame.xml when 1848
|
||||
-- retired that file — the third such squatter after `InspectUnit` (1832) and
|
||||
-- `ToggleTalentFrame` (1833), and by now the expected shape.
|
||||
--
|
||||
-- `MacroFrame_LoadUI` is left out: the addon loads eagerly from benilla.toc, so there is
|
||||
-- nothing for it to do, and 1203 says leave it out rather than stub it. The
|
||||
-- `MacroFrame_Show` guard is the reference's own.
|
||||
function ShowMacroFrame()
|
||||
if ( MacroFrame_Show ) then
|
||||
MacroFrame_Show();
|
||||
end
|
||||
end
|
||||
|
||||
function UIParent_OnEvent(event)
|
||||
-- The reference's own first arm (UIParent.lua l.230-235). `LocalizeFrames` comes from
|
||||
-- the sourced `Interface\FrameXML\Localization.xml` (1751 window 5) and is the file
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
-- verbatim (`UIPanelWindows["MacroFrame"] = { area = "left", pushable = 5, whileDead = 1 }`,
|
||||
-- Blizzard_MacroUI.lua:7). whileDead is the ref's and it earns its keep: writing a macro
|
||||
-- during a corpse run is a real thing people do.
|
||||
UIPanelWindows["BenillaMacroFrame"] = { area = "left", pushable = 5, whileDead = 1 }
|
||||
UIPanelWindows["MacroFrame"] = { area = "left", pushable = 5, whileDead = 1 }
|
||||
-- The crafting window (decision 0437 phase 2, TradeSkillFrame.xml): NOT an NPC session (it
|
||||
-- opens off your own cast, TradeSkillFrame.xml's own header comment) — the ref Lua's own
|
||||
-- registration line (Blizzard_TradeSkillUI.lua:13) is `{ area = "left", pushable = 3 }`,
|
||||
|
||||
@@ -39,6 +39,24 @@
|
||||
# The named virtual Font objects (decision 0084) — must load first so any FontString's
|
||||
# `inherits="GameFontNormal"` resolves against a registered object.
|
||||
Fonts.xml
|
||||
|
||||
# ══ THE STRINGS — the reference's own first line, seated second here (1848) ══
|
||||
# **Second, not first, and that is deliberate.** Manifest entry 0 is the FONT REGISTRY:
|
||||
# `load_font_registry` takes entry 0 and `load_ingame_ui` takes the rest, so entry 0 is the boot
|
||||
# boundary itself (1051). Putting the strings there would move `Fonts.xml` across it.
|
||||
# `GlobalStrings.lua` is line 1 of the reference's `FrameXML.toc`, and it was missing from ours
|
||||
# entirely: the app loaded it out of band at Startup (`ui_script::setup_script`, "the VM's first
|
||||
# load is the real GlobalStrings.lua"), so the running client was right and the MANIFEST was not
|
||||
# self-sufficient. Every harness that loads the manifest without that pre-step got a VM with no
|
||||
# strings, which is a gap four separate windows tripped over before anyone looked at the toc.
|
||||
#
|
||||
# The stock files read these at LOAD, not just at paint: `Blizzard_MacroUI.xml`'s character tab
|
||||
# does `format(CHARACTER_SPECIFIC_MACROS, UnitName("player"))` inside its own OnLoad, and a nil
|
||||
# format string there aborts the handler.
|
||||
#
|
||||
# The app's Startup load stays where it is — the cast-fail display (0427) resolves messages before
|
||||
# the in-game UI materializes at all (1051) — so this is a second, idempotent assignment pass.
|
||||
Interface\FrameXML\GlobalStrings.lua
|
||||
# ══ THE LOCALE ADJUSTMENTS — the reference's own, off the player's chain (1751, window 5) ══
|
||||
# `Localize()` (which this file also CALLS at load, exactly as the reference's own two-part
|
||||
# `<Script>` does) and `LocalizeFrames()`. Nothing of ours ever shipped either, and the gap was
|
||||
@@ -919,14 +937,21 @@ QuestLogFrame.xml
|
||||
# click opens, and whose MAX_QUESTS it shares.
|
||||
Interface\FrameXML\QuestTimerFrame.xml
|
||||
ChatFrame.xml
|
||||
# The macro window (decision 0983): the editor + its name/icon popup, driven over the
|
||||
# engine's OWN macro table (`benilla_ui::script::macros` — 1.12 macros have no server
|
||||
# side, so this is the one window whose model is not an app feed). Needs UiPanels (the
|
||||
# panel manager + `TabButtonTemplate`), ScrollTemplates (BOTH kits — the real one scrolls
|
||||
# the body box, the faux one the icon grid), MicroMenu (`UpdateMicroButtons` on
|
||||
# show/hide), and it must come AFTER SpellBookFrame, whose shift-click reaches
|
||||
# `BenillaMacroFrame_AddMacroLine`. Before GameMenuFrame, whose Macros button opens it.
|
||||
MacroFrame.xml
|
||||
# ══ THE MACRO WINDOW — the reference's own, off the player's chain (1751, window 32) ══
|
||||
# The fourth LoadOnDemand addon (1832's header carries the mechanics). ONE manifest line: its toc
|
||||
# lists `Blizzard_MacroUI.xml`, which sources its own `.lua`, plus the empty `Localization.lua`.
|
||||
#
|
||||
# Nothing to build. 1835 recorded `SaveMacros` as a missing engine verb and it is not one — the
|
||||
# only occurrence in the reference's own file is COMMENTED OUT (`--SaveMacros();` in
|
||||
# `MacroFrame_OnHide`); the triage that found it was not stripping Lua comments.
|
||||
#
|
||||
# It was BLOCKED until 1846: `MacroPopupScrollFrame` inherits FrameXML's
|
||||
# `ClassTrainerListScrollFrameTemplate`, which inherits `FauxScrollFrameTemplate` — and ours was a
|
||||
# same-named `<Frame>` with a Benilla slider that shadowed the reference's `<ScrollFrame>`. With the
|
||||
# panel kit on the chain that shadow is gone, so this window went from blocked to a plain swap.
|
||||
#
|
||||
# `ShowMacroFrame` moved to UIParent.xml, the reference's own home for it — see 1848.
|
||||
Interface\AddOns\Blizzard_MacroUI\Blizzard_MacroUI.xml
|
||||
# The script error log (decision 1495): the window `/errors` opens — the list B293's two
|
||||
# reporters asked for, which the reference's one-message-at-a-time ScriptErrors modal cannot
|
||||
# be. Ours entirely (1.12 has no counterpart), reading the retained log on the model rather
|
||||
|
||||
@@ -1560,6 +1560,18 @@ mod tests {
|
||||
let mut script = UiScript::new().expect("VM");
|
||||
script.register_bindings(®istry_commands());
|
||||
script.set_screen_size(1024.0, 768.0);
|
||||
// The in-game UI materializes on world entry (1051), so a player always exists by the time
|
||||
// the manifest loads — and the stock macro window's character tab formats
|
||||
// `UnitName("player")` into its label in its own OnLoad (decision 1848).
|
||||
script.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let failures = crate::ui_script::load_default_ui(&script);
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
//! **Camera shake** — the thump a heavy creature's footfall puts through the camera, and the
|
||||
//! one-off jolt as its body lands (B298; decision 1540).
|
||||
//! **Camera shake** — the thump a heavy creature's footfall puts through the camera, the one-off
|
||||
//! jolt as its body lands, and the jolt a spell effect puts through it (B298; decisions 1540/1849).
|
||||
//!
|
||||
//! Not an animation event. `$SHK` exists in the M2 vocabulary but **`CGUnit_C::HandleAnimEvent`
|
||||
//! does not decode it** — all 73 tags it dispatches were enumerated and `$SHK` is not among them;
|
||||
//! the only two handlers that decode it hang off the GameObject (typemask `0x20`) and
|
||||
//! DynamicObject (`0x40`) trampolines. A `$SHK` authored on a creature M2 logs
|
||||
//! `UNHANDLEDANIMEVENT` and does nothing, so no creature shake can be `$SHK`-driven — and in fact
|
||||
//! neither Ancient authors one.
|
||||
//! **Four producers, two id spaces, one evaluator.** Every producer ends at the same spawner —
|
||||
//! `AddShake(id, worldPos)` (`0x511d40`), whose five call sites are the whole population (proven by
|
||||
//! xref sweep, wow-re `spell/scratch/camera-shake-producers.md` §Q4: no address-dword reference to
|
||||
//! it anywhere, and no Lua, console, cinematic or quake path).
|
||||
//!
|
||||
//! The real chain is **data, not animation**:
|
||||
//! The **creature** pair names a `CameraShakes.dbc` preset **directly**:
|
||||
//!
|
||||
//! - **`CreatureModelData.FootstepShakeSize`** (field 11) → a `CameraShakes.dbc` row id, fired from
|
||||
//! the **visual** footfall channel — the per-foot plant tags (`$FL0`/`$FR0`/`$RL0`/`$RR0`…), the
|
||||
//! same stream [`crate::footprints`] reads. **Not `$FSD`**, which is the sound handler's alone.
|
||||
//! - **`CreatureModelData.FootstepShakeSize`** (field 11) → fired from the **visual** footfall
|
||||
//! channel — the per-foot plant tags (`$FL0`/`$FR0`/`$RL0`/`$RR0`…), the same stream
|
||||
//! [`crate::footprints`] reads. **Not `$FSD`**, which is the sound handler's alone.
|
||||
//! - **`CreatureModelData.DeathThudShakeSize`** (field 12) → the same table, fired on `$DTH`.
|
||||
//!
|
||||
//! The **spell** pair names a `SpellEffectCameraShakes.dbc` **group** — up to three presets at one
|
||||
//! point ([`benilla_formats::SpellShakeGroup`]):
|
||||
//!
|
||||
//! - **`SpellVisualKit` field 14** (`kit+0x38`, the `ShakeID` column) → once per kit play, via
|
||||
//! [`SpellKitShake`] and [`fire_kit_shakes`], which carries the mechanism and the one deviation.
|
||||
//! - **the `$SHK` animation event** → its payload *is* a group id, fired ungated at the event's own
|
||||
//! bone-transformed point ([`event_point`]).
|
||||
//!
|
||||
//! **`$SHK` is decoded by exactly two handlers**, hanging off the GameObject (typemask `0x20`,
|
||||
//! `0x5f3e20`) and DynamicObject (`0x40`, `0x5d58c0`) trampolines — the dword `0x4b485324` occurs
|
||||
//! twice image-wide, and of the six dispatchers `0x7133a0` registers only those two decode it.
|
||||
//! **`CGUnit_C::HandleAnimEvent` is not one of them**, so a `$SHK` on a creature M2 logs
|
||||
//! `UNHANDLEDANIMEVENT` and does nothing — which is not academic: CryptLord and Thunderaan both
|
||||
//! author markers on their death/emerge clips and the reference plays neither. That gate is why no
|
||||
//! creature shake can be `$SHK`-driven, and it is enforced in [`fire_shakes`].
|
||||
//!
|
||||
//! Only 25 of the 430 shipped models carry a footstep shake, and the set is exactly the
|
||||
//! thumping-giant list; 49 carry one of the two. See `benilla-extract shakecensus`.
|
||||
//! thumping-giant list; 49 carry one of the two, and 58 of the 1772 kits carry a shake group. See
|
||||
//! `benilla-extract shakecensus`, which maps all four producers onto the 24 presets.
|
||||
//!
|
||||
//! ## The law (wow-re `ui/scratch/camera-shake-law.md`, §5-verified)
|
||||
//!
|
||||
@@ -68,6 +83,9 @@
|
||||
//!
|
||||
//! **Combination.** Three slots, one per axis, each keeping the single strongest live shake — same-axis
|
||||
//! shakes do **not** sum, the losers are dropped outright, and at most three shakes play in a frame.
|
||||
//! This is what a spell **group** is shaped for: its three slots name the `direction` 0·1·2 members
|
||||
//! of one preset family, so they land on three different axes and all three contribute. They are
|
||||
//! slots and not axes, though — group 4 is `15 · 14 · 15`, and the duplicate loses the tie-break.
|
||||
//! Ties keep the incumbent, and the walk is oldest→newest, so ties keep the **older** record.
|
||||
//!
|
||||
//! **Lifetime.** A record holds a *snapshot* position and no reference to its source, so a shake
|
||||
@@ -82,12 +100,15 @@ use std::f32::consts::TAU;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_formats::{CameraShake, CameraShakeCatalog};
|
||||
use benilla_formats::{CameraShake, CameraShakeCatalog, SpellShakeGroup};
|
||||
|
||||
use crate::creature_anim::{footfall_side, move_flags, AnimSoundEvent, MovementState};
|
||||
use crate::creature_anim::{
|
||||
footfall_side, move_flags, AnimSoundEvent, MovementState, SpellKitShake,
|
||||
};
|
||||
use crate::entities::{BoneAttach, Creatures};
|
||||
use crate::net::{Embodied, NetEntity, ObjectStore};
|
||||
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
|
||||
use benilla_protocol::EntityKind;
|
||||
use benilla_world::schedule::WorldStage;
|
||||
use benilla_world::view::WorldCamera;
|
||||
|
||||
@@ -130,6 +151,24 @@ impl CameraShakes {
|
||||
});
|
||||
}
|
||||
|
||||
/// Enqueue a whole `SpellEffectCameraShakes` **group** at a world point — the spell side's
|
||||
/// only spawn shape (`0x6ecb40`, byte-read 2026-09-02): walk the three slots `+0x4/+0x8/+0xc`
|
||||
/// in ascending order, skip the zeros, and `AddShake(id, pos)` each. A slot the preset table
|
||||
/// does not carry is dropped by the same bounds check the reference makes (`0x511d40`'s
|
||||
/// `cmp ecx,[maxId]`); a group naming the same preset twice (group 4 is `15 · 14 · 15`)
|
||||
/// enqueues it twice and the duplicate simply loses [`Self::evaluate`]'s strict-`>` tie-break.
|
||||
fn add_group(
|
||||
&mut self,
|
||||
group: &SpellShakeGroup,
|
||||
table: &CameraShakeCatalog,
|
||||
pos: Vec3,
|
||||
now: f32,
|
||||
) {
|
||||
for row in group.shakes().filter_map(|id| table.get(id)) {
|
||||
self.add(*row, pos, now);
|
||||
}
|
||||
}
|
||||
|
||||
/// Retire what has expired and compose this frame's offset.
|
||||
///
|
||||
/// `swimming` is the reference's skip (`0x50ea87`/`0x50ea8b`): while the followed unit swims,
|
||||
@@ -216,7 +255,7 @@ impl Plugin for CameraShakePlugin {
|
||||
// ever reaps — a slow leak across a long capture.
|
||||
.add_systems(
|
||||
Update,
|
||||
fire_shakes
|
||||
(fire_shakes, fire_kit_shakes)
|
||||
.in_set(WorldStage::Present)
|
||||
.run_if(not(resource_exists::<crate::run_mode::CaptureMode>)),
|
||||
);
|
||||
@@ -278,12 +317,34 @@ fn fire_shakes(
|
||||
let now = time.elapsed_secs();
|
||||
for ev in events.read() {
|
||||
let thud = &ev.ident == b"$DTH";
|
||||
if !thud && footfall_side(&ev.ident).is_none() {
|
||||
let shk = &ev.ident == b"$SHK";
|
||||
if !thud && !shk && footfall_side(&ev.ident).is_none() {
|
||||
continue; // `$FSD` is the sound handler's; only the VISUAL channel shakes
|
||||
}
|
||||
let Ok((net, transform, attach, pose)) = units.get(ev.entity) else {
|
||||
continue;
|
||||
};
|
||||
if shk {
|
||||
// `$SHK`: the payload is a **group** id, and the tag is decoded by exactly two
|
||||
// handlers — the GameObject (`0x5f3e20`, typemask 0x20) and DynamicObject
|
||||
// (`0x5d58c0`, 0x40) dispatchers. `CGUnit_C::HandleAnimEvent` decodes no `$SHK`
|
||||
// (the dword occurs twice image-wide), so a creature's marker is inert and this gate
|
||||
// is what keeps it that way: CryptLord and Thunderaan both author one on their
|
||||
// death/emerge clips, and the reference plays neither.
|
||||
if !matches!(net.kind, EntityKind::GameObject | EntityKind::DynamicObject) {
|
||||
continue;
|
||||
}
|
||||
let Some(group) = shakes.0.group(ev.data) else {
|
||||
continue; // a payload the group table lacks — the reference bounds-checks too
|
||||
};
|
||||
// **No gates at all** — no distance, visibility, CVar or state test (the sibling
|
||||
// `$DSL` arm and the footstep path both have them; this one has none). The position is
|
||||
// the event's own bone-transformed point, not the object's origin.
|
||||
let at = event_point(attach, pose, &joints, &ev.ident)
|
||||
.unwrap_or_else(|| transform.translation());
|
||||
live.add_group(group, &shakes.0, at, now);
|
||||
continue;
|
||||
}
|
||||
// The shake reads the ROOT unit's own model row — a mount's row is never stored there, so
|
||||
// a rider on a kodo gets the kodo's *footprints* and not its shake (`0x607a00` writes only
|
||||
// the decal fields). The root is also where the state gates live.
|
||||
@@ -323,12 +384,7 @@ fn fire_shakes(
|
||||
}
|
||||
// The planted foot: the event's own marker through the live joint, exactly as the decal
|
||||
// derives it. No marker/joint = the unit origin.
|
||||
let foot = attach
|
||||
.zip(pose)
|
||||
.and_then(|(a, p)| {
|
||||
let (bone, offset) = a.markers.get(&ev.ident).copied()?;
|
||||
p.posed_point(joints.get(p.joints_root).ok()?, bone, offset)
|
||||
})
|
||||
let foot = event_point(attach, pose, &joints, &ev.ident)
|
||||
.unwrap_or_else(|| transform.translation());
|
||||
if eye.distance_squared(foot) > EMIT_DISTANCE_SQ {
|
||||
continue;
|
||||
@@ -337,6 +393,74 @@ fn fire_shakes(
|
||||
}
|
||||
}
|
||||
|
||||
/// The world point an animation-event marker names on a live model — the planted foot for a
|
||||
/// footfall tag, the authored shake point for `$SHK`. `None` when the model carries no marker for
|
||||
/// the tag or has no live rig, and every caller falls back to the object's own origin.
|
||||
///
|
||||
/// This is the reference's own quantity: `placementMatrix · (boneMatrix[event.bone] ·
|
||||
/// event.position)`, computed once by the M2 event kernel (`0x719370`) and carried by value in the
|
||||
/// queue record it drains. The decal path derives it the same way, which is why the two share this.
|
||||
fn event_point(
|
||||
attach: Option<&BoneAttach>,
|
||||
pose: Option<&benilla_world::rig_anim::RigPose>,
|
||||
joints: &Query<&GlobalTransform>,
|
||||
ident: &[u8; 4],
|
||||
) -> Option<Vec3> {
|
||||
let (a, p) = attach.zip(pose)?;
|
||||
let (bone, offset) = a.markers.get(ident).copied()?;
|
||||
p.posed_point(joints.get(p.joints_root).ok()?, bone, offset)
|
||||
}
|
||||
|
||||
/// Fire the camera shake a spell-visual kit's field 14 names — the **spell-side producer**
|
||||
/// (decision 1849), the counterpart of `crate::sound::spell`'s kit-sound route.
|
||||
///
|
||||
/// One shake per kit play, unconditionally: the reference reaches it from the first-created effect
|
||||
/// node's one-time arm pass (`0x620e11`, gated on that node's flags snapshot carrying bit `0x10`)
|
||||
/// and falls back to the kit tail (`0x60f4e6`) only when the play created no node at all. Neither
|
||||
/// site tests the stage, and there is no cancel path — a shake outlives its kit.
|
||||
///
|
||||
/// **One deliberate deviation, and it is the whole reason this doc paragraph exists.** The
|
||||
/// reference's primary site passes `&node+0x48` — a position field the bone-attached node
|
||||
/// constructor `0x61fdd0` **never writes**, and which the site that would write it (`0x62101d`)
|
||||
/// fills twelve instructions *later*, for the sound. So in the real client a kit whose first
|
||||
/// created node is bone-attached shakes at `(0, 0, 0)` and is culled by the 80 yd cut: **26 of the
|
||||
/// 58 shipped shake kits are silently dead**, including every summon (kit 138) and Goblin Sapper
|
||||
/// Charge (1424). That is a read-before-write of a zero-initialised field, not a design — the
|
||||
/// value it wants is unambiguous, being the same one the sound leg uses a moment later — so we
|
||||
/// spawn at the kit play's real position and the authored data plays. benilla implements the
|
||||
/// mechanism, not the quirk (the contract §3, `method.md` step 3); the reference's own numbers are
|
||||
/// in `decisions/1849`, and reverting to them would mean deliberately modelling an uninitialised
|
||||
/// field we do not have.
|
||||
fn fire_kit_shakes(
|
||||
mut events: MessageReader<SpellKitShake>,
|
||||
time: Res<Time>,
|
||||
transforms: Query<&GlobalTransform>,
|
||||
shakes: Option<Res<Shakes>>,
|
||||
mut live: ResMut<CameraShakes>,
|
||||
) {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(shakes) = shakes else { return };
|
||||
let now = time.elapsed_secs();
|
||||
for ev in events.read() {
|
||||
let (group, at) = match *ev {
|
||||
SpellKitShake::Play { entity, group } => {
|
||||
let Ok(t) = transforms.get(entity) else {
|
||||
continue; // the unit went away between the kit play and this drain
|
||||
};
|
||||
(group, t.translation())
|
||||
}
|
||||
SpellKitShake::PlayAt { pos, group } => (group, pos),
|
||||
};
|
||||
let Some(row) = shakes.0.group(group) else {
|
||||
continue; // a kit naming a group the table lacks — none ship, the bounds check is the
|
||||
// reference's own (`0x6ecb4d`)
|
||||
};
|
||||
live.add_group(row, &shakes.0, at, now);
|
||||
}
|
||||
}
|
||||
|
||||
/// The applier's read of the followed unit: its facing (the shake's body frame) and whether it is
|
||||
/// swimming (the reference's skip).
|
||||
type FollowedUnit = (&'static Transform, Option<&'static MovementState>);
|
||||
@@ -547,6 +671,120 @@ mod tests {
|
||||
assert!(l.y.abs() < 1e-7 && l.z.abs() < 1e-6 && l.x < 0.0, "{l:?}");
|
||||
}
|
||||
|
||||
/// The three spell rows a `SpellEffectCameraShakes` group names, one per axis — group 1's
|
||||
/// `4 · 5 · 6`, verbatim.
|
||||
const SPELL_4: CameraShake = CameraShake {
|
||||
id: 4,
|
||||
shake_type: 0,
|
||||
direction: 0,
|
||||
amplitude: 2.0,
|
||||
frequency: 6.0,
|
||||
duration: 4.0,
|
||||
phase: 0.0,
|
||||
coefficient: 0.4,
|
||||
};
|
||||
const SPELL_5: CameraShake = CameraShake {
|
||||
id: 5,
|
||||
direction: 1,
|
||||
frequency: 4.0,
|
||||
..SPELL_4
|
||||
};
|
||||
const SPELL_6: CameraShake = CameraShake {
|
||||
id: 6,
|
||||
direction: 2,
|
||||
amplitude: 4.0,
|
||||
frequency: 5.2,
|
||||
..SPELL_4
|
||||
};
|
||||
|
||||
/// A catalog holding the three spell presets plus the two groups the tests below name.
|
||||
fn spell_catalog() -> CameraShakeCatalog {
|
||||
CameraShakeCatalog::default()
|
||||
.with_row(SPELL_4)
|
||||
.with_row(SPELL_5)
|
||||
.with_row(SPELL_6)
|
||||
// Group 1 as shipped: one preset per axis.
|
||||
.with_group(SpellShakeGroup {
|
||||
id: 1,
|
||||
slots: [4, 5, 6],
|
||||
})
|
||||
// Group 3's shape: one populated slot, two zeros.
|
||||
.with_group(SpellShakeGroup {
|
||||
id: 3,
|
||||
slots: [6, 0, 0],
|
||||
})
|
||||
// A group naming a preset the table lacks, beside one it has — the reference's own
|
||||
// bounds check drops the first and keeps the second.
|
||||
.with_group(SpellShakeGroup {
|
||||
id: 9,
|
||||
slots: [4, 999, 0],
|
||||
})
|
||||
}
|
||||
|
||||
/// A **group** is the spell side's whole spawn shape: three slots walked in order, zeros
|
||||
/// skipped, each landing on its own axis — so one group play moves the eye on all three.
|
||||
#[test]
|
||||
fn a_group_spawns_one_shake_per_populated_slot() {
|
||||
let table = spell_catalog();
|
||||
let mut s = CameraShakes::default();
|
||||
s.add_group(table.group(1).unwrap(), &table, Vec3::ZERO, 0.0);
|
||||
assert_eq!(s.live.len(), 3, "one record per populated slot");
|
||||
|
||||
// A quarter-second in, all three axes are moving: 4 → forward, 5 → left, 6 → up.
|
||||
let at = 0.25;
|
||||
let offset = s.evaluate(Vec3::ZERO, 0.0, at, false);
|
||||
assert!(
|
||||
offset.z != 0.0 && offset.x != 0.0 && offset.y != 0.0,
|
||||
"{offset:?}"
|
||||
);
|
||||
// And each axis carries exactly its own preset's value — the slots do not sum.
|
||||
assert_eq!(
|
||||
offset.y,
|
||||
vertical(SPELL_6, 0.0, at),
|
||||
"axis 2 is preset 6's alone"
|
||||
);
|
||||
}
|
||||
|
||||
/// Zeros are skipped and a slot the preset table does not carry is dropped — the reference's
|
||||
/// own `test/je` and its `cmp ecx,[maxId]`. Neither is an error; both just contribute nothing.
|
||||
#[test]
|
||||
fn a_group_skips_zero_slots_and_unknown_presets() {
|
||||
let table = spell_catalog();
|
||||
let mut one = CameraShakes::default();
|
||||
one.add_group(table.group(3).unwrap(), &table, Vec3::ZERO, 0.0);
|
||||
assert_eq!(one.live.len(), 1, "two zero slots contribute nothing");
|
||||
|
||||
let mut dangling = CameraShakes::default();
|
||||
dangling.add_group(table.group(9).unwrap(), &table, Vec3::ZERO, 0.0);
|
||||
assert_eq!(dangling.live.len(), 1, "the id the table lacks is dropped");
|
||||
assert_eq!(dangling.live[0].row.id, 4);
|
||||
}
|
||||
|
||||
/// The shipped groups 4 and 7 name the **same preset twice** (`15 · 14 · 15`), which is the
|
||||
/// proof the three slots are slots and not axes. Both records spawn; the duplicate loses the
|
||||
/// strict-`>` tie-break, so the axis reads exactly as it would with one.
|
||||
#[test]
|
||||
fn a_duplicate_slot_spawns_but_never_doubles_the_offset() {
|
||||
let table = spell_catalog();
|
||||
let mut s = CameraShakes::default();
|
||||
s.add_group(
|
||||
&SpellShakeGroup {
|
||||
id: 4,
|
||||
slots: [6, 5, 6],
|
||||
},
|
||||
&table,
|
||||
Vec3::ZERO,
|
||||
0.0,
|
||||
);
|
||||
assert_eq!(s.live.len(), 3, "the duplicate is a real record");
|
||||
let at = 0.25;
|
||||
assert_eq!(
|
||||
s.evaluate(Vec3::ZERO, 0.0, at, false).y,
|
||||
vertical(SPELL_6, 0.0, at),
|
||||
"the duplicate contributes nothing on top of its twin"
|
||||
);
|
||||
}
|
||||
|
||||
/// A `direction` the reference cannot index writes nothing (rather than panicking on our side).
|
||||
#[test]
|
||||
fn an_out_of_range_direction_contributes_nothing() {
|
||||
|
||||
@@ -1184,9 +1184,9 @@ pub(super) fn seed_ui_fixture(
|
||||
// Slot 2 selected: the multi-line body, so the detail pane and the body box both have
|
||||
// something in them. The popup fixture then opens the chooser over that selection via
|
||||
// the ref's `MacroEditButton` path — an EDIT, so the name box arrives pre-filled.
|
||||
seed.push_str("ShowMacroFrame()\nBenillaMacroButton2:Click()\n");
|
||||
seed.push_str("ShowMacroFrame()\nMacroButton2:Click()\n");
|
||||
if fixture == UiFixture::MacroPopup {
|
||||
seed.push_str("BenillaMacroEditButton:Click()\n");
|
||||
seed.push_str("MacroEditButton:Click()\n");
|
||||
}
|
||||
if let Err(e) = script.run(&seed) {
|
||||
warn!("capture: ui-macro seed failed: {e}");
|
||||
|
||||
@@ -724,7 +724,7 @@ use spell_visual::{
|
||||
pub(crate) use spell_visual::arm_aura_state_fx as arm_aura_state_fx_for_test;
|
||||
pub(crate) use spell_visual::{
|
||||
held_strike_sound, ChainProcPlay, FxClass, FxStage, KitPush, MissileSpawn, SpellKitFx,
|
||||
SpellKitSound, SpellVisuals,
|
||||
SpellKitShake, SpellKitSound, SpellVisuals,
|
||||
};
|
||||
|
||||
/// The per-unit animation state machine.
|
||||
@@ -1022,6 +1022,7 @@ impl Plugin for CreatureAnimPlugin {
|
||||
.add_message::<CastEvent>()
|
||||
.add_message::<SpellGoTargets>()
|
||||
.add_message::<SpellKitSound>()
|
||||
.add_message::<SpellKitShake>()
|
||||
.add_message::<SpellKitFx>()
|
||||
.add_message::<MissileSpawn>()
|
||||
// The kit's beam edge (0955) — `crate::entities` owns what it becomes.
|
||||
|
||||
@@ -91,6 +91,28 @@ pub(crate) enum SpellKitSound {
|
||||
StopKit { entity: Entity, kit_sound: u32 },
|
||||
}
|
||||
|
||||
/// A spell-visual kit's **camera shake** (kit field 14 → a `SpellEffectCameraShakes.dbc` group id,
|
||||
/// decision 1849). Written here, consumed by [`crate::camera_shake`], and deliberately shaped like
|
||||
/// [`SpellKitSound`]'s two play arms: the reference reaches both through the same leg of the same
|
||||
/// one-time node arm pass, one field apart.
|
||||
///
|
||||
/// **Exactly once per `PlaySpellVisualKit` call, and always** (wow-re
|
||||
/// `spell/scratch/camera-shake-producers.md`, §5 2026-09-02). The primary site is `0x620e11`,
|
||||
/// inside the first-created effect node's one-time arm pass `0x620be0`, gated on that node's
|
||||
/// flags snapshot carrying bit `0x10` — which `0x60edf0` sets iff `kit+0x38 != 0` and every later
|
||||
/// spawner clears via `(flags & ~0x11) | 0x400`, so exactly the FIRST node carries it. `0x60f4e6`
|
||||
/// in the kit tail is the **no-node fallback**, gated on "nothing was created" rather than on a
|
||||
/// flag. There is no stage gate and no cancel path: a shake outlives its source and plays out.
|
||||
#[derive(Message, Clone, Copy, Debug)]
|
||||
pub(crate) enum SpellKitShake {
|
||||
/// Shake at the playing unit's own world position — the ordinary kit play.
|
||||
Play { entity: Entity, group: u32 },
|
||||
/// Shake at a bare world point — the kit play's `extra` override (`0x60f4c1`'s `[ebx+0x14]`),
|
||||
/// the same arm [`SpellKitSound::PlayAt`] serves: a missile's ground arrival belongs where it
|
||||
/// landed, not at the thrower.
|
||||
PlayAt { pos: Vec3, group: u32 },
|
||||
}
|
||||
|
||||
/// A persistent effect instance's **lifetime class** — which owner's reap can kill it.
|
||||
/// Byte-verified (wow-re `state-kit-aura-lifecycle.md`, §5 2026-07-14): the client's reap walk
|
||||
/// `0x614150(spellId, force)` discriminates by node flags — every SPELL_GO / SPELL_FAILED reap
|
||||
@@ -451,13 +473,15 @@ pub(crate) struct KitPush {
|
||||
|
||||
/// The writer set one discrete kit play fans out to — bundled so [`play_kit`] threads through the
|
||||
/// router's arms as one argument (each writer keeps its own system-param lifetime).
|
||||
struct KitOut<'a, 'w1, 'w2, 'w3, 'w4, 'w5> {
|
||||
struct KitOut<'a, 'w1, 'w2, 'w3, 'w4, 'w5, 'w6> {
|
||||
oneshots: &'a mut MessageWriter<'w1, EmoteAnim>,
|
||||
wounds: &'a mut MessageWriter<'w2, WoundAnim>,
|
||||
sounds: &'a mut MessageWriter<'w3, SpellKitSound>,
|
||||
fx: &'a mut MessageWriter<'w4, SpellKitFx>,
|
||||
/// The kit's beam, if its `CharProc` slots name one — the dispatcher's chain case (0955).
|
||||
chain: &'a mut MessageWriter<'w5, ChainProcPlay>,
|
||||
/// The kit's camera shake, if field 14 names a group (1849).
|
||||
shakes: &'a mut MessageWriter<'w6, SpellKitShake>,
|
||||
}
|
||||
|
||||
/// One kit played as a **discrete event** on a unit — the client's `PlaySpellVisualKit`
|
||||
@@ -518,6 +542,14 @@ fn play_kit(
|
||||
if let Some(kit_sound) = kit.sound.filter(|_| play.sound) {
|
||||
out.sounds.write(SpellKitSound::Play { entity, kit_sound });
|
||||
}
|
||||
// The kit's CAMERA SHAKE (field 14, decision 1849) — once per play, at the unit, at every
|
||||
// stage. Not gated by `play.sound` or `play.effects`: the reference's shake leg sits in the
|
||||
// first-created node's arm pass with no stage test, and the no-node fallback is gated only on
|
||||
// "nothing was created". A kit that spawns no models still shakes — 28 of the 58 shipped
|
||||
// shake kits carry nothing but the shake.
|
||||
if let Some(group) = kit.shake {
|
||||
out.shakes.write(SpellKitShake::Play { entity, group });
|
||||
}
|
||||
// The CharProc dispatcher's beam case — run for EVERY kit play, at every stage, exactly as
|
||||
// `PlaySpellVisualKit`'s tail runs it (`0x60f35c`). It is not gated by `play.effects`: the
|
||||
// beam is not one of the kit's attach-point effect models, and a kit whose caster carries no
|
||||
@@ -613,12 +645,13 @@ pub(super) fn route_cast_visuals(
|
||||
mut fx: MessageWriter<SpellKitFx>,
|
||||
mut sheaths: MessageWriter<super::SheathRequest>,
|
||||
// One tuple param (the 16-SystemParam ceiling): the missile spawns, the dest one-shot orders
|
||||
// (0797) and the beam plays (0955) — the spawn lanes resolved here where the catalogs live,
|
||||
// built by `crate::entities`.
|
||||
// (0797), the beam plays (0955) and the camera shakes (1849) — the lanes resolved here where
|
||||
// the catalogs live, built by `crate::entities` and `crate::camera_shake`.
|
||||
mut spawns: (
|
||||
MessageWriter<MissileSpawn>,
|
||||
MessageWriter<crate::entities::dest_fx::GroundBurst>,
|
||||
MessageWriter<ChainProcPlay>,
|
||||
MessageWriter<SpellKitShake>,
|
||||
),
|
||||
visuals: Option<Res<SpellVisuals>>,
|
||||
spells: Option<Res<crate::ui_action::Spells>>,
|
||||
@@ -632,13 +665,15 @@ pub(super) fn route_cast_visuals(
|
||||
};
|
||||
// Disjoint field borrows: the beam writer rides `out` for the whole body while the missile and
|
||||
// burst writers stay free for the GO loop below.
|
||||
let (missiles, bursts, chains) = (&mut spawns.0, &mut spawns.1, &mut spawns.2);
|
||||
let (missiles, bursts, chains, shakes) =
|
||||
(&mut spawns.0, &mut spawns.1, &mut spawns.2, &mut spawns.3);
|
||||
let mut out = KitOut {
|
||||
oneshots: &mut oneshots,
|
||||
wounds: &mut wounds,
|
||||
sounds: &mut sounds,
|
||||
fx: &mut fx,
|
||||
chain: chains,
|
||||
shakes,
|
||||
};
|
||||
|
||||
// The `holds` query is one command-flush stale: an instant cast's START and GO drain from
|
||||
@@ -881,12 +916,17 @@ pub(super) fn route_cast_visuals(
|
||||
// `|| None`: the weapon merge pointedly skips the dest-anchored `+0x2c/+0x30/
|
||||
// +0x34` block ([`VisualStages::merged_over_weapon`]), so even Volley's area kit
|
||||
// is its own row's — the lookup would be paid for nothing.
|
||||
if let Some(kit_sound) =
|
||||
resolve_kit(spells, &visuals.0, ev.spell_id, |s| s.area_kit, || None)
|
||||
.and_then(|k| k.sound)
|
||||
{
|
||||
let area = resolve_kit(spells, &visuals.0, ev.spell_id, |s| s.area_kit, || None);
|
||||
if let Some(kit_sound) = area.and_then(|k| k.sound) {
|
||||
out.sounds.write(SpellKitSound::PlayAt { pos, kit_sound });
|
||||
}
|
||||
// …and its shake, at the same landing point (1849). Unlike the sound this is NOT
|
||||
// dead data on the reachable rows: kit 1285 (Cannon Ball, Bolt Charge Bramble,
|
||||
// Kill Urok Minion) carries no anim, no sound and no slots — it is a camera shake
|
||||
// and nothing else.
|
||||
if let Some(group) = area.and_then(|k| k.shake) {
|
||||
out.shakes.write(SpellKitShake::PlayAt { pos, group });
|
||||
}
|
||||
}
|
||||
CastEventKind::Fail => {
|
||||
if held_spell(&pending, ev.entity) == Some(ev.spell_id) {
|
||||
@@ -1532,6 +1572,7 @@ pub(super) fn replay_morph_kit(
|
||||
mut sounds: MessageWriter<SpellKitSound>,
|
||||
mut fx: MessageWriter<SpellKitFx>,
|
||||
mut chain: MessageWriter<ChainProcPlay>,
|
||||
mut shakes: MessageWriter<SpellKitShake>,
|
||||
) {
|
||||
for swap in swaps.read() {
|
||||
// Consume-and-clear even when the kit resolves to nothing — the reference clears
|
||||
@@ -1552,6 +1593,7 @@ pub(super) fn replay_morph_kit(
|
||||
sounds: &mut sounds,
|
||||
fx: &mut fx,
|
||||
chain: &mut chain,
|
||||
shakes: &mut shakes,
|
||||
};
|
||||
play_kit(
|
||||
swap.entity,
|
||||
|
||||
@@ -14,7 +14,10 @@ use benilla_formats::{SpellCatalog, SpellDisplay, SpellVisualCatalog, VisualKit,
|
||||
use super::super::{
|
||||
CastEvent, CastEventKind, CastHold, EmoteAnim, RangedHold, SheathRequest, WoundAnim,
|
||||
};
|
||||
use super::{route_cast_visuals, KitPush, MissileSpawn, SpellKitFx, SpellKitSound, SpellVisuals};
|
||||
use super::{
|
||||
route_cast_visuals, KitPush, MissileSpawn, SpellKitFx, SpellKitShake, SpellKitSound,
|
||||
SpellVisuals,
|
||||
};
|
||||
use crate::creature_anim::SpellGoTargets;
|
||||
|
||||
/// Demon Armor's real chain shape (5875 `spellvis 706`): visual 130 → precast kit 217, anim 52 —
|
||||
@@ -40,6 +43,7 @@ fn app() -> App {
|
||||
.add_message::<EmoteAnim>()
|
||||
.add_message::<WoundAnim>()
|
||||
.add_message::<SpellKitSound>()
|
||||
.add_message::<SpellKitShake>()
|
||||
.add_message::<SpellKitFx>()
|
||||
.add_message::<MissileSpawn>()
|
||||
.add_message::<crate::entities::dest_fx::GroundBurst>()
|
||||
@@ -200,6 +204,7 @@ fn precast_kit_sound_rings_once_at_start() {
|
||||
.add_message::<EmoteAnim>()
|
||||
.add_message::<WoundAnim>()
|
||||
.add_message::<SpellKitSound>()
|
||||
.add_message::<SpellKitShake>()
|
||||
.add_message::<SpellKitFx>()
|
||||
.add_message::<MissileSpawn>()
|
||||
.add_message::<crate::entities::dest_fx::GroundBurst>()
|
||||
@@ -484,7 +489,8 @@ fn aura_state_kit_arms_persistent_and_reaps_on_aura_end() {
|
||||
// sound leg (0852). Food's kit 409 carries neither, so nothing is asserted here — the
|
||||
// messages just have to exist for the writers.
|
||||
app.add_message::<crate::aura_visual::AuraProc>();
|
||||
app.add_message::<SpellKitSound>();
|
||||
app.add_message::<SpellKitSound>()
|
||||
.add_message::<SpellKitShake>();
|
||||
app.init_resource::<FxLog>();
|
||||
app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables_with_paths(
|
||||
HashMap::from([(
|
||||
@@ -614,6 +620,7 @@ fn missile_spawn_defers_iff_the_cast_kit_animates() {
|
||||
.add_message::<EmoteAnim>()
|
||||
.add_message::<WoundAnim>()
|
||||
.add_message::<SpellKitSound>()
|
||||
.add_message::<SpellKitShake>()
|
||||
.add_message::<SpellKitFx>()
|
||||
.add_message::<MissileSpawn>()
|
||||
.add_message::<crate::entities::dest_fx::GroundBurst>()
|
||||
@@ -725,6 +732,7 @@ fn a_targetless_dest_go_spawns_a_ground_missile_whose_arrival_sounds_at_the_poin
|
||||
.add_message::<EmoteAnim>()
|
||||
.add_message::<WoundAnim>()
|
||||
.add_message::<SpellKitSound>()
|
||||
.add_message::<SpellKitShake>()
|
||||
.add_message::<SpellKitFx>()
|
||||
.add_message::<MissileSpawn>()
|
||||
.add_message::<crate::entities::dest_fx::GroundBurst>()
|
||||
@@ -1197,6 +1205,7 @@ fn real_shooter(weapon: &RealRanged) -> Option<(App, Entity)> {
|
||||
.add_message::<EmoteAnim>()
|
||||
.add_message::<WoundAnim>()
|
||||
.add_message::<SpellKitSound>()
|
||||
.add_message::<SpellKitShake>()
|
||||
.add_message::<SpellKitFx>()
|
||||
.add_message::<MissileSpawn>()
|
||||
.add_message::<crate::entities::dest_fx::GroundBurst>()
|
||||
|
||||
@@ -237,6 +237,23 @@ pub(crate) const REGISTERED: &[Registered] = &[
|
||||
// `re/cvar/cvar-register-sites.tsv` row 54) and a real 1.12 checkbox: SoundOptionsFrame.lua's
|
||||
// `ENABLE_ERROR_SPEECH`, index 4, which the master enable greys along with Ambience.
|
||||
same("EnableErrorSpeech", "1"),
|
||||
// Sound while the window is in the background (1847). **Not a 1.12 CVar and not a 1.12
|
||||
// checkbox**: none of the reference's 214 `CVar::Register` sites names it
|
||||
// (`re/cvar/cvar-register-sites.tsv`), and `SoundOptionsFrame.lua` declares seven checkboxes
|
||||
// (indices 1, 2, 4-8) and four sliders, none of them this. `CVar::Register` is the only
|
||||
// creation path, so `Config.wtf` can hold no such key either. The spelling is the later-era
|
||||
// engine's — the `autoLootDefault` / `nameplateShowEnemies` posture, where benilla's
|
||||
// persistence IS the CVar store (0954) and a setting 1.12 never made settable takes the era
|
||||
// name rather than an invented one.
|
||||
//
|
||||
// **`same`, not `ours`**, for the nameplate pair's reason: the reference has no CVar to match
|
||||
// but it very much has a *behaviour* to match, and it goes quiet in the background —
|
||||
// unconditionally, on `WM_ACTIVATE` → event-bus category 2 → `0x7a4860`'s
|
||||
// `FSOUND_SetMute(-3, active ? 0 : 1)`, music included (wow-re
|
||||
// `sound/scratch/focus-mute-law.md`, VERIFIED). "0" IS the reference's own behaviour. The knob
|
||||
// is `SoundConfig::background_sound`, which carries the mechanism and the one disclosed
|
||||
// divergence.
|
||||
same("Sound_EnableSoundWhenGameIsInBG", "0"),
|
||||
// Zone reverb (1153). The binary registers this one `"1"` (`0x4573be`) and we register it
|
||||
// `"0"` — the only row here that knowingly leaves the registrar's default, because the
|
||||
// reference's reverb is EAX-over-hardware and that hardware has not existed since Vista:
|
||||
@@ -969,6 +986,7 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
|
||||
"enablemusic" => knobs.sound.music_enabled = v != 0.0,
|
||||
"enableambience" => knobs.sound.ambience_enabled = v != 0.0,
|
||||
"enableerrorspeech" => knobs.sound.error_speech = v != 0.0,
|
||||
"sound_enablesoundwhengameisinbg" => knobs.sound.background_sound = v != 0.0,
|
||||
// The client's own parse for this one is literally `!= 0` too (`0x4574d0`: `setne al`).
|
||||
"soundreverb" => knobs.sound.reverb = v != 0.0,
|
||||
"soundoutputlimiter" => knobs.sound.limiter = v != 0.0,
|
||||
@@ -1337,7 +1355,7 @@ fn sync_cvars(
|
||||
.collect(),
|
||||
);
|
||||
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
|
||||
let session: [(&str, String); 43] = [
|
||||
let session: [(&str, String); 44] = [
|
||||
("MasterVolume", sound.master.to_string()),
|
||||
("SoundVolume", sound.sfx.to_string()),
|
||||
("MusicVolume", sound.music.to_string()),
|
||||
@@ -1346,6 +1364,10 @@ fn sync_cvars(
|
||||
("EnableMusic", flag(sound.music_enabled)),
|
||||
("EnableAmbience", flag(sound.ambience_enabled)),
|
||||
("EnableErrorSpeech", flag(sound.error_speech)),
|
||||
(
|
||||
"Sound_EnableSoundWhenGameIsInBG",
|
||||
flag(sound.background_sound),
|
||||
),
|
||||
("SoundReverb", flag(sound.reverb)),
|
||||
("SoundOutputLimiter", flag(sound.limiter)),
|
||||
("uiScale", scale.0.to_string()),
|
||||
@@ -1683,6 +1705,14 @@ mod tests {
|
||||
assert_eq!(d["EnableMusic"] != 0.0, sound.music_enabled);
|
||||
assert_eq!(d["EnableAmbience"] != 0.0, sound.ambience_enabled);
|
||||
assert_eq!(d["EnableErrorSpeech"] != 0.0, sound.error_speech);
|
||||
assert_eq!(
|
||||
d["Sound_EnableSoundWhenGameIsInBG"] != 0.0,
|
||||
sound.background_sound
|
||||
);
|
||||
assert!(
|
||||
!sound.background_sound,
|
||||
"the reference goes quiet in the background and offers no way out (decision 1847)"
|
||||
);
|
||||
// Welded like the rest — and deliberately NOT the binary's registrar "1" (1153).
|
||||
assert_eq!(d["SoundReverb"] != 0.0, sound.reverb);
|
||||
assert_eq!(d["SoundOutputLimiter"] != 0.0, sound.limiter);
|
||||
|
||||
@@ -141,6 +141,10 @@ pub(crate) struct Mixer {
|
||||
limiter_on: Arc<AtomicBool>,
|
||||
/// The master gain — first in the main chain, **upstream** of the limiter ([`main_track`]).
|
||||
master: VolumeControlHandle,
|
||||
/// The output gate — **last** in the main chain, downstream of every tap ([`main_track`]).
|
||||
/// Open (unity) or shut (silence); never a level. [`Mixer::set_output_gate`] is its only
|
||||
/// writer.
|
||||
output: VolumeControlHandle,
|
||||
/// The pre-limiter tap's frame clock while a probing run records; `None` otherwise.
|
||||
audio_pos: Option<Arc<AtomicU64>>,
|
||||
/// The device's negotiated sample rate — what turns a count of over-scale samples into a
|
||||
@@ -158,6 +162,7 @@ impl Mixer {
|
||||
let MainChain {
|
||||
builder: main_track_builder,
|
||||
master,
|
||||
output,
|
||||
audio_pos,
|
||||
} = main_track(&level, &limiter_on, sample_rate, probe_dir);
|
||||
let settings = AudioManagerSettings::<DefaultBackend> {
|
||||
@@ -209,6 +214,7 @@ impl Mixer {
|
||||
level,
|
||||
limiter_on,
|
||||
master,
|
||||
output,
|
||||
sample_rate,
|
||||
audio_pos,
|
||||
})
|
||||
@@ -278,6 +284,28 @@ impl Mixer {
|
||||
self.master.set_volume(amp_to_db(amp), glide());
|
||||
}
|
||||
|
||||
/// Open or shut the **output gate** — the focus mute (decision 1847), the seam mirror of the
|
||||
/// reference's `FSOUND_SetMute(FSOUND_ALL, …)` on its window-activation event.
|
||||
///
|
||||
/// A *gate*, not a level: unity or silence, nothing between. That is what makes it safe at
|
||||
/// the end of the chain — the limiter upstream sees the same signal either way, so shutting
|
||||
/// it can never make the limiter duck differently, which is the trap that keeps the master
|
||||
/// gain at the *front* ([`main_track`]).
|
||||
///
|
||||
/// Shut is a [`glide`] rather than a step for the usual reason: cutting a full mix in one
|
||||
/// sample is a click. 16 ms is inaudible as a fade and the reference's own mute is instant,
|
||||
/// so nothing about the ramp is a fidelity claim.
|
||||
pub(super) fn set_output_gate(&mut self, open: bool) {
|
||||
self.output.set_volume(
|
||||
if open {
|
||||
Decibels::IDENTITY
|
||||
} else {
|
||||
Decibels::SILENCE
|
||||
},
|
||||
glide(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Arm or bypass the output limiter — the `SoundOutputLimiter` CVar (decision 1551). The
|
||||
/// limiter's delay line runs either way, so this is a fade, never a step in the output.
|
||||
pub(super) fn set_limiter(&mut self, on: bool) {
|
||||
@@ -406,6 +434,11 @@ const SPATIAL_VOICE_CAPACITY: usize = 512;
|
||||
/// 2. **limiter** — the brickwall that makes it fit, so kira's own hard clamp never fires.
|
||||
/// 3. **mix tap** — records what is actually heard (decision 1112, `$WOW_MIX_TAP`; a no-op
|
||||
/// builder unless the env var names a capture path).
|
||||
/// 4. **output gate** — the focus mute (decision 1847), and the *only* stage downstream of every
|
||||
/// tap. It is last on purpose: a mute that sits above the taps falsifies every recording made
|
||||
/// while the window is in the background, which is precisely when an unattended instrumented
|
||||
/// run records. Here it silences the speakers and nothing else — `pre.wav`, `post.wav`, the
|
||||
/// `$WOW_MIX_TAP` capture and the health meter all keep reading the mix the game produced.
|
||||
///
|
||||
/// A free function so the offline harness (`overlapping_kits_*`, below) can build the **same**
|
||||
/// chain over kira's mock backend — a headless proof of the audible claim, not a mirror of it.
|
||||
@@ -436,9 +469,14 @@ fn main_track(
|
||||
if let Some((dir, rate)) = probe {
|
||||
super::mix_tap::install_at(&mut main, &dir.join("post.wav"), rate);
|
||||
}
|
||||
let mut main = super::mix_tap::install(main, sample_rate);
|
||||
// Last, after every tap (see the chain doc above). Opens at unity: a run whose focus never
|
||||
// changes — and every test in this file — is bit-identical to one built without it.
|
||||
let output = main.add_effect(VolumeControlBuilder::new(Decibels::IDENTITY));
|
||||
MainChain {
|
||||
builder: super::mix_tap::install(main, sample_rate),
|
||||
builder: main,
|
||||
master,
|
||||
output,
|
||||
audio_pos,
|
||||
}
|
||||
}
|
||||
@@ -451,6 +489,8 @@ struct MainChain {
|
||||
/// left at unity forever — writing to it would reintroduce the post-limiter stage this
|
||||
/// exists to avoid.
|
||||
master: VolumeControlHandle,
|
||||
/// The output gate, last in the chain — the focus mute's handle (see [`Mixer::set_output_gate`]).
|
||||
output: VolumeControlHandle,
|
||||
/// The pre-limiter tap's frame clock, when a probing run armed one.
|
||||
audio_pos: Option<Arc<AtomicU64>>,
|
||||
}
|
||||
@@ -928,6 +968,79 @@ mod tests {
|
||||
wav
|
||||
}
|
||||
|
||||
/// The **focus gate silences the output and nothing upstream** (decision 1847).
|
||||
///
|
||||
/// This is the claim the whole design rests on, and the one that is expensive to be wrong
|
||||
/// about: an instrumented run's window is unfocused by construction, so a mute placed above
|
||||
/// the taps would make every unattended audio capture record silence — a false negative that
|
||||
/// reads exactly like "the sound never fired". Here the gate is the last effect, so the
|
||||
/// meter/tap plane keeps reading the mix the game produced.
|
||||
///
|
||||
/// **What falsifies it:** move `output`'s `add_effect` above `meter::install` (or onto the
|
||||
/// main track's own volume, which kira applies after every effect) and `asked` collapses to
|
||||
/// silence with `heard` — this test fails on the first assert.
|
||||
#[test]
|
||||
fn the_output_gate_silences_the_output_and_nothing_upstream() {
|
||||
use kira::backend::mock::{MockBackend, MockBackendSettings};
|
||||
|
||||
const RATE: u32 = 44_100;
|
||||
|
||||
let asked = Arc::new(MixLevel::default());
|
||||
let heard = Arc::new(MixLevel::default());
|
||||
let limiter_on = Arc::new(AtomicBool::new(true));
|
||||
let chain = main_track(&asked, &limiter_on, Some(RATE), None);
|
||||
let (mut main, mut output) = (chain.builder, chain.output);
|
||||
// Appended after the gate, so it reads what reaches the device.
|
||||
meter::install(&mut main, &heard);
|
||||
let mut manager = AudioManager::<MockBackend>::new(AudioManagerSettings {
|
||||
backend_settings: MockBackendSettings { sample_rate: RATE },
|
||||
main_track_builder: main,
|
||||
..Default::default()
|
||||
})
|
||||
.expect("mock backend");
|
||||
|
||||
let data = sfx_from_bytes(full_scale_tone(RATE)).expect("tone decodes");
|
||||
let render = |manager: &mut AudioManager<MockBackend>| {
|
||||
manager.play(data.clone()).expect("play");
|
||||
let blocks = (data.duration().as_secs_f64() + 0.05) * f64::from(RATE) / 128.0;
|
||||
let backend = manager.backend_mut();
|
||||
for _ in 0..(blocks.ceil() as usize) {
|
||||
backend.on_start_processing();
|
||||
backend.process();
|
||||
}
|
||||
(asked.take().peak, heard.take().peak)
|
||||
};
|
||||
|
||||
// Gate open (its birth state): what the mix asked for is what comes out.
|
||||
let (open_asked, open_heard) = render(&mut manager);
|
||||
assert!(
|
||||
open_asked > 0.9 && open_heard > 0.9,
|
||||
"gate open: asked {open_asked:.3}, heard {open_heard:.3} — both should be full scale"
|
||||
);
|
||||
|
||||
// Shut it, then settle the 16 ms ramp over silence so what follows measures the steady
|
||||
// state and not the fade.
|
||||
output.set_volume(Decibels::SILENCE, glide());
|
||||
{
|
||||
let backend = manager.backend_mut();
|
||||
for _ in 0..((f64::from(RATE) * 0.05 / 128.0).ceil() as usize) {
|
||||
backend.on_start_processing();
|
||||
backend.process();
|
||||
}
|
||||
}
|
||||
let _ = (asked.take(), heard.take());
|
||||
|
||||
let (shut_asked, shut_heard) = render(&mut manager);
|
||||
assert!(
|
||||
shut_heard < 1e-3,
|
||||
"gate shut: the output must be silent, heard {shut_heard:.6}"
|
||||
);
|
||||
assert!(
|
||||
shut_asked > 0.9,
|
||||
"gate shut: the METER plane must still read the mix the game produced — asked {shut_asked:.3}. A tap that goes silent with the speakers is the false negative this ordering exists to prevent"
|
||||
);
|
||||
}
|
||||
|
||||
/// The master volume must sit **upstream** of the limiter — the ordering fix in
|
||||
/// [`main_track`], and the one place a unit test can prove it.
|
||||
///
|
||||
|
||||
@@ -99,6 +99,49 @@ pub(crate) struct SoundConfig {
|
||||
/// read inside `0x458250` alongside `MasterSoundEffects`, *before* any escalation state moves,
|
||||
/// so turning it off is silence rather than a muted play.
|
||||
pub error_speech: bool,
|
||||
/// **Sound while the window is in the background** — the era engine's
|
||||
/// `Sound_EnableSoundWhenGameIsInBG`, over a setting 1.12 hardcodes and never made settable
|
||||
/// (decision 1847). `false` = the reference's own behaviour: alt-tab away and the client goes
|
||||
/// quiet; come back and it returns.
|
||||
///
|
||||
/// The reference's mechanism, VERIFIED end to end in wow-re
|
||||
/// `sound/scratch/focus-mute-law.md` (§5 round, 2026-09-02, dispatched from here): **`WM_ACTIVATE`
|
||||
/// and nothing else** — `WM_ACTIVATEAPP`, `WM_SETFOCUS` and `WM_KILLFOCUS` all fall through the
|
||||
/// WndProc's remap table to `DefWindowProcA` — normalised to a 0/1 flag at `0x42d080`, enqueued
|
||||
/// as OS-event tag 6 (`0x42d0cb`, the only tag-6 producer image-wide), raised as event-bus
|
||||
/// category 2 (`0x423cc5`, the only direct category-2 raise), handled by `0x7a4860`:
|
||||
/// `FSOUND_SetMute(-3, active ? 0 : 1)`. (`-3` is `FSOUND_ALL`; the *name* is inferred — macro
|
||||
/// names do not survive compilation — the value and its behaviour are not.)
|
||||
///
|
||||
/// **Everything goes silent, music included.** The behaviour-flag bit `0x2` that exempts music
|
||||
/// from the `MasterSoundEffects` pause and from the open-refusal does **not** exempt it here:
|
||||
/// `-3` sweeps `[0, FSOUND_GetMaxChannels())`, which FMOD 3.75's own allocation pools tile
|
||||
/// exactly, and WoW plays every sound — music and SFX alike — as an ordinary stream on a channel
|
||||
/// from those pools (it imports no `FSOUND_PlaySound` at all). This was the open question when
|
||||
/// the gate was built and the answer decided its scope: whole output, not per category.
|
||||
///
|
||||
/// A **mute, not a pause**, also verified: `SetMute` sets `[chan+0x3c]` bit `0x02` and every
|
||||
/// software mixer gates on bit `0x10` (`SetPaused`) alone, so the playback cursor keeps being
|
||||
/// written. A five-second sound started before you alt-tabbed is over when you come back, and
|
||||
/// each channel's own stored volume comes back verbatim. benilla inherits that rather than
|
||||
/// implementing it: a gate on the output cannot stop the schedulers.
|
||||
///
|
||||
/// **Divergence, disclosed.** While inactive the reference also *refuses to open* a new
|
||||
/// non-music stream (`0x7a52bf`), where a new music stream is opened and individually muted;
|
||||
/// benilla starts everything and gates the output, so a sound that begins while you are away is
|
||||
/// still audible on return if it outlives your absence. Bounded by an SFX's own length — the
|
||||
/// long-lived classes are the music/ambience ones the reference start-and-mutes too — and taken
|
||||
/// deliberately: a refusal at the kit-start layer would make an unattended capture record
|
||||
/// silence, which is the false negative [`mixer::Mixer::set_output_gate`]'s position exists to
|
||||
/// prevent.
|
||||
///
|
||||
/// There is **no 1.12 CVar and no 1.12 checkbox** for this — `SoundOptionsFrame.lua` declares
|
||||
/// seven checkboxes (indices 1, 2, 4–8) and four sliders, none of them this, and none of the
|
||||
/// reference's 214 `CVar::Register` sites names it (wow-re `re/cvar/cvar-register-sites.tsv`;
|
||||
/// `Register` is the only creation path, so `Config.wtf` can hold no such key either). So the
|
||||
/// row is the `autoLootDefault` posture: benilla's persistence is the CVar store (0954), and a
|
||||
/// setting with no 1.12 CVar takes the later-era engine's spelling rather than an invented one.
|
||||
pub background_sound: bool,
|
||||
/// Zone reverb — 1.12's `SoundReverb` CVar (`0x4573be` registration, callback `0x4574d0`,
|
||||
/// flag byte `[0x835a4c]`). The flag gates **both** EAX paths: the zone/environment preset
|
||||
/// (`0x45a75b`: flag zero ⇒ `FSOUND_Reverb_SetProperties` is never called) and the
|
||||
@@ -198,6 +241,7 @@ impl Default for SoundConfig {
|
||||
music_enabled: true,
|
||||
ambience_enabled: true,
|
||||
error_speech: true,
|
||||
background_sound: false,
|
||||
reverb: false,
|
||||
world_hold: false,
|
||||
music_suppressed: false,
|
||||
@@ -222,6 +266,37 @@ fn feed_world_hold(
|
||||
}
|
||||
}
|
||||
|
||||
/// The **focus gate** (decision 1847): shut the output while the window is in the background,
|
||||
/// unless [`SoundConfig::background_sound`] says otherwise.
|
||||
///
|
||||
/// Reads the window's own focus rather than a live bit on [`SoundConfig`] — unlike `world_hold`
|
||||
/// and `music_suppressed`, nothing else in the frame needs to agree about it, because this gate
|
||||
/// changes exactly one thing: the level of the last effect in the main chain. No trigger, no
|
||||
/// scheduler and no meter consults it, which is the whole point (see
|
||||
/// [`mixer::Mixer::set_output_gate`]).
|
||||
///
|
||||
/// **A missing window opens the gate**, never shuts it: "we cannot tell whether anyone is looking"
|
||||
/// must not be heard as silence.
|
||||
///
|
||||
/// The `Local` snapshot is what turns a per-frame read into an **edge**: focus is polled off the
|
||||
/// window rather than watched as an event, so without it every frame would re-issue a tween and
|
||||
/// the 16 ms ramp would restart forever instead of ever landing.
|
||||
fn apply_focus_gate(
|
||||
mut out: NonSendMut<SoundOutput>,
|
||||
config: Res<SoundConfig>,
|
||||
windows: Query<&Window, With<bevy::window::PrimaryWindow>>,
|
||||
mut last: Local<Option<bool>>,
|
||||
) {
|
||||
let open = config.background_sound || windows.single().map_or(true, |w| w.focused);
|
||||
if *last == Some(open) {
|
||||
return;
|
||||
}
|
||||
*last = Some(open);
|
||||
if let Some(mixer) = out.mixer.as_mut() {
|
||||
mixer.set_output_gate(open);
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the cinematic's music stop into [`SoundConfig::music_suppressed`], once per frame.
|
||||
///
|
||||
/// **Ordered `WorldStage::Stream`, not `PreUpdate` beside [`feed_world_hold`]** — the two look
|
||||
@@ -458,6 +533,7 @@ impl Plugin for SoundPlugin {
|
||||
update_audio_listener.in_set(WorldStage::Stream),
|
||||
toggle_mute,
|
||||
apply_master_volume.after(toggle_mute),
|
||||
apply_focus_gate,
|
||||
poll_mix_health,
|
||||
hal_overload::poll,
|
||||
),
|
||||
|
||||
@@ -75,6 +75,19 @@ fn a_capture_run_persists_nothing() {
|
||||
fn ui() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||||
// never reaches (decision 1848).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let failures = crate::ui_script::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||||
s
|
||||
|
||||
@@ -449,6 +449,19 @@ fn without_a_player_at_addon_load_bagnon_draws_an_empty_window() {
|
||||
fn the_item_button_helpers_paint_a_slots_icon_and_count() {
|
||||
let mut s = UiScript::new().expect("VM");
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||||
// never reaches (decision 1848).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let failures = super::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "our own FrameXML: {failures:#?}");
|
||||
|
||||
|
||||
@@ -318,6 +318,19 @@ const RAISABLE_BARS: &[&str] = &[
|
||||
fn no_bottom_band_frame_overlaps_a_raised_bar() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1600.0, 900.0);
|
||||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||||
// never reaches (decision 1848).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let failures = super::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||||
|
||||
@@ -439,6 +452,19 @@ fn overlaps(a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)) -> bool {
|
||||
fn the_item_push_card_shares_the_band_with_a_raised_bar_exactly_as_the_reference_does() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1600.0, 900.0);
|
||||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||||
// never reaches (decision 1848).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let failures = super::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||||
s.run("MultiBarBottomLeft:Show() MultiBarBottomRight:Show() UIParent_ManageFramePositions()")
|
||||
|
||||
@@ -231,6 +231,19 @@ fn a_cinematic_hides_the_hud_through_uiparent_and_spares_the_cinematic_frame() {
|
||||
fn the_screenshot_confirmation_shows_during_a_cinematic() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||||
// never reaches (decision 1848).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let failures = super::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||||
s.resolve();
|
||||
|
||||
@@ -573,6 +573,19 @@ fn the_shipped_frames_carry_the_references_flags() {
|
||||
|
||||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||||
// never reaches (decision 1848).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let failures = super::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||||
s.resolve();
|
||||
|
||||
@@ -14,7 +14,31 @@
|
||||
use benilla_ui::script::{CursorPayload, UiScript};
|
||||
|
||||
/// The window's real neighbourhood, in the manifest's own order.
|
||||
/// Click the first macro in the list.
|
||||
///
|
||||
/// The reference selects **nothing** when the window opens: `MacroFrame_OnShow` calls only
|
||||
/// `MacroFrame_Update`, and that function merely *highlights* whichever macro is already selected —
|
||||
/// it never assigns `MacroFrame.selectedMacro`. Our retired file added a benilla-only
|
||||
/// `BenillaMacroFrame_EnsureSelection` that picked the first one, and every test here leaned on it
|
||||
/// without saying so (decision 1848). A player clicks; so do these.
|
||||
fn select_first(s: &UiScript) {
|
||||
s.run("MacroButton1:Click()").unwrap();
|
||||
}
|
||||
|
||||
/// [`harness`] with a chosen player name.
|
||||
///
|
||||
/// The character tab's label is built in its own `OnLoad` — `format(CHARACTER_SPECIFIC_MACROS,
|
||||
/// UnitName("player"))` — so the name has to be in place BEFORE the load, not after it. That is
|
||||
/// also why the name is how a test chooses the label's width (decision 1848).
|
||||
fn harness_named(player: &str) -> UiScript {
|
||||
harness_with(player)
|
||||
}
|
||||
|
||||
fn harness() -> UiScript {
|
||||
harness_with("Probefour")
|
||||
}
|
||||
|
||||
fn harness_with(player: &str) -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
// The global strings the file reads by name (the app runs the real `GlobalStrings.lua`; here
|
||||
@@ -42,27 +66,49 @@ fn harness() -> UiScript {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// **A synchronous measurer, because the app has one.** `PanelTemplates_TabResize` sizes a tab
|
||||
// from its label's laid-out width at OnLoad, and `measured_wh` answers that inline only when a
|
||||
// host font engine is installed — which `ui_script::extract` does (`AtlasMeasurer`). Without
|
||||
// one the measure is pending, the width reads 0, and the stock file never re-runs TabResize
|
||||
// because the reference's own measure is synchronous. This harness used to model the async
|
||||
// round trip and lean on our retired file's OnUpdate re-check (decision 1848).
|
||||
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
|
||||
|
||||
// `CHARACTER_SPECIFIC_MACROS` is a `%s` template the character tab formats
|
||||
// `UnitName("player")` into, in its OnLoad — so the player exists before the load, not after.
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some(player.into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
// Through the shared loader, because the window is off the chain now and a private disk-only
|
||||
// reader cannot name it (decision 1848).
|
||||
for file in [
|
||||
r"Interface\FrameXML\GlobalStrings.lua",
|
||||
"Fonts.xml",
|
||||
"BasicControls.xml", // `TEXT`
|
||||
"MoneyFrame.xml",
|
||||
"UiPanels.xml",
|
||||
// The chain's `PanelTemplates_SelectTab` reaches for `GameTooltip` unguarded.
|
||||
"GameTooltip.xml",
|
||||
"UIParent.xml", // `ShowMacroFrame` lives here now
|
||||
// **ScrollTemplates BEFORE UIPanelTemplates, the manifest's own order.** Ours still
|
||||
// carries dead `FauxScrollFrame_*` copies the chain overrides by loading after (1846's
|
||||
// step 3, deliberately not done); the other way round OUR copies win — the silent
|
||||
// drift that record names.
|
||||
"ScrollTemplates.xml",
|
||||
"MicroMenu.xml",
|
||||
"MacroFrame.xml",
|
||||
r"Interface\FrameXML\UIPanelTemplates.lua",
|
||||
r"Interface\FrameXML\UIPanelTemplates.xml",
|
||||
// `ClassTrainerListScrollFrameTemplate` — the icon chooser's scroll frame inherits it.
|
||||
r"Interface\FrameXML\ClassTrainerFrameTemplates.xml",
|
||||
"MicroMenu.xml", // stock `MacroFrame_OnShow`/`_OnHide` drive the micro button
|
||||
r"Interface\AddOns\Blizzard_MacroUI\Blizzard_MacroUI.xml",
|
||||
] {
|
||||
let text = std::fs::read_to_string(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("assets/ui")
|
||||
.join(file),
|
||||
)
|
||||
.unwrap();
|
||||
let doc = benilla_ui::framexml::parse(&text).unwrap();
|
||||
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
|
||||
assert!(
|
||||
report.errors.is_empty(),
|
||||
"{file}: loader errors: {:?}",
|
||||
report.errors
|
||||
);
|
||||
super::test_ui::load_ui(&s, file);
|
||||
}
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
// The icon chooser's list is the app's push; three entries is enough to index into.
|
||||
@@ -84,32 +130,32 @@ fn no_errors(s: &UiScript, step: &str) {
|
||||
/// wiring this file owns.
|
||||
#[test]
|
||||
fn new_then_pick_an_icon_then_okay_creates_the_macro() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let s = harness();
|
||||
s.run("ShowMacroFrame()").unwrap();
|
||||
select_first(&s);
|
||||
no_errors(&s, "show");
|
||||
|
||||
// OKAY starts disabled: no name, no icon.
|
||||
s.run("BenillaMacroNewButton_OnClick()").unwrap();
|
||||
s.run("MacroNewButton:Click()").unwrap();
|
||||
assert!(s
|
||||
.eval::<bool>("return BenillaMacroPopupFrame:IsVisible()")
|
||||
.eval::<bool>("return MacroPopupFrame:IsVisible()")
|
||||
.unwrap());
|
||||
assert!(
|
||||
!s.eval::<bool>("return BenillaMacroPopupOkayButton:IsEnabled() ~= 0")
|
||||
!s.eval::<bool>("return MacroPopupOkayButton:IsEnabled() ~= 0")
|
||||
.unwrap(),
|
||||
"a nameless, iconless macro cannot be created"
|
||||
);
|
||||
|
||||
s.run(r#"BenillaMacroPopupEditBox:SetText("Ambush")"#)
|
||||
.unwrap();
|
||||
s.run("BenillaMacroPopupButton_OnClick(BenillaMacroPopupButton1)")
|
||||
.unwrap();
|
||||
s.run(r#"MacroPopupEditBox:SetText("Ambush")"#).unwrap();
|
||||
s.run("MacroPopupButton1:Click()").unwrap();
|
||||
assert!(
|
||||
s.eval::<bool>("return BenillaMacroPopupOkayButton:IsEnabled() ~= 0")
|
||||
s.eval::<bool>("return MacroPopupOkayButton:IsEnabled() ~= 0")
|
||||
.unwrap(),
|
||||
"a name and an icon enable OKAY"
|
||||
);
|
||||
|
||||
s.run("BenillaMacroPopupOkayButton_OnClick()").unwrap();
|
||||
s.run("MacroPopupOkayButton:Click()").unwrap();
|
||||
no_errors(&s, "okay");
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>("local a, c = GetNumMacros() return a, c")
|
||||
@@ -123,13 +169,13 @@ fn new_then_pick_an_icon_then_okay_creates_the_macro() {
|
||||
assert_eq!(name, "Ambush");
|
||||
assert_eq!(tex, "Interface\\Icons\\Ability_Ambush");
|
||||
assert!(
|
||||
!s.eval::<bool>("return BenillaMacroPopupFrame:IsVisible()")
|
||||
!s.eval::<bool>("return MacroPopupFrame:IsVisible()")
|
||||
.unwrap(),
|
||||
"OKAY closes the popup"
|
||||
);
|
||||
// …and the new macro is selected and shown in the detail pane.
|
||||
assert_eq!(
|
||||
s.eval::<String>("return BenillaMacroFrameSelectedMacroName:GetText()")
|
||||
s.eval::<String>("return MacroFrameSelectedMacroName:GetText()")
|
||||
.unwrap(),
|
||||
"Ambush"
|
||||
);
|
||||
@@ -140,28 +186,30 @@ fn new_then_pick_an_icon_then_okay_creates_the_macro() {
|
||||
/// A regression here silently discards everything the player typed.
|
||||
#[test]
|
||||
fn typing_a_body_and_closing_the_window_commits_it() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = harness();
|
||||
s.run(r#"CreateMacro("Ambush", 1, "")"#).unwrap();
|
||||
s.run("ShowMacroFrame()").unwrap();
|
||||
s.run(r#"BenillaMacroFrameText:SetText("/cast Ambush\n/say pew")"#)
|
||||
select_first(&s);
|
||||
s.run(r#"MacroFrameText:SetText("/cast Ambush\n/say pew")"#)
|
||||
.unwrap();
|
||||
// The window's dirty flag comes from the box's `OnTextChanged`, which the drain owes until the
|
||||
// next frame (decision 1831).
|
||||
s.tick(0.0);
|
||||
no_errors(&s, "type");
|
||||
assert!(
|
||||
s.eval::<bool>("return BenillaMacroFrame.textChanged == 1")
|
||||
s.eval::<bool>("return MacroFrame.textChanged == 1")
|
||||
.unwrap(),
|
||||
"OnTextChanged marks the window dirty"
|
||||
);
|
||||
// The character counter is the ref's own MACROFRAME_CHAR_LIMIT fill.
|
||||
assert_eq!(
|
||||
s.eval::<String>("return BenillaMacroFrameCharLimitText:GetText()")
|
||||
s.eval::<String>("return MacroFrameCharLimitText:GetText()")
|
||||
.unwrap(),
|
||||
"21/255 Characters Used"
|
||||
);
|
||||
|
||||
s.run(r#"HideUIPanel(BenillaMacroFrame)"#).unwrap();
|
||||
s.run(r#"HideUIPanel(MacroFrame)"#).unwrap();
|
||||
no_errors(&s, "hide");
|
||||
assert_eq!(
|
||||
s.eval::<String>("local _, _, b = GetMacroInfo(1) return b")
|
||||
@@ -175,15 +223,16 @@ fn typing_a_body_and_closing_the_window_commits_it() {
|
||||
/// point: `MacroFrame.macroBase` is 0 or MAX_MACROS, and every binding takes `macroBase + i`.
|
||||
#[test]
|
||||
fn the_character_tab_creates_in_the_second_index_range_and_switching_saves() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = harness();
|
||||
s.run(r#"CreateMacro("Acct", 1, "")"#).unwrap();
|
||||
s.run("ShowMacroFrame()").unwrap();
|
||||
select_first(&s);
|
||||
|
||||
// Type into the account macro, then switch tabs WITHOUT any explicit save.
|
||||
s.run(r#"BenillaMacroFrameText:SetText("/say account")"#)
|
||||
.unwrap();
|
||||
s.run(r#"MacroFrameText:SetText("/say account")"#).unwrap();
|
||||
s.tick(0.0); // as above — the edit marks, the drain notifies (1831)
|
||||
s.run("BenillaMacroFrameTab2:Click()").unwrap();
|
||||
s.run("MacroFrameTab2:Click()").unwrap();
|
||||
no_errors(&s, "tab 2");
|
||||
assert_eq!(
|
||||
s.eval::<String>("local _, _, b = GetMacroInfo(1) return b")
|
||||
@@ -191,18 +240,13 @@ fn the_character_tab_creates_in_the_second_index_range_and_switching_saves() {
|
||||
"/say account",
|
||||
"the tab switch saved the body first"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return BenillaMacroFrame.macroBase").unwrap(),
|
||||
18
|
||||
);
|
||||
assert_eq!(s.eval::<i64>("return MacroFrame.macroBase").unwrap(), 18);
|
||||
|
||||
// Creating on this tab lands at 19 (the character range's base + 1).
|
||||
s.run("BenillaMacroNewButton_OnClick()").unwrap();
|
||||
s.run(r#"BenillaMacroPopupEditBox:SetText("Char")"#)
|
||||
.unwrap();
|
||||
s.run("BenillaMacroPopupButton_OnClick(BenillaMacroPopupButton2)")
|
||||
.unwrap();
|
||||
s.run("BenillaMacroPopupOkayButton_OnClick()").unwrap();
|
||||
s.run("MacroNewButton:Click()").unwrap();
|
||||
s.run(r#"MacroPopupEditBox:SetText("Char")"#).unwrap();
|
||||
s.run("MacroPopupButton2:Click()").unwrap();
|
||||
s.run("MacroPopupOkayButton:Click()").unwrap();
|
||||
no_errors(&s, "create on tab 2");
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>("local a, c = GetNumMacros() return a, c")
|
||||
@@ -216,13 +260,10 @@ fn the_character_tab_creates_in_the_second_index_range_and_switching_saves() {
|
||||
);
|
||||
|
||||
// …and back to tab 1 shows the account macro again.
|
||||
s.run("BenillaMacroFrameTab1:Click()").unwrap();
|
||||
s.run("MacroFrameTab1:Click()").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return MacroFrame.macroBase").unwrap(), 0);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return BenillaMacroFrame.macroBase").unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return BenillaMacroFrameSelectedMacroName:GetText()")
|
||||
s.eval::<String>("return MacroFrameSelectedMacroName:GetText()")
|
||||
.unwrap(),
|
||||
"Acct"
|
||||
);
|
||||
@@ -232,12 +273,14 @@ fn the_character_tab_creates_in_the_second_index_range_and_switching_saves() {
|
||||
/// OnLoad, which re-selects the first macro (or clears the detail pane when none is left).
|
||||
#[test]
|
||||
fn delete_removes_the_macro_and_re_selects() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let s = harness();
|
||||
s.run(r#"CreateMacro("One", 1, "/say one")"#).unwrap();
|
||||
s.run(r#"CreateMacro("Two", 2, "/say two")"#).unwrap();
|
||||
s.run("ShowMacroFrame()").unwrap();
|
||||
select_first(&s);
|
||||
|
||||
s.run("BenillaMacroDeleteButton:Click()").unwrap();
|
||||
s.run("MacroDeleteButton:Click()").unwrap();
|
||||
no_errors(&s, "delete");
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>("local a, c = GetNumMacros() return a, c")
|
||||
@@ -247,13 +290,13 @@ fn delete_removes_the_macro_and_re_selects() {
|
||||
// The list closed its gap, so slot 1 is now the survivor and it is what's selected.
|
||||
assert_eq!(s.eval::<String>("return GetMacroInfo(1)").unwrap(), "Two");
|
||||
assert_eq!(
|
||||
s.eval::<String>("return BenillaMacroFrameSelectedMacroName:GetText()")
|
||||
s.eval::<String>("return MacroFrameSelectedMacroName:GetText()")
|
||||
.unwrap(),
|
||||
"Two"
|
||||
);
|
||||
|
||||
// Deleting the last one clears the detail pane rather than leaving a stale selection.
|
||||
s.run("BenillaMacroDeleteButton:Click()").unwrap();
|
||||
s.run("MacroDeleteButton:Click()").unwrap();
|
||||
no_errors(&s, "delete last");
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>("local a, c = GetNumMacros() return a, c")
|
||||
@@ -261,7 +304,7 @@ fn delete_removes_the_macro_and_re_selects() {
|
||||
(0, 0)
|
||||
);
|
||||
assert!(
|
||||
!s.eval::<bool>("return BenillaMacroFrameSelectedMacroButton:IsVisible()")
|
||||
!s.eval::<bool>("return MacroFrameSelectedMacroButton:IsVisible()")
|
||||
.unwrap(),
|
||||
"no selection, no detail pane"
|
||||
);
|
||||
@@ -272,13 +315,20 @@ fn delete_removes_the_macro_and_re_selects() {
|
||||
/// reaches the action bar.
|
||||
#[test]
|
||||
fn dragging_a_macro_button_loads_the_cursor_with_the_macro_payload() {
|
||||
let s = harness();
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = harness();
|
||||
s.run(r#"CreateMacro("Ambush", 1, "/cast Ambush")"#)
|
||||
.unwrap();
|
||||
s.run("ShowMacroFrame()").unwrap();
|
||||
select_first(&s);
|
||||
|
||||
s.run("BenillaMacroButton1:GetScript(\"OnDragStart\")(BenillaMacroButton1)")
|
||||
.unwrap();
|
||||
// A REAL drag gesture — press, then move — the way `bag_tests` drives one. Calling
|
||||
// `GetScript("OnDragStart")(button)` leaves `this` nil, and the stock handler reads it
|
||||
// (decision 1848); our retired file's took the button as an argument.
|
||||
s.resolve();
|
||||
let (bx, by): (f32, f32) = s.eval("return MacroButton1:GetCenter()").unwrap();
|
||||
s.mouse_button(bx, by, "LeftButton", true);
|
||||
s.mouse_move(bx + 60.0, by + 60.0);
|
||||
no_errors(&s, "drag");
|
||||
let payload = s.cursor_payload();
|
||||
assert!(
|
||||
@@ -301,34 +351,35 @@ fn dragging_a_macro_button_loads_the_cursor_with_the_macro_payload() {
|
||||
/// blank, and a click marking the selection.
|
||||
#[test]
|
||||
fn the_icon_chooser_shows_the_pushed_list_and_hides_its_tail() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let s = harness();
|
||||
s.run("ShowMacroFrame()").unwrap();
|
||||
s.run("BenillaMacroNewButton_OnClick()").unwrap();
|
||||
select_first(&s);
|
||||
s.run("MacroNewButton:Click()").unwrap();
|
||||
no_errors(&s, "popup");
|
||||
|
||||
assert_eq!(s.eval::<i64>("return GetNumMacroIcons()").unwrap(), 3);
|
||||
for i in 1..=3 {
|
||||
assert!(
|
||||
s.eval::<bool>(&format!("return BenillaMacroPopupButton{i}:IsVisible()"))
|
||||
s.eval::<bool>(&format!("return MacroPopupButton{i}:IsVisible()"))
|
||||
.unwrap(),
|
||||
"button {i} shows an icon"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!s.eval::<bool>("return BenillaMacroPopupButton4:IsVisible()")
|
||||
!s.eval::<bool>("return MacroPopupButton4:IsVisible()")
|
||||
.unwrap(),
|
||||
"past the end of the list the button hides — not a blank square"
|
||||
);
|
||||
|
||||
s.run("BenillaMacroPopupButton_OnClick(BenillaMacroPopupButton3)")
|
||||
.unwrap();
|
||||
s.run("MacroPopupButton3:Click()").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return BenillaMacroPopupFrame.selectedIcon")
|
||||
s.eval::<i64>("return MacroPopupFrame.selectedIcon")
|
||||
.unwrap(),
|
||||
3
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return BenillaMacroPopupButton3:GetChecked()")
|
||||
s.eval::<bool>("return MacroPopupButton3:GetChecked()")
|
||||
.unwrap(),
|
||||
"the picked icon is checked"
|
||||
);
|
||||
@@ -343,10 +394,20 @@ fn the_icon_chooser_shows_the_pushed_list_and_hides_its_tail() {
|
||||
/// so has no width anyone can know when the window is written. The reference's answer is the −15
|
||||
/// padding on both and a 150 cap on tab 2; this pins both arms, including the cap actually engaging.
|
||||
///
|
||||
/// Widths are fed the way the app's font atlas feeds them ([`UiScript::set_measured_text`]) — the
|
||||
/// template's fit runs from OnUpdate once the measure lands, so the `tick` is load-bearing.
|
||||
/// **Driven through a SYNCHRONOUS measurer, and that is a correction** (decision 1848). This used
|
||||
/// to feed widths through the async round trip and lean on our retired file's OnUpdate re-check.
|
||||
/// The stock window has no such re-check: its tabs call `PanelTemplates_TabResize` once, in their
|
||||
/// own OnLoad, and the reference's measure is inline — so a client whose measure is pending at that
|
||||
/// moment can never size its tabs at all. The app installs `AtlasMeasurer`, so this harness
|
||||
/// installs one too; modelling the async path here was modelling a configuration the app does not
|
||||
/// have.
|
||||
///
|
||||
/// The label widths therefore come from the text rather than being chosen, so the two arms are a
|
||||
/// SHORT character name and a LONG one, and the cap is asserted against the label the measurer
|
||||
/// actually produced.
|
||||
#[test]
|
||||
fn the_two_tabs_fit_inside_the_window() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
/// `sideWidths` — 2 × the tab's 16-unit end slice (TabButtonTemplate, UiPanels.xml).
|
||||
const SIDES: f64 = 32.0;
|
||||
/// The reference's own padding for this window's tabs (Blizzard_MacroUI.xml l.488/511).
|
||||
@@ -354,65 +415,51 @@ fn the_two_tabs_fit_inside_the_window() {
|
||||
/// …and its cap on tab 2 (l.511).
|
||||
const CAP: f64 = 150.0;
|
||||
|
||||
for (label_width, expect_tab2) in [
|
||||
// A short name: under the cap, so the tab is text + PAD + SIDES.
|
||||
(121.0, 121.0 + PAD + SIDES),
|
||||
// A long one: the reference's cap binds at CAP + PAD + SIDES = 167, and the structural
|
||||
// clamp does NOT — it measures 184 units of room (tab 2's left 160, the parent's right 384,
|
||||
// less the 40-unit drawn-plate inset), which is looser.
|
||||
//
|
||||
// **This pinned 164 until geometry getters settled on demand.** The clamp reads
|
||||
// `tab:GetLeft()`, and before that change the read could return a rect from BEFORE tab 1
|
||||
// was resized — a stale left of 180, giving room 164 and making the clamp appear to be what
|
||||
// held the line. It never was, for this window. The 3-unit difference was a stale read.
|
||||
//
|
||||
// The clamp is still the guarantee that matters, and it is asserted directly below rather
|
||||
// than inferred from a width: tab 2's right edge must stay inside the DRAWN plate (unit
|
||||
// 349, decision 1002's DXT3 alpha measurement), which 160 + 167 = 327 does.
|
||||
(240.0, 167.0),
|
||||
] {
|
||||
assert!(
|
||||
expect_tab2 <= CAP + PAD + SIDES,
|
||||
"the clamp may tighten the reference's cap, never loosen it"
|
||||
);
|
||||
let mut s = harness();
|
||||
// A short name and a long one. `CHARACTER_SPECIFIC_MACROS` formats the name into tab 2's
|
||||
// label, so the name is how the label's width is chosen.
|
||||
for (name, cap_should_bind) in [("Probe", false), ("Bartholomewthelongnamed", true)] {
|
||||
let mut s = harness_named(name);
|
||||
s.run("ShowMacroFrame()").unwrap();
|
||||
s.resolve();
|
||||
// Answer the measure round-trip for the two tab labels (the app's font-atlas job in-game).
|
||||
let measures: Vec<_> = s
|
||||
.fontstrings_needing_measure()
|
||||
.into_iter()
|
||||
.filter(|r| r.text.contains("Macros"))
|
||||
.map(|r| {
|
||||
let w = if r.text == "General Macros" {
|
||||
78.0
|
||||
} else {
|
||||
label_width
|
||||
};
|
||||
(r.id, w as f32, 10.0, r.key)
|
||||
})
|
||||
.collect();
|
||||
assert!(measures.len() >= 2, "both tab labels request a measure");
|
||||
s.set_measured_text_unwrapped(&measures);
|
||||
s.tick(0.016);
|
||||
select_first(&s);
|
||||
s.resolve();
|
||||
|
||||
let (w1, w2, left1): (f64, f64, f64) = s
|
||||
let (w1, w2, left1, label1, label2): (f64, f64, f64, f64, f64) = s
|
||||
.eval(
|
||||
"return BenillaMacroFrameTab1:GetWidth(), BenillaMacroFrameTab2:GetWidth(), \
|
||||
BenillaMacroFrameTab1:GetLeft() - BenillaMacroFrame:GetLeft()",
|
||||
"return MacroFrameTab1:GetWidth(), MacroFrameTab2:GetWidth(), \
|
||||
MacroFrameTab1:GetLeft() - MacroFrame:GetLeft(), \
|
||||
MacroFrameTab1Text:GetStringWidth(), MacroFrameTab2Text:GetStringWidth()",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(label1 > 0.0 && label2 > 0.0, "both labels measured");
|
||||
assert_eq!(
|
||||
w1,
|
||||
78.0 + PAD + SIDES,
|
||||
label1 + PAD + SIDES,
|
||||
"tab 1 is text − 15 + the end slices"
|
||||
);
|
||||
assert_eq!(w2, expect_tab2, "tab 2, label width {label_width}");
|
||||
let uncapped = label2 + PAD + SIDES;
|
||||
if cap_should_bind {
|
||||
assert_eq!(
|
||||
w2,
|
||||
CAP + PAD + SIDES,
|
||||
"the reference's 150 cap binds on a long name"
|
||||
);
|
||||
assert!(
|
||||
uncapped > CAP + PAD + SIDES,
|
||||
"…and the name really was over it"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(w2, uncapped, "a short name is under the cap");
|
||||
}
|
||||
assert!(
|
||||
w2 <= CAP + PAD + SIDES,
|
||||
"the clamp may tighten the reference's cap, never loosen it"
|
||||
);
|
||||
|
||||
// The property the clamp exists for, checked outright: the tab ends inside the plate the
|
||||
// player can actually see, not merely inside the 384-unit frame rect.
|
||||
let tab2_right: f64 = s
|
||||
.eval("return BenillaMacroFrameTab2:GetLeft() + BenillaMacroFrameTab2:GetWidth()")
|
||||
.eval("return MacroFrameTab2:GetLeft() + MacroFrameTab2:GetWidth()")
|
||||
.unwrap();
|
||||
assert!(
|
||||
tab2_right <= 349.0,
|
||||
@@ -434,13 +481,13 @@ fn the_two_tabs_fit_inside_the_window() {
|
||||
/// The frame is invisible, so its rect has exactly one observable consequence and this is it.
|
||||
#[test]
|
||||
fn the_icon_choosers_scroll_bar_sits_on_the_popup_plate() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = harness();
|
||||
s.run("ShowMacroFrame() BenillaMacroNewButton_OnClick()")
|
||||
.unwrap();
|
||||
s.run("ShowMacroFrame() MacroNewButton:Click()").unwrap();
|
||||
s.resolve();
|
||||
let (bar_left, bar_right, plate_left, plate_right): (f64, f64, f64, f64) = s
|
||||
.eval(
|
||||
"local b = BenillaMacroPopupScrollFrameScrollBar local p = BenillaMacroPopupFrame \
|
||||
"local b = MacroPopupScrollFrameScrollBar local p = MacroPopupFrame \
|
||||
return b:GetLeft(), b:GetRight(), p:GetLeft(), p:GetRight()",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -457,25 +504,10 @@ fn the_icon_choosers_scroll_bar_sits_on_the_popup_plate() {
|
||||
no_errors(&s, "popup scroll bar");
|
||||
}
|
||||
|
||||
/// Answer the measure round-trip the way the APP does — every pending request, every frame, with a
|
||||
/// stand-in font metric (6 units/char, wrapping greedily at the requested width). The point is the
|
||||
/// LOOP, not the metric: a hand-fed one-shot `set_measured_text` cannot show a fit that oscillates,
|
||||
/// because oscillation needs the engine to re-request after the fit changes the label's box.
|
||||
fn pump_measures(s: &mut UiScript) {
|
||||
const PER_CHAR: f32 = 8.0;
|
||||
let measures: Vec<_> = s
|
||||
.fontstrings_needing_measure()
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let natural = r.text.chars().count() as f32 * PER_CHAR;
|
||||
let w = r.wrap_width.map_or(natural, |cap| natural.min(cap));
|
||||
let lines = if w > 0.0 { (natural / w).ceil() } else { 1.0 };
|
||||
// The laid-out extent AND the natural one — the distinction this whole test is about.
|
||||
(r.id, w, 13.0 * lines, natural, r.key)
|
||||
})
|
||||
.collect();
|
||||
s.set_measured_text(&measures);
|
||||
}
|
||||
// `pump_measures` stood here: it answered the measure round-trip every frame so a fit that
|
||||
// OSCILLATED would show. Nothing oscillates now and nothing can — this harness installs a
|
||||
// synchronous measurer, because the app does (`AtlasMeasurer`), and the stock tabs size themselves
|
||||
// once in their own OnLoad with no re-check to converge. Decision 1848.
|
||||
|
||||
/// **The tab row is stable and inside the window, frame after frame** — the director's repro
|
||||
/// (2026-08-05: a 9-character character name, "tab is still slightly overlapping"), and the check
|
||||
@@ -488,23 +520,23 @@ fn pump_measures(s: &mut UiScript) {
|
||||
/// width every single frame and could be photographed at any point in the cycle.
|
||||
#[test]
|
||||
fn the_tab_row_settles_and_stays_inside_the_window() {
|
||||
let mut s = harness();
|
||||
s.run(r#"UnitName = function() return "Onehunter" end"#)
|
||||
.unwrap();
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
// The name is chosen at construction, because the stock tab builds its label in its own
|
||||
// OnLoad; and there is no measure round trip to pump any more (decision 1848). The loop stays:
|
||||
// "it does not change every frame" is still the property, it is just satisfied on the first
|
||||
// pass now rather than after a convergence.
|
||||
let mut s = harness_named("Onehunter");
|
||||
s.run("ShowMacroFrame()").unwrap();
|
||||
s.run(r#"BenillaMacroFrameTab2:SetText(BenillaMacroFrame_TabTwoLabel())"#)
|
||||
.unwrap();
|
||||
select_first(&s);
|
||||
|
||||
let mut widths = Vec::new();
|
||||
for _ in 0..12 {
|
||||
s.resolve();
|
||||
pump_measures(&mut s);
|
||||
s.tick(0.016);
|
||||
s.resolve();
|
||||
widths.push(
|
||||
s.eval::<(f64, f64, f64)>(
|
||||
"return BenillaMacroFrameTab1:GetWidth(), BenillaMacroFrameTab2:GetWidth(), \
|
||||
BenillaMacroFrameTab2:GetRight() - BenillaMacroFrame:GetLeft()",
|
||||
"return MacroFrameTab1:GetWidth(), MacroFrameTab2:GetWidth(), \
|
||||
MacroFrameTab2:GetRight() - MacroFrame:GetLeft()",
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
@@ -532,35 +564,21 @@ fn the_tab_row_settles_and_stays_inside_the_window() {
|
||||
/// so this holds for a name no window author could have anticipated.
|
||||
#[test]
|
||||
fn no_character_name_can_push_the_tab_row_off_the_window() {
|
||||
// `capped = false` strips the window's own `benillaTabMaxWidth`, leaving ONLY the structural
|
||||
// clamp — which is the state every other tab in the client is in (no window but this one asks
|
||||
// for a cap), and the state this guarantee exists for.
|
||||
for (name, capped) in [
|
||||
("Ai", true),
|
||||
("Onehunter", true),
|
||||
("Bartholomewthethird", false),
|
||||
(&"W".repeat(64), false),
|
||||
] {
|
||||
let mut s = harness();
|
||||
s.run(&format!("UnitName = function() return \"{name}\" end"))
|
||||
.unwrap();
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
// The `capped = false` arm is gone with decision 1848: it stripped a `benillaTabMaxWidth`
|
||||
// field of our own, and the reference has no such switch — its cap is the literal `150` passed
|
||||
// to `PanelTemplates_TabResize` in the tab's OnLoad, which nothing can turn off. So the
|
||||
// guarantee is now just the guarantee, checked against four names including a 64-character one.
|
||||
for name in ["Ai", "Onehunter", "Bartholomewthethird", &"W".repeat(64)] {
|
||||
let mut s = harness_named(name);
|
||||
s.run("ShowMacroFrame()").unwrap();
|
||||
if !capped {
|
||||
s.run("BenillaMacroFrameTab2.benillaTabMaxWidth = nil")
|
||||
.unwrap();
|
||||
}
|
||||
s.run("BenillaMacroFrameTab2:SetText(BenillaMacroFrame_TabTwoLabel())")
|
||||
.unwrap();
|
||||
for _ in 0..12 {
|
||||
s.resolve();
|
||||
pump_measures(&mut s);
|
||||
s.tick(0.016);
|
||||
s.resolve();
|
||||
}
|
||||
select_first(&s);
|
||||
s.tick(0.016);
|
||||
s.resolve();
|
||||
let (right, w2): (f64, f64) = s
|
||||
.eval(
|
||||
"return BenillaMacroFrameTab2:GetRight() - BenillaMacroFrame:GetLeft(), \
|
||||
BenillaMacroFrameTab2:GetWidth()",
|
||||
"return MacroFrameTab2:GetRight() - MacroFrame:GetLeft(), \
|
||||
MacroFrameTab2:GetWidth()",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
@@ -585,41 +603,69 @@ fn no_character_name_can_push_the_tab_row_off_the_window() {
|
||||
/// label, no clamp can put the glow at a width other than its tab's.**
|
||||
#[test]
|
||||
fn the_tab_highlight_is_exactly_its_tab() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
// A name under the reference's cap, one over it, and one long enough that the structural
|
||||
// drawn-edge clamp (1002) is what sets the width — all three must hold the same property.
|
||||
for name in ["Ai", "Onehunter", &"W".repeat(40)] {
|
||||
let mut s = harness();
|
||||
s.run(&format!("UnitName = function() return \"{name}\" end"))
|
||||
.unwrap();
|
||||
// The name is chosen at construction (the stock tab labels itself in its own OnLoad) and
|
||||
// there is no measure round trip to pump — decision 1848. The frame loop stays: the
|
||||
// highlight tracking its tab on EVERY frame is the property, not just on the first.
|
||||
let mut s = harness_named(name);
|
||||
s.run("ShowMacroFrame()").unwrap();
|
||||
s.run("BenillaMacroFrameTab2:SetText(BenillaMacroFrame_TabTwoLabel())")
|
||||
.unwrap();
|
||||
select_first(&s);
|
||||
|
||||
for frame in 0..12 {
|
||||
s.resolve();
|
||||
pump_measures(&mut s);
|
||||
s.tick(0.016);
|
||||
s.resolve();
|
||||
for tab in ["BenillaMacroFrameTab1", "BenillaMacroFrameTab2"] {
|
||||
for tab in ["MacroFrameTab1", "MacroFrameTab2"] {
|
||||
let (tl, tr, hl, hr): (f64, f64, f64, f64) = s
|
||||
.eval(&format!(
|
||||
"return {tab}:GetLeft(), {tab}:GetRight(), \
|
||||
{tab}HighlightTexture:GetLeft(), {tab}HighlightTexture:GetRight()"
|
||||
))
|
||||
.unwrap();
|
||||
// **The two tabs carry DIFFERENT highlight widths, and that is the reference's,
|
||||
// not a loader bug.** Exactly one `<OnLoad>` runs — the most-derived one; the
|
||||
// template's is installed and then *destroyed*, because `SetScript` releases the
|
||||
// slot's single ref before it even looks at the new body (wow-re
|
||||
// `template-onload-replacement-law.md`). So a tab gets whichever formula ITS OWN
|
||||
// body ends with:
|
||||
//
|
||||
// * tab 2's body is just `PanelTemplates_TabResize(-15, nil, nil, 150)`, whose
|
||||
// own last statement sets the highlight to `tabWidth` — so highlight == width;
|
||||
// * tab 1's body RE-STATES `TabButtonTemplate`'s second line verbatim
|
||||
// (`HighlightTexture:SetWidth(this:GetTextWidth() + 31)`) after calling
|
||||
// TabResize — so its highlight is independent of its width, and 14 wider.
|
||||
//
|
||||
// That re-statement idiom is only necessary if the template's handler does not
|
||||
// run, and the reference uses it deliberately: `FriendsFrame.xml:610`/`:899` copy
|
||||
// both template lines while `:626`/`:881` declare none — a controlled pair in one
|
||||
// file. Our retired transcription made the two tabs agree; the migration reverts
|
||||
// that (decision 1848).
|
||||
let label: f64 = s
|
||||
.eval(&format!("return {tab}Text:GetStringWidth()"))
|
||||
.unwrap();
|
||||
let want = if tab == "MacroFrameTab1" {
|
||||
label + 31.0
|
||||
} else {
|
||||
tr - tl
|
||||
};
|
||||
assert_eq!(
|
||||
hr - hl,
|
||||
tr - tl,
|
||||
"{tab} f{frame} ({name}): highlight {} wide on a {} tab",
|
||||
want,
|
||||
"{tab} f{frame} ({name}): highlight {} wide, label {label}, tab {}",
|
||||
hr - hl,
|
||||
tr - tl
|
||||
);
|
||||
// …and seated where the reference seats it: its own +2 nudge off the tab's edges
|
||||
// (TabButtonTemplate's highlight anchor offset), not centred on some other rect.
|
||||
// …and seated where the reference seats it: `TabButtonTemplate` anchors the
|
||||
// highlight `BOTTOM` with a `(2, -8)` offset, so it is **CENTRED** on the tab and
|
||||
// nudged 2 right — not left-aligned at `tl + 2`, which is what this asserted while
|
||||
// the template was ours (decision 1848). With the overhang above, centring is what
|
||||
// keeps the extra 14 units split evenly rather than all trailing off one end.
|
||||
assert_eq!(
|
||||
hl,
|
||||
tl + 2.0,
|
||||
"{tab} f{frame} ({name}): highlight not on the tab"
|
||||
(hl + hr) / 2.0,
|
||||
(tl + tr) / 2.0 + 2.0,
|
||||
"{tab} f{frame} ({name}): highlight not centred on the tab"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -929,6 +929,46 @@ fn the_checkbox_rows_write_flags_and_the_master_greys_ambience() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The background-sound row (decision 1847): the one Audio row 1.12 has no checkbox for. It
|
||||
/// boots UNCHECKED — the reference's own behaviour, which is to go quiet in the background — and
|
||||
/// its click writes the era CVar. It rides OUTSIDE the master's dependency rule, which names
|
||||
/// exactly two rows in `SoundOptionsFrame_UpdateDependencies` and never grew a third.
|
||||
#[test]
|
||||
fn the_background_sound_row_boots_off_and_writes_the_era_cvar() {
|
||||
let mut s = harness_on(audio_harness());
|
||||
s.run("ShowUIPanel(OptionsFrame)").unwrap();
|
||||
s.run("OptionsFrameCategoryListRowAudio:Click()").unwrap();
|
||||
let _ = s.take_cvar_changes();
|
||||
|
||||
assert!(
|
||||
!s.eval::<bool>(
|
||||
"return OptionsFrameContainerBodyAudioRowBackgroundSoundCheck:GetChecked()"
|
||||
)
|
||||
.unwrap(),
|
||||
"the reference goes quiet in the background, so the box ships unticked"
|
||||
);
|
||||
s.run("OptionsFrameContainerBodyAudioRowBackgroundSoundCheck:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![(
|
||||
"Sound_EnableSoundWhenGameIsInBG".to_string(),
|
||||
"1".to_string()
|
||||
)]
|
||||
);
|
||||
|
||||
// The master's rule greys Ambience and Error Speech and nothing else — this row stays live,
|
||||
// because "should the game be audible at all" is not the question it answers.
|
||||
s.run("OptionsFrameContainerBodyAudioRowEnableAllCheck:Click()")
|
||||
.unwrap();
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"return OptionsFrameContainerBodyAudioRowBackgroundSoundCheck:IsEnabled() ~= 0"
|
||||
)
|
||||
.unwrap());
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// Defaults walks every Audio row's CVar back to its registered default and the rows follow —
|
||||
/// the era per-page reset, on the one page with rows.
|
||||
#[test]
|
||||
@@ -2121,17 +2161,23 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
|
||||
untipped.push(row.to_string());
|
||||
continue;
|
||||
}
|
||||
// The deliberate exceptions (1639 Render Scale, 1650 Display Mode). Neither has a 1.12
|
||||
// counterpart whose `OPTION_TOOLTIP_*` could be resolved — Render Scale has no era row at
|
||||
// all, and Display Mode's era row was a CHECKBOX whose string says "Check to…" — and both
|
||||
// are rows a player needs a description for. Each carries one under a `BENILLA_` prefix so
|
||||
// the reference's namespace stays the reference's, which is exactly what this guard is
|
||||
// here to protect. Everything the guard was built to catch — an invented or typo'd
|
||||
// `OPTION_TOOLTIP_` key that silently resolves to nothing — is untouched: the pairing below
|
||||
// is exact, so a `BENILLA_` key on the wrong row still fails.
|
||||
// The deliberate exceptions (1639 Render Scale, 1650 Display Mode, 1847 Enable Sound in
|
||||
// Background). None has a 1.12 counterpart whose `OPTION_TOOLTIP_*` could be resolved —
|
||||
// Render Scale has no era row at all, Display Mode's era row was a CHECKBOX whose string
|
||||
// says "Check to…", and 1.12 has no background-sound setting at all (it mutes on its
|
||||
// window-activation event and offers no way out) — and each is a row a player needs a
|
||||
// description for. Each carries one under a `BENILLA_` prefix so the reference's
|
||||
// namespace stays the reference's, which is exactly what this guard is here to protect.
|
||||
// Everything the guard was built to catch — an invented or typo'd `OPTION_TOOLTIP_` key
|
||||
// that silently resolves to nothing — is untouched: the pairing below is exact, so a
|
||||
// `BENILLA_` key on the wrong row still fails.
|
||||
const BENILLA_OWNED: &[(&str, &str)] = &[
|
||||
("BENILLA_TOOLTIP_RENDER_SCALE", "GraphicsRowRenderScale"),
|
||||
("BENILLA_TOOLTIP_DISPLAY_MODE", "GraphicsRowDisplayMode"),
|
||||
(
|
||||
"BENILLA_TOOLTIP_BACKGROUND_SOUND",
|
||||
"AudioRowBackgroundSound",
|
||||
),
|
||||
];
|
||||
if let Some((_, want_row)) = BENILLA_OWNED.iter().find(|(k, _)| *k == key) {
|
||||
assert_eq!(row, *want_row, "{row}: not this row's string");
|
||||
@@ -2168,12 +2214,15 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
|
||||
// bar as the other two. Camera Following Style is counted on the key it wears at rest (Smart's
|
||||
// OPTION_TOOLTIP_CAMERA1) and Show When on its own (Always's OPTION_TOOLTIP_TARGETOFTARGET5);
|
||||
// their other entries ride the same census as the selection moves.
|
||||
// 55 of the 57 are 1.12's own; the other two are Render Scale (1639) and Display Mode
|
||||
// (1650), whose descriptions are benilla's and whose carve-out is above.
|
||||
// 55 of the 58 are 1.12's own; the other three are Render Scale (1639), Display Mode
|
||||
// (1650) and Enable Sound in Background (1847), whose descriptions are benilla's and whose
|
||||
// carve-out is above.
|
||||
// The 28th CVar row is Block Trades (1764), on the Controls page — its key
|
||||
// OPTION_TOOLTIP_BLOCK_TRADES is the reference's, so it is counted here like the rest; the
|
||||
// 29th is Enable Error Speech (1815), 1.12's own fourth Sound checkbox, ditto.
|
||||
assert_eq!(checked, 57, "every tipped row carries a live key");
|
||||
// 29th is Enable Error Speech (1815), 1.12's own fourth Sound checkbox, ditto; the 30th is
|
||||
// Enable Sound in Background (1847), the Audio page's fifth checkbox and the one row on that
|
||||
// page the reference never made settable.
|
||||
assert_eq!(checked, 58, "every tipped row carries a live key");
|
||||
assert_eq!(
|
||||
untipped,
|
||||
vec![
|
||||
@@ -2278,11 +2327,12 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
|
||||
// (the lock 1136, Always Show
|
||||
// ActionBars 1500), the Chat page's 1 (Remove Chat Hover Delay, 1589) and 6 API rows (Show
|
||||
// Cloak / Show Helm, 1472; the four multibar switches, 1500).
|
||||
// …plus the Graphics page's Render Scale (1639) and Display Mode (1650), the two rows whose
|
||||
// descriptions are benilla's own rather than 1.12 GlobalStrings — see the guard above.
|
||||
// …plus the Graphics page's Render Scale (1639) and Display Mode (1650) and the Audio page's
|
||||
// Enable Sound in Background (1847), the three rows whose descriptions are benilla's own
|
||||
// rather than 1.12 GlobalStrings — see the guard above.
|
||||
// …and Block Trades (1764), the Controls page's 28th CVar row, and Enable Error Speech
|
||||
// (1815), the Audio page's fourth checkbox and 1.12's own.
|
||||
assert_eq!(raised, 57, "every row but Auto Loot raises a description");
|
||||
assert_eq!(raised, 58, "every row but Auto Loot raises a description");
|
||||
}
|
||||
|
||||
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
|
||||
|
||||
@@ -188,6 +188,19 @@ fn seat_a_hunter(root: &Path) -> UiScript {
|
||||
|
||||
let info = super::addons::info_from_toc("Quiver", &read_toc(root, "Quiver"));
|
||||
s.register_addons(vec![info], Some(root.to_path_buf()), None, None);
|
||||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||||
// never reaches (decision 1848).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let failures = super::load_default_ui(&s);
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
|
||||
@@ -221,6 +221,19 @@ mod tests {
|
||||
|
||||
let mut s = UiScript::new().expect("VM");
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||||
// never reaches (decision 1848).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let failures = super::super::manifest::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "the default UI: {failures:#?}");
|
||||
|
||||
@@ -343,6 +356,19 @@ mod tests {
|
||||
let mut s = UiScript::new().expect("VM");
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
|
||||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||||
// never reaches (decision 1848).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let failures = super::super::manifest::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "the default UI: {failures:#?}");
|
||||
|
||||
@@ -506,6 +532,19 @@ mod tests {
|
||||
}
|
||||
let mut s = UiScript::new().expect("VM");
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||||
// never reaches (decision 1848).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let base = super::super::manifest::load_default_ui(&s);
|
||||
assert!(base.is_empty(), "the shipped manifest itself: {base:#?}");
|
||||
s.resolve();
|
||||
@@ -658,6 +697,19 @@ mod tests {
|
||||
// global our own FrameXML defines.
|
||||
let mut s = UiScript::new().expect("VM");
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||||
// never reaches (decision 1848).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let failures = super::super::manifest::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "the shipped manifest: {failures:#?}");
|
||||
let have: std::collections::HashSet<String> = s
|
||||
@@ -1405,6 +1457,19 @@ mod tests {
|
||||
|
||||
let mut s = UiScript::new().expect("VM");
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||||
// never reaches (decision 1848).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let failures = super::super::manifest::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "the shipped manifest: {failures:#?}");
|
||||
let have: std::collections::HashSet<String> = s
|
||||
@@ -1445,6 +1510,15 @@ mod tests {
|
||||
let toc = &super::super::addons::Addon::builtin().toc.files;
|
||||
let mut missing: Vec<(String, String)> = Vec::new();
|
||||
for entry in toc.iter().filter(|f| super::is_chain_entry(f)) {
|
||||
// `GlobalStrings.lua` is 4000 lines of `NAME = "…";` and nothing else — it calls no
|
||||
// global at all. What it DOES contain is every format specifier and every English
|
||||
// sentence in the interface, and this scanner's `name(` shape reads `%d (`, `%s (` and
|
||||
// "rank (" out of those literals as calls. It became a manifest entry with 1848 (the
|
||||
// reference's own first line, which ours had been loading out of band); skipping it is
|
||||
// not a workaround for that, it is the scanner declining to parse prose.
|
||||
if entry.ends_with("GlobalStrings.lua") {
|
||||
continue;
|
||||
}
|
||||
let leaf = entry.rsplit(['\\', '/']).next().unwrap_or(entry);
|
||||
let mut text = String::new();
|
||||
let mut cands = vec![entry.replace('\\', "/")];
|
||||
|
||||
@@ -49,6 +49,19 @@ fn answer_measures(s: &mut UiScript) -> usize {
|
||||
fn settled_default_ui() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1600.0, 900.0);
|
||||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||||
// never reaches (decision 1848).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
super::load_default_ui(&s);
|
||||
s.set_screen_size(1600.0, 900.0);
|
||||
for _ in 0..12 {
|
||||
|
||||
@@ -48,6 +48,19 @@ fn every_shipped_ui_xml_parses() {
|
||||
fn the_whole_shipped_manifest_loads_without_errors() {
|
||||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||||
// never reaches (decision 1848).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let failures = super::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||||
s.resolve();
|
||||
@@ -74,6 +87,19 @@ fn the_whole_shipped_manifest_loads_without_errors() {
|
||||
fn loading_the_shipped_ui_queues_no_sounds() {
|
||||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||||
// never reaches (decision 1848).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let failures = super::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||||
s.resolve();
|
||||
@@ -757,6 +783,19 @@ fn every_texture_frame_outranks_its_status_bars() {
|
||||
|
||||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||||
// never reaches (decision 1848).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let failures = super::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||||
// Every unit-frame family painting at once: the frames hide themselves without a unit, and a
|
||||
@@ -890,6 +929,19 @@ fn the_font_registry_alone_covers_the_whole_bake_plan() {
|
||||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
if whole {
|
||||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||||
// never reaches (decision 1848).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let _ = super::load_default_ui(&s);
|
||||
} else {
|
||||
let _ = super::load_font_registry(&s);
|
||||
@@ -931,6 +983,19 @@ fn the_font_registry_alone_covers_the_whole_bake_plan() {
|
||||
fn the_shipped_ui_takes_variables_loaded_without_a_script_error() {
|
||||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||||
// never reaches (decision 1848).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let failures = super::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "loader errors: {failures:?}");
|
||||
let _ = s.errors(); // drain anything the load itself logged; this test is about the event
|
||||
@@ -958,6 +1023,19 @@ fn the_shipped_ui_takes_variables_loaded_without_a_script_error() {
|
||||
fn every_shipped_font_object_is_published_as_a_lua_global() {
|
||||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||||
// never reaches (decision 1848).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let failures = super::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "loader errors: {failures:?}");
|
||||
|
||||
@@ -1029,6 +1107,19 @@ fn every_shipped_font_object_is_published_as_a_lua_global() {
|
||||
fn the_inheritable_reference_templates_confer_their_shape() {
|
||||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||||
// never reaches (decision 1848).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert!(super::load_default_ui(&s).is_empty());
|
||||
|
||||
// `CT_UnitFrames/CT_TargetFrame.xml:4` builds its own virtual template on top of this one, so
|
||||
@@ -1080,6 +1171,19 @@ fn the_inheritable_reference_templates_confer_their_shape() {
|
||||
fn the_inspect_cursor_pair_takes_both_arms() {
|
||||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||||
// never reaches (decision 1848).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert!(super::load_default_ui(&s).is_empty());
|
||||
|
||||
// Both exist as functions — the shape a caller checks before hooking.
|
||||
@@ -1143,6 +1247,19 @@ fn the_inspect_cursor_pair_takes_both_arms() {
|
||||
fn a_cinematic_leaves_nothing_of_the_interface_on_screen() {
|
||||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||||
// never reaches (decision 1848).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let failures = super::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||||
s.resolve();
|
||||
@@ -1212,6 +1329,19 @@ fn a_cinematic_leaves_nothing_of_the_interface_on_screen() {
|
||||
fn every_declared_parent_really_attaches() {
|
||||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||||
// never reaches (decision 1848).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let failures = super::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||||
s.resolve();
|
||||
|
||||
@@ -633,16 +633,39 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// The stock macro window's character tab formats `UnitName("player")` into its label in its
|
||||
// own OnLoad, so the player exists before the load (decision 1848).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
for file in [
|
||||
r"Interface\FrameXML\GlobalStrings.lua",
|
||||
"Fonts.xml",
|
||||
"BasicControls.xml", // `TEXT`
|
||||
"MoneyFrame.xml",
|
||||
"UiPanels.xml",
|
||||
// `ShowMacroFrame` lives here since 1848.
|
||||
"UIParent.xml",
|
||||
"GameTooltip.xml",
|
||||
"Cooldown.xml",
|
||||
// **ScrollTemplates BEFORE UIPanelTemplates, which is the manifest's own order.** Ours
|
||||
// still carries dead `FauxScrollFrame_*` copies that the chain overrides by loading after
|
||||
// (1846's step 3, deliberately not done); load them the other way round and OUR copies win
|
||||
// — which is exactly the silent drift that record names.
|
||||
"ScrollTemplates.xml",
|
||||
r"Interface\FrameXML\UIPanelTemplates.lua",
|
||||
r"Interface\FrameXML\UIPanelTemplates.xml",
|
||||
// The icon chooser's scroll frame inherits `ClassTrainerListScrollFrameTemplate`.
|
||||
r"Interface\FrameXML\ClassTrainerFrameTemplates.xml",
|
||||
"MicroMenu.xml",
|
||||
"ActionBar.xml",
|
||||
"MacroFrame.xml",
|
||||
r"Interface\AddOns\Blizzard_MacroUI\Blizzard_MacroUI.xml",
|
||||
"SpellBookFrame.xml",
|
||||
] {
|
||||
load_xml(&s, file);
|
||||
@@ -668,15 +691,16 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
|
||||
|
||||
s.run(r#"CreateMacro("Ambush", 1, "")"#).unwrap();
|
||||
s.run("ShowMacroFrame()").unwrap();
|
||||
// The reference selects NOTHING on open — `MacroFrame_Update` only highlights an existing
|
||||
// selection, it never assigns one — so the details pane stays down until a macro is clicked.
|
||||
// Our retired file auto-selected the first (decision 1848).
|
||||
s.run("MacroButton1:Click()").unwrap();
|
||||
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
|
||||
assert!(s.errors().is_empty(), "open errors: {:?}", s.errors());
|
||||
let body = |s: &UiScript| -> String {
|
||||
s.eval::<String>("return BenillaMacroFrameText:GetText()")
|
||||
.unwrap()
|
||||
};
|
||||
let body =
|
||||
|s: &UiScript| -> String { s.eval::<String>("return MacroFrameText:GetText()").unwrap() };
|
||||
assert!(
|
||||
s.eval::<bool>("return BenillaMacroFrameText:IsVisible()")
|
||||
.unwrap(),
|
||||
s.eval::<bool>("return MacroFrameText:IsVisible()").unwrap(),
|
||||
"the editor's body box is up — the ref's own `MacroFrame_AddMacroLine` gate"
|
||||
);
|
||||
assert_eq!(body(&s), "");
|
||||
@@ -726,7 +750,7 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
|
||||
// write landed in the buffer synchronously, the notification did not (decision 1831).
|
||||
s.tick(0.0);
|
||||
assert!(
|
||||
s.eval::<bool>("return BenillaMacroFrame.textChanged == 1")
|
||||
s.eval::<bool>("return MacroFrame.textChanged == 1")
|
||||
.unwrap(),
|
||||
"the write goes through the box's own OnTextChanged, so the window is dirty"
|
||||
);
|
||||
@@ -750,19 +774,17 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
|
||||
assert!(s.cursor_payload().is_none());
|
||||
|
||||
// The name/icon popup hides the body box; the ref's AddMacroLine gates on exactly that.
|
||||
s.run("BenillaMacroNewButton_OnClick()").unwrap();
|
||||
assert!(!s
|
||||
.eval::<bool>("return BenillaMacroFrameText:IsVisible()")
|
||||
.unwrap());
|
||||
s.run("MacroNewButton_OnClick()").unwrap();
|
||||
assert!(!s.eval::<bool>("return MacroFrameText:IsVisible()").unwrap());
|
||||
click(&mut s, "SpellButton1", "LeftButton");
|
||||
assert!(s.errors().is_empty(), "popup-open errors: {:?}", s.errors());
|
||||
assert!(s.cursor_payload().is_none(), "still not a pickup");
|
||||
s.run("BenillaMacroPopupFrame:Hide()").unwrap();
|
||||
s.run("BenillaMacroFrame_Update()").unwrap();
|
||||
s.run("MacroPopupFrame:Hide()").unwrap();
|
||||
s.run("MacroFrame_Update()").unwrap();
|
||||
assert_eq!(body(&s), before, "nothing landed while the popup was up");
|
||||
|
||||
// ── Editor hidden: the shift-click is a PICKUP again (ref l.281-282's else) ───────────────
|
||||
s.run("HideUIPanel(BenillaMacroFrame)").unwrap();
|
||||
s.run("HideUIPanel(MacroFrame)").unwrap();
|
||||
click(&mut s, "SpellButton1", "LeftButton");
|
||||
s.set_modifiers(false, false, false);
|
||||
assert!(s.errors().is_empty(), "pickup errors: {:?}", s.errors());
|
||||
|
||||
@@ -306,6 +306,19 @@ fn the_maps_own_furniture_survives_the_hide_that_showing_it_performs() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1600.0, 900.0);
|
||||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||||
// never reaches (decision 1848).
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let failures = super::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||||
s.resolve();
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
//! **Do any 1.12.1 WMOs author an ANTIPORTAL group, and if so which MOGP bit says so?**
|
||||
//! `cargo run -p benilla-formats --example wmo_antiportal_census`.
|
||||
//!
|
||||
//! Later WoW documentation (wowdev.wiki `SMOGroupFlags`) names a high MOGP `flags` bit
|
||||
//! `ANTIPORTAL`: a group with **no render batches at all**, kept only for its bounding box, which
|
||||
//! the portal flood uses as an occluder rather than something it ever draws. Nothing in benilla or
|
||||
//! `wow-5875-re` has verified that bit for build 5875 — the portal flood
|
||||
//! ([`benilla_world::wmo_portal`]) only ever branches on `flags & 0x48` (EXTERIOR / EXTERIOR_LIT)
|
||||
//! and never treats any group as occlusion-only. So the question is purely empirical: does the
|
||||
//! shipped 5875 data contain such a group at all, and which bit(s) does it carry?
|
||||
//!
|
||||
//! **Bit-agnostic by construction.** Rather than assume the wowdev bit number applies unchanged to
|
||||
//! this build, this walks every group of every root WMO in the install and builds two independent
|
||||
//! signals, then cross-tabulates them:
|
||||
//!
|
||||
//! 1. A histogram of **every** MOGP flag bit (0..31) — how many groups set it, how many distinct
|
||||
//! roots contain at least one.
|
||||
//! 2. The **batch-less set** — every group whose MOGP-owned MOBA chunk has zero render batches,
|
||||
//! found without looking at flags at all. This is the wowdev ANTIPORTAL *shape*: no geometry to
|
||||
//! draw, kept only for `MOGI`'s bounding box.
|
||||
//!
|
||||
//! If 1.12 ships any antiportal-shaped groups, they land in the batch-less set regardless of which
|
||||
//! bit (if any) names them, and the cross-tab against the flag histogram is what tells us — from
|
||||
//! the data, not a doc — which bit (if any) 5875 actually uses for it.
|
||||
//!
|
||||
//! **The run (2026-09-01, 815/815 roots, 5220/5220 groups, zero read/parse failures): yes, but no
|
||||
//! single bit names it.** 15 groups across 9 roots ship with zero MOBA render batches — Naxxramas'
|
||||
//! `frostwyrm_final01.wmo` (7 tiny connector groups), three of AQ40's boss-encounter "enterance"
|
||||
//! shells, and the three Hyjal `worldtreeroots` collision volumes among them (see the batch-less
|
||||
//! census below for the full list) — so the wowdev *shape* is real in 5875. But the cross-tab finds
|
||||
//! **no MOGP bit set by all 15 and rare among the other 5205**: bit 0 (`0x1`) is set on literally
|
||||
//! every group in the corpus (batch-less or not) and so distinguishes nothing; EXTERIOR (`0x8`)
|
||||
//! covers 13/15 but also 1052/5205 batch-having groups; every other bit covers fewer than a third of
|
||||
//! the batch-less set. Whatever these 15 groups are for — several read as pure collision volumes by
|
||||
//! name/folder (`collidabledoodads\hyjal\worldtreeroots`) rather than deliberate portal occluders —
|
||||
//! 5875 does not flag them with a dedicated ANTIPORTAL bit the way later clients' `SMOGroupFlags`
|
||||
//! documentation describes. That is a fact about the *data*, not yet about the *engine*: whether
|
||||
//! `WoW.exe` 5875's occluder pass (if it has one) singles these 15 out by shape rather than by flag
|
||||
//! is a `wow-5875-re` question this census doesn't answer.
|
||||
//!
|
||||
//! Only three MOGP bits are named anywhere in benilla's own code today (verified by grep over
|
||||
//! `benilla-world/src/wmo_portal`, `benilla-assets/src/wmo.rs`, and
|
||||
//! `benilla-formats/src/models/wmo`, 2026-09-01): `0x8` EXTERIOR, `0x40` EXTERIOR_LIT
|
||||
//! ([`benilla_world::wmo_portal`]'s flood-defer/lighting-class bits), and `0x40000` SHOW_SKYBOX
|
||||
//! ([`benilla_formats::WmoGroupInfo::show_skybox`]). The other candidate bits a hypothetical
|
||||
//! ANTIPORTAL census might reach for — `0x1` (HAS_BSP/MOBN), `0x4` (VERTEXCOLOR), `0x200`
|
||||
//! (HAS_LIGHTS/MOLR), `0x800` (HAS_LIQUID/MLIQ), `0x2000` (INTERIOR), `0x800000`/`0x1000000`
|
||||
//! (second MOCV/MOTV) — are *not* named or tested anywhere in our reader: we discover MOLR/MLIQ/a
|
||||
//! second MOTV by the sub-chunk's presence, never by these flag bits, and INTERIOR is our own
|
||||
//! `flags & 0x48 == 0` derived test, not a single bit. None of those six get a name in the table
|
||||
//! below; only a bit our code already states gets one, per the census's own charter.
|
||||
//!
|
||||
//! Every field per group comes from the same bytes the runtime reads: [`wmo_group_header`]'s
|
||||
//! `flags` (MOGP `+0x08`, full 32 bits — **not** the two-bit-derived `interior`/`show_skybox` bools
|
||||
//! `parse_wmo_root`'s MOGI reader exposes), `benilla_wmo::parse_wmo`'s `render_batches`/
|
||||
//! `vertex_positions` (MOBA/MOVT), `wmo_group_header`'s `portal_ref_count` (MOPR, sliced from the
|
||||
//! group's own header span), and the root's [`WmoGroupInfo`] bounding box (MOGI, the same box the
|
||||
//! portal code treats as a loose AABB of the group's volume).
|
||||
//!
|
||||
//! Output is Blizzard data — pipe it to the scratchpad, never into the repo.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::io::Cursor;
|
||||
|
||||
use benilla_formats::{parse_wmo_root, wmo_group_header, Chain};
|
||||
use benilla_wmo::{parse_wmo, ParsedWmo};
|
||||
|
||||
/// A bit counts as "rare" — and earns an example listing — below this many groups. Chosen well
|
||||
/// above the size of any plausible antiportal set (a handful of buildings at most) and well below
|
||||
/// EXTERIOR/EXTERIOR_LIT, which sit in the thousands.
|
||||
const RARE_THRESHOLD: u32 = 200;
|
||||
/// Example rows kept per rare bit.
|
||||
const MAX_EXAMPLES: usize = 40;
|
||||
/// Batch-less rows printed in full before the report falls back to "N more, elided".
|
||||
const MAX_BATCHLESS_PRINTED: usize = 200;
|
||||
/// Root-failure examples kept for the audit trail.
|
||||
const MAX_FAILURE_EXAMPLES: usize = 20;
|
||||
/// Rows in the "most EXTERIOR groups" table (see its section for why that ranking is worth having).
|
||||
const MAX_EXTERIOR_RANK: usize = 25;
|
||||
|
||||
/// MOGP EXTERIOR — the bit the deferred-window worklist and its Pass-2 replay key on.
|
||||
const EXTERIOR: u32 = 0x8;
|
||||
|
||||
/// One group's full fact row — what every example listing and the batch-less census print.
|
||||
#[derive(Clone)]
|
||||
struct GroupRow {
|
||||
/// The ROOT's chain path (e.g. `world\wmo\...\foo.wmo`); the group file is `{stem}_{NNN}.wmo`.
|
||||
root_path: String,
|
||||
group_index: u32,
|
||||
/// MOGP `flags` @ `+0x08`, all 32 bits, unfiltered.
|
||||
flags: u32,
|
||||
/// MOBA render-batch count.
|
||||
batches: usize,
|
||||
/// MOVT vertex count.
|
||||
vertices: usize,
|
||||
/// MOPR portal-ref count (this group's slice, from its own MOGP header span).
|
||||
portal_refs: u16,
|
||||
/// MOGI bounding box, WMO model space (WoW axes) — `None` if the root's MOGI table is shorter
|
||||
/// than its declared group count (never observed, but not asserted away).
|
||||
bbox: Option<([f32; 3], [f32; 3])>,
|
||||
}
|
||||
|
||||
impl GroupRow {
|
||||
/// `{root}#{group}` for compact example rows.
|
||||
fn label(&self) -> String {
|
||||
format!("{}#{:03}", self.root_path, self.group_index)
|
||||
}
|
||||
|
||||
/// Bounding-box extent (max − min) per axis, or `?` if MOGI didn't carry one.
|
||||
fn bbox_str(&self) -> String {
|
||||
match self.bbox {
|
||||
Some((min, max)) => {
|
||||
let ext = [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
|
||||
format!(
|
||||
"{:.1}×{:.1}×{:.1} yd [{:.0},{:.0},{:.0}]..[{:.0},{:.0},{:.0}]",
|
||||
ext[0], ext[1], ext[2], min[0], min[1], min[2], max[0], max[1], max[2]
|
||||
)
|
||||
}
|
||||
None => "?".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-bit tally: how many groups/roots set it, split by batch-less vs. batch-having, plus a
|
||||
/// bounded example list for the rare-bit report.
|
||||
#[derive(Default)]
|
||||
struct BitStat {
|
||||
groups: u32,
|
||||
roots: BTreeSet<String>,
|
||||
batchless_groups: u32,
|
||||
other_groups: u32,
|
||||
examples: Vec<GroupRow>,
|
||||
}
|
||||
|
||||
/// Normalize a chain path the way the MPQ hash compares them (matches `wmo_ownerless_pools`).
|
||||
fn key(name: &str) -> String {
|
||||
name.replace('/', "\\").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// Bench a known-name label for a MOGP bit — filled in ONLY for bits benilla's own code already
|
||||
/// names as a flag test (verified 2026-09-01; see the module doc for what was checked and ruled
|
||||
/// out). Everything else prints blank: this census must not invent names the data alone can't back.
|
||||
fn known_name(bit: u32) -> &'static str {
|
||||
match bit {
|
||||
3 => "EXTERIOR (0x8)",
|
||||
6 => "EXTERIOR_LIT (0x40)",
|
||||
18 => "SHOW_SKYBOX (0x40000)",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let data = benilla_formats::wow_data()
|
||||
.ok_or_else(|| anyhow::anyhow!("no 1.12.1 install found (set $WOW_DATA)"))?;
|
||||
let chain = Chain::open(&data)?;
|
||||
|
||||
// Every ROOT `.wmo` the chain lists, across every mounted archive — a group file's stem ends
|
||||
// `_NNN`, a root's does not (same heuristic as `wmo_ownerless_pools`). The listfile is not a
|
||||
// provably complete index of every archive (`Chain::list`'s own doc), so a root reachable ONLY
|
||||
// by name and absent from every listfile would be missed here; nothing in the corpus is known
|
||||
// to do that, but the failure counts below exist so a gap like it would show up as a number
|
||||
// rather than silence.
|
||||
let roots: BTreeSet<String> = chain
|
||||
.list()?
|
||||
.into_iter()
|
||||
.map(|e| key(&e.name))
|
||||
.filter(|k| {
|
||||
let Some(stem) = k.strip_suffix(".wmo") else {
|
||||
return false;
|
||||
};
|
||||
!stem
|
||||
.rsplit('_')
|
||||
.next()
|
||||
.is_some_and(|t| t.len() == 3 && t.bytes().all(|b| b.is_ascii_digit()))
|
||||
})
|
||||
.collect();
|
||||
eprintln!("scanning {} listed WMO roots…", roots.len());
|
||||
|
||||
let mut bits: Vec<BitStat> = (0..32).map(|_| BitStat::default()).collect();
|
||||
let mut batchless: Vec<GroupRow> = Vec::new();
|
||||
let mut exterior_per_root: BTreeMap<String, u32> = BTreeMap::new();
|
||||
let mut groups_scanned = 0u32;
|
||||
let mut roots_scanned = 0u32;
|
||||
let (mut root_read_fail, mut root_parse_fail) = (0u32, 0u32);
|
||||
let (mut group_read_fail, mut group_parse_fail) = (0u32, 0u32);
|
||||
let mut failure_examples: Vec<String> = Vec::new();
|
||||
|
||||
for root_path in &roots {
|
||||
let bytes = match chain.read(root_path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
root_read_fail += 1;
|
||||
if failure_examples.len() < MAX_FAILURE_EXAMPLES {
|
||||
failure_examples.push(format!("{root_path}: root unreadable ({e})"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let root = match parse_wmo_root(&bytes) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
root_parse_fail += 1;
|
||||
if failure_examples.len() < MAX_FAILURE_EXAMPLES {
|
||||
failure_examples.push(format!("{root_path}: root did not parse ({e})"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
roots_scanned += 1;
|
||||
let stem = root_path.strip_suffix(".wmo").unwrap_or(root_path);
|
||||
let infos = root.group_infos();
|
||||
|
||||
for gi in 0..root.group_count() {
|
||||
let group_path = format!("{stem}_{gi:03}.wmo");
|
||||
let gbytes = match chain.read(&group_path) {
|
||||
Ok(b) => b,
|
||||
Err(_) => {
|
||||
group_read_fail += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Two independent reads of the same bytes, deliberately: `wmo_group_header` for the raw
|
||||
// MOGP `flags`/MOPR span (the load-bearing 32 bits this census exists to histogram), and
|
||||
// `benilla_wmo::parse_wmo` for MOBA/MOVT counts — the same split `wmo_group_submeshes`
|
||||
// itself makes.
|
||||
let Some(header) = wmo_group_header(&gbytes) else {
|
||||
group_parse_fail += 1;
|
||||
continue;
|
||||
};
|
||||
let Ok(ParsedWmo::Group(group)) = parse_wmo(&mut Cursor::new(gbytes.as_slice())) else {
|
||||
group_parse_fail += 1;
|
||||
continue;
|
||||
};
|
||||
groups_scanned += 1;
|
||||
|
||||
let bbox = infos.get(gi as usize).map(|i| (i.bbox_min, i.bbox_max));
|
||||
let row = GroupRow {
|
||||
root_path: root_path.clone(),
|
||||
group_index: gi,
|
||||
flags: header.flags,
|
||||
batches: group.render_batches.len(),
|
||||
vertices: group.vertex_positions.len(),
|
||||
portal_refs: header.portal_ref_count,
|
||||
bbox,
|
||||
};
|
||||
let is_batchless = row.batches == 0;
|
||||
|
||||
for (bit, stat) in bits.iter_mut().enumerate() {
|
||||
if header.flags & (1u32 << bit) == 0 {
|
||||
continue;
|
||||
}
|
||||
stat.groups += 1;
|
||||
stat.roots.insert(root_path.clone());
|
||||
if is_batchless {
|
||||
stat.batchless_groups += 1;
|
||||
} else {
|
||||
stat.other_groups += 1;
|
||||
}
|
||||
if stat.examples.len() < MAX_EXAMPLES {
|
||||
stat.examples.push(row.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if header.flags & EXTERIOR != 0 {
|
||||
*exterior_per_root.entry(root_path.clone()).or_default() += 1u32;
|
||||
}
|
||||
|
||||
if is_batchless {
|
||||
batchless.push(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==== headline ================================================================================
|
||||
println!("==== WMO ANTIPORTAL census (1.12.1 / build 5875) ====\n");
|
||||
println!(
|
||||
"roots: {roots_scanned} parsed / {} listed (read failures {root_read_fail}, parse failures {root_parse_fail})",
|
||||
roots.len()
|
||||
);
|
||||
println!(
|
||||
"groups: {groups_scanned} parsed (read failures {group_read_fail}, parse failures {group_parse_fail})\n"
|
||||
);
|
||||
|
||||
let batchless_roots: BTreeSet<&str> = batchless.iter().map(|r| r.root_path.as_str()).collect();
|
||||
println!(
|
||||
"batch-less groups (zero MOBA render batches — the wowdev ANTIPORTAL shape): {} across {} roots",
|
||||
batchless.len(),
|
||||
batchless_roots.len()
|
||||
);
|
||||
if batchless.is_empty() {
|
||||
println!(
|
||||
"VERDICT: ZERO — no shipped 1.12.1 WMO authors an antiportal-shaped group. Every group \
|
||||
in the corpus that carries a MOGI bounding box also carries at least one MOBA render batch."
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"VERDICT: {} antiportal-shaped group(s) shipped — see the batch-less census and the \
|
||||
cross-tab below for which flag bit(s) the data implicates.",
|
||||
batchless.len()
|
||||
);
|
||||
}
|
||||
if !failure_examples.is_empty() {
|
||||
println!("\nroot failures (up to {MAX_FAILURE_EXAMPLES}):");
|
||||
for f in &failure_examples {
|
||||
println!(" {f}");
|
||||
}
|
||||
}
|
||||
|
||||
// ==== which buildings own MANY exterior groups ================================================
|
||||
//
|
||||
// Not an antiportal question — a *consumer* one, and the reason it sits in this census rather
|
||||
// than in a throwaway script. The reference draws a building's EXTERIOR groups only through the
|
||||
// deferred portal windows the interior flood leaves behind, each group tested against its own
|
||||
// window's sub-frustum (`wow-5875-re` `wmo-insideleg-phase3.md` Pass 2; benilla decision 1826).
|
||||
// A building with ONE exterior group cannot show that law at work: a single whole-envelope shell
|
||||
// has a box wide enough to intersect any window, so it draws from everywhere and looks identical
|
||||
// either way — which is exactly why Stormwind (306 groups, one `0x8`) is the wrong subject to
|
||||
// eyeball the behaviour on. The buildings below, whose shells are split into many groups, are
|
||||
// where per-window culling is visible at all, and so where a retest belongs.
|
||||
println!("\n---- roots by EXTERIOR (0x8) group count, top {MAX_EXTERIOR_RANK} ----");
|
||||
let mut ranked: Vec<(&String, &u32)> = exterior_per_root.iter().collect();
|
||||
ranked.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
|
||||
let multi = ranked.iter().filter(|(_, &n)| n > 1).count();
|
||||
println!(
|
||||
"{multi} of {} roots with any exterior group have MORE THAN ONE",
|
||||
ranked.len()
|
||||
);
|
||||
for (path, n) in ranked.iter().take(MAX_EXTERIOR_RANK) {
|
||||
println!(" {n:4} {path}");
|
||||
}
|
||||
|
||||
// ==== the full 32-bit histogram ===============================================================
|
||||
println!("\n---- MOGP flags histogram (every bit 0..31) ----");
|
||||
println!(
|
||||
"{:>3} {:>10} {:>8} {:>8} known name",
|
||||
"bit", "mask", "groups", "roots"
|
||||
);
|
||||
for (bit, stat) in bits.iter().enumerate() {
|
||||
if stat.groups == 0 {
|
||||
continue;
|
||||
}
|
||||
println!(
|
||||
"{:>3} {:>#10x} {:>8} {:>8} {}",
|
||||
bit,
|
||||
1u32 << bit,
|
||||
stat.groups,
|
||||
stat.roots.len(),
|
||||
known_name(bit as u32)
|
||||
);
|
||||
}
|
||||
let unset_bits: Vec<u32> = bits
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, s)| s.groups == 0)
|
||||
.map(|(b, _)| b as u32)
|
||||
.collect();
|
||||
if !unset_bits.is_empty() {
|
||||
println!(
|
||||
"\nbits never set by any shipped group: {}",
|
||||
unset_bits
|
||||
.iter()
|
||||
.map(|b| format!("{b}(0x{:x})", 1u32 << b))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
// ==== rare-bit examples ========================================================================
|
||||
println!(
|
||||
"\n---- rare bits (< {RARE_THRESHOLD} groups): up to {MAX_EXAMPLES} example rows each ----"
|
||||
);
|
||||
let mut any_rare = false;
|
||||
for (bit, stat) in bits.iter().enumerate() {
|
||||
if stat.groups == 0 || stat.groups >= RARE_THRESHOLD {
|
||||
continue;
|
||||
}
|
||||
any_rare = true;
|
||||
println!(
|
||||
"\nbit {bit} (0x{:x}) — {} groups / {} roots{}",
|
||||
1u32 << bit,
|
||||
stat.groups,
|
||||
stat.roots.len(),
|
||||
if known_name(bit as u32).is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" [{}]", known_name(bit as u32))
|
||||
}
|
||||
);
|
||||
for r in &stat.examples {
|
||||
println!(
|
||||
" {:<48} flags {:#010x} batches {:>3} verts {:>5} portal-refs {:>3} bbox {}",
|
||||
r.label(),
|
||||
r.flags,
|
||||
r.batches,
|
||||
r.vertices,
|
||||
r.portal_refs,
|
||||
r.bbox_str()
|
||||
);
|
||||
}
|
||||
if stat.groups as usize > stat.examples.len() {
|
||||
println!(
|
||||
" … {} more not shown",
|
||||
stat.groups as usize - stat.examples.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
if !any_rare {
|
||||
println!("(none — every set bit is carried by >= {RARE_THRESHOLD} groups)");
|
||||
}
|
||||
|
||||
// ==== batch-less census ========================================================================
|
||||
println!(
|
||||
"\n---- batch-less groups (zero MOBA — flag-agnostic antiportal shape): {} ----",
|
||||
batchless.len()
|
||||
);
|
||||
for r in batchless.iter().take(MAX_BATCHLESS_PRINTED) {
|
||||
println!(
|
||||
" {:<48} flags {:#010x} verts {:>5} portal-refs {:>3} bbox {}",
|
||||
r.label(),
|
||||
r.flags,
|
||||
r.vertices,
|
||||
r.portal_refs,
|
||||
r.bbox_str()
|
||||
);
|
||||
}
|
||||
if batchless.len() > MAX_BATCHLESS_PRINTED {
|
||||
println!(
|
||||
" … {} more not shown (true count {})",
|
||||
batchless.len() - MAX_BATCHLESS_PRINTED,
|
||||
batchless.len()
|
||||
);
|
||||
}
|
||||
|
||||
// ==== cross-tab: which bit(s) does the batch-less set actually carry? =========================
|
||||
// For every bit, how it splits across the batch-less/batch-having populations — the
|
||||
// flag-agnostic and flag-based signals collide here. A bit set by (close to) ALL batch-less
|
||||
// groups and by (close to) none of the batch-having ones is the data's own answer to "which bit
|
||||
// means antiportal in 5875"; the reverse (never set on a batch-less group) rules a candidate out
|
||||
// regardless of what later documentation calls it.
|
||||
let other_total = groups_scanned - batchless.len() as u32;
|
||||
println!(
|
||||
"\n---- cross-tab: bit occurrence, batch-less ({} groups) vs. batch-having ({} groups) ----",
|
||||
batchless.len(),
|
||||
other_total
|
||||
);
|
||||
if batchless.is_empty() {
|
||||
println!(
|
||||
"(the batch-less set is empty, so there is nothing to cross-tabulate — no bit can be \
|
||||
implicated as ANTIPORTAL from this corpus)"
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{:>3} {:>10} {:>10} {:>10} known name",
|
||||
"bit", "mask", "batch-less", "batch-have"
|
||||
);
|
||||
for (bit, stat) in bits.iter().enumerate() {
|
||||
if stat.batchless_groups == 0 && stat.other_groups == 0 {
|
||||
continue;
|
||||
}
|
||||
println!(
|
||||
"{:>3} {:>#10x} {:>6}/{:<3} {:>6}/{:<3} {}",
|
||||
bit,
|
||||
1u32 << bit,
|
||||
stat.batchless_groups,
|
||||
batchless.len(),
|
||||
stat.other_groups,
|
||||
other_total,
|
||||
known_name(bit as u32)
|
||||
);
|
||||
}
|
||||
let implicated: Vec<u32> = bits
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, s)| {
|
||||
s.batchless_groups == batchless.len() as u32 && s.other_groups < other_total / 10
|
||||
})
|
||||
.map(|(b, _)| b as u32)
|
||||
.collect();
|
||||
if implicated.is_empty() {
|
||||
println!(
|
||||
"\nno bit is set by EVERY batch-less group while staying rare among batch-having \
|
||||
groups — the batch-less set carries no single common flag distinguishing it from the rest of the \
|
||||
corpus."
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"\nimplicated bit(s) — set by every batch-less group, and rare (< 10%) among \
|
||||
batch-having groups: {}",
|
||||
implicated
|
||||
.iter()
|
||||
.map(|b| format!("bit {b} (0x{:x})", 1u32 << b))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -46,7 +46,7 @@ pub use world::{doodadscan, placescan, skyboxscan, wmodoodads};
|
||||
/// [`Chain::read_file`] is handed); the `.m2` test and the prefix match are both done on a
|
||||
/// lowercased copy, and the prefix is normalized to the chain's own `\` separators, so a caller
|
||||
/// may pass either slash in either case.
|
||||
fn m2_names(chain: &mut Chain, prefix: Option<&str>) -> Result<Vec<String>> {
|
||||
pub(crate) fn m2_names(chain: &mut Chain, prefix: Option<&str>) -> Result<Vec<String>> {
|
||||
let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\"));
|
||||
Ok(chain
|
||||
.list()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! `shakecensus`: the whole-table view of the **camera-shake** system (decision 1540) — the 24
|
||||
//! shipped `CameraShakes.dbc` presets, and every creature model that names one.
|
||||
//! `shakecensus`: the whole-table view of the **camera-shake** system (decisions 1540/1849) — the
|
||||
//! 24 shipped `CameraShakes.dbc` presets, and everything that names one: the creature models
|
||||
//! (footstep + death thud), and the `SpellEffectCameraShakes.dbc` groups the spell side reaches
|
||||
//! them through.
|
||||
//!
|
||||
//! The scope instrument for B298 ("walking past an Ancient Protector shakes no screen"). It
|
||||
//! answers, from the shipped data rather than from expectation, the two questions that turn that
|
||||
@@ -12,8 +14,19 @@
|
||||
//! Read the output beside the module doc of [`benilla_formats::CameraShakeCatalog`]: the creature
|
||||
//! rows and the spell rows are visibly different shapes (phase, duration, the direction triples),
|
||||
//! and that split is itself the check.
|
||||
//!
|
||||
//! The **group** half (decision 1849) is the same instrument one indirection out: 58 of the 1772
|
||||
//! shipped `SpellVisualKit` rows name a group in field 14, every value must land on the 9-row
|
||||
//! table, and the census names the kits so a spell-side shake can be traced from a preset back to
|
||||
//! the kit that fires it. Then the `$SHK` sweep, whose **host** column is the one that matters —
|
||||
//! only a GameObject or DynamicObject decodes the tag, so a marker on a creature or spell-effect
|
||||
//! model, or on one nothing hosts at all, is authored-but-inert.
|
||||
//!
|
||||
//! The roll-up at the end is deliberately **two tiers**, because they answer different questions: a
|
||||
//! preset can be *named by a table* and still have nothing that fires it. Keeping them apart is
|
||||
//! what caught this census's own field-12 mistake (see the `$SHK` block).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use anyhow::Result;
|
||||
use benilla_formats::Chain;
|
||||
@@ -25,6 +38,16 @@ struct Users {
|
||||
death_thud: Vec<String>,
|
||||
}
|
||||
|
||||
/// A model path reduced to a comparison key: lowercase, back-slashed, extension dropped. The DBCs
|
||||
/// name models `.mdx`; the archives hold them as `.m2`, and the casing is arbitrary in both.
|
||||
fn mdx_key(path: &str) -> String {
|
||||
let p = path.to_ascii_lowercase().replace('/', "\\");
|
||||
match p.rsplit_once('.') {
|
||||
Some((stem, _)) => stem.to_string(),
|
||||
None => p,
|
||||
}
|
||||
}
|
||||
|
||||
/// The model's own file name — the census reads better without the `Creature\\Foo\\` prefix.
|
||||
fn short(path: &str) -> String {
|
||||
path.rsplit('\\').next().unwrap_or(path).to_string()
|
||||
@@ -55,6 +78,30 @@ pub fn shakecensus(chain: &mut Chain) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Which groups name each preset, and which kits name each group — the spell side's two hops.
|
||||
let mut group_users: BTreeMap<u32, Vec<u32>> = BTreeMap::new();
|
||||
for g in shakes.groups() {
|
||||
for id in g.shakes() {
|
||||
let entry = group_users.entry(id).or_default();
|
||||
if !entry.contains(&g.id) {
|
||||
entry.push(g.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for groups in group_users.values_mut() {
|
||||
groups.sort_unstable();
|
||||
}
|
||||
let visuals = benilla_formats::load_spell_visual_catalog(chain)?;
|
||||
let mut kits_by_group: BTreeMap<u32, Vec<u32>> = BTreeMap::new();
|
||||
for kit_id in visuals.kit_ids() {
|
||||
if let Some(group) = visuals.kit(kit_id).and_then(|k| k.shake) {
|
||||
kits_by_group.entry(group).or_default().push(kit_id);
|
||||
}
|
||||
}
|
||||
for kits in kits_by_group.values_mut() {
|
||||
kits.sort_unstable();
|
||||
}
|
||||
|
||||
println!("CameraShakes.dbc — {} presets\n", shakes.len());
|
||||
println!(
|
||||
"{:>4} {:>4} {:>4} {:>9} {:>9} {:>8} {:>7} {:>7} named by",
|
||||
@@ -63,18 +110,26 @@ pub fn shakecensus(chain: &mut Chain) -> Result<()> {
|
||||
let mut rows: Vec<_> = shakes.iter().collect();
|
||||
rows.sort_by_key(|r| r.id);
|
||||
for r in rows {
|
||||
let named = match users.get(&r.id) {
|
||||
None => "— (spell-side or unused)".to_string(),
|
||||
Some(u) => {
|
||||
let mut parts = Vec::new();
|
||||
if !u.footstep.is_empty() {
|
||||
parts.push(format!("footstep: {}", u.footstep.join(", ")));
|
||||
}
|
||||
if !u.death_thud.is_empty() {
|
||||
parts.push(format!("death thud: {}", u.death_thud.join(", ")));
|
||||
}
|
||||
parts.join(" · ")
|
||||
let mut parts = Vec::new();
|
||||
if let Some(u) = users.get(&r.id) {
|
||||
if !u.footstep.is_empty() {
|
||||
parts.push(format!("footstep: {}", u.footstep.join(", ")));
|
||||
}
|
||||
if !u.death_thud.is_empty() {
|
||||
parts.push(format!("death thud: {}", u.death_thud.join(", ")));
|
||||
}
|
||||
}
|
||||
if let Some(g) = group_users.get(&r.id) {
|
||||
parts.push(format!(
|
||||
"group{} {}",
|
||||
if g.len() == 1 { "" } else { "s" },
|
||||
g.iter().map(u32::to_string).collect::<Vec<_>>().join(", ")
|
||||
));
|
||||
}
|
||||
let named = if parts.is_empty() {
|
||||
"— NOTHING NAMES THIS ROW".to_string()
|
||||
} else {
|
||||
parts.join(" · ")
|
||||
};
|
||||
println!(
|
||||
"{:>4} {:>4} {:>4} {:>9.3} {:>9.3} {:>8.3} {:>7.3} {:>7.3} {}",
|
||||
@@ -111,5 +166,206 @@ pub fn shakecensus(chain: &mut Chain) -> Result<()> {
|
||||
for d in &dangling {
|
||||
println!(" DANGLING {d}");
|
||||
}
|
||||
|
||||
println!(
|
||||
"\nSpellEffectCameraShakes.dbc — {} groups (the id space every SPELL-side producer speaks)\n",
|
||||
shakes.group_len()
|
||||
);
|
||||
println!("{:>5} {:>14} named by SpellVisualKit", "group", "shakes");
|
||||
let mut groups: Vec<_> = shakes.groups().collect();
|
||||
groups.sort_by_key(|g| g.id);
|
||||
let mut group_dangling = Vec::new();
|
||||
for g in groups {
|
||||
let slots = g
|
||||
.shakes()
|
||||
.map(|id| {
|
||||
if shakes.get(id).is_none() {
|
||||
group_dangling.push(format!("group {}: shake {id}", g.id));
|
||||
}
|
||||
id.to_string()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ");
|
||||
let kits = match kits_by_group.get(&g.id) {
|
||||
None => "— (reached only by a $SHK animation event, if at all)".to_string(),
|
||||
Some(k) => format!(
|
||||
"{} kit(s): {}",
|
||||
k.len(),
|
||||
k.iter().map(u32::to_string).collect::<Vec<_>>().join(", ")
|
||||
),
|
||||
};
|
||||
println!("{:>5} {:>14} {}", g.id, slots, kits);
|
||||
}
|
||||
let kit_shakes: usize = kits_by_group.values().map(Vec::len).sum();
|
||||
println!(
|
||||
"\n{kit_shakes} of the shipped SpellVisualKit rows name a group; {} dangling slot(s)",
|
||||
group_dangling.len()
|
||||
);
|
||||
for d in &group_dangling {
|
||||
println!(" DANGLING {d}");
|
||||
}
|
||||
for group in kits_by_group.keys() {
|
||||
if shakes.group(*group).is_none() {
|
||||
println!(" DANGLING kit group {group} (no such SpellEffectCameraShakes row)");
|
||||
}
|
||||
}
|
||||
|
||||
// The third producer: the `$SHK` animation event, whose payload is a GROUP id (never a preset
|
||||
// id). Only the GameObject and DynamicObject dispatchers decode it — `CGUnit_C::HandleAnimEvent`
|
||||
// does not — so a `$SHK` on a creature M2 is inert, and this sweep is what says whether the
|
||||
// shipped content authors any at all.
|
||||
println!("\n$SHK animation events — the third producer (GameObject / DynamicObject only)\n");
|
||||
// The HOST column is what decides whether a marker is live at all: only the GameObject
|
||||
// (typemask 0x20, `0x5f3e20`) and DynamicObject (0x40, `0x5d58c0`) dispatchers decode `$SHK`.
|
||||
// A marker on a creature M2 reaches `CGUnit_C::HandleAnimEvent`, which does not decode the tag
|
||||
// (decision 1540); one on a model that is only ever a bone-attached `CEffect` reaches the fixed
|
||||
// `$SND`/`$HIT` router `0x61f6f0`. Both are authored-but-inert.
|
||||
//
|
||||
// The DynamicObject side is easy to get wrong and this census got it wrong once: the anchor's
|
||||
// area model is `SpellVisual` **field 12**, gated by field 11 ([`VisualStages::area_effect`] /
|
||||
// `area_gate` — wow-re `dynobject-visual-machine.md`, benilla's `entities::dest_fx`). Judging
|
||||
// the `Spells\*` carriers by "no display table names it" reported six presets dead that are
|
||||
// not: Warlock **Inferno** (SpellVisual 4859 → effect 2362 `Infernal_Impact_Base`) and
|
||||
// Ragnaros's **Meteor** (7479 → 3007 `Meteor_Impact_Base`) both plant one, and both models
|
||||
// carry `$SHK` group 2. So the host set is three, not two.
|
||||
let gos = benilla_formats::load_gameobject_catalog(chain)?;
|
||||
let go_models: BTreeSet<String> = gos.iter().map(|(_, path)| mdx_key(path)).collect();
|
||||
let creature_models: BTreeSet<String> = creatures.model_paths().map(mdx_key).collect();
|
||||
let dynobj_models: BTreeSet<String> = visuals
|
||||
.visuals()
|
||||
.filter(|(_, v)| v.area_gate != 0 && v.area_effect != 0)
|
||||
.filter_map(|(_, v)| visuals.effect_path(v.area_effect))
|
||||
.map(mdx_key)
|
||||
.collect();
|
||||
let mut shk_models = 0u32;
|
||||
let mut shk_marks = 0u32;
|
||||
let mut shk_live = 0u32;
|
||||
let mut shk_groups: BTreeMap<u32, u32> = BTreeMap::new();
|
||||
let mut shk_live_groups: BTreeMap<u32, u32> = BTreeMap::new();
|
||||
for name in crate::scan::m2_names(chain, None)? {
|
||||
let Ok(bytes) = chain.read_file(&name) else {
|
||||
continue;
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
let mut groups_here = Vec::new();
|
||||
for a in benilla_formats::parse_m2_animations(&bytes) {
|
||||
for e in a.events.iter().filter(|e| &e.ident == b"$SHK") {
|
||||
*shk_groups.entry(e.data).or_default() += 1;
|
||||
groups_here.push(e.data);
|
||||
shk_marks += 1;
|
||||
rows.push(format!(
|
||||
" seq {:>2} anim {:>3} {:>7.3}s group {}{}",
|
||||
a.seq_index,
|
||||
a.anim_id,
|
||||
e.time,
|
||||
e.data,
|
||||
match shakes.group(e.data) {
|
||||
Some(g) => format!(
|
||||
" → {}",
|
||||
g.shakes()
|
||||
.map(|id| id.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ")
|
||||
),
|
||||
None => " DANGLING (no such group)".to_string(),
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
if !rows.is_empty() {
|
||||
shk_models += 1;
|
||||
let key = mdx_key(&name);
|
||||
let live = go_models.contains(&key) || dynobj_models.contains(&key);
|
||||
if live {
|
||||
shk_live += rows.len() as u32;
|
||||
for g in &groups_here {
|
||||
*shk_live_groups.entry(*g).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let host = if go_models.contains(&key) {
|
||||
"GameObject display — LIVE"
|
||||
} else if dynobj_models.contains(&key) {
|
||||
"DynamicObject area model (SpellVisual field 12) — LIVE"
|
||||
} else if creature_models.contains(&key) {
|
||||
"creature model — INERT (CGUnit_C::HandleAnimEvent decodes no $SHK)"
|
||||
} else {
|
||||
"no host names it — INERT"
|
||||
};
|
||||
println!(" {name} [{host}]");
|
||||
for r in rows {
|
||||
println!("{r}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let tally = |m: &BTreeMap<u32, u32>| {
|
||||
if m.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
m.iter()
|
||||
.map(|(g, n)| format!("{g}×{n}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
};
|
||||
println!(
|
||||
"\n{shk_models} model(s) author $SHK, {shk_marks} marker(s); groups named: {}",
|
||||
tally(&shk_groups)
|
||||
);
|
||||
println!(
|
||||
"{shk_live} marker(s) sit on a host that DECODES the tag; live groups: {}",
|
||||
tally(&shk_live_groups)
|
||||
);
|
||||
|
||||
// The roll-up, in two tiers, because they answer different questions. A preset can be NAMED by
|
||||
// a table and still have nothing that fires it — presets 7/8/9 sit only in group 2, and a group
|
||||
// no kit and no `$SHK` marker names is a dead row. A shipped-but-unreachable preset is a
|
||||
// finding, not a bug; it is printed rather than inferred so nobody has to guess.
|
||||
let named: BTreeSet<u32> = users
|
||||
.keys()
|
||||
.copied()
|
||||
.chain(group_users.keys().copied())
|
||||
.collect();
|
||||
let live_groups: BTreeSet<u32> = kits_by_group
|
||||
.keys()
|
||||
.copied()
|
||||
.chain(shk_live_groups.keys().copied())
|
||||
.collect();
|
||||
let fired: BTreeSet<u32> = users
|
||||
.keys()
|
||||
.copied()
|
||||
.chain(
|
||||
shakes
|
||||
.groups()
|
||||
.filter(|g| live_groups.contains(&g.id))
|
||||
.flat_map(|g| g.shakes()),
|
||||
)
|
||||
.collect();
|
||||
let missing = |set: &BTreeSet<u32>| {
|
||||
let out: Vec<String> = shakes
|
||||
.iter()
|
||||
.map(|r| r.id)
|
||||
.filter(|id| !set.contains(id))
|
||||
.collect::<BTreeSet<_>>()
|
||||
.iter()
|
||||
.map(u32::to_string)
|
||||
.collect();
|
||||
if out.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
out.join(", ")
|
||||
}
|
||||
};
|
||||
println!(
|
||||
"\nreachability: {} of {} presets are NAMED by a table (unnamed: {})",
|
||||
named.len(),
|
||||
shakes.len(),
|
||||
missing(&named)
|
||||
);
|
||||
println!(
|
||||
" {} of {} have a live PRODUCER behind them (dead: {})",
|
||||
fired.len(),
|
||||
shakes.len(),
|
||||
missing(&fired)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! `spellvis`: dump a spell's visual chain — spell → SpellVisual stages → each kit's anim/sound/
|
||||
//! attach effects, plus the missile block — decision 0099's phase-2 instrument (columns per
|
||||
//! decision 0107).
|
||||
//! camera shake/attach effects, plus the missile block — decision 0099's phase-2 instrument
|
||||
//! (columns per decisions 0107 and 1849).
|
||||
|
||||
use anyhow::Result;
|
||||
use benilla_formats::Chain;
|
||||
@@ -9,6 +9,7 @@ use benilla_formats::Chain;
|
||||
pub fn run(chain: &mut Chain, spell_id: u32) -> Result<()> {
|
||||
let spells = benilla_formats::load_spell_catalog(chain)?;
|
||||
let visuals = benilla_formats::load_spell_visual_catalog(chain)?;
|
||||
let shakes = benilla_formats::load_camera_shakes(chain)?;
|
||||
let Some(spell) = spells.get(spell_id) else {
|
||||
anyhow::bail!("spell {spell_id} not in Spell.dbc");
|
||||
};
|
||||
@@ -37,6 +38,23 @@ pub fn run(chain: &mut Chain, spell_id: u32) -> Result<()> {
|
||||
kit.anim_id.map_or("—".into(), |a| a.to_string()),
|
||||
kit.sound.map_or("—".into(), |s| s.to_string()),
|
||||
);
|
||||
// The kit's CAMERA SHAKE (field 14, decision 1849): a
|
||||
// `SpellEffectCameraShakes` GROUP id, expanded to the presets it fires.
|
||||
// `benilla-extract … shakecensus` is the whole-table view.
|
||||
if let Some(group) = kit.shake {
|
||||
match shakes.group(group) {
|
||||
Some(g) => println!(
|
||||
" shake group {group} -> presets {}",
|
||||
g.shakes()
|
||||
.map(|id| id.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ")
|
||||
),
|
||||
None => println!(
|
||||
" shake group {group} (NO SUCH SpellEffectCameraShakes ROW)"
|
||||
),
|
||||
}
|
||||
}
|
||||
// The kit's CharProcs (fields 15-34): what it does to the BODY.
|
||||
crate::charprocs::print_kit_procs(&visuals, kit_id, " ");
|
||||
// The kit's BEAM, if it draws one (decision 0955): the chain CharProc's
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
//! `CameraShakes.dbc` — the shipped camera-shake presets.
|
||||
//!
|
||||
//! A shake is **not** authored per model or per animation: the model names a preset id, and this
|
||||
//! 24-row table holds the shape. Two independent producers reach it (decision 1540):
|
||||
//! 24-row table holds the shape. Two id spaces reach it (decisions 1540, 1849):
|
||||
//!
|
||||
//! - **The big-creature footstep/death thud** — `CreatureModelData.FootstepShakeSize` (field 11)
|
||||
//! and `.DeathThudShakeSize` (field 12), read here through
|
||||
//! [`CreatureCatalog::footstep_shake`](crate::CreatureCatalog::footstep_shake).
|
||||
//! - **Spell effects** — `SpellEffectCameraShakes.dbc`, a separate store the `$SHK` anim event
|
||||
//! indexes (wow-re `go-display-sound-events.md`: `[0xc0d814]`, bound `0xc0d818`). Not read here.
|
||||
//! - **The big-creature footstep/death thud** names a preset **directly** —
|
||||
//! `CreatureModelData.FootstepShakeSize` (field 11) and `.DeathThudShakeSize` (field 12), read
|
||||
//! here through [`CreatureCatalog::footstep_shake`](crate::CreatureCatalog::footstep_shake).
|
||||
//! - **Everything else names a GROUP** — `SpellEffectCameraShakes.dbc`, the 9-row indirection read
|
||||
//! here too ([`SpellShakeGroup`]): up to three `CameraShakes` ids fired at one point. Both
|
||||
//! `SpellVisualKit` field 14 and the `$SHK` animation event speak this id space and never the
|
||||
//! preset one (wow-re `go-display-sound-events.md`: `[0xc0d814]`, bound `0xc0d818`).
|
||||
//!
|
||||
//! **Layout — VERIFIED** against build 5875 (header + row decode, 2026-08-22): `24 × 8 × 32 B`,
|
||||
//! string block empty. The **column names** are the conventional map (wowdev.wiki + vmangos
|
||||
@@ -29,11 +31,39 @@
|
||||
//! the dragons take row 2 (`amplitude 7.0`), and the death thuds (rows 11/12) are longer and
|
||||
//! stronger than the footsteps.
|
||||
//!
|
||||
//! **What is NOT settled here.** The *semantics* — what `Type` and `Direction` select, what the
|
||||
//! evaluator does with `Amplitude`/`Frequency`/`Phase`/`Coefficient`, and above all the distance
|
||||
//! attenuation — are the reference's to state, and are the subject of the wow-re dispatch behind
|
||||
//! decision 1540. This module is the shipped data and nothing more: it deliberately carries **no**
|
||||
//! evaluator, so nothing can quietly come to depend on a guessed formula.
|
||||
//! **The semantics** — what `ShakeType` and `Direction` select, and the distance attenuation the
|
||||
//! evaluator applies — were settled by the wow-re dispatch behind decision 1540 and live beside the
|
||||
//! evaluator, in `benilla-app`'s `camera_shake`. This module stays the shipped data and nothing
|
||||
//! more.
|
||||
//!
|
||||
//! ## The group table — `SpellEffectCameraShakes.dbc` (9 rows × 4 fields, recsize 16)
|
||||
//!
|
||||
//! Every **spell-side** producer names a row of *this* table, never a `CameraShakes` id: the
|
||||
//! reference's shake spawner is reached through a group that fires **up to three presets at one
|
||||
//! point**. Layout verified against build 5875 the same way ([`SpellShakeGroup`]): `ID` plus three
|
||||
//! `CameraShakes` ids at `+0x4/+0x8/+0xc`, zeros skipped, string block empty. The shipped rows:
|
||||
//!
|
||||
//! | group | slots | group | slots |
|
||||
//! |---|---|---|---|
|
||||
//! | 1 | 4 · 5 · 6 | 6 | 4 · 5 · 6 |
|
||||
//! | 2 | 7 · 8 · 9 | 7 | 18 · 17 · 18 |
|
||||
//! | 3 | 1 | 26 | 36 · 37 · 38 |
|
||||
//! | 4 | 15 · 14 · 15 | 66 | 76 · 77 · 78 |
|
||||
//! | 5 | 11 | | |
|
||||
//!
|
||||
//! **The three slots are slots, not axes.** Group 4 lists `15` twice and group 7 lists `18` twice —
|
||||
//! a per-axis reading cannot survive that. The *intent* is clearly a per-axis triple (the ids named
|
||||
//! are the `direction` 0·1·2 members of a `CameraShakes` family), but the duplicate simply loses the
|
||||
//! evaluator's strict-`>` tie-break and contributes nothing.
|
||||
//!
|
||||
//! **Reachability.** The creature columns name `CameraShakes` {1, 2, 10} (footstep) and
|
||||
//! {10, 11, 12, 38} (thud); the nine groups name {1, 4, 5, 6, 7, 8, 9, 11, 14, 15, 17, 18, 36, 37,
|
||||
//! 38, 76, 77, 78}. Their union is 21 of the 24 shipped rows — **13, 16 and 56 are named by neither
|
||||
//! table**. All three are `direction 0` rows; 13 and 16 are the missing member of a family whose
|
||||
//! other two *are* named (14/15 by group 4, 17/18 by group 7), and 56 stands alone. See
|
||||
//! `benilla-extract shakecensus`, which prints the whole map — and keeps "named by a table" apart
|
||||
//! from "has a live producer behind it", which are the same 21 rows here only because every group
|
||||
//! turns out to have a producer.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -44,6 +74,7 @@ use crate::dbc::{f32_at, parse, u32_at};
|
||||
use crate::Chain;
|
||||
|
||||
const CAMERA_SHAKES: &str = "DBFilesClient\\CameraShakes.dbc";
|
||||
const SPELL_EFFECT_CAMERA_SHAKES: &str = "DBFilesClient\\SpellEffectCameraShakes.dbc";
|
||||
|
||||
/// One `CameraShakes.dbc` row — the authored shape of a shake, in the table's own units.
|
||||
///
|
||||
@@ -65,13 +96,36 @@ pub struct CameraShake {
|
||||
pub coefficient: f32,
|
||||
}
|
||||
|
||||
/// `CameraShakes.dbc`, keyed by row id.
|
||||
/// One `SpellEffectCameraShakes.dbc` row — **up to three [`CameraShake`] ids fired at one point**.
|
||||
///
|
||||
/// This is the id space every *spell-side* producer speaks: `SpellVisualKit` field 14 and the
|
||||
/// `$SHK` animation event both name a **group**, never a preset. Zero is the empty slot; the
|
||||
/// reference walks the three in order and skips them (`edi = 3`, `0x6ecb40`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SpellShakeGroup {
|
||||
pub id: u32,
|
||||
/// The three slots as shipped, zeros included — iterate with [`Self::shakes`].
|
||||
pub slots: [u32; 3],
|
||||
}
|
||||
|
||||
impl SpellShakeGroup {
|
||||
/// The populated slots, in the reference's walk order. A group can name the same preset twice
|
||||
/// (group 4 is `15 · 14 · 15`); the duplicate is kept here and loses the evaluator's tie-break.
|
||||
pub fn shakes(&self) -> impl Iterator<Item = u32> + '_ {
|
||||
self.slots.iter().copied().filter(|&id| id != 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The camera-shake tables: `CameraShakes.dbc` keyed by row id, and the
|
||||
/// `SpellEffectCameraShakes.dbc` groups that index it — **two id spaces, never conflated**
|
||||
/// (the same per-table split [`crate::SpellVisualCatalog`] keeps).
|
||||
///
|
||||
/// `Default` is the **empty** catalog — the same thing "the DBC failed to load" already means to
|
||||
/// every consumer (every lookup misses and the caller takes its documented fallback).
|
||||
#[derive(Default)]
|
||||
pub struct CameraShakeCatalog {
|
||||
rows: HashMap<u32, CameraShake>,
|
||||
groups: HashMap<u32, SpellShakeGroup>,
|
||||
}
|
||||
|
||||
impl CameraShakeCatalog {
|
||||
@@ -93,6 +147,37 @@ impl CameraShakeCatalog {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rows.is_empty()
|
||||
}
|
||||
|
||||
/// One `SpellEffectCameraShakes.dbc` group by id — the spell side's whole lookup. `None` for
|
||||
/// id 0 (the "no shake" value the kit column uses) and for any id the 9-row table lacks.
|
||||
pub fn group(&self, id: u32) -> Option<&SpellShakeGroup> {
|
||||
self.groups.get(&id)
|
||||
}
|
||||
|
||||
/// Every group, unordered.
|
||||
pub fn groups(&self) -> impl Iterator<Item = &SpellShakeGroup> {
|
||||
self.groups.values()
|
||||
}
|
||||
|
||||
pub fn group_len(&self) -> usize {
|
||||
self.groups.len()
|
||||
}
|
||||
|
||||
/// Seed one preset — for fixtures exercising the evaluator without a client install (the same
|
||||
/// builder shape [`crate::SpellVisualCatalog::with_chain_effect`] uses). The live path is
|
||||
/// [`load_camera_shakes`].
|
||||
#[must_use]
|
||||
pub fn with_row(mut self, row: CameraShake) -> Self {
|
||||
self.rows.insert(row.id, row);
|
||||
self
|
||||
}
|
||||
|
||||
/// Seed one group. See [`Self::with_row`].
|
||||
#[must_use]
|
||||
pub fn with_group(mut self, group: SpellShakeGroup) -> Self {
|
||||
self.groups.insert(group.id, group);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn camera_shakes_schema() -> Schema {
|
||||
@@ -112,7 +197,20 @@ fn camera_shakes_schema() -> Schema {
|
||||
s
|
||||
}
|
||||
|
||||
/// Read `CameraShakes.dbc` off the patch chain.
|
||||
fn spell_effect_camera_shakes_schema() -> Schema {
|
||||
let mut s = Schema::new("SpellEffectCameraShakes");
|
||||
for name in ["ID", "CameraShake1", "CameraShake2", "CameraShake3"] {
|
||||
s.add_field(SchemaField::new(name, FieldType::UInt32));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Read both camera-shake tables off the patch chain.
|
||||
///
|
||||
/// `SpellEffectCameraShakes.dbc` is loaded beside `CameraShakes.dbc` rather than on demand: it is
|
||||
/// nine rows, it ships in the same archive, and every spell-side producer needs both to resolve a
|
||||
/// single shake. A missing or malformed table is a hard error like every other DBC (1300) — the
|
||||
/// caller's fallback is to run with no shake system at all, which is what an empty catalog means.
|
||||
pub fn load_camera_shakes(chain: &mut Chain) -> Result<CameraShakeCatalog> {
|
||||
let bytes = chain
|
||||
.read_file(CAMERA_SHAKES)
|
||||
@@ -135,5 +233,100 @@ pub fn load_camera_shakes(chain: &mut Chain) -> Result<CameraShakeCatalog> {
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(CameraShakeCatalog { rows })
|
||||
|
||||
let bytes = chain
|
||||
.read_file(SPELL_EFFECT_CAMERA_SHAKES)
|
||||
.with_context(|| format!("reading {SPELL_EFFECT_CAMERA_SHAKES}"))?;
|
||||
let gs = parse(
|
||||
&bytes,
|
||||
spell_effect_camera_shakes_schema(),
|
||||
"SpellEffectCameraShakes",
|
||||
)?;
|
||||
let mut groups = HashMap::with_capacity(gs.records().len());
|
||||
for r in gs.records() {
|
||||
let Some(id) = u32_at(r, 0) else { continue };
|
||||
let mut slots = [0; 3];
|
||||
for (i, slot) in slots.iter_mut().enumerate() {
|
||||
*slot = u32_at(r, 1 + i).unwrap_or(0);
|
||||
}
|
||||
groups.insert(id, SpellShakeGroup { id, slots });
|
||||
}
|
||||
|
||||
Ok(CameraShakeCatalog { rows, groups })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// The nine shipped `SpellEffectCameraShakes.dbc` groups, verbatim — the id space every
|
||||
/// spell-side producer speaks. Sparse ids (`…7, 26, 66`) and duplicate slots (group 4 lists
|
||||
/// `15` twice, group 7 lists `18` twice) are both load-bearing: the sparseness is what makes a
|
||||
/// wrong column map impossible to miss, and the duplicate is what proves the three slots are
|
||||
/// **slots, not axes**.
|
||||
#[test]
|
||||
fn the_spell_shake_groups_decode_as_shipped() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = super::load_camera_shakes(&mut chain).expect("load the camera-shake tables");
|
||||
|
||||
assert_eq!(cat.group_len(), 9, "the shipped group table");
|
||||
for (id, slots) in [
|
||||
(1, [4, 5, 6]),
|
||||
(2, [7, 8, 9]),
|
||||
(3, [1, 0, 0]),
|
||||
(4, [15, 14, 15]),
|
||||
(5, [11, 0, 0]),
|
||||
(6, [4, 5, 6]),
|
||||
(7, [18, 17, 18]),
|
||||
(26, [36, 37, 38]),
|
||||
(66, [76, 77, 78]),
|
||||
] {
|
||||
let g = cat
|
||||
.group(id)
|
||||
.unwrap_or_else(|| panic!("group {id} missing"));
|
||||
assert_eq!(g.slots, slots, "group {id}");
|
||||
}
|
||||
assert!(cat.group(0).is_none(), "0 is the no-shake value, not a row");
|
||||
assert!(cat.group(8).is_none(), "the id space is sparse, not dense");
|
||||
|
||||
// Zeros are skipped, duplicates are not: group 3 fires one shake, group 4 fires three.
|
||||
assert_eq!(cat.group(3).unwrap().shakes().count(), 1);
|
||||
assert_eq!(
|
||||
cat.group(4).unwrap().shakes().collect::<Vec<_>>(),
|
||||
vec![15, 14, 15],
|
||||
"a duplicate slot survives the walk and loses the evaluator's tie-break"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The property that licenses the column map**: every id the group table names must land on a
|
||||
/// real row of the 24-row preset table. A merely plausible map would dangle.
|
||||
#[test]
|
||||
fn every_group_slot_resolves_to_a_preset() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = super::load_camera_shakes(&mut chain).expect("load the camera-shake tables");
|
||||
|
||||
assert_eq!(cat.len(), 24, "the shipped preset table");
|
||||
let named: BTreeSet<u32> = cat.groups().flat_map(|g| g.shakes()).collect();
|
||||
for id in &named {
|
||||
assert!(cat.get(*id).is_some(), "group slot {id} dangles");
|
||||
}
|
||||
assert_eq!(
|
||||
named.iter().copied().collect::<Vec<_>>(),
|
||||
vec![1, 4, 5, 6, 7, 8, 9, 11, 14, 15, 17, 18, 36, 37, 38, 76, 77, 78],
|
||||
"the 18 presets the spell side reaches"
|
||||
);
|
||||
|
||||
// The census the module doc states: with the creature columns' {1,2,10} and
|
||||
// {10,11,12,38}, three shipped rows are named by NEITHER table.
|
||||
let creature: BTreeSet<u32> = [1, 2, 10, 11, 12, 38].into_iter().collect();
|
||||
let all: BTreeSet<u32> = cat.iter().map(|r| r.id).collect();
|
||||
let unreached: Vec<u32> = all.difference(&(&named | &creature)).copied().collect();
|
||||
assert_eq!(
|
||||
unreached,
|
||||
vec![13, 16, 56],
|
||||
"shipped presets no table names — each the direction-0 member of a named family"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +204,13 @@ impl CreatureCatalog {
|
||||
.map(|(id, m)| (*id, m.path.as_str(), m.footstep_shake, m.death_thud_shake))
|
||||
}
|
||||
|
||||
/// Every `CreatureModelData` model path, unordered — the census surface for "is this M2 a
|
||||
/// creature model?", which is what decides whether an animation event on it reaches
|
||||
/// `CGUnit_C::HandleAnimEvent` at all.
|
||||
pub fn model_paths(&self) -> impl Iterator<Item = &str> + '_ {
|
||||
self.models.values().map(|m| m.path.as_str())
|
||||
}
|
||||
|
||||
/// A display's **spawned-creature render scale** — the product
|
||||
/// `CreatureModelData.modelScale × CreatureDisplayInfo.creatureModelScale`, the same number
|
||||
/// [`CreatureModel::scale`] carries, without the row's string clones.
|
||||
|
||||
@@ -38,7 +38,7 @@ mod cinematics;
|
||||
mod creatures;
|
||||
mod dbc;
|
||||
mod unit_blood;
|
||||
pub use camera_shakes::{load_camera_shakes, CameraShake, CameraShakeCatalog};
|
||||
pub use camera_shakes::{load_camera_shakes, CameraShake, CameraShakeCatalog, SpellShakeGroup};
|
||||
pub use cinematics::{
|
||||
camera_model_path, load_cinematics, CinematicCameraRow, CinematicCatalog, CinematicPath,
|
||||
CinematicSequence, CinematicView, SEQUENCE_CAMERAS,
|
||||
|
||||
@@ -42,8 +42,13 @@
|
||||
//! Flamestrike's 420, Vanish's 389). Same `CEffect` lifecycle as the nine (spell-id-tagged on
|
||||
//! the unit's `+0xb4` list, the stage sets lifetime — §1.5). Placement is byte-pinned (wow-re
|
||||
//! `kit30-effect-slot.md`, folded back 0850): no bone — a one-time **world plant** at the
|
||||
//! owner's position/facing/scale, [`WORLD_EFFECT_TAG`]. Field 14 is a visual-group fallback
|
||||
//! id — not read here. **Fields 15–34 are the four `CharProc` slots** — the
|
||||
//! owner's position/facing/scale, [`WORLD_EFFECT_TAG`]. **Field 14 is the kit's CAMERA SHAKE**
|
||||
//! ([`VisualKit::shake`]) — a `SpellEffectCameraShakes.dbc` **group** id, not the "visual-group
|
||||
//! fallback" earlier notes called it: 58 shipped kits carry one and all 58 values land on that
|
||||
//! table's nine sparse ids, `Spell.dbc` 12816 is literally named *"Idom Rool Camera Shake"*, and
|
||||
//! the set reads as nothing else (kit 356 = War Stomp/Thunder Clap/Kodo Stomp, kit 348 = the
|
||||
//! Knockdown/Slam/Charge impacts, kit 138 = every summon). **Fields 15–34 are the four
|
||||
//! `CharProc` slots** — the
|
||||
//! *character* half of a kit (the body's own alpha/tint, as opposed to the attach-point emitters):
|
||||
//! five parallel 4-element arrays, `CharProcType[4]` @+0x3c then `CharParamZero/One/Two/Three[4]`
|
||||
//! @+0x4c/+0x5c/+0x6c/+0x7c (wow-re `spellvisual-schema.md`, byte-pinned: all 20 consumed by the
|
||||
@@ -355,6 +360,11 @@ pub struct VisualKit {
|
||||
/// (the real missile is `SpellVisual` field 7's, [`VisualStages::missile_model`]). Folded
|
||||
/// into [`Self::effects`] at [`WORLD_EFFECT_TAG`].
|
||||
pub world_effect: Option<u32>,
|
||||
/// Kit field 14 (`kit+0x38`) — the **`SpellEffectCameraShakes.dbc` group** this kit shakes the
|
||||
/// camera with (`None` = no shake). Community name `ShakeID`; the value is a *group* id, never a
|
||||
/// `CameraShakes` row id — see [`crate::SpellShakeGroup`]. 58 of the 1772 shipped kits carry
|
||||
/// one, and every value lands on the group table's nine sparse ids `{3, 4, 5, 6, 7, 26, 66}`.
|
||||
pub shake: Option<u32>,
|
||||
/// The four `CharProc` slots (kit fields 15–34, transposed out of the five parallel arrays).
|
||||
/// An unfilled slot is `None` — see [`char_proc_slot`] for the sentinel law; iterate the filled
|
||||
/// ones via [`Self::char_procs`]. **Two consumers, two halves of the same table:** the *body*
|
||||
@@ -482,6 +492,13 @@ impl SpellVisualCatalog {
|
||||
self.visuals.is_empty()
|
||||
}
|
||||
|
||||
/// Every loaded `SpellVisual` row as `(id, stages)`, unordered — for whole-table census
|
||||
/// instruments (`benilla-extract shakecensus` walks it to find which effect models a
|
||||
/// **DynamicObject** can host). Runtime consumers resolve one by id through [`Self::stages`].
|
||||
pub fn visuals(&self) -> impl Iterator<Item = (u32, &VisualStages)> + '_ {
|
||||
self.visuals.iter().map(|(id, s)| (*id, s))
|
||||
}
|
||||
|
||||
/// Every loaded `SpellVisualKit` id, ascending — for whole-table census instruments
|
||||
/// (`benilla-extract charprocs`). Runtime consumers resolve a kit by id through [`Self::kit`].
|
||||
pub fn kit_ids(&self) -> impl Iterator<Item = u32> + '_ {
|
||||
@@ -669,6 +686,7 @@ pub fn load_spell_visual_catalog(chain: &mut Chain) -> Result<SpellVisualCatalog
|
||||
sound,
|
||||
effect_slots,
|
||||
world_effect: u32_at(r, 12).and_then(some_unless_none),
|
||||
shake: u32_at(r, 14).and_then(some_unless_none),
|
||||
char_proc_slots,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -204,12 +204,18 @@ impl Plugin for ExteriorCullPlugin {
|
||||
|
||||
/// The sub-frustum for one NDC window rect.
|
||||
///
|
||||
/// **Two callers, deliberately one construction** (decision 1826): this module gates the open world
|
||||
/// through these rects, and `crate::wmo_portal`'s Pass 2 gates the containing building's own
|
||||
/// exterior groups through the same ones. The reference likewise builds one volume per window and
|
||||
/// hands it to both (`0x682930` for `0x6b3b20`'s Pass 2, and the copy at `0xc7cb7c` for
|
||||
/// `0x682fa0`), so a doorway too narrow to admit a hillside cannot admit a wall either.
|
||||
///
|
||||
/// An NDC rect maps to the full screen by a scale+offset **on clip space** — `clip.x` scaled by
|
||||
/// `2/(x1-x0)` plus `clip.w` times `-(x0+x1)/(x1-x0)`, and likewise in y — so pre-multiplying
|
||||
/// `clip_from_world` by that matrix yields a projection whose 6 extracted planes bound exactly the
|
||||
/// window's sub-frustum. Depth rows are untouched: the window narrows the view laterally and inherits
|
||||
/// the camera's own near/far.
|
||||
fn window_frustum(rect: Rect, clip_from_world: &Mat4) -> Option<Frustum> {
|
||||
pub(crate) fn window_frustum(rect: Rect, clip_from_world: &Mat4) -> Option<Frustum> {
|
||||
let [x0, y0, x1, y1] = rect;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
if w < MIN_WINDOW_NDC || h < MIN_WINDOW_NDC {
|
||||
|
||||
@@ -56,6 +56,7 @@ use crate::terrain_stream::{terrain_height_under, TerrainStreamer};
|
||||
use crate::view::WorldCamera;
|
||||
use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
|
||||
use benilla_assets::{AdtTile, WmoGroupNav, WmoModel, WmoPortalInfo};
|
||||
use bevy::camera::primitives::{Aabb, Frustum};
|
||||
use bevy::camera::Projection;
|
||||
use bevy::math::{Affine3A, Vec4};
|
||||
use bevy::prelude::*;
|
||||
@@ -99,6 +100,22 @@ const PORTAL_PLANE_SNAP: f32 = 0.1;
|
||||
/// within this of the running nearest face hit still wins — a floor-hole portal coincident with the
|
||||
/// slab's collision faces flips the room at the plane.
|
||||
const NEAREST_TIE_EPS: f32 = 1.0e-4;
|
||||
/// MOGI/MOGP `0x10000` — the **callback-pass** group. The reference draws these in a third pass of
|
||||
/// its own (`0x6b3d6f..0x6b3dbc`), against the FULL camera frustum and gated on nothing, and Pass 2
|
||||
/// explicitly skips them (`0x6b3d0b`). Both passes read the bit off MOGI; benilla's
|
||||
/// [`WmoGroupNav::flags`] is MOGP, which wow-re verified is a strict SUPERSET of MOGI differing by
|
||||
/// exactly `0x1|0x4|0x200|0x800|0x1000` in all 5219 shipped groups — so the two agree on this bit
|
||||
/// and on [`EXTERIOR`], which is why reading MOGP here is safe.
|
||||
///
|
||||
/// 24 groups in 1.12 carry it: 14 in `stormwind.wmo` (the canals and the tall district shell),
|
||||
/// 6 across the two Dire Maul roots, and the 4 Razorfen enclosing shells that also carry `0x8`.
|
||||
const CALLBACK_PASS: u32 = 0x10000;
|
||||
/// The client's deferred-window worklist is a **fixed 16 records with no bound check** (`0x6b37b0`);
|
||||
/// nothing in the shipped data comes near it. We do not reproduce the overflow — emulating a buffer
|
||||
/// overrun is not fidelity — but we do watch the number, because a flood producing more windows than
|
||||
/// the reference's own designers left room for is far more likely to be OUR bug than the data's
|
||||
/// (decision 1148: one Trade District room once opened eleven windows onto Elwynn).
|
||||
const WORKLIST_CAP: usize = 16;
|
||||
/// Recursion depth cap (the client carries one) — a backstop against pathological portal cycles.
|
||||
const DEPTH_CAP: u32 = 64;
|
||||
/// Hard cap on flood iterations — a runaway-graph backstop; real WMOs settle far below this.
|
||||
@@ -436,7 +453,7 @@ fn compute_wmo_pvs(
|
||||
.then(|| TraceLog::new(model, eye_local, terrain_local));
|
||||
// Compare-then-write (1362's shape): a parked camera's flood answers the same set every
|
||||
// frame, and writing it anyway re-marked every instance changed — see the branch above.
|
||||
let fresh = compute_pvs_traced(
|
||||
let mut fresh = compute_pvs_traced(
|
||||
model,
|
||||
eye_local,
|
||||
terrain_local,
|
||||
@@ -508,20 +525,17 @@ fn compute_wmo_pvs(
|
||||
});
|
||||
// The deferred exterior-window list is this claiming placement's alone: the
|
||||
// reference floods the CONTAINING map object (`[0xc7b748]`) and defers that
|
||||
// flood's windows. The push test is [`opens_a_window`] — `0x8` ALONE, NOT the
|
||||
// `0x148` on the line above. Those two are neighbours in the binary and
|
||||
// neighbours here, and 0774 recorded them as *correlated, not equivalent*;
|
||||
// the port read `0x148` anyway, which made every `0x40`-without-`0x8` group a
|
||||
// doorway onto the open world. Stormwind's streets are 115 of its 306 groups
|
||||
// (decision 0475), so one Trade District room opened eleven windows onto
|
||||
// Elwynn — decision 1148.
|
||||
windows = Some(
|
||||
tap.entered
|
||||
.iter()
|
||||
.filter(|(to, _)| opens_a_window(&model.group_nav, *to))
|
||||
.map(|(_, rect)| *rect)
|
||||
.collect(),
|
||||
);
|
||||
// flood's windows. It arrives already built — [`GroupPvs::windows`], the same
|
||||
// list the flood's own Pass 2 tested this building's exterior groups against.
|
||||
//
|
||||
// The push test inside it is [`opens_a_window`] — `0x8` ALONE, NOT the `0x148`
|
||||
// the `exterior_visible` line above reads. Those two are neighbours in the
|
||||
// binary and neighbours here, and 0774 recorded them as *correlated, not
|
||||
// equivalent*; the port read `0x148` anyway, which made every
|
||||
// `0x40`-without-`0x8` group a doorway onto the open world. Stormwind's streets
|
||||
// are 115 of its 306 groups (decision 0475), so one Trade District room opened
|
||||
// eleven windows onto Elwynn — decision 1148.
|
||||
windows = Some(std::mem::take(&mut fresh.windows));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -560,13 +574,12 @@ fn compute_wmo_pvs(
|
||||
}
|
||||
|
||||
/// The per-frame [`FloodTrace`] tap: the down-ray's in-group seed (the camera's current group), which
|
||||
/// the interior-fog resolve reads — no duplicate down-ray — plus every portal step's *destination
|
||||
/// group and narrowed rect*, from which [`ExteriorWindows`] is built.
|
||||
/// the interior-fog resolve reads — no duplicate down-ray.
|
||||
///
|
||||
/// The windows come out of the flood for free: it already clips each doorway's polygon to a screen
|
||||
/// rect and intersects it with the rect it arrived on, which is precisely the accumulated
|
||||
/// screen-space AABB the reference defers per exterior window. We used to throw that rect away and
|
||||
/// keep only a boolean (`exterior_visible`, for weather).
|
||||
/// The deferred windows used to be reconstructed here, by filtering every entered step for an
|
||||
/// exterior destination. They are [`GroupPvs::windows`] now — the flood's own output — because the
|
||||
/// flood's **Pass 2** needs the identical list to decide which exterior groups draw (decision 1826),
|
||||
/// and two filters over the same steps are two places for the push test to drift.
|
||||
#[derive(Default)]
|
||||
struct SeedTap {
|
||||
in_group: Option<usize>,
|
||||
@@ -575,24 +588,24 @@ struct SeedTap {
|
||||
/// tests (and which is NOT the flood's visible set, a confusion wow-re corrected in
|
||||
/// `models/scratch/wmo-interior-fog-gate.md`).
|
||||
across: Option<usize>,
|
||||
/// `(destination group, rect it was entered on)` for every accepted portal step.
|
||||
entered: Vec<(usize, Rect)>,
|
||||
}
|
||||
impl FloodTrace for SeedTap {
|
||||
fn seed(&mut self, seeds: DownRaySeeds) {
|
||||
self.in_group = seeds.in_group;
|
||||
self.across = seeds.across;
|
||||
}
|
||||
fn entered(&mut self, _from: usize, _portal: u16, to: usize, rect: Rect, _on_plane: bool) {
|
||||
self.entered.push((to, rect));
|
||||
}
|
||||
}
|
||||
|
||||
/// **The deferred-window push test.** A flood step opens a window onto the OPEN WORLD exactly when
|
||||
/// its DESTINATION group carries `0x8` — the recursion resolves the neighbour and tests
|
||||
/// `0x6b44f8 test byte [eax+0x10],0x8; 0x6b44fc je <skip-defer>` before pushing the clipped rect into
|
||||
/// `0xcbe310`/`0xcbe320` (wow-re `models/scratch/wmo-insideleg-phase3.md`, "Worklist provenance" —
|
||||
/// VERIFIED at the bytes). Nothing else qualifies: not [`EXTERIOR_LIT`], not the weather global's
|
||||
/// `[0xcbe324]`/`[0xcbe320]` (wow-re `models/scratch/wmo-insideleg-phase3.md`, "Worklist
|
||||
/// provenance", and the record layout in `wmo-antiportal-and-window-record.md` — VERIFIED at the
|
||||
/// bytes). **`0xcbe310` is not the array**: the fill's `lea edi,[4*ecx+0xcbe310]` is an addressing
|
||||
/// form whose addend is never dereferenced (record 0 is at `0xcbe324`), and `0xcbe310` itself is
|
||||
/// the tail of the per-frame view-projection matrix `[0xcbe2d8]+0x38`. wow-re's ledger row for
|
||||
/// `0x6b4680` called it the "antiportal quad depth"; that was a Ghidra artifact and is corrected
|
||||
/// there now. Nothing else qualifies: not [`EXTERIOR_LIT`], not the weather global's
|
||||
/// `0x148` (that mask lives at `0x6b42d0` and drives `[0xca80c4]`, a *correlated but different*
|
||||
/// signal — decision 0774 said so and the port read it anyway; decision 1148).
|
||||
fn opens_a_window(nav: &[WmoGroupNav], to: usize) -> bool {
|
||||
@@ -608,6 +621,15 @@ pub(crate) trait FloodTrace {
|
||||
fn rect_none(&mut self, _from: usize, _portal: u16, _to: usize) {}
|
||||
fn rect_collapse(&mut self, _from: usize, _portal: u16, _to: usize) {}
|
||||
fn entered(&mut self, _from: usize, _portal: u16, _to: usize, _rect: Rect, _on_plane: bool) {}
|
||||
/// One Pass-2 verdict: an EXTERIOR group tested against the whole deferred worklist. Without
|
||||
/// this, "why is that wall still drawn?" has no reading at all — the flood's own hop lines
|
||||
/// never mention a group no portal reaches, which is exactly the population Pass 2 decides.
|
||||
fn pass2(&mut self, _group: usize, _flags: u32, _windows: usize, _admitted: bool) {}
|
||||
/// One Pass-3 verdict: a `0x10000` callback-pass group against the full frustum.
|
||||
fn pass3(&mut self, _group: usize, _flags: u32, _admitted: bool) {}
|
||||
/// The flood produced more deferred windows than the reference's fixed worklist holds — a
|
||||
/// tripwire on OUR flood, not a client behaviour (see [`WORKLIST_CAP`]).
|
||||
fn worklist_over_cap(&mut self, _windows: usize) {}
|
||||
}
|
||||
impl FloodTrace for () {}
|
||||
|
||||
@@ -636,6 +658,18 @@ impl<A: FloodTrace, B: FloodTrace> FloodTrace for (&mut A, &mut B) {
|
||||
self.0.entered(from, portal, to, rect, on_plane);
|
||||
self.1.entered(from, portal, to, rect, on_plane);
|
||||
}
|
||||
fn pass2(&mut self, group: usize, flags: u32, windows: usize, admitted: bool) {
|
||||
self.0.pass2(group, flags, windows, admitted);
|
||||
self.1.pass2(group, flags, windows, admitted);
|
||||
}
|
||||
fn pass3(&mut self, group: usize, flags: u32, admitted: bool) {
|
||||
self.0.pass3(group, flags, admitted);
|
||||
self.1.pass3(group, flags, admitted);
|
||||
}
|
||||
fn worklist_over_cap(&mut self, windows: usize) {
|
||||
self.0.worklist_over_cap(windows);
|
||||
self.1.worklist_over_cap(windows);
|
||||
}
|
||||
}
|
||||
|
||||
/// An absent recorder records nothing — so "attach the dump only when asked" costs no second flood.
|
||||
@@ -665,6 +699,21 @@ impl<T: FloodTrace> FloodTrace for Option<T> {
|
||||
t.entered(from, portal, to, rect, on_plane);
|
||||
}
|
||||
}
|
||||
fn pass2(&mut self, group: usize, flags: u32, windows: usize, admitted: bool) {
|
||||
if let Some(t) = self {
|
||||
t.pass2(group, flags, windows, admitted);
|
||||
}
|
||||
}
|
||||
fn pass3(&mut self, group: usize, flags: u32, admitted: bool) {
|
||||
if let Some(t) = self {
|
||||
t.pass3(group, flags, admitted);
|
||||
}
|
||||
}
|
||||
fn worklist_over_cap(&mut self, windows: usize) {
|
||||
if let Some(t) = self {
|
||||
t.worklist_over_cap(windows);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The portal flood. Returns the per-group visible set (indexed by absolute group index). `eye_local` is
|
||||
@@ -714,6 +763,13 @@ pub(crate) struct GroupPvs {
|
||||
pub(crate) visible: Vec<bool>,
|
||||
/// Per absolute group index: draws with the interior fog triple (the `[0xca7f00]` equivalent).
|
||||
pub(crate) interior_fog: Vec<bool>,
|
||||
/// The **deferred exterior-window worklist** this flood left behind — the client's
|
||||
/// `[0xcbe324]` records (count `[0xcbe320]`), one clipped NDC [`Rect`] per doorway the flood
|
||||
/// reached onto an `0x8` group. Two consumers, and that is the point of publishing it here:
|
||||
/// the flood's own Pass 2 (which exterior groups of THIS building draw) and
|
||||
/// [`ExteriorWindows`] (what of the open world draws), which the reference likewise feeds from
|
||||
/// one list — `0x681070` `rep movsd`s the very same records into `0xc7cb7c`.
|
||||
pub(crate) windows: Vec<Rect>,
|
||||
}
|
||||
|
||||
/// [`compute_pvs`] with a [`FloodTrace`] tap on every decision — the same single flood; the probe dump
|
||||
@@ -741,6 +797,21 @@ fn compute_pvs_traced<T: FloodTrace>(
|
||||
let mut stack: Vec<(usize, usize, Rect, u32, bool)> = Vec::new();
|
||||
let seeds = down_ray_seeds(model, eye_local, terrain_z);
|
||||
trace.seed(seeds);
|
||||
// **The inside-leg flag** — the client's `[0xcde5ac]`, set only for Pass 1 of `0x6b3b20`
|
||||
// (`0x6b3b47`, cleared `0x6b3c1a`), which is why Pass 1 is the ONLY pass that records deferred
|
||||
// windows: the push itself is guarded on it (`0x6b4511`). And `0x6b3b20` runs at all only when
|
||||
// the camera has a CONTAINING map object — `[0xc7b748]`, whose writer rejects an `0x8` group
|
||||
// outright (round-6 Q-H(c), the same rule the interior claim below takes).
|
||||
//
|
||||
// So the eye standing on a building's own porch or outer terrace is the driver's OUTSIDE leg
|
||||
// (`0x6811ca`): no worklist, no Pass 2, the building drawn like any other placement. Without
|
||||
// this guard our flood would seed inside that exterior group anyway, push windows from its
|
||||
// portal steps, and then let Pass 2 cull the rest of the shell from a vantage where the
|
||||
// reference frustum-tests every group normally.
|
||||
let inside_leg = seeds
|
||||
.in_group
|
||||
.and_then(|gi| nav.get(gi))
|
||||
.is_some_and(|n| n.flags & EXTERIOR == 0);
|
||||
match seeds.in_group {
|
||||
Some(c) => {
|
||||
stack.push((c, usize::MAX, FULL_SCREEN, 0, true));
|
||||
@@ -759,9 +830,14 @@ fn compute_pvs_traced<T: FloodTrace>(
|
||||
}
|
||||
}
|
||||
|
||||
// Did the interior flood reach a doorway onto an exterior group? The client's deferred-window gate
|
||||
// (`wow-5875-re` `wmo-insideleg-phase3.md`): only then is the exterior shell drawn (below).
|
||||
let mut deferred_exterior = false;
|
||||
// The deferred-window worklist Pass 1 leaves behind — the client's `[0xcbe324]` records, reset to
|
||||
// empty at `0x6b3b51` and filled by the recursion at `0x6b4541`. Pass 2 replays it below.
|
||||
let mut windows: Vec<Rect> = Vec::new();
|
||||
// The client's per-frame stamp on the PORTAL record (`0x6b4505`, `[esi+0x18]` vs `[0xca8014]`):
|
||||
// a portal contributes **at most one** window per frame, however many paths the flood reaches it
|
||||
// by. It guards the store alone — the recursion runs regardless — so this is a dedup, never a
|
||||
// skip. First push wins, which our stack walk resolves the same way the reference's does.
|
||||
let mut portal_pushed = vec![false; model.portal_infos.len()];
|
||||
let mut iters = 0u32;
|
||||
while let Some((g, came, rect, depth, chain)) = stack.pop() {
|
||||
iters += 1;
|
||||
@@ -837,36 +913,117 @@ fn compute_pvs_traced<T: FloodTrace>(
|
||||
continue;
|
||||
};
|
||||
trace.entered(g, r.portal, neighbour, inter, on_plane);
|
||||
// Reaching a portal onto an exterior group records a deferred window; the interior neighbour
|
||||
// is still flooded normally.
|
||||
if nav.get(neighbour).is_some_and(|n| n.flags & EXTERIOR != 0) {
|
||||
deferred_exterior = true;
|
||||
// Reaching a portal onto an exterior group records a deferred window — the client's
|
||||
// push at `0x6b44f8`(test `0x8`)/`0x6b4539`(count)/`0x6b4541`(rect). [`opens_a_window`]
|
||||
// is the predicate, shared with nothing else because there IS nothing else: this is the
|
||||
// one place the worklist is built.
|
||||
if inside_leg && opens_a_window(nav, neighbour) {
|
||||
if let Some(stamp) = portal_pushed.get_mut(r.portal as usize) {
|
||||
if !*stamp {
|
||||
*stamp = true;
|
||||
windows.push(inter);
|
||||
}
|
||||
}
|
||||
}
|
||||
stack.push((neighbour, g, inter, depth + 1, chain_on));
|
||||
}
|
||||
}
|
||||
|
||||
// Deferred exterior-window pass — the faithful approximation of the client's Pass 2 (`wow-5875-re`
|
||||
// `wmo-insideleg-phase3.md`): once the interior flood reaches a doorway onto an exterior group, the
|
||||
// building's exterior shell is drawn — INCLUDING portal-disconnected groups (an inn's outer walls
|
||||
// carry no portals, so the flood alone never reaches them, which is why they vanished from inside).
|
||||
// Gated exactly like the client: no doorway onto an exterior reached ⇒ the shell stays culled (you
|
||||
// can't see out, so it isn't drawn). We draw the shell to the full frustum (Bevy's per-Aabb cull
|
||||
// bounds it) rather than clipped to each doorway window — a benign over-draw, since from inside you're
|
||||
// meant to see the enclosing walls anyway.
|
||||
if deferred_exterior {
|
||||
// **Pass 2 — the deferred exterior-window replay** (`0x6b3c73..0x6b3d6f`), carved at the bytes in
|
||||
// `wow-5875-re` `models/scratch/wmo-insideleg-phase3.md` (§5 1v1, VERIFIED). Gated on the worklist
|
||||
// being non-empty (`0x6b3c87 jbe 0x6b3d6f` — a sealed room draws no exterior shell at all), and
|
||||
// then, **per window**, every group is admitted on `0x8 ∧ visible against a frustum clipped to
|
||||
// THAT window` (`0x6b3d13` / `0x6b3d22`) — never against the full view. That is what draws the
|
||||
// portal-disconnected shells (an inn's outer walls carry no portals, so the flood alone never
|
||||
// reaches them) without drawing the ones no doorway shows.
|
||||
//
|
||||
// We used to mark EVERY `0x8` group visible the moment any window existed, against the full
|
||||
// frustum, with a comment calling it "a benign over-draw". It is not benign: it is the
|
||||
// building's own far side drawing through its own walls, and decision 0784 had already flagged
|
||||
// this exact line — *"the exemption is per placement, not per group … if a case turns up where
|
||||
// it is not, this is the line to revisit"*. The case is the carve itself. Decision 1826.
|
||||
//
|
||||
// The sub-frustum is [`crate::exterior_cull::window_frustum`] — the SAME construction, and the
|
||||
// same too-narrow-window reject, that gates the open world through these very rects. One
|
||||
// definition, so a doorway that cannot admit a hillside cannot admit a wall either.
|
||||
if windows.len() > WORKLIST_CAP {
|
||||
trace.worklist_over_cap(windows.len());
|
||||
}
|
||||
if !windows.is_empty() {
|
||||
let frusta: Vec<Frustum> = windows
|
||||
.iter()
|
||||
.filter_map(|r| crate::exterior_cull::window_frustum(*r, clip_from_world))
|
||||
.collect();
|
||||
for (gi, g) in nav.iter().enumerate() {
|
||||
if g.flags & EXTERIOR != 0 {
|
||||
visible[gi] = true;
|
||||
// `¬0x10000 ∧ 0x8` — the client's `0x6b3d0b`/`0x6b3d13`, in that order. A callback-pass
|
||||
// group is Pass 3's alone (below); admitting it here too would be the double-submit the
|
||||
// reference's two predicates exist to prevent.
|
||||
if visible[gi] || g.flags & (EXTERIOR | CALLBACK_PASS) != EXTERIOR {
|
||||
continue;
|
||||
}
|
||||
let aabb = group_aabb(g);
|
||||
let admitted = frusta
|
||||
.iter()
|
||||
.any(|f| f.intersects_obb(&aabb, world_from_local, true, true));
|
||||
trace.pass2(gi, g.flags, frusta.len(), admitted);
|
||||
visible[gi] |= admitted;
|
||||
}
|
||||
}
|
||||
// **Pass 3 — the callback pass** (`0x6b3d6f..0x6b3dbc`). Two things make it unlike Pass 2, and
|
||||
// both matter: it tests the group against the **full camera frustum** (no window narrowing —
|
||||
// there is no `0x682930` call on this path), and it is the `jbe` **fall-through**, so it runs
|
||||
// even when the worklist is empty. A `0x10000` group therefore draws from a SEALED interior
|
||||
// room, where nothing else of the building's outside does. Its draw is `0x6b4160` → `0x685d70`,
|
||||
// the same per-group draw callback the flood itself uses, minus the portal recursion.
|
||||
//
|
||||
// **Both legs, unlike the two passes above.** Those live in `0x6b3b20`, which runs only when the
|
||||
// camera has a containing map object; but the camera-OUTSIDE seed `0x6b3dd0` walks the same MOGI
|
||||
// array with the same fork — `flags & 0x10000 -> 0x6b4160`, `flags & 0x8` frustum-pass -> the
|
||||
// portal flood — so a callback group is drawn from outdoors by the identical callback. Gating
|
||||
// this on the inside leg would have left 20 of the 24 undrawable from anywhere, including
|
||||
// `stormwind.wmo#086`, a 322×236×258 yd piece of the city (wow-re ledger row `0x6b3dd0`).
|
||||
//
|
||||
// What this restores: the 24 groups no pass of ours reached from inside — 14 in `stormwind.wmo`
|
||||
// (the canals and the tall district shell), 6 across the two Dire Maul roots, and Razorfen
|
||||
// Downs'/Kraul's four 450–520 yd enclosing shells. Those last four also carry `0x8`, so before
|
||||
// this they were window-tested by Pass 2 and vanished whenever no doorway showed them; the
|
||||
// reference draws them regardless.
|
||||
// **All four dispatch arms across both legs frustum-test the MOGI AABB first** — inside Pass 3 at
|
||||
// `0x6b3d9d`, the outside `0x10000` arm at `0x6b3f2d`, and the two `0x8` arms — with byte-identical
|
||||
// setup (`lea ecx,[eax+4]` = the record's six AABB floats, `call 0x682f60`, `test eax,eax; jne`,
|
||||
// culled = nonzero), differing only in stack slots. `0x6b4160` itself is `__thiscall(root,
|
||||
// groupIndex)`, `ret 4`: no rect, depth, owner or per-group stamp that could make the legs
|
||||
// diverge.
|
||||
let base = Frustum::from_clip_from_world(clip_from_world);
|
||||
for (gi, g) in nav.iter().enumerate() {
|
||||
if visible[gi] || g.flags & CALLBACK_PASS == 0 {
|
||||
continue;
|
||||
}
|
||||
let aabb = group_aabb(g);
|
||||
let admitted = base.intersects_obb(&aabb, world_from_local, true, true);
|
||||
trace.pass3(gi, g.flags, admitted);
|
||||
visible[gi] |= admitted;
|
||||
}
|
||||
GroupPvs {
|
||||
visible,
|
||||
interior_fog,
|
||||
windows,
|
||||
}
|
||||
}
|
||||
|
||||
/// A group's MOGI bounding box as a Bevy-space [`Aabb`] in the placement's LOCAL frame — the
|
||||
/// candidate bound Pass 2 hands the window frustum (the client's `0x682f60` takes the same per-group
|
||||
/// box out of the root's group-info array).
|
||||
///
|
||||
/// [`benilla_assets::coords::wow_to_bevy`] is a signed axis permutation, so an axis-aligned box maps
|
||||
/// to an axis-aligned box exactly: transforming the two corners and taking the componentwise
|
||||
/// min/max is the whole conversion, with no hull to rebuild.
|
||||
fn group_aabb(nav: &WmoGroupNav) -> Aabb {
|
||||
let a = wow_to_bevy(nav.bbox_min);
|
||||
let b = wow_to_bevy(nav.bbox_max);
|
||||
Aabb::from_min_max(a.min(b), a.max(b))
|
||||
}
|
||||
|
||||
/// The portal's polygon vertices (WoW model space), or `None` if the span is out of range or
|
||||
/// degenerate (< 3 vertices).
|
||||
fn portal_poly<'a>(
|
||||
@@ -1005,6 +1162,8 @@ mod audit;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use benilla_assets::WmoPortalRef;
|
||||
|
||||
/// A prop is drawn while ANY room that names it is visible — the reference's per-visible-group
|
||||
/// MODR walk. Blackrock's lava falls hang through up to 19 groups; keying them to one owner hid
|
||||
/// the fall from every camera angle whose flood reached a different referrer. Geometry (a
|
||||
@@ -1244,6 +1403,211 @@ mod tests {
|
||||
assert!(portal_screen_rect(&model, info, &clip, &Affine3A::IDENTITY).is_none());
|
||||
}
|
||||
|
||||
/// **Pass 2 is a per-window test, not a blanket** (decision 1826, `wow-5875-re`
|
||||
/// `wmo-insideleg-phase3.md` `0x6b3d13`/`0x6b3d22`). One interior room with a narrow doorway onto
|
||||
/// the outdoors, and two portal-disconnected exterior shells: one straight through the doorway,
|
||||
/// one 30 yd off to the side. The reference draws each `0x8` group only where a *deferred
|
||||
/// window* admits it, so the shell in the doorway draws and the one beside it does not.
|
||||
///
|
||||
/// Before 1826 we set every `0x8` group visible the moment any window existed — so `g3`, a wall
|
||||
/// no doorway shows, drew through the room's own walls. If this test ever goes green with `g3`
|
||||
/// visible again, the building's far side is leaking again.
|
||||
#[test]
|
||||
fn pass_two_admits_only_the_exterior_shells_a_doorway_shows() {
|
||||
let model = doorway_model();
|
||||
// Eye 3 yd back from the doorway, WoW (-3, 0, 1) = Bevy (0, 1, 3), looking along WoW +x
|
||||
// (Bevy −z) — straight through the opening.
|
||||
let eye_local = [-3.0, 0.0, 1.0];
|
||||
let view = Mat4::look_at_rh(
|
||||
bevy::math::Vec3::new(0.0, 1.0, 3.0),
|
||||
bevy::math::Vec3::new(0.0, 1.0, -10.0),
|
||||
bevy::math::Vec3::Y,
|
||||
);
|
||||
let clip = Mat4::perspective_rh(0.9, 16.0 / 9.0, 0.1, 1000.0) * view;
|
||||
let pvs = compute_pvs_traced(&model, eye_local, None, &clip, &Affine3A::IDENTITY, &mut ());
|
||||
|
||||
assert_eq!(
|
||||
pvs.windows.len(),
|
||||
1,
|
||||
"the one portal onto an exterior group is the one deferred window"
|
||||
);
|
||||
assert!(pvs.visible[0], "the camera's own room");
|
||||
assert!(pvs.visible[1], "the exterior group the portal opens onto");
|
||||
assert!(
|
||||
pvs.visible[2],
|
||||
"a portal-disconnected shell straight through the doorway is admitted by its window \
|
||||
— this is the half the flood alone can never reach"
|
||||
);
|
||||
assert!(
|
||||
!pvs.visible[3],
|
||||
"a portal-disconnected shell 30 yd off the doorway's axis is culled: no window shows \
|
||||
it, and drawing it is exactly the over-draw 1826 removed"
|
||||
);
|
||||
|
||||
// The control: widen the window to the whole screen (stand IN the doorway, which takes the
|
||||
// on-plane full-screen branch) and the side shell comes back — proving the cull above is the
|
||||
// window's doing and not the group's.
|
||||
let in_door = compute_pvs_traced(
|
||||
&model,
|
||||
[0.0, 0.0, 1.0],
|
||||
None,
|
||||
&clip,
|
||||
&Affine3A::IDENTITY,
|
||||
&mut (),
|
||||
);
|
||||
assert!(
|
||||
in_door.visible[3],
|
||||
"with the doorway full-screen, every shell in front of the camera draws"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Pass 3 draws a `0x10000` group from a SEALED room** — the `jbe` fall-through at
|
||||
/// `0x6b3d6f`, against the full frustum, gated on nothing (decision 1826). This is the half
|
||||
/// Pass 2 cannot express: with an empty worklist Pass 2 does not run at all, and every `0x8`
|
||||
/// group is correctly dark, yet a callback-pass group still draws.
|
||||
///
|
||||
/// The subjects in the real data are Razorfen Downs'/Kraul's 450–520 yd enclosing shells (which
|
||||
/// carry `0x8` **and** `0x10000`, so Pass 2 alone would have culled them whenever no doorway
|
||||
/// showed them) and Stormwind's canals.
|
||||
#[test]
|
||||
fn pass_three_draws_a_callback_group_from_a_sealed_room() {
|
||||
// The doorway model with its portal graph removed: the room is sealed, so the flood reaches
|
||||
// nothing and leaves no windows. g1..g3 keep their `0x8`; g3 also gains `0x10000`.
|
||||
let mut model = doorway_model();
|
||||
model.portal_refs.clear();
|
||||
model.group_nav[0].ref_count = 0;
|
||||
model.group_nav[1].ref_count = 0;
|
||||
model.group_nav[3].flags |= CALLBACK_PASS;
|
||||
// g3 sits 30 yd off-axis; aim the camera at it so the FULL frustum contains it while no
|
||||
// window ever could — WoW (2..6, 30..34) → Bevy x ∈ [-34,-30], z ∈ [-6,-2].
|
||||
let view = Mat4::look_at_rh(
|
||||
bevy::math::Vec3::new(0.0, 1.0, 3.0),
|
||||
bevy::math::Vec3::new(-32.0, 1.0, -4.0),
|
||||
bevy::math::Vec3::Y,
|
||||
);
|
||||
let clip = Mat4::perspective_rh(0.9, 16.0 / 9.0, 0.1, 1000.0) * view;
|
||||
let pvs = compute_pvs_traced(
|
||||
&model,
|
||||
[-3.0, 0.0, 1.0],
|
||||
None,
|
||||
&clip,
|
||||
&Affine3A::IDENTITY,
|
||||
&mut (),
|
||||
);
|
||||
|
||||
assert!(
|
||||
pvs.windows.is_empty(),
|
||||
"a sealed room opens no doorway onto the outdoors"
|
||||
);
|
||||
assert!(pvs.visible[0], "the camera's own room");
|
||||
assert!(
|
||||
!pvs.visible[1] && !pvs.visible[2],
|
||||
"plain 0x8 shells stay dark with an empty worklist — Pass 2 never runs"
|
||||
);
|
||||
assert!(
|
||||
pvs.visible[3],
|
||||
"the 0x10000 group draws anyway: Pass 3 is the jbe fall-through, full frustum, ungated"
|
||||
);
|
||||
|
||||
// The control: Pass 3 is a frustum test, not an unconditional pass. Turn away from g3 and it
|
||||
// goes dark — otherwise this test would pass just as well on a blanket.
|
||||
let away = Mat4::perspective_rh(0.9, 16.0 / 9.0, 0.1, 1000.0)
|
||||
* Mat4::look_at_rh(
|
||||
bevy::math::Vec3::new(0.0, 1.0, 3.0),
|
||||
bevy::math::Vec3::new(32.0, 1.0, 10.0),
|
||||
bevy::math::Vec3::Y,
|
||||
);
|
||||
let behind = compute_pvs_traced(
|
||||
&model,
|
||||
[-3.0, 0.0, 1.0],
|
||||
None,
|
||||
&away,
|
||||
&Affine3A::IDENTITY,
|
||||
&mut (),
|
||||
);
|
||||
assert!(
|
||||
!behind.visible[3],
|
||||
"a callback group behind the camera is still frustum-culled"
|
||||
);
|
||||
|
||||
// …and the OUTSIDE leg draws it too — the camera-outside seed `0x6b3dd0` forks on the same
|
||||
// `0x10000` before its `0x8` frustum-pass. Eye over open ground (no floor under it ⇒ no
|
||||
// containing group), so the flood takes its outside branch and seeds only `0x8` groups;
|
||||
// g3 has no portal refs and would be unreachable, yet the callback arm still draws it.
|
||||
let outside = compute_pvs_traced(
|
||||
&model,
|
||||
[-3.0, 0.0, 500.0],
|
||||
None,
|
||||
&clip,
|
||||
&Affine3A::IDENTITY,
|
||||
&mut (),
|
||||
);
|
||||
assert!(
|
||||
outside.visible[3],
|
||||
"the callback arm belongs to BOTH legs — gating it on the inside leg left 20 of the 24 \
|
||||
real groups undrawable from anywhere"
|
||||
);
|
||||
}
|
||||
|
||||
/// One interior room (`g0`) whose single doorway (`p0`, WoW x=0, y ∈ [-0.5,0.5], z ∈ [0,2]) opens
|
||||
/// onto an exterior group (`g1`), plus two portal-DISCONNECTED exterior shells: `g2` straight
|
||||
/// ahead through the opening, `g3` 30 yd to the side. `g0` carries a floor at z=0 so the down-ray
|
||||
/// seeds the flood there.
|
||||
fn doorway_model() -> WmoModel {
|
||||
let nav = |flags: u32, min: [f32; 3], max: [f32; 3], ref_start: u16, ref_count: u16| {
|
||||
WmoGroupNav {
|
||||
flags,
|
||||
bbox_min: min,
|
||||
bbox_max: max,
|
||||
ref_start,
|
||||
ref_count,
|
||||
area_table_id: 0,
|
||||
fog_indices: [0; 4],
|
||||
group_liquid: benilla_formats::NO_GROUP_LIQUID,
|
||||
}
|
||||
};
|
||||
let floor = |z: f32| {
|
||||
vec![
|
||||
[[-10.0, -10.0, z], [0.0, -10.0, z], [0.0, 10.0, z]],
|
||||
[[-10.0, -10.0, z], [0.0, 10.0, z], [-10.0, 10.0, z]],
|
||||
]
|
||||
};
|
||||
WmoModel {
|
||||
portal_vertices: vec![
|
||||
[0.0, -0.5, 0.0],
|
||||
[0.0, 0.5, 0.0],
|
||||
[0.0, 0.5, 2.0],
|
||||
[0.0, -0.5, 2.0],
|
||||
],
|
||||
portal_infos: vec![WmoPortalInfo {
|
||||
start_vertex: 0,
|
||||
count: 4,
|
||||
plane: [1.0, 0.0, 0.0, 0.0],
|
||||
}],
|
||||
// g0 -> g1 and back. `side = -1` puts the eye at x < 0 on the entering half-space.
|
||||
portal_refs: vec![
|
||||
WmoPortalRef {
|
||||
portal: 0,
|
||||
group: 1,
|
||||
side: -1,
|
||||
},
|
||||
WmoPortalRef {
|
||||
portal: 0,
|
||||
group: 0,
|
||||
side: 1,
|
||||
},
|
||||
],
|
||||
group_nav: vec![
|
||||
nav(0x2000, [-10.0, -10.0, 0.0], [0.0, 10.0, 3.0], 0, 1),
|
||||
nav(EXTERIOR, [1.0, -0.5, 0.0], [5.0, 0.5, 2.0], 1, 1),
|
||||
nav(EXTERIOR, [2.0, -0.5, 0.0], [6.0, 0.5, 2.0], 0, 0),
|
||||
nav(EXTERIOR, [2.0, 30.0, 0.0], [6.0, 34.0, 2.0], 0, 0),
|
||||
],
|
||||
group_collision_tris: vec![floor(0.0), Vec::new(), Vec::new(), Vec::new()],
|
||||
..portal_model()
|
||||
}
|
||||
}
|
||||
|
||||
/// A bare model holding one vertical doorway portal in the WoW x=0 plane (y ∈ [-1,1], z ∈ [-1,1]),
|
||||
/// i.e. straddling the world origin.
|
||||
fn portal_model() -> WmoModel {
|
||||
|
||||
@@ -144,4 +144,22 @@ impl FloodTrace for TraceLog {
|
||||
if on_plane { " (in-plane)" } else { "" }
|
||||
));
|
||||
}
|
||||
fn pass2(&mut self, group: usize, flags: u32, windows: usize, admitted: bool) {
|
||||
self.text.push_str(&format!(
|
||||
" pass2 g{group:02} EXT flags {flags:#07x} vs {windows} window(s): {}\n",
|
||||
if admitted { "DRAWN" } else { "culled" }
|
||||
));
|
||||
}
|
||||
fn pass3(&mut self, group: usize, flags: u32, admitted: bool) {
|
||||
self.text.push_str(&format!(
|
||||
" pass3 g{group:02} CALLBACK flags {flags:#07x} vs the full frustum: {}\n",
|
||||
if admitted { "DRAWN" } else { "culled" }
|
||||
));
|
||||
}
|
||||
fn worklist_over_cap(&mut self, windows: usize) {
|
||||
self.text.push_str(&format!(
|
||||
" WARN: {windows} deferred windows — the reference's worklist holds 16. Suspect our \
|
||||
flood, not the data.\n"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user