the loading screen lets nothing through, and the micro buttons go stock

- units could be hovered through the loading screen — the sword cursor gave it away: the world pick chain has no state gate, one of fifty-one input sites, so a gate per consumer is a rule that regresses; the cut is at the source instead — one `PreUpdate` system empties the button planes, drains the raw queues, zeroes the accumulators and blanks the window's cursor position while the cover is up (the alt-tabbed-away state every consumer already handles); a press under the cover never happened, a held button is released once, a gesture in flight is cancelled, and a wall test counts the channels; the reference does the same — its raise registers a veto stub (`0x406a50`) on five event-bus categories — and one gap closed with it: the reference parks the plain arrow and drops any item overlay before the screen goes up, where benilla kept a held item's icon over the loading art (1986, 1990)
- the micro-button row is the reference's own `MainMenuBarMicroButtons.xml`, our transcription deleted — no engine verb was missing, and the whole engine-side cost is one alias, `SoundOptionsFrame = OptionsFrame`, because the stock file reads the reference's three options windows and ours is the one window for all three; what the stock file brings back: `UpdateTalentButton`'s own −2 re-seat (the five buttons after the talent button sit 1 px further right — the reference's own pixel), the raw binding token in the hover label (`(C)`, `(SHIFT-P)`), the sixty-second talent-button pulse on a ding while the sheet is closed (0304's scope-out retired), the keyring arm, and "Abilities" on the spellbook hover for a character with no spells; `BenillaActionBarArt_SeatAbove` goes with the file — a child is born one level above its parent (`SetParent` `0x76ab65`), the seat it set by hand (1987)
- hovering the player arrow on the stock world map showed an empty tooltip: narrower than 4:3 the map is scaled under 1, and our `MouseIsOver` had dropped the reference's division by `GetEffectiveScale` as "always 1" — the frame getters answer in the frame's own units, `GetCursorPosition` in the screen's; the division is back, and the region getters divide by their owner's scale too; and the ready check times out: the stock `UIParent.xml` ticks `CheckReadyCheckTime` every frame, the one unbound name in that file's way, so the mechanism behind it is built — a 30 s deadline and a "has not answered" flag per roster entry, the leader getting no popup while members print "%s has initiated a ready check" and get it, and on expiry `RAID_MEMBERS_AFK` with the unanswered names or `READY_CHECK_NO_AFK`, the last answer closing the check at once (1985, 1989)
- a review of the floor grind closed eight stale-input and bypassed-side-effect siblings the gates had left open: a never-placed card places on a still frame, the dormant-emitter skip sits below the emitter's lifetime blocks, PVS changes (not only spawns) un-still the gates, the appear ramp's retirement and a far-side unmark come due, a host born under a parked camera is judged, the projection counts as a still input, a fresh palette range is zeroed, the stable feed runs once more after a close, the unit publisher's viewer query is filtered, solo UI materials are reused by key, and the draw-list cache compares exactly; and three doc links the chat window's stock move (1948) left dangling point at what replaced them — the colour table and the tell ring are the stock Lua's now — which is what had the doc-links gate (1925) red on main

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-04 21:01:38 +07:00
co-authored by Claude Fable 5.1
parent a5382d7b37
commit dbcf60fc31
59 changed files with 2135 additions and 772 deletions
@@ -2,7 +2,7 @@
<!--
The GAME MENU — the frame ESC opens (decision 0674). The last hole in the game shell's
outermost layer: until now ESC's chain ran out of things to close and did nothing, and the
Main Menu micro button was drawn but dead (MicroMenu.xml's SCOPE note).
Main Menu micro button was drawn but dead (our since-retired MicroMenu.xml's SCOPE note).
PROVENANCE (0985 — the three-source split, stated the same way in every era-shaped
window; OptionsFrame.xml carries the sibling block):
@@ -45,7 +45,7 @@
278 LOGOUT_CANCEL), fired by `crate::ui_logout` off `SMSG_LOGOUT_RESPONSE`.
NAMES are the reference's, unprefixed (`GameMenuFrame`, `GameMenuButton*`) — the same call
MicroMenu.xml made for its straight transcriptions, and required here besides: `ToggleGameMenu`
our retired MicroMenu.xml made for its straight transcriptions, and required here besides: `ToggleGameMenu`
and `GameMenuFrame` are Era API surface an addon addresses by name.
SCOPE / divergences (noted, not bugs):
@@ -101,7 +101,7 @@
-->
<Script><![CDATA[
-- The reference GlobalStrings (patch-2.MPQ Interface\FrameXML\GlobalStrings.lua), quoted
-- verbatim. `X = X or "..."` so a later real strings table wins, the MicroMenu.xml idiom.
-- verbatim. `X = X or "..."` so a later real strings table wins, the retired MicroMenu.xml's idiom.
MAIN_MENU = MAIN_MENU or "Main Menu"
HUD_EDIT_MODE_MENU = HUD_EDIT_MODE_MENU or "Edit Mode"
GAMEMENU_SUPPORT = GAMEMENU_SUPPORT or "Support"
-423
View File
@@ -1,423 +0,0 @@
<Ui>
<!--
The MICRO MENU — the row of eight small arched buttons in the main bar's right-hand recess
(character, spellbook, talents, quest log, socials, world map, main menu, help). It was the
last hole in the bar: ActionBar.xml's SCOPE note named "the bag/keyring/micro cluster on the
bar's right" as the visible remaining gap, and with the bag bar landed (decision 0216 slice
2) the micro row was the empty dark panel between the page arrows and the bags.
Transcribed from the real 1.12 FrameXML — ref-MainMenuBarMicroButtons.xml (the template +
the eight buttons' geometry) and ref-MainMenuBarMicroButtons.lua (LoadMicroButtonTextures,
UpdateMicroButtons, the CharacterMicroButton portrait, UpdateTalentButton), both extracted
from patch.MPQ. Geometry QUOTED: each button 29x58 with `HitRectInsets top=18`; the first at
MainMenuBarArtFrame BOTTOMLEFT +(552,2); the rest chained BOTTOMLEFT to the previous
BOTTOMRIGHT +(-3,0) — a 26 px stride, so the row runs x 552..757 inside the 1024-wide bar.
NAMES are the reference's, not Benilla*-prefixed (unlike the multibars/stance bar, which are
benilla-shaped divergences). These are straight transcriptions, and `UpdateMicroButtons` is
the ref global that CharacterFrame/SpellBookFrame/TalentFrame already cite by name in their
own OnShow/OnHide comments — those calls are now live instead of OMITTED, in all five
panels the ref calls it from (+ QuestLogFrame, WorldMapFrame). They are nil-GUARDED at the
call sites (`if UpdateMicroButtons then … end`) — not because the load order is in doubt
(this file always precedes them), but because a dozen test harnesses load one panel without
the action bar behind it, and an unguarded call would abort the rest of that OnShow. Same
posture as UiPanels.xml's `CloseMenus and CloseMenus()` and the `if GameTooltip` guards.
Structure: the buttons are declared at this file's top level and carry
`parent="MainMenuBarArtFrame"`, which really does attach them to the bar art — the loader
reads a cross-file `parent=`, resolving the name by global lookup at load (decision 1734),
so the art must already exist when this file loads, which the manifest order gives it. They
are anchored into it by name as well, and seated one frame level above it by
BenillaActionBarArt_SeatAbove (ActionBar.xml) so the bar plate doesn't paint over them —
both kept as they are, because that is the row the bar was built and approved against. Same
shape the bag bar next door already uses.
SCOPE / real-client divergences (noted, not bugs):
- EVERY button now opens something. Help was the last "art only, wired the day the panel
lands" one, and HelpFrame.xml is that panel — its click is `ToggleHelpFrame()` and its
pushed arm is in UpdateMicroButtons below, exactly as the ref writes both. (Socials went
live with decision 0668, Main Menu with 0674, Help with the HelpFrame transcription.)
The bar was deliberately never greyed with the ref's `-Disabled` art while the gap
lasted: a dead grey button mid-row reads as a broken bar, and it really was one OnClick
line from live.
- The "(key)" suffix is LIVE since 0997: `button.binding` carries the binding ACTION
name (TOGGLECHARACTER0, TOGGLESPELLBOOK, ...) and the tooltip looks the key up through
GetBindingKey at hover time — the ref MicroButtonTooltipText's own contract, restored
now that the binding table exists. A genuinely unbound action shows the bare label.
- The hover is the ref's real two-line GameTooltip_AddNewbieTip (GameTooltip.xml): the label,
then the button's NEWBIE_TOOLTIP_* explanation, at the default screen corner. An earlier
note here claimed detailed tips were OFF in 1.12 and open-coded the else branch — the ref
ships them ON (UIOptionsFrame.lua l.100), so that was a one-line tooltip where the real
client shows a paragraph. Corrected by decision 0661.
- TalentMicroButton registers UNIT_LEVEL + PLAYER_ENTERING_WORLD but NOT the ref's
PLAYER_LEVEL_UP (this client never fires it) — and PLAYER_LEVEL_UP's only other job there
is SetButtonPulse, which decision 0304 already scoped out ("no microbutton pulse").
- UpdateTalentButton's re-anchor uses -3 both ways; the ref writes -3 in the XML but -2 in
the Lua, so a real client nudges the whole row 1 px left the first time it re-anchors at
level 10. That's a ref typo, not a mechanism — not reproduced.
- SetButtonState is called without the ref's second "lock" argument (this engine has no
state lock; the pushed texture already draws from pushed-state OR a live mouse hold, so
the rendered result is identical for a button nothing else pushes).
- The keyring button (ref KeyRingButton, `hidden="true"` by default) stays out with the
keyring container itself.
-->
<!-- ── MainMenuBarMicroButton (ref MainMenuBarMicroButtons.xml l.4-18, VERBATIM) ─────────────
The micro-button base template. Ours declares its buttons directly, so nothing here needed
it — but an ADDON that adds a micro button inherits this name, and an unresolved
`inherits=` is silent: no error, just a button with no size, no hit-rect inset and no
tooltip scripts.
Verbatim, and it costs nothing: `GameTooltip_AddNewbieTip` is already ours (GameTooltip.xml)
and the rest is geometry. The HitRectInsets top=18 is the reference's own — the micro
buttons overlap the bar art above them, and without it the top third of every addon micro
button would eat clicks meant for the bar. -->
<Button name="MainMenuBarMicroButton" virtual="true">
<Size><AbsDimension x="29" y="58"/></Size>
<HitRectInsets><AbsInset left="0" right="0" top="18" bottom="0"/></HitRectInsets>
<Scripts>
<OnEnter>
GameTooltip_AddNewbieTip(this.tooltipText, 1.0, 1.0, 1.0, this.newbieText);
</OnEnter>
<OnLeave>
GameTooltip:Hide();
</OnLeave>
</Scripts>
</Button>
<Script><![CDATA[
-- Seat a micro button above the bar's art plate (was ActionBar.xml's; moved here with 1938 —
-- this file is its one caller, and it goes when this window migrates).
function BenillaActionBarArt_SeatAbove(frame)
local art = getglobal("MainMenuBarArtFrame")
if art then frame:SetFrameLevel(art:GetFrameLevel() + 1) end
end
-- ref GlobalStrings.lua — the eight labels (enUS), in the `X = X or` FALLBACK form: the
-- app runs the real localized GlobalStrings.lua off the patch chain BEFORE this file
-- (ui_script/mod.rs load_global_strings), so the installed locale wins and these copies
-- only fill in for the test harnesses, which load no patch chain. CombatText.xml's shape.
CHARACTER_BUTTON = CHARACTER_BUTTON or "Character Info"
SPELLBOOK_ABILITIES_BUTTON = SPELLBOOK_ABILITIES_BUTTON or "Spellbook & Abilities"
TALENTS_BUTTON = TALENTS_BUTTON or "Talents"
QUESTLOG_BUTTON = QUESTLOG_BUTTON or "Quest Log"
SOCIAL_BUTTON = SOCIAL_BUTTON or "Social"
WORLDMAP_BUTTON = WORLDMAP_BUTTON or "World Map"
MAINMENU_BUTTON = MAINMENU_BUTTON or "Main Menu"
HELP_BUTTON = HELP_BUTTON or "Help Request"
-- ...and their second lines, ref GlobalStrings.lua l.2722/2766/2768/2758/2765/2788/2747/2739,
-- verbatim (enUS). 1.12 ships detailed tooltips ON (decision 0661), so every one of these is
-- part of the default hover, not an opt-in extra.
NEWBIE_TOOLTIP_CHARACTER = NEWBIE_TOOLTIP_CHARACTER or "Information about your character, including equipment, statistics, skills, and reputation."
NEWBIE_TOOLTIP_SPELLBOOK = NEWBIE_TOOLTIP_SPELLBOOK or "All of your spells and abilities. To move a spell or ability to your Action Bar, open the Spellbook & Abilities window, left-click that spell or ability, and drag it down to your Action Bar."
NEWBIE_TOOLTIP_TALENTS = NEWBIE_TOOLTIP_TALENTS or "The array of talents available to enhance and specialize your character."
NEWBIE_TOOLTIP_QUESTLOG = NEWBIE_TOOLTIP_QUESTLOG or "A list of all the active quests you currently have. You can have up to 20 active quests at one time."
NEWBIE_TOOLTIP_SOCIAL = NEWBIE_TOOLTIP_SOCIAL or "Information about other people in the game. You can use the Social window to manage your friends list and ignore list, as well as see who is online."
NEWBIE_TOOLTIP_WORLDMAP = NEWBIE_TOOLTIP_WORLDMAP or "As you explore a zone, more areas of interest will become visible on the world map. You can use left-click to zoom in, or right-click to zoom out."
NEWBIE_TOOLTIP_MAINMENU = NEWBIE_TOOLTIP_MAINMENU or "Here you can modify your video, sound, and interface settings, or create custom hotkeys. You can also choose to log out or exit the program altogether."
NEWBIE_TOOLTIP_HELP = NEWBIE_TOOLTIP_HELP or "Through this menu, you can report a bug. You can also use the Help Request menu to talk to one of our Game Masters (GMs) in the game if you have questions or need assistance."
-- ref LoadMicroButtonTextures (MainMenuBarMicroButtons.lua l.1-9): the Up/Down/Disabled trio
-- under one art name plus the shared highlight. `art` is nil for CharacterMicroButton, whose
-- textures don't follow the pattern (UI-MicroButtonCharacter-*, and no Disabled variant
-- exists in the archives) — it sets its own.
-- `newbie` is the ref's per-button `this.newbieText` (set in each button's own OnLoad there —
-- MainMenuBarMicroButtons.lua l.95 and .xml l.101/127/149/174/193/215); Spellbook is the one
-- the ref hands to the call site instead, because its label swaps with GetSpellBookState.
function BenillaMicroButton_OnLoad(button, art, label, binding, newbie)
button.label = label
button.binding = binding
button.newbieText = newbie
-- Seat over the bar plate (ActionBar.xml's helper, which carries the why). Called
-- nil-guarded for the same reason BagFrame.xml's two calls are: the shipped load order
-- always has the bar first, but a test harness may load this file alone — and with no
-- bar there is nothing to seat above.
if BenillaActionBarArt_SeatAbove then BenillaActionBarArt_SeatAbove(button) end
if art then
local prefix = "Interface\\Buttons\\UI-MicroButton-" .. art
button:SetNormalTexture(prefix .. "-Up")
button:SetPushedTexture(prefix .. "-Down")
button:SetDisabledTexture(prefix .. "-Disabled")
end
button:SetHighlightTexture("Interface\\Buttons\\UI-MicroButton-Hilight")
button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
end
-- ref MicroButtonTooltipText (MainMenuBarMicroButtons.lua l.11-18), VERBATIM — the
-- reference's own two-argument form: a label and a binding ACTION name, returning the
-- label with the bound key in NORMAL_FONT_COLOR parentheses, or the bare label when the
-- action is unbound.
--
-- It lives here because this file is our `MainMenuBarMicroButtons.xml`, which is where the
-- reference declares it. It is not the micro bar's own helper: the stock `CharacterFrame`
-- tab row calls it at all five `<OnEnter>` sites
-- (`MicroButtonTooltipText(TEXT(CHARACTER_INFO), "TOGGLECHARACTER0")` and its four
-- siblings), so migrating that window onto the chain (decision 1751) needs this name to
-- answer or every character-tab hover raises. `SpellBookFrame.xml` already guards on it
-- with `if MicroButtonTooltipText then`; that guard is now always true.
--
-- Ours below is the BUTTON-shaped variant, kept because our own micro buttons carry the
-- action in `button.binding` and the label in `button.label`, and because it renders the
-- key through the Key Bindings window's display law. The reference's version prints
-- `GetBindingKey`'s raw token, which is what the real client shows, so the two are not
-- folded together: this one is the reference's text, the other is ours.
function MicroButtonTooltipText(text, action)
if GetBindingKey(action) then
return text .. " " .. NORMAL_FONT_COLOR_CODE .. "(" .. GetBindingKey(action) .. ")"
.. FONT_COLOR_CODE_CLOSE
else
return text
end
end
-- ref MicroButtonTooltipText (l.11-18), the LIVE contract since 0997: `button.binding`
-- is the binding ACTION name and the key resolves through GetBindingKey at hover time
-- (rendered through the Key Bindings window's display law when it is loaded — the
-- shipped order always has it; a lone-file harness shows the raw token).
function BenillaMicroButton_TooltipText(button)
if button.binding then
local key = GetBindingKey(button.binding)
if key then
if KeyBindings_KeyText then key = KeyBindings_KeyText(key) end
return button.label .. " " .. NORMAL_FONT_COLOR_CODE .. "(" .. key .. ")" .. FONT_COLOR_CODE_CLOSE
end
end
return button.label
end
-- ref MainMenuBarMicroButton's OnEnter (l.13), verbatim — the reference's helper reads
-- the firing frame off `this`, which is `button` here (the XML hands it `self`):
-- GameTooltip_AddNewbieTip(this.tooltipText, 1.0, 1.0, 1.0, this.newbieText);
function BenillaMicroButton_OnEnter(button)
if not GameTooltip_AddNewbieTip then return end -- engine tests load file subsets
GameTooltip_AddNewbieTip(BenillaMicroButton_TooltipText(button), 1.0, 1.0, 1.0, button.newbieText)
end
-- Is a panel up? `getglobal` because every panel this asks about loads AFTER this file —
-- the first UpdateMicroButtons can land before most of them exist.
function BenillaMicro_PanelShown(name)
local frame = getglobal(name)
return frame ~= nil and frame:IsVisible()
end
function BenillaMicroButton_SetPushed(button, isPushed)
if not button then return end
if isPushed then
button:SetButtonState("PUSHED")
else
button:SetButtonState("NORMAL")
end
end
-- ref UpdateMicroButtons (l.20-84): each button reflects whether its panel is up. The
-- MainMenu arm is the ref's own six-way test (l.47-58) minus the five frames that don't
-- exist here — GameMenuFrame is the one that does (decision 0674), and it is the one the
-- reference lists first. Its keyring arm (l.79-83) still has no frame here.
--
-- The Help arm is the ref's l.72-76, live since HelpFrame.xml landed. It calls BACK into
-- this function from its own OnShow and OnHide, which is the ref's wiring too and is why
-- the reference's HelpFrame is the one panel that does not need an event to stay in sync.
function UpdateMicroButtons()
BenillaMicroButton_SetPushed(CharacterMicroButton, BenillaMicro_PanelShown("CharacterFrame"))
BenillaMicroButton_SetPushed(SpellbookMicroButton, BenillaMicro_PanelShown("SpellBookFrame"))
BenillaMicroButton_SetPushed(TalentMicroButton, BenillaMicro_PanelShown("TalentFrame"))
BenillaMicroButton_SetPushed(QuestLogMicroButton, BenillaMicro_PanelShown("QuestLogFrame"))
-- ref l.60-64. Missing since the Socials button went live with decision 0668: the
-- window opened but its button never read pushed. Spotted while wiring Help, which is
-- the same arm one line down; fixed here rather than left for a bug report.
BenillaMicroButton_SetPushed(SocialsMicroButton, BenillaMicro_PanelShown("FriendsFrame"))
BenillaMicroButton_SetPushed(WorldMapMicroButton, BenillaMicro_PanelShown("WorldMapFrame"))
BenillaMicroButton_SetPushed(MainMenuMicroButton, BenillaMicro_PanelShown("GameMenuFrame"))
BenillaMicroButton_SetPushed(HelpMicroButton, BenillaMicro_PanelShown("HelpFrame"))
end
-- ref CharacterMicroButton_SetPushed/_SetNormal (l.109-117): the portrait shifts its window
-- and dims while the button is down. The window is a TexCoord crop of the SAME "player"
-- portrait bake the unit frame samples (SetPortraitTexture below) — the round stencil lives
-- in the bake's own UV space, so cropping it yields the ref's rectangular slice of a masked
-- face rather than an ellipse fitted to this 18x25 region.
function CharacterMicroButton_SetPushed()
MicroButtonPortrait:SetTexCoord(0.2666, 0.8666, 0, 0.8333)
MicroButtonPortrait:SetAlpha(0.5)
end
function CharacterMicroButton_SetNormal()
MicroButtonPortrait:SetTexCoord(0.2, 0.8, 0.0666, 0.9)
MicroButtonPortrait:SetAlpha(1.0)
end
-- ref CharacterMicroButton's OnUpdate (its XML l.55-61) drives the portrait off the live
-- button state — which is what makes the face dim mid-mouse-hold, a thing UpdateMicroButtons
-- can't see. Transcribed, but EDGE-TRIGGERED: the ref re-applies both setters every frame;
-- there is no reason to re-push identical TexCoord/alpha 60x a second.
function CharacterMicroButton_OnUpdate(button)
local pushed = (button:GetButtonState() == "PUSHED")
if pushed ~= button.portraitPushed then
button.portraitPushed = pushed
if pushed then
CharacterMicroButton_SetPushed()
else
CharacterMicroButton_SetNormal()
end
end
end
function CharacterMicroButton_OnLoad(button)
-- The player's face, from the same portrait slot the unit frame binds (portrait/mod.rs
-- keeps the "player" entry live across re-bakes, so unlike the ref there is no
-- UNIT_PORTRAIT_UPDATE re-bind to register — the binding is by token, not by handle).
SetPortraitTexture(MicroButtonPortrait, "player")
button:SetNormalTexture("Interface\\Buttons\\UI-MicroButtonCharacter-Up")
button:SetPushedTexture("Interface\\Buttons\\UI-MicroButtonCharacter-Down")
BenillaMicroButton_OnLoad(button, nil, CHARACTER_BUTTON, "TOGGLECHARACTER0", NEWBIE_TOOLTIP_CHARACTER)
end
-- ref UpdateTalentButton (l.133-141): no talents before level 10, so the button goes away
-- and the row closes up behind it. Guarded on BOTH frames: it re-anchors the quest-log
-- button, which is created after the talent one, so the very first call has to be able to
-- land before the row is fully built. (`UnitLevel` answers 0, never nil, for a unit the
-- client hasn't got yet — so pre-login the button is simply hidden until the level lands.)
function UpdateTalentButton()
if not TalentMicroButton or not QuestLogMicroButton then return end
if UnitLevel("player") < 10 then
TalentMicroButton:Hide()
QuestLogMicroButton:SetPoint("BOTTOMLEFT", "TalentMicroButton", "BOTTOMLEFT", 0, 0)
else
TalentMicroButton:Show()
QuestLogMicroButton:SetPoint("BOTTOMLEFT", "TalentMicroButton", "BOTTOMRIGHT", -3, 0)
end
end
function TalentMicroButton_OnLoad(button)
BenillaMicroButton_OnLoad(button, "Talents", TALENTS_BUTTON, "TOGGLETALENTS", NEWBIE_TOOLTIP_TALENTS)
button:RegisterEvent("UNIT_LEVEL")
button:RegisterEvent("PLAYER_ENTERING_WORLD")
end
function TalentMicroButton_OnEvent()
-- UNIT_LEVEL names its unit in arg1; every other unit's ding is none of our business.
if event == "UNIT_LEVEL" and arg1 ~= "player" then return end
UpdateTalentButton()
end
]]></Script>
<!-- ref MainMenuBarMicroButton (l.4-19): 29x58, with the top 18 inset out of the HIT rect — the
art only fills the lower ~40 px (verified against the 32x64 BLPs: the top ~22/64 rows are
fully transparent), and without the inset that empty header would eat hover and clicks
aimed at the bar's XP strip and the sliver of world above it. -->
<Button name="BenillaMicroButtonTemplate" virtual="true">
<Size><AbsDimension x="29" y="58"/></Size>
<HitRectInsets><AbsInset left="0" right="0" top="18" bottom="0"/></HitRectInsets>
<Scripts>
<OnEnter>BenillaMicroButton_OnEnter(self)</OnEnter>
<OnLeave>if GameTooltip then GameTooltip:Hide() end</OnLeave>
</Scripts>
</Button>
<!-- ref CharacterMicroButton (l.20-63): the row's head, at the art frame's BOTTOMLEFT +(552,2).
MicroButtonPortrait is the ref's own global name (not $parent-prefixed), 18x25 hung from the
button's TOP -28. -->
<Button name="CharacterMicroButton" inherits="BenillaMicroButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="MainMenuBarArtFrame" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="552" y="2"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture name="MicroButtonPortrait">
<Size><AbsDimension x="18" y="25"/></Size>
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="0" y="-28"/></Offset></Anchor></Anchors>
<TexCoords left="0.2" right="0.8" top="0.0666" bottom="0.9"/>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnLoad>CharacterMicroButton_OnLoad(self)</OnLoad>
<OnClick>ToggleCharacter("PaperDollFrame")</OnClick>
<OnUpdate>CharacterMicroButton_OnUpdate(self)</OnUpdate>
</Scripts>
</Button>
<Button name="SpellbookMicroButton" inherits="BenillaMicroButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="CharacterMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>BenillaMicroButton_OnLoad(self, "Spellbook", SPELLBOOK_ABILITIES_BUTTON, "TOGGLESPELLBOOK", NEWBIE_TOOLTIP_SPELLBOOK)</OnLoad>
<OnClick>ToggleSpellBook(BOOKTYPE_SPELL)</OnClick>
</Scripts>
</Button>
<Button name="TalentMicroButton" inherits="BenillaMicroButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="SpellbookMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>TalentMicroButton_OnLoad(self)</OnLoad>
<OnEvent>TalentMicroButton_OnEvent()</OnEvent>
<OnClick>ToggleTalentFrame()</OnClick>
</Scripts>
</Button>
<Button name="QuestLogMicroButton" inherits="BenillaMicroButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="TalentMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<!-- The row's initial talent-gate pass runs here, not in TalentMicroButton_OnLoad: this
is the button UpdateTalentButton re-anchors, so it must already exist. (The ref gets
its first pass from PLAYER_ENTERING_WORLD, which we register too — this just means
the row is right before any event lands, e.g. under the capture harness.) -->
<OnLoad>BenillaMicroButton_OnLoad(self, "Quest", QUESTLOG_BUTTON, "TOGGLEQUESTLOG", NEWBIE_TOOLTIP_QUESTLOG); UpdateTalentButton()</OnLoad>
<OnClick>ToggleQuestLog()</OnClick>
</Scripts>
</Button>
<Button name="SocialsMicroButton" inherits="BenillaMicroButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="QuestLogMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>BenillaMicroButton_OnLoad(self, "Socials", SOCIAL_BUTTON, "TOGGLESOCIAL", NEWBIE_TOOLTIP_SOCIAL)</OnLoad>
<OnClick>ToggleFriendsFrame(1)</OnClick>
</Scripts>
</Button>
<Button name="WorldMapMicroButton" inherits="BenillaMicroButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="SocialsMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>BenillaMicroButton_OnLoad(self, "World", WORLDMAP_BUTTON, "TOGGLEWORLDMAP", NEWBIE_TOOLTIP_WORLDMAP)</OnLoad>
<OnClick>ToggleWorldMap()</OnClick>
</Scripts>
</Button>
<!-- The game menu (decision 0674). `ToggleGameMenu(1)` is the ref's own OnClick — the argument
is what makes a CLICK a plain toggle (close it, or close everything and open it) instead of
the ESC key's one-eater-per-press ladder; UiPanels.xml has both halves. -->
<Button name="MainMenuMicroButton" inherits="BenillaMicroButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="WorldMapMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>BenillaMicroButton_OnLoad(self, "MainMenu", MAINMENU_BUTTON, nil, NEWBIE_TOOLTIP_MAINMENU)</OnLoad>
<OnClick>ToggleGameMenu(1)</OnClick>
</Scripts>
</Button>
<!-- The GM help window (HelpFrame.xml). `ToggleHelpFrame()` is the ref's own OnClick
(ref-MainMenuBarMicroButtons.xml l.217-219) and it is the whole wiring: the toggle takes
care of the panel manager, and the panel's own OnShow/OnHide call UpdateMicroButtons back
so this button's pushed state follows it however the window was opened or closed.
It takes no binding — 1.12 has no TOGGLEHELP action — so the tooltip shows the bare
label, which is the ref's behaviour for this one too. -->
<Button name="HelpMicroButton" inherits="BenillaMicroButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="MainMenuMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>BenillaMicroButton_OnLoad(self, "Help", HELP_BUTTON, nil, NEWBIE_TOOLTIP_HELP)</OnLoad>
<OnClick>ToggleHelpFrame()</OnClick>
</Scripts>
</Button>
</Ui>
+10 -4
View File
@@ -4055,11 +4055,17 @@
</OnHide>
</Scripts>
</Frame>
<!-- The reference names its options window UIOptionsFrame, and its FloatingChatFrame.lua reads
`UIOptionsFrame:IsShown()` every frame (FCF_OnUpdate's hover gate). Ours is OptionsFrame
until the options window migrates (1751); this alias is the adapter, and it retires with
the file. BENILLA_ALLOW_OWN_UI=1 -->
<!-- The reference has three options windows — OptionsFrame (video), SoundOptionsFrame and
UIOptionsFrame (interface) — and ours is the one window for all three (0985), kept ours by
the director's call. The stock files address the three by name: FloatingChatFrame.lua reads
`UIOptionsFrame:IsShown()` every frame (FCF_OnUpdate's hover gate), and the stock micro
row's UpdateMicroButtons reads all three UNGUARDED to decide whether the Main Menu button
is down (MainMenuBarMicroButtons.lua l.47-52; decision 1987). These two aliases are the
adapter: a stock read of either sibling is answered by this window, which is the honest
answer — while ours is up, the reference's question "is an options window open" is yes.
BENILLA_ALLOW_OWN_UI=1 -->
<Script><![CDATA[
UIOptionsFrame = OptionsFrame
SoundOptionsFrame = OptionsFrame
]]></Script>
</Ui>
+12 -7
View File
@@ -1480,8 +1480,8 @@
--
-- **benilla's own drag code does NOT use them**, and that is deliberate rather than an
-- oversight: our UI space is 1:1 logical px and `GetCursorPosition` already reports in
-- the same space `SetPoint` offsets are read in (the same identity `MouseIsOver`'s
-- comment below records for `GetEffectiveScale`). Multiplying here would place a dragged
-- the same space `SetPoint` offsets are read in at the UI root's scale of 1 (a frame
-- under a `SetScale` reads its own units — `MouseIsOver` below divides for that). Multiplying here would place a dragged
-- frame at 1.5x the cursor on a 1600-wide window. Kept for the addons, unused by us.
function GetScreenHeightScale()
return GetScreenHeight() / 768
@@ -1512,13 +1512,18 @@
-- hand back for it, and the reference's own comment on that guard ("a symptom not the real
-- issue") is about where the unresolved frame came from, not about the guard.
--
-- ONE DIVERGENCE, and it is a no-op: the reference divides the cursor by
-- `frame:GetEffectiveScale()` because 1.12 hands Lua screen pixels. benilla's UI space is
-- 1:1 logical px and `GetEffectiveScale` is the constant 1 (`script/pointer.rs`,
-- `object.rs` l.271), so the division is written out rather than left in to divide by one
-- forever. If a real UI scale ever lands, this is one of the sites that needs it back.
-- The cursor is divided by `frame:GetEffectiveScale()` exactly as the reference does
-- (UIParent.lua l.1389-1390): `GetLeft`/`GetRight`/`GetTop`/`GetBottom` answer in the
-- frame's OWN units (screen ÷ effective scale, `object/layout_methods.rs`), and a frame
-- CAN be scaled — the stock world map is (`SetupFullscreenScale`, 1981), and with the
-- division written out its player blip answered "not over" under the very cursor that
-- had just entered it, so the hover tooltip came up empty (decision 1985). An earlier
-- note here called the scale "the constant 1"; that was true of the UI root and of
-- nothing a `SetScale` has touched.
function MouseIsOver(frame, topOffset, bottomOffset, leftOffset, rightOffset)
local x, y = GetCursorPosition()
x = x / frame:GetEffectiveScale()
y = y / frame:GetEffectiveScale()
local left, right = frame:GetLeft(), frame:GetRight()
local top, bottom = frame:GetTop(), frame:GetBottom()
if not left then
+1 -1
View File
@@ -404,7 +404,7 @@
-- The reference keeps `CloseMenus` in `UIParent.lua:984`, not in its dropdown file, and
-- this file is already where our slice of UIParent.lua lives (`IsOptionFrameOpen` below,
-- `CloseAllWindows`, `ToggleGameMenu`). It sits at manifest line 144, ahead of all five
-- consumers — the stock dropdown at 155, MicroMenu, GameMenuFrame and two test kits.
-- consumers — the stock dropdown at 155, the stock micro row, GameMenuFrame and two test kits.
--
-- The BOUND is inlined rather than read from `UIDROPDOWNMENU_MAXLEVELS`, which lives in the
-- dropdown file: a kit that loads this file and not that one would otherwise fail on
+16 -12
View File
@@ -443,13 +443,17 @@ PetActionBarAdapters.xml
# reference's toc seats it right after PetActionBarFrame (l.73-74): the bonus buttons'
# Up/Down call PetActionButtonUp/Down, which PetActionBar.xml above defines.
Interface\FrameXML\BonusActionBarFrame.xml
# The micro-button row in the bar's right-hand recess (the ref's own file split, its TOC
# loads MainMenuBarMicroButtons right after MainMenuBar). After MainMenuBar.xml: it anchors
# into MainMenuBarArtFrame by name and seats itself above the art plate with its own
# BenillaActionBarArt_SeatAbove (moved here from ActionBar.xml with 1938). It must come BEFORE the panels it toggles, because each of
# their OnShow/OnHide calls the `UpdateMicroButtons` this file defines (the panel frames
# themselves are read back through getglobal, so the reverse dependency is call-time).
MicroMenu.xml
# The micro-button row in the bar's right-hand recess — the reference's own file (1751;
# decision 1987), which its toc seats right after MainMenuBar (l.34-35). After MainMenuBar.xml:
# the eight buttons carry parent="MainMenuBarArtFrame", resolved by name at load (1734), and a
# child is born one level above its parent (order.rs; SetParent 0x76ab65) — the seat our retired
# MicroMenu.xml set by hand. BEFORE the panels it toggles: each of their OnShow/OnHide calls the
# UpdateMicroButtons this file defines, and that function reads CharacterFrame, SpellBookFrame,
# QuestLogFrame, GameMenuFrame, OptionsFrame, SoundOptionsFrame, UIOptionsFrame, FriendsFrame,
# WorldMapFrame, HelpFrame and KeyRingButton UNGUARDED at call time, so every one of them is up
# before anything can be shown (SoundOptionsFrame and UIOptionsFrame are OptionsFrame.xml's two
# aliases — our one options window answers for the reference's three).
Interface\FrameXML\MainMenuBarMicroButtons.xml
# ══ THE BATTLEGROUND LIST + THE MINIMAP CLUSTER — the reference's own, off the player's chain
# (1751; decision 1974) ══
# BattlefieldFrame.xml + BattlefieldFrame.lua: the battlemaster's instance list (the "First
@@ -688,7 +692,7 @@ Interface\FrameXML\ContainerFrame.xml
# LOAD), TextStatusBar.xml (`SetTextStatusBarTextPrefix`, called at load too, plus the
# `TextStatusBar` the pet page's XP bar inherits), UnitFrames.xml (`PlayerFrameHealthBar` /
# `PlayerFrameManaBar`, named in that same OnLoad), MainMenuBar.xml (`MainMenuExpBar`, likewise),
# MicroMenu.xml (`MicroButtonTooltipText`, the five tab hovers) and UIParent.xml
# MainMenuBarMicroButtons.xml (`MicroButtonTooltipText`, the five tab hovers) and UIParent.xml
# (`Model_OnLoad`/`_RotateLeft`/`_RotateRight`/`_OnUpdate` — the model pane's turntable, the
# reference's own code living in our UIParent.lua counterpart).
#
@@ -817,7 +821,7 @@ Interface\FrameXML\DressUpFrame.xml
# the Spell/Pet toggle tabs. Its whole engine surface was already ours (1924's UpdateSpells, 1931's
# GetSpellTabInfo tuple); what stopped it in 1926 — MultiActionBar_ShowAllGrids/HideAllGrids on
# show/hide — has been the reference's MultiActionBars.lua since 1938. Loaded here, after the
# action-bar files it calls into and after MicroMenu (UpdateMicroButtons on show). The 'P' binding
# action-bar files it calls into and after MainMenuBarMicroButtons (UpdateMicroButtons on show). The 'P' binding
# calls its own ToggleSpellBook(BOOKTYPE_SPELL). 0216's transcription is gone.
Interface\FrameXML\SpellBookFrame.xml
# The one adapter the reference's file needs from this engine: its pet-page autocast shine is a
@@ -1029,7 +1033,7 @@ Interface\FrameXML\PetitionFrame.xml
# Stock QuestLogFrame.xml sources QuestLogFrame.lua; the 'L' binding calls its ToggleQuestLog(),
# and its reward hovers are GameTooltip:SetQuestLogItem / SetQuestLogRewardSpell (engine, 1944).
# After QuestFrame.xml (QuestItemTemplate, QuestMoneyFrame_OnLoad, QuestFrameItems_Update),
# MicroMenu.xml (QuestLog_OnShow calls UpdateMicroButtons unguarded) and the same kit as the
# MainMenuBarMicroButtons.xml (QuestLog_OnShow calls UpdateMicroButtons unguarded) and the same kit as the
# giver; the reference's toc has the pair at l.53-54.
Interface\FrameXML\QuestLogFrame.xml
# The timed-quest countdown window under the minimap (decision 1150): the reference's own
@@ -1093,7 +1097,7 @@ ScriptLogFrame.xml
# UIPanelTemplates (UIPanelCloseButton, UIPanelButtonTemplate2, GameMenuButtonTemplate and the
# real UIPanelScrollFrameTemplate the ticket editor is built on), ScrollTemplates (the faux kit
# behind the GM category list, its shared trough, and the ScrollingEdit pair), GameTooltip (the
# toast wears the tooltip backdrop colours at OnLoad) and MicroMenu (whose Help button calls this
# toast wears the tooltip backdrop colours at OnLoad) and MainMenuBarMicroButtons (whose Help button calls this
# file's ToggleHelpFrame, and whose UpdateMicroButtons this file calls back on show/hide — both
# resolved at call time, so that pair could sit either way round). The one seat that is a REAL
# requirement rather than legibility is **after BuffFrame.xml**: the toast re-anchors
@@ -1130,7 +1134,7 @@ Interface\FrameXML\TabardFrame.xml
Interface\FrameXML\TutorialFrame.xml
# The game menu (decision 0674): the frame ESC opens. LAST on purpose — it is the outermost
# layer of the shell, it depends on everything it touches being loaded (UiPanels' popup engine
# + panel manager, MicroMenu's UpdateMicroButtons, BagFrame's Disable_BagButtons), and nothing
# + panel manager, MainMenuBarMicroButtons' UpdateMicroButtons, BagFrame's Disable_BagButtons), and nothing
# depends on it: the ESC chain and the micro button both reach it by name at call time.
#
# NOT yet on the chain, and the reason is not in the gap report: the stock file's buttons reach
+8 -1
View File
@@ -48,7 +48,14 @@ impl Plugin for ProbeTaxiPlugin {
load_expectations.after(benilla_assets::AssetSet::Open),
)
.add_systems(Update, taxi_probe)
.add_systems(PreUpdate, hold_w_post_land.after(bevy::input::InputSystems));
.add_systems(
PreUpdate,
// After the loading cover's input swallow as well as winit's input pass — see
// `probes::act::ProbeKeyPlugin` for why a synthetic press orders past it.
hold_w_post_land
.after(bevy::input::InputSystems)
.after(crate::loading_screen::CoverInput),
);
}
}
+6 -1
View File
@@ -145,7 +145,12 @@ impl Plugin for ProbeKeyPlugin {
app.insert_resource(ProbeKeys { taps, armed: false })
.add_systems(
bevy::app::PreUpdate,
fire_probe_key.after(bevy::input::InputSystems),
fire_probe_key
.after(bevy::input::InputSystems)
// …and after the loading cover's input swallow, which runs in the same window
// and empties every button plane: an instrument driving the client is the
// operator, not the player, so its press has to land on the far side of it.
.after(crate::loading_screen::CoverInput),
);
}
}
+84 -3
View File
@@ -34,8 +34,18 @@ use bevy::prelude::*;
/// The held cursor payload's icon path (`Interface\Icons\…`, extensionless — the DBC/FrameXML
/// convention), any arm — `None` if nothing is held or that arm's icon hasn't resolved yet
/// (either way, the caller falls back to the mode cursor).
fn payload_icon(script: &benilla_ui::script::UiScript) -> Option<String> {
///
/// **`covered` — the loading cover drops the overlay** (decision 1990, VERIFIED). The reference's
/// world transition ends `0x401900 → 0x495920 → 0x6e4940` with `0x523d20(1)` + `0x523c20(1)`:
/// cursor index **1**, the plain arrow, with any item/spell overlay dropped, set *before* the
/// screen goes up. Without this a portal taken with an item on the cursor showed the item's icon
/// over the loading art for the whole load — the mode half of the same rule is
/// [`drive_displayed_cursor`]'s.
fn payload_icon(script: &benilla_ui::script::UiScript, covered: bool) -> Option<String> {
use benilla_ui::script::CursorPayload;
if covered {
return None;
}
match script.cursor_payload()? {
CursorPayload::Item(i) => i.texture,
CursorPayload::Spell(s) => s.texture,
@@ -193,6 +203,8 @@ fn drive_displayed_cursor(
// world, so re-entering the UI always restores once.
mut last: Local<crate::ui_script::VmMemo<Option<crate::target::WorldCursor>>>,
mut displayed: ResMut<DisplayedCursor>,
// The loading cover parks the mode at Point — see the arm at the top of the body.
screen: Res<crate::loading_screen::LoadingScreen>,
) {
use crate::target::{CursorKind, WorldCursor};
use benilla_ui::script::UiCursorMode;
@@ -204,6 +216,19 @@ fn drive_displayed_cursor(
// glue phase instead of every frame inside it.
let last = last.get_for(script.as_deref());
// **Under the loading cover the cursor is the plain arrow** (decision 1990, VERIFIED). The
// reference's transition parks cursor index **1** at `0x6e49f5`/`0x6e49ff` before raising the
// screen; ours is parked here every covered frame instead of once, which reads the same because
// the cover has already taken the input plane and nothing can write the mode under it. Ahead of
// every other arm — a FrameXML write, the repair/targeting base, the sticky UI mode — because
// the reference's park is likewise unconditional. `last` is cleared like the over-world arm, so
// the first crossing back into the UI after the reveal restores the base once.
if screen.covering() {
displayed.0 = WorldCursor::default();
*last = None;
return;
}
// **The BASE mode is not a constant** (`0xbe2c4c`, wow-re cursor-system.md §7: *"it is
// independently mutable — e.g. a spell-cancel flow parks it at Cast(2)"*), and that is the
// piece B208 kept missing. `ResetCursor` restores *the value of this cell*, not a hardcoded
@@ -372,6 +397,8 @@ mod other {
world_assets: Option<ResMut<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
mut payload_cursors: ResMut<PayloadCursorImages>,
// The loading cover drops the payload overlay — see [`payload_icon`].
screen: Res<crate::loading_screen::LoadingScreen>,
window: Option<Single<Entity, With<PrimaryWindow>>>,
// The key of the cursor we last handed the window — the macOS arm's `last_set` under the
// same name, because it is the same fact: OS state, not memory about the VM, so it is not a
@@ -382,7 +409,9 @@ mod other {
let Some(window) = window else {
return;
};
let held_icon = script.as_ref().and_then(|s| payload_icon(s));
let held_icon = script
.as_ref()
.and_then(|s| payload_icon(s, screen.covering()));
if let Some(icon) = held_icon {
if last_set.as_deref() == Some(icon.as_str()) {
return; // already showing this icon
@@ -521,6 +550,8 @@ mod macos {
script: Option<NonSend<benilla_ui::script::UiScript>>,
world_assets: Option<ResMut<WorldAssets>>,
mode: Res<super::DisplayedCursor>,
// The loading cover drops the payload overlay — see [`super::payload_icon`].
screen: Res<crate::loading_screen::LoadingScreen>,
rig: Res<CameraControl>,
cinematic: Option<Res<crate::cinematic::Cinematic>>,
mut focus: MessageReader<bevy::window::WindowFocused>,
@@ -552,7 +583,9 @@ mod macos {
*last_set = None;
}
}
let held_icon = script.as_ref().and_then(|s| payload_icon(s));
let held_icon = script
.as_ref()
.and_then(|s| payload_icon(s, screen.covering()));
if let Some(icon) = &held_icon {
if !payload_cursors.0.contains_key(icon) && !decode_failed.contains(icon) {
let built = world_assets.and_then(|mut a| {
@@ -704,11 +737,29 @@ mod tests {
over_ui: bool,
lua: &str,
armed: bool,
) -> WorldCursor {
frame_covered(standing, world, over_ui, lua, armed, false)
}
/// One frame, with `covered` deciding whether the loading cover is up — the arm that parks the
/// plain arrow ahead of every other rule.
fn frame_covered(
standing: WorldCursor,
world: WorldCursor,
over_ui: bool,
lua: &str,
armed: bool,
covered: bool,
) -> WorldCursor {
let mut app = App::new();
let script = benilla_ui::script::UiScript::new().unwrap();
script.run(lua).unwrap();
app.insert_non_send_resource(script);
app.insert_resource(if covered {
crate::loading_screen::LoadingScreen::test_covering()
} else {
crate::loading_screen::LoadingScreen::default()
});
app.insert_resource(world);
app.insert_resource(crate::ui_script::PointerOverUi(over_ui));
app.insert_resource(DisplayedCursor(standing));
@@ -724,6 +775,35 @@ mod tests {
app.world().resource::<DisplayedCursor>().0
}
/// **Under the loading cover the cursor is the plain arrow, whatever else is true** (decision
/// 1990). The reference's world transition parks cursor index 1 (`0x6e49f5`/`0x6e49ff`) and
/// drops any item/spell overlay *before* the screen goes up, so a portal taken with a spell
/// armed — or with an item on the cursor — arrives showing the arrow, not the payload.
///
/// Both of the arms that would otherwise win are exercised here: an armed spell (which parks
/// the BASE at Cast, so even the UI restore reads blue) and a FrameXML write in the same frame.
#[test]
fn the_loading_cover_parks_the_plain_arrow_over_every_other_rule() {
for (over_ui, lua, armed) in [
(false, "", false),
(true, "", true),
(true, "SetCursor(\"CAST_CURSOR\")", true),
(false, "", true),
] {
assert_eq!(
frame_covered(SWORD, SWORD, over_ui, lua, armed, true),
WorldCursor::default(),
"covered: the arrow wins (over_ui={over_ui}, armed={armed}, lua={lua:?})"
);
}
// …and the cover is the only reason: the same frame uncovered keeps the world's verdict.
assert_eq!(
frame_covered(SWORD, SWORD, false, "", false, false),
SWORD,
"no cover, no park"
);
}
/// **The base mode is Cast while a spell awaits its click** (wow-re cursor-system.md §7: the
/// base cell `0xbe2c4c` "is independently mutable — e.g. a spell-cancel flow parks it at
/// Cast(2)"), and `ResetCursor` restores *that value*, never a hardcoded Point.
@@ -819,6 +899,7 @@ mod tests {
);
script.run("ShowContainerSellCursor(0, 1)").unwrap();
app.insert_non_send_resource(script);
app.insert_resource(crate::loading_screen::LoadingScreen::default());
app.insert_resource(CAST_GREY);
app.insert_resource(crate::ui_script::PointerOverUi(true));
app.insert_resource(DisplayedCursor(CAST_GREY));
+16 -5
View File
@@ -670,6 +670,19 @@ type WireBody = (
Option<&'static benilla_world::world_unit::WorldUnit>,
);
/// One viewer-reconcile row: the entity, whether it is the embodied self, whether it is marked.
type ViewerRow = (
Entity,
Has<crate::net::Embodied>,
Has<benilla_world::world_unit::ViewerUnit>,
);
/// Only entities that can be a viewer at all — one of the two markers present.
type ViewerCandidate = Or<(
With<crate::net::Embodied>,
With<benilla_world::world_unit::ViewerUnit>,
)>;
/// The edges on which a body's [`WorldUnit`](benilla_world::world_unit::WorldUnit) restatement can
/// move — [`publish_world_units`]' query filter (the anchor's removal is read separately).
type WireBodyMoved = Or<(
@@ -696,11 +709,9 @@ fn publish_world_units(
bodies: Query<WireBody, WireBodyMoved>,
all_bodies: Query<WireBody>,
mut unanchored: RemovedComponents<crate::transport::TransportAnchor>,
viewers: Query<(
Entity,
Has<crate::net::Embodied>,
Has<benilla_world::world_unit::ViewerUnit>,
)>,
// Only entities that can be a viewer at all: the reconcile below needs one of the two
// present, and an unfiltered query walked every entity in the world each frame.
viewers: Query<ViewerRow, ViewerCandidate>,
) {
let freed: Vec<Entity> = unanchored.read().collect();
let due = bodies
+22 -4
View File
@@ -11,9 +11,16 @@
//! The two cases out of current scope (taxi/boat/zeppelin's moving flight-path icon; a richer
//! progress model) slot in as an extra overlay layer / a different progress source without restructure.
//!
//! **The lifecycle is event-raised, readiness-cleared (decision 0737).** The reference *blocks* on
//! its world load, so its screen covers from the entry edge until the world is loaded by
//! construction; an async-streaming client has to build that observable explicitly. The screen
//! **The lifecycle is event-raised, readiness-cleared (decision 0737)** — and the reference's is
//! too, which 0737 assumed it was not. This header claimed for months that the reference *blocks*
//! on its world load and so covers "by construction"; the wow-re round behind decision 1990 says
//! half of that is wrong. The reference runs ordinary frames with **one** blocking stretch inside
//! them (`SMSG_NEW_WORLD`'s `0x401b00` defers `0x401bc0` onto the deadline heap, which drains at
//! `0x420d0c` and runs to completion in that one iteration, `Sleep(1)` residency spins and all) —
//! but the screen is raised *frames earlier*, at `TRANSFER_PENDING`, and dismissed *frames later*
//! by a per-frame readiness poll. Which is this file's shape. What an async-streaming client has to
//! build explicitly is not the lifecycle after all: it is the **input suppression** the blocking
//! stretch does not provide either (see [`input`], decision 1990). The screen
//! rises at the explicit edges — the character pick's `Connected` edge (before the glue tears
//! down), `SMSG_TRANSFER_PENDING` (the portal walk-in), the worldport snap — all observed *here*,
//! from the messages/resources the net bridge already publishes; a residency backstop catches any
@@ -37,6 +44,12 @@ use benilla_world::schedule::WorldStage;
use benilla_world::terrain_stream::WorldLoadProgress;
use benilla_world::world_map::CurrentMap;
/// **The cover takes the input plane** — the loading screen's input half, in its own file because
/// it is a different mechanism (a source cut in `PreUpdate`) from the state machine below, and one
/// file should not have to explain both.
mod input;
pub(crate) use input::CoverInput;
// Bar layout — VERIFIED THREE ways (build 5875): the `WoW.exe` `LoadingScreen.cpp` bar descriptor
// table (@0x7ffd34, `FUN_00407150`) gives entry = {cx, cy, halfW, halfH} with rect = [cx ± halfW·0.5]
// × [cy ± halfH·0.5] and fill right edge = left + progress·halfW; Border {0.5,0.075,0.600,0.050}, Fill
@@ -211,7 +224,8 @@ impl LoadingScreen {
self.active
}
/// An active cover, for tests that drive covered-frame accounting (the entry-load deferral).
/// An active cover, for tests that drive covered-frame accounting (the entry-load deferral)
/// and the cursor's covered arm.
#[cfg(test)]
pub(crate) fn test_covering() -> Self {
Self {
@@ -259,6 +273,10 @@ impl Plugin for LoadingScreenPlugin {
// we cover it here. Visibility set now propagates in PostUpdate and renders this frame, so
// the swap never flashes.
.add_systems(Update, drive_loading_screen.in_set(WorldStage::Present));
// …and the other half of what a cover *is*: while it is up, the client takes no input
// (`input`). Wired here rather than as a plugin of its own so the cover and its input rule
// can never be registered apart.
input::build(app);
}
}
@@ -0,0 +1,440 @@
//! **The cover takes the input plane.** While the loading screen covers the frame, benilla accepts
//! no mouse and no keyboard: no hover, no cursor classification, no click, no keybinding, no
//! camera, no movement. One system, at the source, so *nothing under the cover has to know the
//! cover exists*.
//!
//! ## The reference does this too, and it does it at the source
//!
//! This file was written on the assumption that the reference simply blocks inside its world load,
//! so the question never arises there. The wow-re round behind decision 1990 says otherwise, and
//! the answer is better than the assumption: **the reference has an explicit input veto, registered
//! by the screen's own raise.**
//!
//! `0x406800` (the raise) ends `0x4068e0 call 0x4069e0`, which registers the three-byte stub
//! `0x406a50` — `xor eax,eax; ret` — on event-bus categories **1, 8 (key down), 0xa (auto-repeat),
//! 0xb (button down) and 0xc (mouse move)** at priority **8.0f**; `0x407e80` (the dismiss)
//! unregisters all five. The dispatcher `0x4245b0` stops its walk on a zero return (`0x4246ad`),
//! and every `CSimpleTop` input handler sits at 1.0f — so those five categories never reach the
//! interface at all while the screen is up. Category 0xc's registrant is `UpdateMouseFocus`
//! (`0x7660d0`) itself, so the veto stops the pointer resolver outright: **the cursor freezes.**
//! Key-up, button-up, the wheel, category 0x10 and `WM_ACTIVATE` are deliberately let through.
//!
//! So the shape below is the reference's shape — one veto at the source, not a condition on each
//! consumer — arrived at independently and then confirmed. Two deliberate differences, named
//! rather than drifted into:
//!
//! - **We swallow the wheel and the up edges too.** The up edges are equivalent by outcome: the
//! reference's held key comes back **released** anyway (`0x4908c0 → 0x49093c → 0x5144c0` zeroes
//! the movement mask on world-enter, and a still-held key cannot re-arm it — it reclassifies to
//! the vetoed auto-repeat category), which is exactly what the release edge below produces. The
//! wheel is a real deviation: in the reference a notch under the loading screen still zooms the
//! camera you cannot see. Swallowing it is the director's "nothing through the screen", and it
//! costs a zoom nobody asked for.
//! - **We clear the world pick; the reference leaves it running at the frozen point.** `0x481790`
//! hangs off the layer walk rather than the bus, so it keeps picking — and in the two windows
//! where a world is populated (before `SMSG_NEW_WORLD`, and again once the new player object
//! exists) it *can* set a context cursor under the screen. It is invisible there only because the
//! pointer cannot move. Ours is strictly quieter and observably identical.
//!
//! ## Why this is a source cut and not a run condition
//!
//! The obvious way to build it is a run condition per input system. That is the way it regresses:
//! the census behind this module counted **fifty-one** places raw input enters the app, more than
//! half of them with no state gate of any kind — the whole `target::TargetUpdate` pick chain
//! included, which is the one the director caught. Every one of them would have had to remember,
//! and every future one would have to remember too. The director's report was exactly a thing
//! nobody remembered: the world pick ran under the cover, found a unit, and the *hardware cursor
//! changed to the sword* over art the player was only waiting on.
//!
//! So the cut is at the source, in `PreUpdate`, after `bevy::input::InputSystems` has built this
//! frame's input and before anything reads it. A consumer written a year from now is covered
//! without knowing this file exists — which is the only property that survives a multi-year
//! codebase.
//!
//! ## The four things it takes, and what makes the pointer half work
//!
//! 1. **The button planes** — `ButtonInput<KeyCode>`/`<Key>`/`<MouseButton>`. See [`swallow`] for
//! the exact rule: a press that arrives under the cover never happened, and anything held from
//! *before* the cover is released once, with a real release edge, so a gesture already in
//! flight (a mouselook session, a held `MOVEFORWARD` binding, an armed UI drag) unwinds through
//! the path it already has for a released button instead of being stranded down forever.
//! 2. **The raw message queues** — `KeyboardInput`, `MouseButtonInput`, `MouseMotion`,
//! `MouseWheel`, `CursorMoved` — drained, because a `MessageReader` reads the queue, not the
//! button state (`feed_ui_input`'s character/EditBox feed and the glue screens all read raw).
//! 3. **The accumulators** — `AccumulatedMouseMotion`/`AccumulatedMouseScroll`, which
//! `InputSystems` builds from those same messages and which the camera and the wheel bindings
//! poll rather than read.
//! 4. **The pointer position itself**, which is the half a message drain cannot reach: a hit-test
//! does not read an event, it reads `Window::cursor_position()`, a *field*. So the cover blanks
//! it and restores it on the way out ([`CoveredPointer`]).
//!
//! That fourth one is the load-bearing trick, and it is not a hack: **"the pointer is not in the
//! window" is a state every pointer consumer in the client already implements correctly**, because
//! it is the ordinary alt-tabbed-away case. `update_hover`/`update_hovered_object` clear
//! [`crate::target::Hovered`] and return; `update_pick_occlusion` leaves the ray at infinity;
//! `classify_cursor` then reads an empty pick and settles on Point; `feed_ui_input` takes its
//! `pointer_left_window()` arm, which leaves the hovered frame (one `OnLeave`) and disarms any
//! press/drag; bevy's own `ui_focus_system` clears every `Interaction`, so `glue_clicks` goes
//! quiet with it. Not one of those had to be told about loading screens.
//!
//! ## What it deliberately does not take
//!
//! **Synthetic input.** The capture probes press into the same `ButtonInput` resources — but they
//! do it in `Update`, after this system, and the pointer probes drive
//! `ui_script::SyntheticPointer` rather than the window. An instrument driving the client is the
//! operator, not the player. The two probe writers that *do* run in `PreUpdate` order themselves
//! [`after`](CoverInput) this set so the relationship is written down rather than left ambiguous.
//!
//! **The cursor's own art.** Parking it at the plain arrow and dropping any item/spell overlay is
//! the reference's `0x6e4940` (cursor index 1, set *before* the screen goes up), and it lives with
//! the cursor in [`crate::cursor`] rather than here — it is an output, not a channel.
//!
//! **Nothing else.** There is no dev exemption: the debug panel, the perf pill, the inspector and
//! their chords go quiet under the cover along with everything else. That is a real cost — a stuck
//! load is exactly when you want the instruments — and it is deliberate, because the alternative
//! is a keycode allowlist, and a keycode handed to the dev plane is also a keycode handed to the
//! keybinding table. The diagnosis path for a stuck load is the wait instrument
//! ([`super::WAIT_LOG_AFTER`]), which names the blocking term without anyone touching a key.
use bevy::input::keyboard::{Key, KeyboardInput};
use bevy::input::mouse::{
AccumulatedMouseMotion, AccumulatedMouseScroll, MouseButtonInput, MouseMotion, MouseWheel,
};
use bevy::math::DVec2;
use bevy::prelude::*;
use bevy::window::{CursorMoved, PrimaryWindow};
use super::LoadingScreen;
/// The set [`swallow_input_under_the_cover`] runs in — `PreUpdate`, after
/// `bevy::input::InputSystems` and before `bevy::ui::UiSystems::Focus`.
///
/// Exported so the handful of `PreUpdate` systems that legitimately write input — the capture
/// probes — can order themselves after it instead of racing it.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct CoverInput;
/// Where the OS pointer really is while the cover has the window's cursor position blanked.
///
/// Re-read every covered frame, not stashed once: `bevy_winit` keeps writing the window's position
/// on every physical move regardless of whether anyone reads the `CursorMoved` message we drain,
/// so this tracks the pointer through the whole load and hands back the *current* place on the way
/// out. Without the hand-back the position would stay `None` until the player's next mouse move —
/// hover would come back dead after a teleport, which is the same defect in the other direction.
#[derive(Resource, Default)]
pub(crate) struct CoveredPointer {
/// The last position seen while covered, in physical pixels.
stashed: Option<DVec2>,
/// Is the window's cursor position currently ours (blanked) rather than winit's?
blanked: bool,
}
/// One frame's swallow of one button plane.
///
/// The whole rule, and it needs no memory of its own:
///
/// - **A press that arrives under the cover never happened** — `just_pressed` is exactly this
/// frame's arrivals, and [`ButtonInput::reset`] takes each out of all three sets.
/// - **Whatever is still held came from before the cover, and is released once, with an edge** —
/// after the step above, `pressed` can only contain buttons held on an earlier frame, so
/// [`ButtonInput::release_all`] moves precisely those into `just_released`. On every *later*
/// covered frame `pressed` is already empty and this is a no-op, so the release edge is
/// delivered exactly once per cover, with no flag to keep.
///
/// A real OS release arriving mid-cover is likewise silent: bevy's own release only records an
/// edge for a button it still has pressed, and we took it out on the first covered frame.
fn swallow<T>(input: &mut ButtonInput<T>)
where
T: Copy + Eq + std::hash::Hash + Send + Sync + 'static,
{
let arrived: Vec<T> = input.get_just_pressed().copied().collect();
for button in arrived {
input.reset(button);
}
input.release_all();
}
/// `swallow` for a plane whose button type is not `Copy` (`ButtonInput<Key>`'s logical keys carry
/// a `SmolStr`).
fn swallow_cloned<T>(input: &mut ButtonInput<T>)
where
T: Clone + Eq + std::hash::Hash + Send + Sync + 'static,
{
let arrived: Vec<T> = input.get_just_pressed().cloned().collect();
for button in arrived {
input.reset(button);
}
input.release_all();
}
/// The raw input-message queues, as one [`bevy::ecs::system::SystemParam`] — bevy's 16-element
/// system-param ceiling, the same squeeze `drive_loading_screen` next door already pays.
#[derive(bevy::ecs::system::SystemParam)]
struct RawInput<'w> {
keyboard: ResMut<'w, Messages<KeyboardInput>>,
buttons: ResMut<'w, Messages<MouseButtonInput>>,
motion: ResMut<'w, Messages<MouseMotion>>,
wheel: ResMut<'w, Messages<MouseWheel>>,
moved: ResMut<'w, Messages<CursorMoved>>,
}
impl RawInput<'_> {
fn drain(&mut self) {
self.keyboard.clear();
self.buttons.clear();
self.motion.clear();
self.wheel.clear();
self.moved.clear();
}
}
/// The button planes + the accumulators, as one param (see [`RawInput`] on the ceiling).
#[derive(bevy::ecs::system::SystemParam)]
struct Buttons<'w> {
keys: ResMut<'w, ButtonInput<KeyCode>>,
logical: ResMut<'w, ButtonInput<Key>>,
mouse: ResMut<'w, ButtonInput<MouseButton>>,
motion: ResMut<'w, AccumulatedMouseMotion>,
scroll: ResMut<'w, AccumulatedMouseScroll>,
}
impl Buttons<'_> {
fn swallow_all(&mut self) {
swallow(&mut *self.keys);
swallow_cloned(&mut *self.logical);
swallow(&mut *self.mouse);
*self.motion = AccumulatedMouseMotion::default();
*self.scroll = AccumulatedMouseScroll::default();
}
}
/// `PreUpdate`, in [`CoverInput`]: take the whole input plane for as long as the cover is up.
///
/// **The one-frame edge, stated so nobody has to rediscover it.** The cover is raised in `Update`
/// (`drive_loading_screen`, in `WorldStage::Present`), and this runs in `PreUpdate`, so the raise
/// frame itself still takes input — its `Input` stage ran before the raise did. That is the
/// correct boundary rather than a miss: the raise frame's *render* is the first one that draws the
/// cover, so input dies on exactly the frames whose previous present showed it, and the player
/// never sees the cover on a frame that also acted on their mouse.
fn swallow_input_under_the_cover(
screen: Res<LoadingScreen>,
mut pointer: ResMut<CoveredPointer>,
mut window: Query<&mut Window, With<PrimaryWindow>>,
mut buttons: Buttons,
mut raw: RawInput,
) {
if !screen.covering() {
// The way out: hand the window its pointer back, once, on the frame the cover drops.
if pointer.blanked {
pointer.blanked = false;
let stashed = pointer.stashed.take();
if let Ok(mut window) = window.single_mut() {
window.set_physical_cursor_position(stashed);
}
}
return;
}
buttons.swallow_all();
raw.drain();
// The pointer half. Re-stash before blanking so the position tracks the real cursor through
// the load (see [`CoveredPointer`]); `physical_cursor_position()` already answers `None` for a
// pointer outside the window, which is the same nothing we are about to write.
if let Ok(mut window) = window.single_mut() {
if let Some(seen) = window.physical_cursor_position() {
pointer.stashed = Some(seen.as_dvec2());
}
if window.physical_cursor_position().is_some() {
window.set_physical_cursor_position(None);
}
pointer.blanked = true;
}
}
/// Wire the gate. Called by [`super::LoadingScreenPlugin`] — this is not a plugin of its own,
/// because the cover and its input rule are one system and must never be registerable apart.
pub(super) fn build(app: &mut App) {
app.init_resource::<CoveredPointer>().add_systems(
PreUpdate,
swallow_input_under_the_cover
.in_set(CoverInput)
// After the input is built, before anything reads it. `UiSystems::Focus` is bevy's own
// `PreUpdate` reader (it hit-tests the cursor into `Interaction`, which is what
// `glue::glue_clicks` rides), so it has to land on the other side of us.
.after(bevy::input::InputSystems)
.before(bevy::ui::UiSystems::Focus),
);
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::input::ButtonState;
/// The [`swallow`] rule, both halves, in the order they matter: a press that arrives under the
/// cover is erased, and a button held from before it is released **once**, with a real edge.
#[test]
fn a_press_under_the_cover_never_happened_and_a_held_one_is_released_once() {
let mut keys = ButtonInput::<KeyCode>::default();
// Held from before the cover: pressed on an earlier frame, so its `just_pressed` edge is
// gone by now (bevy clears the edge sets at the head of every frame).
keys.press(KeyCode::KeyW);
keys.clear();
// …and this frame, under the cover, the player also presses SPACE.
keys.press(KeyCode::Space);
swallow(&mut keys);
assert!(!keys.pressed(KeyCode::Space), "the arrival is erased");
assert!(!keys.just_pressed(KeyCode::Space), "…edge and all");
assert!(
!keys.just_released(KeyCode::Space),
"a press that never happened cannot release either"
);
assert!(!keys.pressed(KeyCode::KeyW), "the held key is let go");
assert!(
keys.just_released(KeyCode::KeyW),
"…with a real release edge, so a held MOVEFORWARD binding unwinds"
);
// The next covered frame: bevy clears the edge sets, and there is nothing left to release.
keys.clear();
swallow(&mut keys);
assert_eq!(
keys.get_just_released().count(),
0,
"the release is delivered exactly once per cover, not every frame"
);
assert_eq!(keys.get_pressed().count(), 0);
}
/// A real OS release arriving mid-cover produces no second edge — bevy's own `release` only
/// records one for a button it still holds pressed, and the first covered frame took it out.
#[test]
fn an_os_release_under_the_cover_is_silent() {
let mut keys = ButtonInput::<KeyCode>::default();
keys.press(KeyCode::KeyW);
keys.clear();
swallow(&mut keys); // frame 1: W released, with its edge
keys.clear(); // frame 2's head, as bevy's own input systems do it
keys.release(KeyCode::KeyW); // the player physically lets go
swallow(&mut keys);
assert!(
!keys.just_released(KeyCode::KeyW),
"no second release edge for the same key"
);
}
/// The gate against a real `App` and bevy's own `InputSystems`: with the cover up, a frame of
/// input reaches nobody — button planes, raw queues and the window's cursor position all read
/// empty — and the pointer comes back on the frame the cover drops, with no mouse move needed.
#[test]
fn a_covered_frame_hands_no_input_to_anyone_and_gives_the_pointer_back() {
let mut app = App::new();
app.add_plugins((
bevy::input::InputPlugin,
// For `Messages<CursorMoved>` — a window message, not an input one — and for the
// window types themselves. `primary_window: None`: the test spawns its own.
bevy::window::WindowPlugin {
primary_window: None,
exit_condition: bevy::window::ExitCondition::DontExit,
..default()
},
))
.init_resource::<LoadingScreen>()
.init_resource::<CoveredPointer>()
// `UiSystems::Focus` is bevy's, and pulling `UiPlugin` into a unit test to state an
// ordering the real wiring already states would test bevy, not us.
.add_systems(
PreUpdate,
swallow_input_under_the_cover
.in_set(CoverInput)
.after(bevy::input::InputSystems),
);
let window = app
.world_mut()
.spawn((
Window {
resolution: bevy::window::WindowResolution::new(800, 600),
..default()
},
PrimaryWindow,
))
.id();
let seen = |app: &App| {
app.world()
.entity(window)
.get::<Window>()
.unwrap()
.physical_cursor_position()
};
let put_cursor = |app: &mut App, at: Option<DVec2>| {
app.world_mut()
.entity_mut(window)
.get_mut::<Window>()
.unwrap()
.set_physical_cursor_position(at);
};
// A pointer in the middle of the window, and a key held from before the cover.
put_cursor(&mut app, Some(DVec2::new(400.0, 300.0)));
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyW);
// --- Uncovered: an ordinary frame. Nothing is touched.
app.update();
assert!(seen(&app).is_some(), "no cover, no blanking");
assert!(
app.world()
.resource::<ButtonInput<KeyCode>>()
.pressed(KeyCode::KeyW),
"no cover, no swallow"
);
// --- The cover goes up, and the player presses SPACE under it.
app.world_mut().resource_mut::<LoadingScreen>().active = true;
app.world_mut().write_message(KeyboardInput {
key_code: KeyCode::Space,
logical_key: Key::Space,
state: ButtonState::Pressed,
text: None,
repeat: false,
window,
});
app.update();
assert!(
seen(&app).is_none(),
"the pointer is not in the window while the cover is up — the one fact every \
hit-test in the client already handles"
);
let keys = app.world().resource::<ButtonInput<KeyCode>>();
assert_eq!(keys.get_pressed().count(), 0, "no key survives the cover");
assert!(
keys.just_released(KeyCode::KeyW),
"…and the one held across the raise is released with an edge"
);
assert!(
!keys.pressed(KeyCode::Space) && !keys.just_pressed(KeyCode::Space),
"the press that arrived under the cover never happened"
);
assert!(
app.world().resource::<Messages<KeyboardInput>>().is_empty(),
"the raw queue is drained too, so a MessageReader sees nothing either"
);
// --- The cover clears: the pointer comes straight back.
app.world_mut().resource_mut::<LoadingScreen>().active = false;
app.update();
assert_eq!(
seen(&app),
Some(Vec2::new(400.0, 300.0)),
"the position is handed back on the frame the cover drops"
);
}
}
+10 -7
View File
@@ -1234,12 +1234,13 @@ pub(super) fn apply_net_updates(
}
SessionEvent::RaidTargetSet { icon, guid } => group.apply_raid_target(icon, guid),
SessionEvent::RaidTargetList { entries } => group.apply_raid_target_list(&entries),
// The ready check came back with the Raid tab (decision 1549): the open form bumps
// the ticket the feed turns into a `READY_CHECK` edge. The ANSWER form is still
// ignored — the reference has no per-member answer surface in 1.12 (the raid pane
// shows no ready column; only later clients do), so there is nothing to show and
// storing it would be state with no reader.
SessionEvent::ReadyCheckRequest => group.apply_ready_check(),
// The ready check (decision 1549, completed by 1989): the open form takes the
// leader arm or the popup arm by our guid; the ANSWER form — forwarded to the leader
// alone — is logged for the engine's flags, which the timeout tick sums up after 30 s
// (1.12 has no per-member answer surface, only the AFK summary line).
SessionEvent::ReadyCheckRequest => {
group::ready_check_request(&mut group, &mut chat_log, &self_guid)
}
SessionEvent::RaidInstanceInfo { entries } => group.apply_raid_instance_info(entries),
// A group member pinged (decision 1596). The wire carries raw world floats and the
// relay is stateless in the reference too — we seat them as the pin and the minimap
@@ -1249,7 +1250,9 @@ pub(super) fn apply_net_updates(
SessionEvent::MinimapPing { guid, x, y } => {
ping.seat((x, y), guid);
}
SessionEvent::ReadyCheckAnswer { .. } => {}
SessionEvent::ReadyCheckAnswer { guid, ready } => {
group.apply_ready_check_answer(guid, ready != 0)
}
// ── The duel family (decision 0633): the session mirror + the two DisplayError
// lines the handlers emit inline; the Era events fire off the mirror's edges in
// `ui_duel::feed_duel`, and the countdown ticks in its own system ──
+12
View File
@@ -26,6 +26,18 @@ fn push_group_lines(chat_log: &mut ChatLog, lines: Vec<String>) {
}
}
/// `MSG_RAID_READY_CHECK`, the open form (decision 1989): our own echo as leader takes the
/// response-collection arm and prints nothing; as anyone else we print the leader's line and take
/// the popup ticket. The leader test is the reference's guid compare (`0x4ba3a0`).
pub(super) fn ready_check_request(
group: &mut GroupState,
chat_log: &mut ChatLog,
self_guid: &SelfGuid,
) {
let we_lead = self_guid.0 == Some(group.leader);
push_group_lines(chat_log, group.apply_ready_check_request(we_lead));
}
/// `SMSG_GROUP_INVITE` — someone asked us into their group.
pub(super) fn invited(group: &mut GroupState, chat_log: &mut ChatLog, inviter: &str) {
push_group_lines(chat_log, group.apply_invited(inviter));
@@ -98,6 +98,10 @@ pub(super) fn control(
// The spyglass scope ([`scoped_view`]): while held, the rig is pinned to first person and
// the wheel cannot leave it.
Res<scoped_view::ScopedView>,
// Is the loading cover up? The cover takes the whole input plane at the source
// (`loading_screen::input`), so nothing new arrives here — but a mouse gesture already in
// flight is retained state, and only its owner can unwind it. See the cancel below.
Res<crate::loading_screen::LoadingScreen>,
),
mut commands: Commands,
mut player: ResMut<Player>,
@@ -156,6 +160,7 @@ pub(super) fn control(
let view_subject = &speed_capsule.8;
let self_guid = speed_capsule.9 .0;
let scoped = &speed_capsule.10;
let covered = speed_capsule.11.covering();
// The auto-follow knobs (decisions 1493/1502), with far sight's one exception folded in here so
// both camera seats below agree: while the rig orbits somebody ELSE's body (Mind Vision, Sentry
// Totem), our own facing is not what "behind" means, so the return is forced off rather than
@@ -264,6 +269,20 @@ pub(super) fn control(
drunk::wobble(time.elapsed().as_millis() as u32, f)
}
};
// **A cover cancels a gesture; it never completes one.** The cover emptied the button planes
// in `PreUpdate`, so the look session below ends on its own this frame — and ending it also
// *settles* the click test the press armed, against `PressGesture::is_click`'s free window. A
// right-press 0.1 s before a portal would therefore have dispatched a `WorldRightClick` under
// the loading screen, acting on the pick latched in the world we just left. Dropping both
// tests first leaves the session to end with nothing to fire. This is the one place the cover
// has to be named by hand: taking the input away cannot rewind a state machine that is already
// mid-gesture, and this system owns the only such state the world side has (the UI's own armed
// press/drag is unwound by `UiScript::pointer_left_window`, which the blanked cursor already
// triggers).
if covered {
*left_click = None;
*right_click = None;
}
run_look_session(
&buttons,
mouse_motion,
+2 -1
View File
@@ -633,7 +633,8 @@ fn notice_event(
/// **YOU_JOINED lands before, YOU_LEFT lands after.** The notice's arg4/arg7/arg8/arg9 are read off
/// the client's channel record ([`ChannelState::stamp_channel`] is our leg of that), so a record
/// torn down before the line is composed costs it the slot number — and, since the color resolves
/// through `ChatTypeInfo["CHANNEL"..arg8]` ([`super::event::resolved_color`]), its color with it.
/// through `ChatTypeInfo["CHANNEL"..arg8]` — the stock window's own Lua since 1948 — its color
/// with it.
/// The reference's YOU_LEFT arm only *flags* the teardown (`0x49c115 mov dword [ebp-0xc],1`); the
/// event fires first (`0x49c5b0 call 0x49a870`) and only then does `0x49c5b5 test` /
/// `0x49c5c2 call 0x49bbd0` destroy the record — VERIFIED by disassembly at those addresses (1275).
+2 -1
View File
@@ -6,7 +6,8 @@
//! or monster lines), the `[Language]` header, the `[N. Name]` channel prefix with its " - Zone"
//! tail stripped (the SPEECH branch only — a notice prints arg4 whole, 1275), and the
//! `CHAT_<X>_NOTICE` channel-notice strings. Formats are QUOTED from the extracted GlobalStrings
//! (0288's pin, §2/§4); colors come from [`super::event::resolved_color`].
//! (0288's pin, §2/§4); colors come from the stock window's `ChatTypeInfo` table (1948), whose
//! shipped defaults are transcribed in [`super::event::default_color`].
use bevy::prelude::*;
@@ -39,7 +39,8 @@ fn social_body(key: &str, args: &str) -> ParsedChat {
/// resource or a network channel.
#[derive(Debug, Clone, PartialEq)]
pub(in crate::ui_chat) enum ParsedChat {
/// `/r [text]` — reply to the last received tell ([`crate::ui_chat::edit::ChatEditState::last_tell`]).
/// `/r [text]` — reply to the last received tell (the tell ring is the stock `ChatEdit_*`
/// Lua's since 1948 — see [`crate::ui_chat::edit`]).
Reply { text: String },
/// `/join <name> [password]` (aliases /channel /chan — SLASH_JOIN).
Join { name: String, password: String },
+41 -6
View File
@@ -56,6 +56,10 @@ pub(super) struct FedParty {
saved_answers: u32,
/// The ready-check ticket last seen ([`GroupState::ready_check`]).
ready_check: u32,
/// The request generation last seen ([`GroupState::ready_check_requests`]) and how far into
/// that request's answer log the engine has been fed (decision 1989).
ready_check_requests: u32,
answers_forwarded: usize,
/// The raid-target icon board last pushed — what `RAID_TARGET_UPDATE` fires on. Eight guids,
/// so a plain copy rather than the `Vec` diffs above.
raid_targets: [u64; 8],
@@ -124,11 +128,21 @@ pub(super) fn feed_party(
// harness has none, and a lockout then shows its map id, never a blank row.
map_catalog: Option<Res<benilla_assets::MapCatalogRes>>,
mut fed: Local<crate::ui_script::VmMemo<FedParty>>,
mut chat: ResMut<crate::ui_chat::ChatLog>,
) {
let Some(mut script) = script else {
return;
};
let (fed, vm_reset) = fed.get_reset(&script);
// The ready-check summary the timeout tick composed (decision 1989) — a client-composed
// `CHAT_MSG_SYSTEM` line, pushed the way every other one is. Ahead of the gate: the tick
// runs on the frame clock, not on anything the gate watches.
for line in script.take_ready_check_lines() {
chat.push_event(crate::ui_chat::ChatEvent::text_only(
crate::ui_chat::ChatEventKind::System,
line,
));
}
let chr = classes.as_deref().map(|t| &t.0);
// The gate (1439): the group state, any member/self descriptor change or DESPAWN (a removed
// store is invisible to `Changed`), the streamed-guid index the merged view resolves
@@ -277,6 +291,7 @@ pub(super) fn feed_party(
// descriptor, and the compare must still answer). Zero when ungrouped, and deliberately
// not guarded against zero at the comparison — see `PartyState::leader_guid`.
leader_guid: group.leader,
own_guid: self_guid.unwrap_or(0),
raid,
loot_method,
master_looter,
@@ -468,6 +483,20 @@ pub(super) fn feed_party(
script.fire_event("READY_CHECK", vec![]);
}
}
// The request generation — both arms — is the engine's state half (decision 1989): the leader
// arm's close-if-nobody-pending, the member arm's 30 s deadline. Then the answer log, replayed
// from where this memo left off; a new request restarts the cursor with the log.
if group.ready_check_requests != fed.ready_check_requests {
fed.ready_check_requests = group.ready_check_requests;
fed.answers_forwarded = 0;
if !vm_reset {
script.ready_check_request(Some(group.leader) == self_guid);
}
}
for &(guid, ready) in group.ready_check_answers.iter().skip(fed.answers_forwarded) {
script.ready_check_answered(guid, ready);
}
fed.answers_forwarded = group.ready_check_answers.len();
// ── UPDATE_INSTANCE_INFO (decision 1549) ────────────────────────────────────────────────
//
@@ -1217,9 +1246,11 @@ fn test_apply_local(
true
}
PartyRequest::ReadyCheckStart => {
// The echo a real server sends back to the whole raid, us included — which is what
// makes the popup appear for the person who pressed the button.
group.apply_ready_check();
// The echo a real server sends back to the whole raid, us included. The presser is the
// leader (the stock Raid tab offers the button to no one else), and the leader's own
// echo takes the response-collection arm: no popup for the person who pressed it,
// which is the reference's behaviour (decision 1989) — the summary line is theirs.
group.apply_ready_check_request(true);
true
}
PartyRequest::RequestRaidInfo => {
@@ -1909,15 +1940,19 @@ mod tests {
"9 is not a subgroup"
);
// Ready Check echoes back to us, which is what puts the popup on the asker's screen.
let before = group.ready_check;
// Ready Check echoes back to us as the leader: the response-collection arm, which bumps
// the request generation and never the popup ticket (decision 1989).
let (ticket, requests) = (group.ready_check, group.ready_check_requests);
assert!(test_apply_local(
&mut group,
&PartyRequest::ReadyCheckStart,
me,
None
));
assert_eq!(group.ready_check, before + 1);
assert_eq!(
(group.ready_check, group.ready_check_requests),
(ticket, requests + 1)
);
// And the kick empties a seat — but never our own row.
let n = group.members.len();
+73 -3
View File
@@ -112,6 +112,15 @@ pub struct GroupState {
/// fires `READY_CHECK` on a counter edge and a second check while the first popup is still up
/// re-arms it. A boolean could not tell the two apart.
pub ready_check: u32,
/// The request GENERATION — bumped by BOTH arms of the open form (decision 1989), unlike the
/// ticket above, which only the non-leader arm bumps: the leader never gets the popup, because
/// `0x4ba360`'s leader arm never reaches the `READY_CHECK` fire. The feed turns an edge here
/// into the engine's `ready_check_request` and resets its answer cursor.
pub ready_check_requests: u32,
/// The answers forwarded to us as leader since the last request — `{guid, ready}` in wire
/// order. A log the feed replays into the engine from its own cursor, append-only within one
/// check so the feed never has to mutate this state; the next request clears it.
pub ready_check_answers: Vec<(u64, bool)>,
}
// The GlobalStrings templates, quoted verbatim from the reference client's own patch chain
@@ -129,6 +138,7 @@ const NEW_LEADER_YOU: &str = "You are now the group leader."; // ERR_NEW_LEADER_
const RAID_MEMBER_ADDED: &str = "%s has joined the raid group"; // ERR_RAID_MEMBER_ADDED_S (GlobalStrings:1824)
const RAID_MEMBER_REMOVED: &str = "%s has left the raid group"; // ERR_RAID_MEMBER_REMOVED_S (GlobalStrings:1825)
const RAID_YOU_JOINED: &str = "You have joined a raid group"; // ERR_RAID_YOU_JOINED (GlobalStrings:1826)
const READY_CHECK_START_S: &str = "%s has initiated a ready check"; // ERR_RAID_LEADER_READY_CHECK_START_S (GlobalStrings:1823)
/// `format!`-free "%s" substitution — the templates are quoted verbatim from GlobalStrings, so
/// they carry printf placeholders, not Rust ones.
@@ -389,10 +399,34 @@ impl GroupState {
self.saved_instances_answers = self.saved_instances_answers.wrapping_add(1);
}
/// `MSG_RAID_READY_CHECK` (open form) — the leader started one. Bumps the ticket the feed
/// turns into a `READY_CHECK` event edge.
pub fn apply_ready_check(&mut self) {
/// `MSG_RAID_READY_CHECK` (open form) — the leader started one, and the server echoes the
/// request to every member including the leader. The handler `0x4ba360` splits on the leader
/// guid (decision 1989): the **leader** takes the response-collection arm and neither prints
/// nor pops; everyone **else** prints `ERR_RAID_LEADER_READY_CHECK_START_S` with the leader's
/// name and gets the popup — the ticket the feed turns into a `READY_CHECK` event edge. Both
/// arms bump the request generation and start a fresh answer log.
pub fn apply_ready_check_request(&mut self, we_lead: bool) -> Vec<String> {
self.ready_check_requests = self.ready_check_requests.wrapping_add(1);
self.ready_check_answers.clear();
if we_lead {
return Vec::new();
}
self.ready_check = self.ready_check.wrapping_add(1);
// The reference resolves the leader's name from its cache and prints whatever it holds;
// ours is the roster's name for the leader's guid, which is the same cache's content.
let leader = self
.members
.iter()
.find(|m| m.guid == self.leader)
.map(|m| m.name.as_str())
.unwrap_or("");
vec![fmt_s(READY_CHECK_START_S, leader)]
}
/// `MSG_RAID_READY_CHECK` (answer form) — one member's answer, which the server forwards to
/// the leader alone. Logged for the feed to replay into the engine's flags (decision 1989).
pub fn apply_ready_check_answer(&mut self, guid: u64, ready: bool) {
self.ready_check_answers.push((guid, ready));
}
}
@@ -446,6 +480,42 @@ mod tests {
/// The ungated diff (0440 byte law): a FIRST roster prints joins for everyone already
/// there; later rosters diff both ways.
/// The open form's two arms (decision 1989): the leader's own echo neither prints nor pops;
/// a member prints the leader's line and takes the popup ticket. Both bump the request
/// generation and restart the answer log.
#[test]
fn the_open_form_pops_for_a_member_and_stays_quiet_for_the_leader() {
let mut g = GroupState {
leader: 0xA11CE,
members: vec![member("Alice", 0xA11CE), member("Bob", 0xB0B)],
..Default::default()
};
g.apply_ready_check_answer(0xB0B, true);
assert_eq!(
g.apply_ready_check_request(false),
vec!["Alice has initiated a ready check".to_string()]
);
assert_eq!((g.ready_check, g.ready_check_requests), (1, 1));
assert!(
g.ready_check_answers.is_empty(),
"a request starts a fresh log"
);
assert!(
g.apply_ready_check_request(true).is_empty(),
"the leader's echo"
);
assert_eq!(
(g.ready_check, g.ready_check_requests),
(1, 2),
"no popup for the leader"
);
g.apply_ready_check_answer(0xB0B, false);
assert_eq!(g.ready_check_answers, vec![(0xB0B, false)]);
}
#[test]
fn join_lines_come_from_roster_diffs() {
let mut g = GroupState::default();
+31 -4
View File
@@ -854,6 +854,10 @@ struct BatchPools {
materials: std::collections::HashMap<MatKey, Handle<UiQuadMaterial>>,
/// Per slot: the solo-run material (key + tint it was built with), `None` while pooled.
solo: Vec<Option<SoloMaterial>>,
/// Solo materials a slot let go of, by key — taken before a fresh one is built, so a run
/// shifting slots (a tooltip opening above it) re-points at a material that exists
/// instead of adding one and dropping one per shifted slot per frame (review 2026-09-04).
solo_free: std::collections::HashMap<MatKey, Vec<SoloMaterial>>,
/// Per slot: the material the batch entity currently carries.
bound: Vec<Option<AssetId<UiQuadMaterial>>>,
}
@@ -1059,6 +1063,7 @@ fn rebuild_ui_mesh(
*slot = None;
}
}
pools.solo_free.retain(|k, _| !retired.contains(&k.0));
}
let (hidden, mesh_cost, cost_wanted) =
(&hide_and_meter.0, &mut hide_and_meter.1, &hide_and_meter.2);
@@ -1282,6 +1287,7 @@ fn rebuild_ui_mesh(
// on their next miss.
if pools.materials.len() > 256 {
pools.materials.clear();
pools.solo_free.clear();
}
let mut used = 0usize;
let mut n_rewrites = 0usize;
@@ -1370,7 +1376,10 @@ fn rebuild_ui_mesh(
tint: Vec4::from_array(tint),
})
};
match &mut pools.solo[used] {
let BatchPools {
solo, solo_free, ..
} = &mut *pools;
match &mut solo[used] {
Some((handle, k, t)) if *k == key => {
if *t != tint {
if let Some(m) = materials.get_mut(&*handle) {
@@ -1381,14 +1390,32 @@ fn rebuild_ui_mesh(
handle.clone()
}
slot => {
let handle = fresh(materials);
*slot = Some((handle.clone(), key, tint));
if let Some(old) = slot.take() {
solo_free.entry(old.1).or_default().push(old);
}
let reused =
solo_free
.get_mut(&key)
.and_then(Vec::pop)
.map(|(handle, k, t)| {
if t != tint {
if let Some(m) = materials.get_mut(&handle) {
m.tint = Vec4::from_array(tint);
}
}
(handle, k, tint)
});
let (handle, k, t) =
reused.unwrap_or_else(|| (fresh(materials), key, tint));
*slot = Some((handle.clone(), k, t));
handle
}
}
}
None => {
pools.solo[used] = None;
if let Some(old) = pools.solo[used].take() {
pools.solo_free.entry(old.1).or_default().push(old);
}
pools
.materials
.entry(key)
@@ -414,7 +414,7 @@ fn the_quest_tracker_stacks_below_the_durability_guy() {
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\ItemButtonTemplate.xml",
"Interface\\FrameXML\\QuestFrame.xml",
"MicroMenu.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
"Interface\\FrameXML\\QuestLogFrame.xml",
] {
load_xml(&s, f);
@@ -131,7 +131,7 @@ fn an_error_toast_draws_over_an_open_panel_window() {
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "MicroMenu.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
// A left-slot panel open, and the toast raised after it — the order that must not decide.
@@ -574,10 +574,10 @@ fn the_shipped_frames_carry_the_references_flags() {
// frames are the reference's own now), which took the paired count from the low 400s to
// the high 150s; 1966 (TradeFrame), 1968 (GameTooltip), 1969 (DressUpFrame) and 1970
// (MailFrame) to the high 80s, and 1980 (WorldMapFrame, whose 50-odd blip frames were
// most of what was left) to the high teens. The floor guards the pairing, not the
// census — it comes down with every window that migrates, and reaches zero with the
// last file of ours that declares a reference-named frame.
compared > 10,
// most of what was left) to the high teens, and 1987 (the micro row) to nine. The floor
// guards the pairing, not the census — it comes down with every window that migrates,
// and reaches zero with the last file of ours that declares a reference-named frame.
compared > 5,
"only {compared} frames compared — the pairing broke, and the sweep guards nothing"
);
assert!(
@@ -577,7 +577,7 @@ fn the_world_map_cannot_open_behind_the_menu_and_gives_its_slot_back() {
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
"MicroMenu.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
r"Interface\FrameXML\WorldMapFrame.xml",
]);
@@ -638,7 +638,7 @@ fn nothing_opens_behind_the_world_map_and_escape_closes_it_first() {
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
"MicroMenu.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
r"Interface\FrameXML\WorldMapFrame.xml",
"Interface\\FrameXML\\MerchantFrame.xml",
// The loot window is the reference's own since 1751 — see `test_ui::LOOT_UI` for what
@@ -38,7 +38,7 @@ fn setup() -> UiScript {
"Interface\\FrameXML\\StaticPopup.xml",
// The stock file's `HelpFrame_OnShow` calls `UpdateMicroButtons()` before
// `GetGMStatus()`, so without the micro row the OnShow raises and the status ask never
// happens. Ours never called it. MicroMenu needs the bar's button kit under it.
// happens. Ours never called it. The row needs the bar's button kit under it.
"Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
@@ -47,7 +47,7 @@ fn setup() -> UiScript {
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
"MicroMenu.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
// `TicketStatusFrame_OnEvent` re-anchors TemporaryEnchantFrame before it arms the repoll
// timer, so without BuffFrame the handler raises and `refreshTime` is never set. This is
// the one ordering requirement benilla.toc already calls out for this window.
@@ -108,7 +108,7 @@ fn harness_with(player: &str) -> UiScript {
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\FrameXML\MainMenuBarMicroButtons.xml", // stock `MacroFrame_OnShow`/`_OnHide` drive the micro button
] {
super::test_ui::load_ui(&s, file);
}
@@ -1,85 +1,119 @@
//! The shipped `assets/ui/MicroMenu.xml` — the eight micro buttons in the main bar's right-hand
//! recess — loaded behind `ActionBar.xml` (their anchor target `MainMenuBarArtFrame` and the
//! `BenillaActionBarArt_SeatAbove` seating helper) into a bare engine.
//! The stock `Interface\FrameXML\MainMenuBarMicroButtons.xml` — the eight micro buttons in the
//! main bar's right-hand recess — off the player's chain behind the stock bar (their anchor
//! target `MainMenuBarArtFrame`) into a bare engine (decision 1987).
//!
//! What these guard, in order: the row's ref geometry (29×58 at art-frame BOTTOMLEFT +(552,2), a
//! 26 px stride) — which is the whole point of the slice, since the gap the row fills is exactly
//! that panel of empty bar art; the `HitRectInsets` header that keeps a button's transparent top
//! from eating the mouse; `UpdateMicroButtons` tracking a panel's visibility; and
//! `UpdateTalentButton`'s level-10 gate closing the row up behind the hidden talent button.
//! What these guard, in order: the row's geometry (29×58 at art-frame BOTTOMLEFT +(552,2), a
//! 26 px stride, and the reference's own 1 px nudge once the talent gate has run); the
//! `HitRectInsets` header that keeps a button's transparent top from eating the mouse; the
//! character button's portrait crop; `UpdateTalentButton`'s level-10 gate closing the row up
//! behind the hidden talent button; the two-line hover with the bound key in its label; and,
//! under the whole shipped manifest, `UpdateMicroButtons` following a real panel and the
//! level-up pulse the reference hangs on the talent button.
use benilla_ui::script::{QuadContent, ScriptValue, TexCoords, UiScript};
use benilla_ui::script::{QuadContent, ScriptValue, TexCoords, UiScript, UnitState};
/// Load `ActionBar.xml` then `MicroMenu.xml` into a 1024×768 engine, asserting both load clean.
/// 1024 wide is deliberate: the 1024-wide bar then centers at x=0, so every ref offset below is
/// The eight, in row order.
const ROW: [&str; 8] = [
"CharacterMicroButton",
"SpellbookMicroButton",
"TalentMicroButton",
"QuestLogMicroButton",
"SocialsMicroButton",
"WorldMapMicroButton",
"MainMenuMicroButton",
"HelpMicroButton",
];
/// The stock bar, then the stock row, into a 1024×768 engine, asserting both load clean. 1024
/// wide is deliberate: the 1024-wide bar then centers at x=0, so every reference offset below is
/// also an absolute screen coordinate.
fn harness() -> UiScript {
harness_with(&[])
}
/// …with more shipped files behind the row — the tooltip test needs the real plate.
fn harness_with(extra: &[&str]) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let files: Vec<&str> = [
for file in [
"Interface\\FrameXML\\Fonts.xml",
"Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"UIParent.xml",
// The eight labels and their NEWBIE_TOOLTIP_* lines, read at each button's OnLoad…
"Interface\\FrameXML\\GlobalStrings.lua",
// …through `TEXT()`.
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
"MicroMenu.xml",
]
.into_iter()
.chain(extra.iter().copied())
.collect();
// Through `test_ui::load_ui`, not off disk: the list above now names a chain file, and a
// reader that joins `assets/ui` cannot resolve one (the wall 1838 hit, and 1887 again).
for file in files {
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
] {
super::test_ui::load_ui(&s, file);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s
}
/// The row's geometry, quoted from ref-MainMenuBarMicroButtons.xml: CharacterMicroButton at the
/// art frame's BOTTOMLEFT +(552,2), each button 29×58, the rest chained BOTTOMLEFT-to-previous-
/// BOTTOMRIGHT +(-3,0) ⇒ a 26 px stride, so the eight run x 552..757 — inside the 1024-wide bar,
/// between the page arrows (x≈522) and the bag cluster at the far right.
/// Set the player's level and fire the `UNIT_LEVEL` the talent gate listens for.
fn set_player_level(s: &mut UiScript, level: u32) {
s.set_unit(
"player",
Some(UnitState {
exists: true,
level,
..Default::default()
}),
);
s.fire_event("UNIT_LEVEL", vec![ScriptValue::Str("player".into())]);
}
fn left_of(s: &UiScript, name: &str) -> f64 {
s.eval::<f64>(&format!("return {name}:GetLeft()"))
.unwrap_or_else(|e| panic!("{name}: {e}"))
}
/// The row's geometry, the reference's own numbers: CharacterMicroButton at the art frame's
/// BOTTOMLEFT +(552,2), each button 29×58, the rest chained BOTTOMLEFT-to-previous-BOTTOMRIGHT
/// +(-3,0) ⇒ a 26 px stride, so the eight run x 552..757 as declared — between the page arrows
/// (x≈522) and the bag cluster at the far right. **As declared** is the operative phrase: the XML
/// seats the quest-log button at −3 and `UpdateTalentButton` re-seats it at −2
/// (`MainMenuBarMicroButtons.lua:139`), so the first level pass at 10+ nudges the tail right by
/// one pixel. A real row is 1 px wider past its first `PLAYER_ENTERING_WORLD` than its XML
/// says; that is the client's, and it is asserted here as the reference's law rather than
/// sanded off (our retired file wrote −3 both ways).
#[test]
fn the_micro_row_sits_where_the_reference_puts_it() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
// Past level 10 so all eight are shown and the row is unbroken.
set_player_level(&mut s, 10);
s.resolve();
let names = [
"CharacterMicroButton",
"SpellbookMicroButton",
"TalentMicroButton",
"QuestLogMicroButton",
"SocialsMicroButton",
"WorldMapMicroButton",
"MainMenuMicroButton",
"HelpMicroButton",
];
for (i, name) in names.iter().enumerate() {
// As declared, before any level pass: a clean 26 px stride.
for (i, name) in ROW.iter().enumerate() {
let (left, bottom, w, h) = s
.eval::<(f64, f64, f64, f64)>(&format!(
"return {name}:GetLeft(), {name}:GetBottom(), {name}:GetWidth(), {name}:GetHeight()"
))
.unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!((w, h), (29.0, 58.0), "{name} size");
assert_eq!(left, 552.0 + 26.0 * i as f64, "{name} left edge");
assert_eq!(
left,
552.0 + 26.0 * i as f64,
"{name} left edge, as declared"
);
assert_eq!(bottom, 2.0, "{name} sits 2 above the bar's bottom");
}
// Past level 10 the gate re-seats the quest-log button at −2: the tail moves 1 px right.
set_player_level(&mut s, 60);
s.resolve();
for (i, name) in ROW.iter().enumerate().skip(3) {
assert_eq!(
left_of(&s, name),
553.0 + 26.0 * i as f64,
"{name} after the talent gate's own −2"
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -88,8 +122,8 @@ fn the_micro_row_sits_where_the_reference_puts_it() {
/// bar's XP strip and the sliver of world above it.
#[test]
fn the_transparent_top_of_a_micro_button_does_not_capture_the_mouse() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
set_player_level(&mut s, 10);
s.resolve();
// Mid-button horizontally (552 + 14), inside the art band (y 2..42) ⇒ captures.
@@ -98,7 +132,7 @@ fn the_transparent_top_of_a_micro_button_does_not_capture_the_mouse() {
Some("CharacterMicroButton"),
"the art band takes the mouse"
);
// Same column, in the dead 18-unit header (y 42..60) ⇒ nothing of ours captures it.
// Same column, in the dead 18-unit header (y 42..60) ⇒ nothing of the row captures it.
assert_ne!(
s.hit_test_name(566.0, 50.0).as_deref(),
Some("CharacterMicroButton"),
@@ -107,13 +141,14 @@ fn the_transparent_top_of_a_micro_button_does_not_capture_the_mouse() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The character button's face: the region binds the SAME `"player"` portrait slot the unit frame
/// samples, and carries the ref's crop window out to the renderer. Both halves matter — the crop is
/// what makes an 18×25 rectangle show a face instead of the whole square bake squashed into it, and
/// the extract path used to pin portrait quads to full UVs and drop it (fixed with this slice).
/// Pushing the button swaps the window and dims it (ref CharacterMicroButton_SetPushed).
/// The character button's face: `MicroButtonPortrait` binds the SAME `"player"` portrait slot
/// the unit frame samples, and carries the reference's crop window out to the renderer. Both
/// halves matter — the crop is what makes an 18×25 rectangle show a face instead of the whole
/// square bake squashed into it. Pushing the button swaps the window and dims it
/// (`CharacterMicroButton_SetPushed`, `MainMenuBarMicroButtons.lua:109-117`).
#[test]
fn the_character_button_carries_the_player_portrait_through_the_reference_crop() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.resolve();
@@ -136,8 +171,8 @@ fn the_character_button_carries_the_player_portrait_through_the_reference_crop()
assert_eq!(unit, "player");
assert!(
circular,
"the ref's round stencil lives in the bake's own UV space, so the crop below yields a \
rectangular slice OF a masked face — not an ellipse fitted to this 18x25 region"
"the reference's round stencil lives in the bake's own UV space, so the crop below yields \
a rectangular slice OF a masked face — not an ellipse fitted to this 18x25 region"
);
let round4 = |c: TexCoords| match c {
TexCoords::Rect(e) => e.map(|v| (v * 10_000.0).round() / 10_000.0),
@@ -146,7 +181,7 @@ fn the_character_button_carries_the_player_portrait_through_the_reference_crop()
assert_eq!(
round4(coords.expect("a crop window")),
[0.2, 0.8, 0.0666, 0.9],
"the normal window (ref CharacterMicroButton_SetNormal)"
"the normal window (CharacterMicroButton_SetNormal)"
);
s.run("CharacterMicroButton_SetPushed()").unwrap();
@@ -155,7 +190,7 @@ fn the_character_button_carries_the_player_portrait_through_the_reference_crop()
assert_eq!(
round4(coords.expect("a crop window")),
[0.2666, 0.8666, 0.0, 0.8333],
"the held-down window (ref CharacterMicroButton_SetPushed)"
"the held-down window (CharacterMicroButton_SetPushed)"
);
assert_eq!(
s.eval::<f64>("return MicroButtonPortrait:GetAlpha()")
@@ -166,50 +201,12 @@ fn the_character_button_carries_the_player_portrait_through_the_reference_crop()
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `UpdateMicroButtons` (ref MainMenuBarMicroButtons.lua l.20-84): a button is PUSHED exactly
/// while its panel is up. Driven here with a stand-in frame under the real panel's name rather
/// than the whole character window — this is the visibility contract, not that window's.
#[test]
fn a_micro_button_pushes_while_its_panel_is_open() {
let s = harness();
s.run(
r#"
local f = CreateFrame("Frame", "CharacterFrame")
f:SetPoint("CENTER", 0, 0); f:SetSize(100, 100); f:Hide()
"#,
)
.unwrap();
let state = |s: &UiScript| {
s.eval::<String>("return CharacterMicroButton:GetButtonState()")
.unwrap()
};
s.run("UpdateMicroButtons()").unwrap();
assert_eq!(state(&s), "NORMAL", "closed panel ⇒ button up");
s.run("CharacterFrame:Show(); UpdateMicroButtons()")
.unwrap();
assert_eq!(state(&s), "PUSHED", "open panel ⇒ button held down");
s.run("CharacterFrame:Hide(); UpdateMicroButtons()")
.unwrap();
assert_eq!(state(&s), "NORMAL", "closed again ⇒ button pops back");
// A button whose panel doesn't exist at all (the three unwired ones, and any panel a test
// harness didn't load) never pushes — and never errors.
assert_eq!(
s.eval::<String>("return SocialsMicroButton:GetButtonState()")
.unwrap(),
"NORMAL"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `UpdateTalentButton` (ref l.133-141): under level 10 the talent button is hidden and the quest
/// log button slides onto its seat, so the row has no hole in it; at 10 the button returns and the
/// tail shifts back out by one stride.
/// `UpdateTalentButton` (l.133-141): under level 10 the talent button is hidden and the quest
/// log button slides onto its seat, so the row has no hole in it; at 10 the button returns and
/// the tail shifts back out — by one stride less two, the gate's own −2.
#[test]
fn the_talent_button_appears_at_level_ten_and_the_row_closes_up_below_it() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
set_player_level(&mut s, 9);
@@ -220,13 +217,9 @@ fn the_talent_button_appears_at_level_ten_and_the_row_closes_up_below_it() {
"no talents before 10"
);
// The quest log button takes the talent button's own seat — slot 3, not slot 4.
assert_eq!(left_of(&s, "QuestLogMicroButton"), 552.0 + 26.0 * 2.0);
assert_eq!(
s.eval::<f64>("return QuestLogMicroButton:GetLeft()")
.unwrap(),
552.0 + 26.0 * 2.0
);
assert_eq!(
s.eval::<f64>("return HelpMicroButton:GetLeft()").unwrap(),
left_of(&s, "HelpMicroButton"),
552.0 + 26.0 * 6.0,
"the whole tail moves up one slot with it"
);
@@ -239,51 +232,84 @@ fn the_talent_button_appears_at_level_ten_and_the_row_closes_up_below_it() {
"the button returns at 10"
);
assert_eq!(
s.eval::<f64>("return QuestLogMicroButton:GetLeft()")
.unwrap(),
552.0 + 26.0 * 3.0
left_of(&s, "QuestLogMicroButton"),
553.0 + 26.0 * 3.0,
"…and the tail goes back out, at the gate's own −2"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The hover (ref MainMenuBarMicroButton's OnEnter, l.13): every button's plate is the ref's
/// TWO-line `GameTooltip_AddNewbieTip` — the label, then that button's own `NEWBIE_TOOLTIP_*`
/// explanation in gold. 1.12 ships detailed tips ON (`SHOW_NEWBIE_TIPS = "1"`, ref
/// UIOptionsFrame.lua l.100), so the paragraph is the DEFAULT hover; this file used to open-code
/// the tips-off branch and show a bare label. Decision 0661.
///
/// Checked across all eight, because the explanation is threaded per button through OnLoad and a
/// single missing argument is invisible until someone hovers that one button.
/// The hover (the template's OnEnter, `MainMenuBarMicroButtons.xml:12-14`): every button's plate
/// is the reference's TWO-line `GameTooltip_AddNewbieTip` — the label, then that button's own
/// `NEWBIE_TOOLTIP_*` explanation in gold. 1.12 ships detailed tips ON (`SHOW_NEWBIE_TIPS = "1"`,
/// `UIOptionsFrame.lua:100`; ours sits in OptionsFrame.xml's uvar block), so the paragraph is the
/// DEFAULT hover (decision 0661). Through the engine's own hover, because the stock OnEnter reads
/// the firing frame off `this`. For the bound ones the label carries the key the way the
/// reference prints it — `GetBindingKey`'s raw token in `NORMAL_FONT_COLOR_CODE` parentheses
/// (`MicroButtonTooltipText`, l.11-18) — re-read on `UPDATE_BINDINGS`, which is the event
/// `bindings.rs` fires after every (re)load of the table.
#[test]
fn every_micro_button_hovers_with_its_reference_explanation() {
let mut s = harness_with(&["UIParent.xml"]);
set_player_level(&mut s, 10);
// 1.12 ships detailed tips ON — `SHOW_NEWBIE_TIPS = "1"` is UIOptionsFrame_Init's (ref
// UIOptionsFrame.lua l.100; ours sits in OptionsFrame.xml's uvar block, 1968), and a harness
// without the options file says so itself, the way the reference's tooltip would read it.
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.run("SHOW_NEWBIE_TIPS = \"1\"").unwrap();
// A key the character button's label can carry: the registry, then the reference's own
// `SetBinding`, then the event the row re-reads its labels on.
s.register_bindings(&crate::bindings::registry_commands());
assert_eq!(
s.eval::<Option<i64>>("return SetBinding(\"C\", \"TOGGLECHARACTER0\")")
.unwrap(),
Some(1)
);
s.fire_event("UPDATE_BINDINGS", vec![]);
s.resolve();
for (button, newbie) in [
("CharacterMicroButton", "NEWBIE_TOOLTIP_CHARACTER"),
("SpellbookMicroButton", "NEWBIE_TOOLTIP_SPELLBOOK"),
("TalentMicroButton", "NEWBIE_TOOLTIP_TALENTS"),
("QuestLogMicroButton", "NEWBIE_TOOLTIP_QUESTLOG"),
("SocialsMicroButton", "NEWBIE_TOOLTIP_SOCIAL"),
("WorldMapMicroButton", "NEWBIE_TOOLTIP_WORLDMAP"),
("MainMenuMicroButton", "NEWBIE_TOOLTIP_MAINMENU"),
("HelpMicroButton", "NEWBIE_TOOLTIP_HELP"),
for (button, label, newbie) in [
(
"CharacterMicroButton",
"CHARACTER_BUTTON",
"NEWBIE_TOOLTIP_CHARACTER",
),
// The spellbook's own OnEnter (l.79-87) picks its label by `PlayerHasSpells()`.
(
"SpellbookMicroButton",
"PlayerHasSpells() and SPELLBOOK_ABILITIES_BUTTON or ABILITYBOOK_BUTTON",
"NEWBIE_TOOLTIP_SPELLBOOK",
),
(
"TalentMicroButton",
"TALENTS_BUTTON",
"NEWBIE_TOOLTIP_TALENTS",
),
(
"QuestLogMicroButton",
"QUESTLOG_BUTTON",
"NEWBIE_TOOLTIP_QUESTLOG",
),
(
"SocialsMicroButton",
"SOCIAL_BUTTON",
"NEWBIE_TOOLTIP_SOCIAL",
),
(
"WorldMapMicroButton",
"WORLDMAP_BUTTON",
"NEWBIE_TOOLTIP_WORLDMAP",
),
(
"MainMenuMicroButton",
"MAINMENU_BUTTON",
"NEWBIE_TOOLTIP_MAINMENU",
),
("HelpMicroButton", "HELP_BUTTON", "NEWBIE_TOOLTIP_HELP"),
] {
// A hover is a handler: the reference's helper reads the firing frame off `this`.
s.run(&format!("this = {button} BenillaMicroButton_OnEnter(this)"))
.unwrap();
super::test_ui::hover(&mut s, button);
assert_eq!(
s.eval::<i64>("return GameTooltip:NumLines()").unwrap(),
2,
"{button}: the label, then the explanation"
);
// Line 1 is the button's own label (plus a `|cff…(key)|r` suffix on the five bound ones).
let label = s.eval::<String>(&format!("return {button}.label")).unwrap();
let label = s.eval::<String>(&format!("return {label}")).unwrap();
let line1 = s
.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap();
@@ -295,26 +321,130 @@ fn every_micro_button_hovers_with_its_reference_explanation() {
s.eval::<String>("return GameTooltipTextLeft2:GetText()")
.unwrap(),
s.eval::<String>(&format!("return {newbie}")).unwrap(),
"{button}: line 2 is the ref's {newbie}, verbatim"
"{button}: line 2 is the reference's {newbie}, verbatim"
);
assert_eq!(
s.eval::<i64>("return GameTooltip.default").unwrap(),
1,
"{button}: the default-corner anchor, not ANCHOR_RIGHT off the button"
);
super::test_ui::unhover(&mut s);
}
// The bound one carries its key the reference's way: the raw token, gold, in parentheses,
// after a space — `MicroButtonTooltipText`'s own concatenation.
super::test_ui::hover(&mut s, "CharacterMicroButton");
let line1 = s
.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap();
assert_eq!(
line1,
s.eval::<String>(
"return CHARACTER_BUTTON .. \" \" .. NORMAL_FONT_COLOR_CODE .. \"(C)\" .. FONT_COLOR_CODE_CLOSE"
)
.unwrap(),
"the key read on UPDATE_BINDINGS"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Set the player's level and fire the `UNIT_LEVEL` the talent gate listens for.
fn set_player_level(s: &mut UiScript, level: u32) {
/// Under the whole shipped manifest — the only VM in which every panel `UpdateMicroButtons`
/// (l.20-84) reads exists — a button is PUSHED exactly while its panel is up, the character
/// button's face dims with it, and the level-up pulse the reference hangs on the talent button
/// (`TalentMicroButton_OnEvent`, l.119-124: sixty seconds, unless the character sheet is open)
/// is live — 0304's "no microbutton pulse" scope-out, retired by the file that owns the pulse.
#[test]
fn a_micro_button_pushes_while_its_panel_is_open_and_the_talent_button_pulses_on_a_ding() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// A player always exists by the time the manifest loads (1051/1848) — with a race and a
// class, which the sheet's own `PaperDollFrame_SetLevel` formats into its title on show.
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
Some(UnitState {
exists: true,
level,
name: Some("Probefour".into()),
level: 60,
race: Some("Night Elf".into()),
race_file: Some("NightElf".into()),
class: Some("Warrior".into()),
class_file: Some("WARRIOR".into()),
sex: 2,
is_player: true,
player_controlled: true,
..Default::default()
}),
);
s.fire_event("UNIT_LEVEL", vec![ScriptValue::Str("player".into())]);
// The reference's strings, which the app runs ahead of the manifest (lifecycle.rs) — the
// ding's chat lines below format `SPELL_STAT0_NAME`..`4` through them.
super::test_ui::load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua");
assert!(super::load_default_ui(&s).is_empty());
s.resolve();
let state = |s: &UiScript, button: &str| {
s.eval::<String>(&format!("return {button}:GetButtonState()"))
.unwrap()
};
let face = |s: &UiScript| {
s.eval::<f64>("return MicroButtonPortrait:GetAlpha()")
.unwrap()
};
assert_eq!(state(&s, "CharacterMicroButton"), "NORMAL");
assert_eq!(face(&s), 1.0);
// The button's own click (its OnClick, l.49-51) — the sheet's OnShow calls back.
s.run("ToggleCharacter(\"PaperDollFrame\")").unwrap();
assert_eq!(
state(&s, "CharacterMicroButton"),
"PUSHED",
"open sheet ⇒ button held down"
);
assert_eq!(
face(&s),
0.5,
"…and the face dims (CharacterMicroButton_SetPushed)"
);
s.run("ToggleCharacter(\"PaperDollFrame\")").unwrap();
assert_eq!(state(&s, "CharacterMicroButton"), "NORMAL");
assert_eq!(face(&s), 1.0);
// The reference's own full-screen panel; its OnShow/OnHide call back the same way.
s.run("ToggleWorldMap()").unwrap();
assert_eq!(state(&s, "WorldMapMicroButton"), "PUSHED");
s.run("ToggleWorldMap()").unwrap();
assert_eq!(state(&s, "WorldMapMicroButton"), "NORMAL");
// A ding with the sheet closed: sixty seconds on the pulse list (UiPanels.xml's
// SetButtonPulse, the reference's UIParent.lua machinery). The nine args are the ones
// `ui_unit` fires (level, health, power, talent points, five stats).
s.fire_event(
"PLAYER_LEVEL_UP",
vec![
ScriptValue::Int(61),
ScriptValue::Int(12),
ScriptValue::Int(0),
ScriptValue::Int(1),
ScriptValue::Int(1),
ScriptValue::Int(1),
ScriptValue::Int(1),
ScriptValue::Int(0),
ScriptValue::Int(0),
],
);
assert_eq!(
s.eval::<f64>("return TalentMicroButton.pulseTimeLeft")
.unwrap(),
60.0,
"the sixty-second pulse"
);
assert!(
s.eval::<bool>(
"for _, b in ipairs(PULSEBUTTONS) do if b == TalentMicroButton then return true end end \
return false"
)
.unwrap(),
"on the pulse list"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -796,7 +796,7 @@ fn interface_harness() -> UiScript {
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\ItemButtonTemplate.xml",
"Interface\\FrameXML\\QuestFrame.xml",
"MicroMenu.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
"Interface\\FrameXML\\QuestLogFrame.xml",
],
);
@@ -692,7 +692,7 @@ fn tooltip_line1(s: &UiScript) -> String {
///
/// The exact string is pinned, colour codes and all, because the ref's concatenation puts the
/// colour BEFORE the space (`Attack|cffffd200 (CTRL-1)|r`, not `Attack |cffffd200(CTRL-1)|r` —
/// which is what MicroMenu.xml's different-shaped version of the same idea produces).
/// which is what the stock `MicroButtonTooltipText` — the same idea, the other shape — produces).
#[test]
fn token_tooltips_name_their_binding_and_pet_spells_do_not() {
let mut s = hovered_pet_bar();
@@ -44,7 +44,7 @@ fn harness() -> UiScript {
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "MicroMenu.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
load_xml(&s, "Interface\\FrameXML\\QuestTimerFrame.xml");
s
@@ -105,7 +105,7 @@ fn shipped_questlog_frame_loads_clean() {
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "MicroMenu.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
}
@@ -139,7 +139,7 @@ fn shipped_questlog_frame_drives_end_to_end() {
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "MicroMenu.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest_log(eight_entries());
@@ -349,7 +349,7 @@ fn shift_click_toggles_the_watch_checkbox_and_the_tracker_hud() {
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "MicroMenu.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
// A shift-click reaches stock QuestLogTitleButton_OnClick's unguarded `ChatFrameEditBox:IsVisible()`
// (QuestLogFrame.lua:478) — the chat window is on the manifest long before the log (1944).
load_xml(&s, "UIParent.xml");
@@ -498,7 +498,7 @@ fn watch_guards_no_op_without_erroring() {
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "MicroMenu.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
// A shift-click reaches stock QuestLogTitleButton_OnClick's unguarded `ChatFrameEditBox:IsVisible()`
// (QuestLogFrame.lua:478) — the chat window is on the manifest long before the log (1944).
load_xml(&s, "UIParent.xml");
@@ -619,7 +619,7 @@ fn progress_auto_watches_for_five_minutes() {
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "MicroMenu.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest_log(eight_entries());
@@ -705,7 +705,7 @@ fn the_auto_watch_flag_is_the_references_uvar_and_gates_the_watch() {
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "MicroMenu.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest_log(eight_entries());
assert_eq!(s.eval::<String>("return AUTO_QUEST_WATCH").unwrap(), "1");
@@ -761,7 +761,7 @@ fn the_row_tag_is_its_own_right_flush_string_and_the_state_word_wins() {
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "MicroMenu.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
let mut state = eight_entries();
@@ -844,7 +844,7 @@ fn empty_quest_log_hides_rows_and_disables_abandon() {
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "MicroMenu.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest_log(QuestLogState::default());
@@ -909,7 +909,7 @@ fn reward_rows_follow_the_refs_two_per_row_layout() {
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "MicroMenu.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
let mut state = eight_entries();
@@ -1098,7 +1098,7 @@ fn overflowing_detail_content_clips_to_the_scrollframe_rect() {
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "MicroMenu.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest_log(overflowing_entry());
@@ -1173,7 +1173,7 @@ fn wheel_over_the_detail_pane_changes_vertical_scroll() {
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "MicroMenu.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest_log(overflowing_entry());
@@ -1238,7 +1238,7 @@ fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "MicroMenu.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest_log(overflowing_entry());
@@ -1320,7 +1320,7 @@ fn reward_row_hover_serves_the_shared_item_tooltip() {
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "MicroMenu.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest_log(eight_entries());
@@ -1475,7 +1475,7 @@ fn reward_rows_preview_and_post_and_a_plain_click_stays_inert() {
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "MicroMenu.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
// A shift-click reaches stock QuestLogTitleButton_OnClick's unguarded `ChatFrameEditBox:IsVisible()`
// (QuestLogFrame.lua:478) — the chat window is on the manifest long before the log (1944).
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
@@ -1574,7 +1574,7 @@ fn shift_click_on_a_title_posts_the_quest_name_with_chat_open_and_watches_with_i
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "MicroMenu.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
// A shift-click reaches stock QuestLogTitleButton_OnClick's unguarded `ChatFrameEditBox:IsVisible()`
// (QuestLogFrame.lua:478) — the chat window is on the manifest long before the log (1944).
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
@@ -1684,7 +1684,7 @@ fn share_quest_needs_both_a_sharable_selection_and_a_party() {
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "MicroMenu.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
// Solo, quest 1 sharable and selected by the auto-selection.
@@ -1771,7 +1771,7 @@ fn share_quest_is_dark_on_an_empty_log_even_in_a_party() {
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "MicroMenu.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest_log(QuestLogState::default());
@@ -1812,7 +1812,7 @@ fn share_quest_click_queues_the_selected_quests_id() {
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "MicroMenu.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest_log(entries_sharable(&[1, 5]));
@@ -80,6 +80,7 @@ fn push_raid(s: &mut UiScript, raid: Vec<RaidMemberInfo>) {
members,
leader_index: 0,
leader_guid: 0, // the player leads; their guid is unset in this fixture
own_guid: 0,
raid,
loot_method: "group".into(),
master_looter: None,
@@ -1116,12 +1116,13 @@ mod tests {
/// do not ship under its own name usually has a counterpart of ours under a different one, and
/// both would declare the same frames.
///
/// That half doubles as the map nothing else holds: `FloatingChatFrame.xml` is our `ChatFrame.xml`,
/// `MainMenuBarMicroButtons.xml` is our `MicroMenu.xml`, `StaticPopup.xml` is our
/// `UiPanels.xml`. (The row this map used to lead with — `PlayerFrame.xml`/`TargetFrame.xml`/
/// `PetFrame.xml` all being our one `UnitFrames.xml` — is retired: those four are the
/// reference's own files now, 1751.) `chain_gap_report` calls several of those unblocked, and they are —
/// individually. They just cannot load beside the file of ours already holding their names.
/// That half used to double as the map nothing else held — `FloatingChatFrame.xml` was our
/// `ChatFrame.xml`, `MainMenuBarMicroButtons.xml` our `MicroMenu.xml`, `StaticPopup.xml` our
/// `UiPanels.xml`'s dialog half, `PlayerFrame.xml`/`TargetFrame.xml`/`PetFrame.xml` our one
/// `UnitFrames.xml`. Every one of those is the reference's own file now (1751; the micro row
/// last, 1987), so the frame half names nothing today. It stays because a stock window
/// declaring a frame one of ours still holds is the first thing a swap has to rule out —
/// such a pair can load individually and cannot load side by side.
///
/// Run it before attempting a swap. It predicts which ones will fail without attempting them.
#[test]
@@ -1419,8 +1420,8 @@ mod tests {
/// FrameXML function is invisible to both, because loading a file never runs the body that
/// calls it: `LootFrame.xml` shipped load-clean and raised at `LootFrame.lua:85` the first
/// time it met real data, and stock `CharacterFrame.xml` would have raised on the first tab
/// HOVER, because `MicroButtonTooltipText` did not exist here (it does now — `MicroMenu.xml`,
/// our `MainMenuBarMicroButtons.xml` counterpart, is where the reference declares it).
/// HOVER, because `MicroButtonTooltipText` did not exist here (it does now — the stock
/// `MainMenuBarMicroButtons.xml` declares it, off the chain since 1987).
///
/// So: for every chain `.xml` in the manifest, census the bare `Name(` call sites across it
/// and its `.lua`, subtract what those two define themselves, keep the names the reference's
@@ -625,10 +625,10 @@ fn every_archive_path_a_shipped_lua_chunk_names_survives_its_own_escaping() {
}
}
}
// Never let the sweep pass by matching nothing (19 after 1971, 8 after 1980; the floor
// follows the census down as windows migrate).
// Never let the sweep pass by matching nothing (19 after 1971, 8 after 1980, 4 after 1987;
// the floor follows the census down as windows migrate).
assert!(
paths.len() >= 5,
paths.len() >= 4,
"only {} archive paths swept out of the shipped Lua",
paths.len()
);
@@ -1375,9 +1375,10 @@ fn a_cinematic_leaves_nothing_of_the_interface_on_screen() {
let before = names.iter().filter(|n| visible(&s, n)).count();
assert!(
// 50 until 1938 took the three bar files stock, 20 until 1974 took the minimap cluster
// (its 22 named frames were most of what this sweep counted); the floor follows the
// census of OUR files down, and 12 is what is left — the glue and dev frames.
before > 10,
// (its 22 named frames were most of what this sweep counted), 12 until 1987 took the
// micro row; the floor follows the census of OUR files down, and 2 is what is left —
// the glue and dev frames.
before >= 2,
"only {before} frames visible before the cinematic — the sweep found no interface to \
hide, so it would pass no matter what the cascade did"
);
@@ -1448,8 +1449,9 @@ fn every_declared_parent_really_attaches() {
let declared = shipped_frame_parents();
assert!(
// A sanity floor for the scan, not a census — the declarations retire with the files
// that carry them (1751); 67 before 1938, 30 after 1956, 14 after 1970.
declared.len() >= 10,
// that carry them (1751); 67 before 1938, 30 after 1956, 14 after 1970, 2 after 1987
// (the micro row's eight were most of what was left).
declared.len() >= 2,
"only {} parent declarations found — the scan broke",
declared.len()
);
@@ -114,7 +114,7 @@ pub(super) fn spellbook_ui(w: f32, h: f32) -> UiScript {
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
"MicroMenu.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
"Interface\\FrameXML\\SpellBookFrame.xml",
"SpellBookAdapters.xml",
] {
@@ -703,7 +703,7 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
r"Interface\FrameXML\UIPanelTemplates.xml",
// The icon chooser's scroll frame inherits `ClassTrainerListScrollFrameTemplate`.
r"Interface\FrameXML\ClassTrainerFrameTemplates.xml",
"MicroMenu.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
+50 -6
View File
@@ -70,6 +70,14 @@ fn load_entry(s: &UiScript, entry: &str, strict_templates: bool, no_warnings: bo
"{entry}: loader errors: {:?}",
report.errors
);
if path
.rsplit('/')
.next()
.is_some_and(|leaf| leaf.eq_ignore_ascii_case("MainMenuBarMicroButtons.xml"))
{
s.run(MICRO_BUTTON_STAND_INS)
.expect("the micro-button stand-ins");
}
if no_warnings {
assert!(
report.warnings.is_empty(),
@@ -92,6 +100,39 @@ fn load_entry(s: &UiScript, entry: &str, strict_templates: bool, no_warnings: bo
report.frames
}
/// **What a kit owes the stock micro-button row.** `UpdateMicroButtons`
/// (`MainMenuBarMicroButtons.lua:20-84`) reads ten panels, `KeyRingButton` and `IsBagOpen`
/// UNGUARDED — the reference's own contract, because in the client every one of them is up
/// before anything can be shown. A kit is a prefix of the manifest and stops wherever its window
/// does, so [`load_entry`] wraps the function the moment the row loads: on its FIRST call — a
/// panel's OnShow, by which time the kit is complete — the wrapper seats a hidden, unnamed
/// stand-in under each name the kit never declared (a hidden frame is what a closed panel
/// answers), then runs the reference's own body. First use rather than load time, and unnamed,
/// because both registries keep the first writer: the arena's name map
/// (`named_registry_is_non_overwriting`) and `_G` itself — a named frame is published
/// non-overwriting (RF-0023: a `lua_gettable` nil check on GLOBALSINDEX, which is why a
/// metatable fallback would block the real publish just the same). A stand-in seated before the
/// real declaration shadows the real frame for good; the first cut did exactly that, 126
/// failures, every tabbed panel's `numTabs` read off the stand-in. `fire_chat_login`'s
/// `UIOptionsFrame` stand-in is the same move for the chat files. Seated here rather than at the
/// kits' seventy-odd consumers because the dependency is the row's, not any one window's — and
/// the shipped manifest never needs it. `tests/common/mod.rs` carries the same chunk for the
/// integration tests, which cannot reach this module (decision 1987).
pub(super) const MICRO_BUTTON_STAND_INS: &str = r#"
local real = UpdateMicroButtons
function UpdateMicroButtons()
for _, name in ipairs({ "CharacterFrame", "SpellBookFrame", "QuestLogFrame", "GameMenuFrame",
"OptionsFrame", "SoundOptionsFrame", "UIOptionsFrame", "FriendsFrame", "WorldMapFrame",
"HelpFrame" }) do
if not getglobal(name) then local f = CreateFrame("Frame") f:Hide() setglobal(name, f) end
end
if not KeyRingButton then KeyRingButton = CreateFrame("Button") KeyRingButton:Hide() end
if not KEYRING_CONTAINER then KEYRING_CONTAINER = -2 end
if not IsBagOpen then function IsBagOpen() return nil end end
return real()
end
"#;
/// One file's bytes, from whichever store its path names — the chain for a path, this crate's
/// `assets/ui` for a bare name. Also the `<Include>` / `<Script file=>` provider, which is why it
/// takes an already-resolved path in either space.
@@ -222,9 +263,12 @@ pub(super) const LOOT_UI: &[&str] = &[
/// `HonorLevelText` and `HonorGuildText` "while we're at it" (`PaperDollFrame.lua:100`/`:120`).
/// Nothing touches them at load, so leaving this out loads clean and then raises the first time
/// the window is SHOWN. It sits below the character block in the manifest for the same reason.
/// * **`MicroMenu.xml`** — `UpdateMicroButtons` (called by both `CharacterFrame_OnShow` and
/// `_OnHide`) and `MicroButtonTooltipText` (all five tab hovers). A tab hover is the only thing
/// that reaches the second one, so its absence is invisible until a test hovers a tab.
/// * **`MainMenuBarMicroButtons.xml`** — `UpdateMicroButtons` (called by both
/// `CharacterFrame_OnShow` and `_OnHide`) and `MicroButtonTooltipText` (all five tab hovers).
/// A tab hover is the only thing that reaches the second one, so its absence is invisible
/// until a test hovers a tab. The reference's own file since 1987; the panels its
/// `UpdateMicroButtons` reads unguarded and this kit stops short of are
/// [`MICRO_BUTTON_STAND_INS`]'s.
///
/// Needs client data, like its three siblings: open with `benilla_formats::wow_data_or_skip!()`.
pub(super) const CHARACTER_UI: &[&str] = &[
@@ -272,7 +316,7 @@ pub(super) const CHARACTER_UI: &[&str] = &[
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
"MicroMenu.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
// The two page files these three need before the window can be OPENED, which is not the same
// as before it can load: `CHARACTERFRAME_SUBFRAMES` lists all five pages by name and
// `CharacterFrame_ShowSubFrame` calls `getglobal(value):Hide()` on each one it is not showing
@@ -331,7 +375,7 @@ pub(super) const SOCIAL_UI: &[&str] = &[
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
"MicroMenu.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
"Interface\\FrameXML\\UnitPopup.xml",
"Interface\\FrameXML\\UIMenu.xml",
"Interface\\FrameXML\\ChatFrame.xml",
@@ -393,7 +437,7 @@ pub(super) const BAG_UI: &[&str] = &[
"Interface\\FrameXML\\BonusActionBarFrame.xml",
// `UpdateMicroButtons` — the KEYRING's own OnShow/OnHide calls it (ContainerFrame.lua l.117,
// l.137), because in the reference the keyring's existence moves the micro-button row.
"MicroMenu.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
// The dialog engine, after the UIPanelCloseButton it inherits (1960).
@@ -71,7 +71,7 @@ fn trainer_script_base() -> UiScript {
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
"MicroMenu.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
"Interface\\FrameXML\\MerchantFrame.xml",
"Interface\\FrameXML\\ClassTrainerFrameTemplates.xml",
] {
@@ -284,3 +284,42 @@ fn the_font_path_globals_are_the_references_own_four() {
"|cffffff9a"
);
}
/// `MouseIsOver` divides the cursor by the frame's effective scale, as the reference does
/// (UIParent.lua l.1389-1390): a frame scaled to 0.5 whose screen footprint holds the cursor
/// answers 1 — with the division written out (the old "the scale is the constant 1" note) it
/// answered nil under the very cursor that had just entered it (decision 1985).
#[test]
fn mouse_is_over_reads_a_scaled_frame_in_its_own_units() {
let mut s = ui_parent();
s.run(
r#"Scaled = CreateFrame("Frame", "Scaled", UIParent) Scaled:SetWidth(200) Scaled:SetHeight(100)
Scaled:SetPoint("BOTTOMLEFT", 100, 100) Scaled:SetScale(0.5)"#,
)
.unwrap();
s.resolve();
// The frame's screen footprint is its own-unit box times its effective scale (offsets and
// size both scale); the probe sits at its centre, then just past its right edge.
let (l, r, b, t, eff) = s
.eval::<(f64, f64, f64, f64, f64)>(
"return Scaled:GetLeft(), Scaled:GetRight(), Scaled:GetBottom(), Scaled:GetTop(), Scaled:GetEffectiveScale()",
)
.unwrap();
assert!(
(eff - 0.5).abs() < 1e-6 && (r - l - 200.0).abs() < 1e-3,
"own units: {l}..{r} at {eff}"
);
let (cx, cy) = (((l + r) * 0.5 * eff) as f32, ((b + t) * 0.5 * eff) as f32);
s.mouse_move(cx, cy);
assert_eq!(
s.eval::<Option<i64>>("return MouseIsOver(Scaled)").unwrap(),
Some(1),
"the cursor at the scaled footprint's centre ({cx}, {cy})"
);
s.mouse_move((r * eff) as f32 + 10.0, cy);
assert_eq!(
s.eval::<Option<i64>>("return MouseIsOver(Scaled)").unwrap(),
None,
"past the scaled footprint (inside the unscaled one) is outside"
);
}
@@ -469,6 +469,7 @@ fn left_clicking_the_player_frame_targets_self() {
// The player's own guid, which this fixture leaves unset — spelled out because a bare 0
// is also the reference's "ungrouped" sentinel and this party has a member.
leader_guid: 0,
own_guid: 0,
raid: Vec::new(),
loot_method: "group".into(),
master_looter: None,
@@ -551,6 +552,7 @@ fn raid_mark_clicks_through_the_nested_level() {
}],
leader_index: 0, // we lead — the mark rows are leader-gated
leader_guid: 0, // the player's own guid; this fixture leaves it unset
own_guid: 0,
raid: Vec::new(),
loot_method: "group".into(),
master_looter: None,
@@ -663,7 +665,7 @@ fn shipped_target_frame_runs_the_level_law() {
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "MicroMenu.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
// The player at level 3, both feeds (the snapshot UnitLevel("player") reads; the req state
// the −1 gate and GetQuestGreenRange read) — the app keeps the two in step.
@@ -1270,6 +1272,7 @@ fn the_party_art_paints_over_the_bars() {
}],
leader_index: 0,
leader_guid: 0,
own_guid: 0,
raid: Vec::new(),
loot_method: "group".into(),
master_looter: None,
@@ -1999,6 +2002,7 @@ fn the_player_frame_wears_the_leader_and_master_looter_icons() {
leader_index,
// Follows `leader_index`: 0 = the player (unset here), else the member who leads.
leader_guid: if leader_index == 0 { 0 } else { 0x7A17 },
own_guid: 0,
raid: Vec::new(),
loot_method: method.into(),
master_looter,
@@ -537,3 +537,54 @@ fn the_maps_own_furniture_survives_the_hide_that_showing_it_performs() {
assert_eq!(visible(&s, "BlackoutWorld"), 0, "with the blackout down");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Hovering the player arrow on a scaled map names the player** (director report: an empty
/// tooltip). On a window narrower than 4:3 the stock `SetupFullscreenScale` scales the map
/// under 1 (0.802 here); `WorldMapUnit_OnEnter` then asks `MouseIsOver(WorldMapPlayer)` for the
/// text, which read the cursor against the blip's own-unit edges without the reference's scale
/// division and answered nil — a tooltip with no lines (decision 1985).
#[test]
fn hovering_the_player_blip_on_a_scaled_map_names_the_player() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(821.0, 768.0);
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();
s.run("ShowUIPanel(WorldMapFrame)").unwrap();
s.resolve();
assert!(
s.eval::<f64>("return WorldMapFrame:GetScale()").unwrap() < 0.99,
"the narrow window scales the map under 1"
);
s.set_world_map_feed(None, Some((0.5, 0.5)), 0.0, None, Vec::new(), Vec::new());
update(&mut s);
s.resolve();
let (px, py, eff) = s
.eval::<(f64, f64, f64)>(
"local x, y = WorldMapPlayer:GetCenter() return x, y, WorldMapPlayer:GetEffectiveScale()",
)
.unwrap();
s.mouse_move((px * eff) as f32, (py * eff) as f32);
assert_eq!(
s.hit_test_name((px * eff) as f32, (py * eff) as f32)
.as_deref(),
Some("WorldMapPlayer")
);
assert!(s.eval::<bool>("return WorldMapTooltip:IsShown()").unwrap());
assert_eq!(
s.eval::<String>("return WorldMapTooltipTextLeft1:GetText()")
.unwrap(),
"Probefour"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+11 -1
View File
@@ -68,7 +68,17 @@ impl Plugin for UiStablePlugin {
.after(UiInput)
// Nothing samples the booth while no stable is open, and the feed's
// first line is a name scan over the whole frame arena (1979's floor).
.run_if(|open: Res<StableOpen>| open.npc.is_some()),
.run_if(
|open: Res<StableOpen>, booth: Res<crate::portrait::StableBooth>| {
// …and one more run after the close: the feed is also what
// EMPTIES the booth (unit/display_id → None), and a gate that
// shut on the close frame left the pet standing in a closed
// window's pane for good (review of 2026-09-04).
open.npc.is_some()
|| booth.unit.is_some()
|| booth.display_id.is_some()
},
),
),
);
}
+27
View File
@@ -52,4 +52,31 @@ pub fn load_ui(script: &UiScript, entry: &str) {
"{entry}: loader errors: {:#?}",
report.errors
);
if entry
.rsplit(['\\', '/'])
.next()
.is_some_and(|leaf| leaf.eq_ignore_ascii_case("MainMenuBarMicroButtons.xml"))
{
script
.run(MICRO_BUTTON_STAND_INS)
.expect("the micro-button stand-ins");
}
}
/// The stock micro-button row's unguarded reads, stood in for on the row's first call — one copy
/// per store, like the loader itself. `ui_script::test_ui::MICRO_BUTTON_STAND_INS` is the
/// original and carries the why (decision 1987).
const MICRO_BUTTON_STAND_INS: &str = r#"
local real = UpdateMicroButtons
function UpdateMicroButtons()
for _, name in ipairs({ "CharacterFrame", "SpellBookFrame", "QuestLogFrame", "GameMenuFrame",
"OptionsFrame", "SoundOptionsFrame", "UIOptionsFrame", "FriendsFrame", "WorldMapFrame",
"HelpFrame" }) do
if not getglobal(name) then local f = CreateFrame("Frame") f:Hide() setglobal(name, f) end
end
if not KeyRingButton then KeyRingButton = CreateFrame("Button") KeyRingButton:Hide() end
if not KEYRING_CONTAINER then KEYRING_CONTAINER = -2 end
if not IsBagOpen then function IsBagOpen() return nil end end
return real()
end
"#;
@@ -0,0 +1,264 @@
//! **The cover-input wall** — the loading screen's input rule, counted from source on every
//! `cargo test`, so the swallow list can never quietly fall behind what the client actually reads.
//!
//! `loading_screen/input.rs` takes the whole input plane while the cover is up, at the source, in
//! `PreUpdate`. That cut is what makes the rule survive a consumer nobody remembered — the world
//! pick chain, which hovered units and changed the hardware cursor *through* the loading screen
//! until the director noticed. But a source cut only covers the channels it names, and bevy has
//! more channels than we currently read: touch, IME, gamepads, the trackpad gestures. The day
//! someone reads one of those, the cover has to grow a line — and nothing in the compiler will say
//! so.
//!
//! This is that "nothing". It scans every workspace source for the input channels the client
//! reaches for and requires each to carry a **verdict** in [`VERDICTS`] below:
//!
//! - **`Swallowed`** — the cover empties it. Cross-checked: the name must literally appear in the
//! gate's own source, so the table cannot claim coverage the gate does not implement.
//! - **`Open`** — deliberately *not* swallowed, with the reason written down. Window lifecycle is
//! the whole of this class today: a close request, a focus change and an occlusion change must
//! still land while a load is running.
//! - **`Plumbing`** — a type, enum, plugin or system-set name that carries no player input at all.
//!
//! An unclassified name fails the test with the question it wants answered: *does the cover
//! swallow this?* Answering it is one line here and, when the answer is yes, one line in the gate.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Verdict {
/// The cover empties this channel — see `loading_screen/input.rs`.
Swallowed,
/// Deliberately left open under the cover. The `&str` is the reason.
Open(&'static str),
/// Not a player-input channel at all.
Plumbing,
}
use Verdict::{Open, Plumbing, Swallowed};
/// Every `bevy::input::…` / `bevy::window::…` leaf the workspace names, plus the prelude-sourced
/// input items, each with its verdict. **The wall itself.**
const VERDICTS: &[(&str, Verdict)] = &[
// ── The channels the cover takes ────────────────────────────────────────────────────────
("ButtonInput", Swallowed),
("KeyboardInput", Swallowed),
("MouseButtonInput", Swallowed),
("MouseMotion", Swallowed),
("MouseWheel", Swallowed),
("CursorMoved", Swallowed),
("AccumulatedMouseMotion", Swallowed),
("AccumulatedMouseScroll", Swallowed),
// The pointer position is a *field*, not a message; the cover blanks it, which is why every
// hit-test in the client goes quiet without knowing the cover exists.
("cursor_position", Swallowed),
("physical_cursor_position", Swallowed),
// ── Deliberately open under the cover ───────────────────────────────────────────────────
(
"WindowCloseRequested",
Open("closing the window during a load must work — it is the OS asking, not the player"),
),
(
"WindowFocused",
Open("alt-tab bookkeeping; `cursor.rs` re-asserts the hardware cursor on the focus edge"),
),
(
"WindowOccluded",
Open("the present-mode throttle: a load behind another window must still throttle"),
),
(
"CursorOptions",
Open("an OUTPUT (grab/visible), not a channel — the cover has nothing to take from it"),
),
// ── Not input at all ────────────────────────────────────────────────────────────────────
("ButtonState", Plumbing),
// The cursor OUTPUTS — the art and the grab mode benilla *writes*. Nothing arrives through
// them, so there is nothing for the cover to take.
("CursorGrabMode", Plumbing),
("CursorIcon", Plumbing),
("CustomCursor", Plumbing),
("CustomCursorImage", Plumbing),
("InputPlugin", Plumbing),
("InputSystems", Plumbing),
("Key", Plumbing),
("KeyCode", Plumbing),
("MouseButton", Plumbing),
("MouseScrollUnit", Plumbing),
("NativeKey", Plumbing),
("ExitCondition", Plumbing),
("Monitor", Plumbing),
("MonitorSelection", Plumbing),
("PresentMode", Plumbing),
("PrimaryWindow", Plumbing),
("RawHandleWrapper", Plumbing),
("Window", Plumbing),
("WindowLevel", Plumbing),
("WindowMode", Plumbing),
("WindowPlugin", Plumbing),
("WindowPosition", Plumbing),
("WindowResolution", Plumbing),
];
/// The gate's own source — the cross-check target for every `Swallowed` row.
const GATE: &str = "crates/benilla-app/src/loading_screen/input.rs";
/// The prelude re-exports these, so a source can name them with no `bevy::input::` path in sight.
/// Scanned for by bare identifier; anything matched still has to carry a verdict above.
const PRELUDE_INPUT_NAMES: &[&str] = &[
"ButtonInput",
"AccumulatedMouseMotion",
"AccumulatedMouseScroll",
"cursor_position",
"physical_cursor_position",
];
#[test]
fn every_input_channel_the_client_reads_has_a_verdict_under_the_cover() {
let root = workspace_root();
let mut found: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for file in workspace_sources(&root) {
// The wall names every channel in its own prose; scanning it would only find itself.
if file.ends_with("tests/cover_input_wall.rs") {
continue;
}
let Ok(text) = std::fs::read_to_string(&file) else {
continue;
};
let rel = file
.strip_prefix(&root)
.unwrap_or(&file)
.to_string_lossy()
.to_string();
for name in input_names(&text) {
found.entry(name).or_default().insert(rel.clone());
}
}
let table: BTreeMap<&str, Verdict> = VERDICTS.iter().copied().collect();
let unclassified: Vec<_> = found
.iter()
.filter(|(name, _)| !table.contains_key(name.as_str()))
.collect();
assert!(
unclassified.is_empty(),
"the client reads input channels the cover has never been asked about.\n\
For each, decide: does the loading cover swallow it? If yes, add the line to {GATE} \
and a `Swallowed` row to VERDICTS; if not, an `Open` row saying why.\n{}",
unclassified
.iter()
.map(|(name, files)| format!(
" {name} — {}",
files.iter().take(3).cloned().collect::<Vec<_>>().join(", ")
))
.collect::<Vec<_>>()
.join("\n")
);
// A `Swallowed` verdict is a claim about the gate's source; hold it to it. (The reverse is not
// asserted: the gate may name a channel nothing reads yet — that is the cover being ahead.)
let gate = std::fs::read_to_string(root.join(GATE)).expect("the gate's source");
let unbacked: Vec<&str> = VERDICTS
.iter()
.filter(|(name, v)| *v == Swallowed && !gate.contains(name))
.map(|(name, _)| *name)
.collect();
assert!(
unbacked.is_empty(),
"VERDICTS claims the cover swallows these, but {GATE} never names them: {unbacked:?}"
);
// And the table itself must not rot: a row nothing in the workspace names any more is a row to
// delete, not to keep as decoration. (`Plumbing` rows are exempt — they exist to absorb noise.)
let stale: Vec<&str> = VERDICTS
.iter()
.filter(|(name, v)| *v != Plumbing && !found.contains_key(*name))
.map(|(name, _)| *name)
.collect();
assert!(
stale.is_empty(),
"VERDICTS rows nothing reads any more — delete them: {stale:?}"
);
}
/// Every input-ish identifier `text` names: the leaf of any `bevy::input::…`/`bevy::window::…`
/// path (grouped `use` braces expanded), plus the prelude-sourced names.
fn input_names(text: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for prefix in ["bevy::input::", "bevy::window::"] {
let mut rest = text;
while let Some(at) = rest.find(prefix) {
rest = &rest[at + prefix.len()..];
// `use bevy::input::mouse::{A, B};` — take the braced group whole.
let path: String = rest
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_' || *c == ':')
.collect();
let leaf_source = if rest[path.len()..].starts_with('{') {
let end = rest[path.len()..].find('}').unwrap_or(0);
rest[path.len() + 1..path.len() + end].to_string()
} else {
path.clone()
};
for item in leaf_source.split(',') {
// `keyboard::KeyboardInput`, `KeyCode::Space`, `self` → the first segment that is
// a type-shaped name (upper-case initial) wins; a module segment is skipped.
if let Some(name) = item
.split("::")
.map(str::trim)
.find(|seg| seg.starts_with(|c: char| c.is_ascii_uppercase()))
{
out.insert(name.to_string());
}
}
rest = &rest[path.len()..];
}
}
for name in PRELUDE_INPUT_NAMES {
if text.contains(name) {
out.insert((*name).to_string());
}
}
out
}
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(Path::parent)
.expect("crates/benilla-app → the workspace root")
.to_path_buf()
}
/// Every `.rs` under each crate's `src/` and `examples/` — the client's own code. Test files are
/// included on purpose: a test that reaches for a new input channel is still a signal that the
/// channel exists, and the verdict costs one line.
fn workspace_sources(root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
for crate_dir in std::fs::read_dir(root.join("crates"))
.into_iter()
.flatten()
.flatten()
{
for sub in ["src", "examples", "tests"] {
let dir = crate_dir.path().join(sub);
if dir.is_dir() {
collect_rs(&dir, &mut out);
}
}
}
out.sort();
out
}
fn collect_rs(dir: &Path, out: &mut Vec<PathBuf>) {
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
for e in std::fs::read_dir(&d).into_iter().flatten().flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
} else if p.extension().is_some_and(|x| x == "rs") {
out.push(p);
}
}
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ const FILES: &[&str] = &[
// `UpdateMicroButtons()` — both live here, and a nil `TalentMicroButton` throws out of OnShow
// BEFORE `TalentFrame_Update()`, so the whole window comes up empty (decision 1833). Our
// retired file's OnShow called neither, which is why this was never a dependency before.
"MicroMenu.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
// Five files behind one line: the `.xml` sources its own `.lua` and `<Include>`s the
// templates file that declares TalentButton/Branch/Arrow/TabTemplate.
"Interface\\AddOns\\Blizzard_TalentUI\\Blizzard_TalentUI.xml",
+54 -43
View File
@@ -368,35 +368,15 @@ pub enum ZTarget {
Region(RegionHandle),
}
/// Produce the render list in the client's exact `render_traverse_order 0x765650` order.
///
/// Only **effective-visible** frames contribute: because `effective_visible` is maintained across
/// the whole subtree by the arena's propagation (`set_shown`/`set_parent`), filtering on the flag is
/// equivalent to the client's "recurse to child frames, a hidden mid-tree frame blocks its subtree"
/// (`propagation.md`) — a child of a hidden frame already carries `effective_visible == false` and is
/// skipped here. Each visible frame emits its own [`ZTarget::Frame`] entry followed by one
/// [`ZTarget::Region`] per owned region; the returned vec is sorted ascending by [`ZKey`], which *is*
/// the total draw order (strata → frame level → **draw layer** → texture<fontstring → frame
/// link-stamp → is-region → sub-level → decl; the layer outranks the frame — see the `ZKey`
/// bit-layout note and decision 0884). Ties are impossible: distinct frames differ in link-stamp, a frame and its
/// regions differ in the is-region bit, and a frame's regions differ in the remaining fields.
///
/// Ordering only: this emits an entry for *every* region of a visible frame. Region-level
/// `Show`/`Hide` (the VisibleRegion bit, `region+0xc4`) is applied one layer up, where paint lives —
/// [`UiScript::extract`](crate::script::UiScript::extract) drops hidden regions before they become
/// quads. (The 1.12 region-draw cluster that would pin the *draw-time* skip is still flagged unread
/// in wow-re's findings; the flag itself and its setter `0x77fcb0` are recorded.)
/// The sorted draw list, cached against a fingerprint of its inputs (decision 1979): the list
/// is a pure function of every visible frame's `(strata, level, insertion)` and every attached
/// region's layer/sub-level/kind/decl — a walk over ~2 k frames and ~6 k regions, then a sort,
/// and it was rebuilt on every frame for the extract, the pointer and the edit box alike. The
/// walk stays (it is the fingerprint); the sort and the allocation happen only when the walk
/// hashes differently from the last one, which on a still frame it never does.
/// The sorted draw list itself — shared, so a frame that changed nothing hands out the same one.
pub type DrawList = std::sync::Arc<[(ZTarget, ZKey)]>;
#[derive(Default, Debug, Clone)]
pub struct OrderCache(std::cell::RefCell<Option<(u64, DrawList)>>);
/// [`traversal`]'s memo: the last walk (arena order) and the sorted list built from it.
#[derive(Default, Debug)]
pub struct OrderCache(std::cell::RefCell<Option<OrderMemo>>);
/// The last walk, in arena order, and the sorted list built from it.
type OrderMemo = (Vec<(ZTarget, ZKey)>, DrawList);
/// Every `(target, key)` of the draw list, in ARENA order — the one walk both the fingerprint
/// and the rebuild are made of, so they cannot disagree.
@@ -430,27 +410,58 @@ fn walk(arena: &WidgetArena, mut f: impl FnMut(ZTarget, ZKey)) {
}
}
/// Produce the render list in the client's exact `render_traverse_order 0x765650` order.
///
/// Only **effective-visible** frames contribute: because `effective_visible` is maintained across
/// the whole subtree by the arena's propagation (`set_shown`/`set_parent`), filtering on the flag is
/// equivalent to the client's "recurse to child frames, a hidden mid-tree frame blocks its subtree"
/// (`propagation.md`) — a child of a hidden frame already carries `effective_visible == false` and is
/// skipped here. Each visible frame emits its own [`ZTarget::Frame`] entry followed by one
/// [`ZTarget::Region`] per owned region; the returned vec is sorted ascending by [`ZKey`], which *is*
/// the total draw order (strata → frame level → **draw layer** → texture<fontstring → frame
/// link-stamp → is-region → sub-level → decl; the layer outranks the frame — see the `ZKey`
/// bit-layout note and decision 0884). Ties are impossible: distinct frames differ in link-stamp, a frame and its
/// regions differ in the is-region bit, and a frame's regions differ in the remaining fields.
///
/// Ordering only: this emits an entry for *every* region of a visible frame. Region-level
/// `Show`/`Hide` (the VisibleRegion bit, `region+0xc4`) is applied one layer up, where paint lives —
/// [`UiScript::extract`](crate::script::UiScript::extract) drops hidden regions before they become
/// quads. (The 1.12 region-draw cluster that would pin the *draw-time* skip is still flagged unread
/// in wow-re's findings; the flag itself and its setter `0x77fcb0` are recorded.)
///
/// Cached against its own inputs (decision 1979): the list
/// is a pure function of every visible frame's `(strata, level, insertion)` and every attached
/// region's layer/sub-level/kind/decl — a walk over ~2 k frames and ~6 k regions, then a sort,
/// and it was rebuilt on every frame for the extract, the pointer and the edit box alike. The
/// walk stays (it is the fingerprint); the sort and the allocation happen only when the walk
/// hashes differently from the last one, which on a still frame it never does.
pub fn traversal(arena: &WidgetArena) -> DrawList {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
let mut n = 0usize;
walk(arena, |t, k| {
t.hash(&mut hasher);
k.hash(&mut hasher);
n += 1;
});
n.hash(&mut hasher);
let fingerprint = hasher.finish();
if let Some((cached, list)) = arena.order_cache.0.borrow().as_ref() {
if *cached == fingerprint {
return list.clone();
let mut cache = arena.order_cache.0.borrow_mut();
// One walk into a scratch (the previous walk's buffer, reused), then an EXACT compare
// against the last walk: equal inputs hand out the same list; a hash would have made a
// wrong draw order a possibility, however remote, and cost a second walk on every miss.
let (mut walked, last) = match cache.take() {
Some((prev, list)) => {
let mut scratch = Vec::with_capacity(prev.len());
walk(arena, |t, k| scratch.push((t, k)));
if scratch == prev {
*cache = Some((prev, list.clone()));
return list;
}
(scratch, Some(prev))
}
}
let mut out: Vec<(ZTarget, ZKey)> = Vec::with_capacity(n);
walk(arena, |t, k| out.push((t, k)));
None => {
let mut scratch = Vec::new();
walk(arena, |t, k| scratch.push((t, k)));
(scratch, None)
}
};
drop(last);
let mut out = walked.clone();
out.sort_by_key(|&(_, k)| k);
let list: DrawList = out.into();
*arena.order_cache.0.borrow_mut() = Some((fingerprint, list.clone()));
walked.shrink_to_fit();
*cache = Some((walked, list.clone()));
list
}
+3
View File
@@ -490,6 +490,8 @@ pub(crate) struct Model {
/// Party/loot intents (`AcceptGroup`/`InviteToParty`/`SetLootMethod`/…) queued since the
/// app's last [`super::UiScript::take_party_requests`] drain — the outbound seam ([`party`]).
pub(crate) party_requests: Vec<party::PartyRequest>,
/// The ready-check deadline and the unanswered flags the timeout tick reads (decision 1989).
pub(crate) ready_check: party::ReadyCheckState,
/// The saved raid lockouts the Raid tab's info panel reads (decision 1549), pushed by the app
/// from `SMSG_RAID_INSTANCE_INFO`. Its own field rather than a [`party::PartyState`] member
/// because it arrives on its own packet and survives every roster change.
@@ -1776,6 +1778,7 @@ impl Model {
joined_channels: Vec::new(),
party: party::PartyState::default(),
party_requests: Vec::new(),
ready_check: party::ReadyCheckState::default(),
saved_instances: Vec::new(),
raid_selection: 0,
instance_type: None,
+6 -3
View File
@@ -29,6 +29,7 @@ use crate::widget::{FrameHandle, FrameKind};
mod events_regions;
mod frame_state;
mod layout_methods;
pub(crate) use layout_methods::eff_scale;
pub(crate) mod movable;
pub(crate) mod toplevel;
pub(crate) use layout_methods::{anchor_bits_eq, anchor_retarget_is_structural};
@@ -358,9 +359,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// GetCursorPosition() → x, y — the last cursor position the host fed (`mouse_move`/
// `mouse_button`), UI units y-up like every other coordinate read. The real client scales by
// the UI scale; ours is the constant 1 (`GetEffectiveScale`), so the ref's `/scale` dance is
// an identity. The world map polls this every OnUpdate for hover/click math.
// `mouse_button`), UI units y-up like every other coordinate read — the SCREEN's units, which
// is what the reference hands Lua too; a caller inside a scaled frame divides by its
// `GetEffectiveScale()` (the reference's own `MouseIsOver` does, and the stock world map at
// a scale under 1 is the case that made the division load-bearing here — decision 1985). The
// world map polls this every OnUpdate for hover/click math.
lua.globals().set(
"GetCursorPosition",
lua.create_function(|lua, ()| {
@@ -312,7 +312,7 @@ fn settle(lua: &Lua) {
crate::script::UiScript::resolve_layout(&mut model);
}
pub(super) fn eff_scale(model: &Model, h: FrameHandle) -> f32 {
pub(crate) fn eff_scale(model: &Model, h: FrameHandle) -> f32 {
let s = model
.arena
.frame(h)
+314
View File
@@ -155,6 +155,11 @@ pub struct PartyState {
/// the reference answers **`1` while solo**. A client that answers `nil` there is not
/// reproducing 1.12 (wow-re `ui/scratch/party-leader-and-nameplate-verbs.md`).
pub leader_guid: u64,
/// **The active player's own GUID** — the reference's `0x468550` read (`[0xb41414]+0xc0`),
/// `0` out of world. Fed by the app beside the leader; the pair is the leader gate the
/// ready-check timeout runs on (`CheckReadyCheckTime`, decision 1989): the reference compares
/// the two guids and nothing else, so a solo player (leader `0`, own non-zero) never matches.
pub own_guid: u64,
/// The **whole raid roster**, `GetRaidRosterInfo`'s 1-based array — empty outside a raid,
/// and **including the player** (the reference's array does; it is why `UnitInRaid("player")`
/// answers `1` in a raid and why this list is not `members`'s recipient-excluded shape).
@@ -245,6 +250,62 @@ impl super::UiScript {
/// job, never auto-fired here.
pub fn set_party(&mut self, state: PartyState) {
self.model_mut().party = state;
// `SMSG_GROUP_LIST`'s ready-check leg (`0x4ba5f0`, reached only from the `0x7d` handler):
// the roster scan over the NEW roster — a member who left took their pending flag with
// them — and when nobody is left pending, a forced close (decision 1989).
let lua = self.lua();
let mut model = self.model_mut();
if model.ready_check.deadline.is_some() {
let roster: Vec<u64> = model.party.raid.iter().map(|m| m.guid).collect();
model.ready_check.unanswered.retain(|g| roster.contains(g));
if model.ready_check.unanswered.is_empty() {
ready_check_force_close(lua, &mut model);
}
}
}
/// The `MSG_RAID_READY_CHECK` open form arrived (decision 1989). The handler `0x4ba360` splits
/// on the leader guid: the **leader** takes the response-collection arm — reads the per-member
/// records the body carries (none, on vmangos) and force-closes if nobody is left pending;
/// everyone **else** arms the 30 s deadline (`0x4ba535`) that the leader-gated tick never
/// acts on for them, and gets the `READY_CHECK` popup — which the app fires on its own
/// ticket edge, so this method carries only the state half.
pub fn ready_check_request(&mut self, we_lead: bool) {
let lua = self.lua();
let now = clock(lua);
let mut model = self.model_mut();
if we_lead {
if model.ready_check.deadline.is_some() && model.ready_check.unanswered.is_empty() {
ready_check_force_close(lua, &mut model);
}
} else {
model.ready_check.deadline = Some(now + READY_CHECK_SECONDS);
}
}
/// One member's answer, forwarded to the leader (`{guid, status}`, decision 1989): the
/// member's "has not answered" flag clears (`0x4ba40e`), and once nobody is left pending the
/// check closes at once (`0x4ba4ce` → the worker) rather than at the deadline.
///
/// **INFERRED, pending wow-re:** the flag clears on ANY answer, ready or not — the flag's
/// meaning is "has not answered yet" (its writer census), and the summary the reference
/// prints is headed "AFK", not "not ready". The polarity of the store at `0x4ba40e` against
/// the wire byte is dispatched for a byte read; `ready` is carried here so that one line
/// changes if the answer says otherwise.
pub fn ready_check_answered(&mut self, guid: u64, _ready: bool) {
let lua = self.lua();
let mut model = self.model_mut();
model.ready_check.unanswered.retain(|g| *g != guid);
if model.ready_check.deadline.is_some() && model.ready_check.unanswered.is_empty() {
ready_check_force_close(lua, &mut model);
}
}
/// The summary lines the timeout worker composed since the last drain — the reference's
/// `0x49a870(text, 10)` = a `CHAT_MSG_SYSTEM` line the client prints itself; the app pushes
/// them into the chat log the way every other client-composed system line goes.
pub fn take_ready_check_lines(&mut self) -> Vec<String> {
std::mem::take(&mut self.model_mut().ready_check.lines)
}
/// Drain the party/loot intents queued since the last call.
@@ -676,11 +737,45 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"DoReadyCheck",
lua.create_function(|lua, ()| {
let now = clock(lua);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// The worker `0x4bb1d0` (decision 1989): every roster member except the caller is
// flagged "has not answered" (`0x4bb24c`), the caller cleared (`0x4bb258`), the 30 s
// deadline armed (`0x4bb2b6`) — then the packet. A party has no raid roster, so the
// flags are empty there and the leader's own echo closes the check at once.
let own = model.party.own_guid;
model.ready_check.unanswered = model
.party
.raid
.iter()
.map(|m| m.guid)
.filter(|g| *g != own)
.collect();
model.ready_check.deadline = Some(now + READY_CHECK_SECONDS);
model.party_requests.push(PartyRequest::ReadyCheckStart);
Ok(())
})?,
)?;
// `CheckReadyCheckTime` (`0x4bc120` → `0x4bb310`) — the ready-check timeout tick, called from
// the stock `UIParent.xml`'s `<OnUpdate>` every frame and driving the 30-second expiry alone
// (nothing in the engine polls it). Four conjuncts, any failure writing nothing: a deadline is
// armed, it has been reached, and the active player's guid equals the group leader's — a
// LEADER gate, so it is observably inert for anyone else. On expiry it disarms the deadline
// (the only clear-to-zero outside module init), lists every roster member still flagged as
// unanswered, and prints `RAID_MEMBERS_AFK` with the `", "`-joined names — or
// `READY_CHECK_NO_AFK` — as a `CHAT_MSG_SYSTEM` line. Both keys are read raw off `_G`
// (`0x704350`: `lua_gettable` on GLOBALSINDEX, empty-string default), never through
// `GetText`. Sends no packet, raises nothing, returns nothing (decision 1989; wow-re
// `ui/scratch/uiparent-onupdate-engine-verbs.md` §3).
g.set(
"CheckReadyCheckTime",
lua.create_function(|lua, ()| {
let now = clock(lua);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
ready_check_tick(lua, &mut model, now);
Ok(())
})?,
)?;
// ConfirmReadyCheck(ready) — the popup's two buttons, and the argument is read for TRUTH, not
// for a number: the reference's own No button calls it with no argument at all
// (`ConfirmReadyCheck()`) while Yes passes `1`, so "absent" has to mean "not ready".
@@ -772,6 +867,68 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(())
}
/// The ready-check deadline, `0x7530` ms after the arm (`0x4ba535`, `0x4bb2b6`).
const READY_CHECK_SECONDS: f64 = 30.0;
/// The client's ready-check state (`RaidInfo.cpp`, decision 1989): the armed deadline
/// (`[0xb713f4]`, `None` = disarmed) and the roster members whose "has not answered yet" flag
/// (`[entry+0x158]`) is set, plus the summary lines the timeout worker composed and the app has
/// not yet pushed into chat. Outside [`PartyState`] because a roster push replaces that whole
/// snapshot and this survives one — the reference keeps the two in different tables too.
#[derive(Debug, Default)]
pub(crate) struct ReadyCheckState {
pub(crate) deadline: Option<f64>,
pub(crate) unanswered: Vec<u64>,
pub(crate) lines: Vec<String>,
}
/// `GetTime()`'s clock — the session seconds `UiScript::tick` advances.
fn clock(lua: &Lua) -> f64 {
lua.globals().get("__benilla_now").unwrap_or(0.0)
}
/// The timeout worker `0x4bb310` — see `CheckReadyCheckTime`'s note for the contract.
fn ready_check_tick(lua: &Lua, model: &mut Model, now: f64) {
let Some(deadline) = model.ready_check.deadline else {
return;
};
if now < deadline {
return;
}
if model.party.own_guid != model.party.leader_guid {
return;
}
model.ready_check.deadline = None;
// The roster walk: an unresolved name is silently skipped (`0x55f080` with its query arm
// off), so a flagged member the roster cannot name contributes nothing to the list.
let names: Vec<&str> = model
.ready_check
.unanswered
.iter()
.filter_map(|g| model.party.raid.iter().find(|m| m.guid == *g))
.map(|m| m.name.as_str())
.filter(|n| !n.is_empty())
.collect();
let raw = |key: &str| lua.globals().get::<String>(key).unwrap_or_default();
let text = if names.is_empty() {
raw("READY_CHECK_NO_AFK")
} else {
raw("RAID_MEMBERS_AFK").replacen("%s", &names.join(", "), 1)
};
model.ready_check.lines.push(text);
}
/// The two C-side force-close sites (`0x4ba4d4`, `0x4bacb4`): the deadline is set to `now − 1`
/// and the worker runs at once. The arm test is theirs too — both force only an armed check.
fn ready_check_force_close(lua: &Lua, model: &mut Model) {
if model.ready_check.deadline.is_none() {
return;
}
let now = clock(lua);
model.ready_check.deadline = Some(now - 1.0);
ready_check_tick(lua, model, now);
}
#[cfg(test)]
mod tests {
use crate::script::{PartyMemberInfo, PartyRequest, PartyState, UiScript};
@@ -790,6 +947,7 @@ mod tests {
],
leader_index: 1, // Alice (party1) leads
leader_guid: 0xA11CE,
own_guid: 0x5E1F,
raid: Vec::new(),
loot_method: "group".into(),
master_looter: None,
@@ -1489,4 +1647,160 @@ mod tests {
.eval::<bool>(r#"return GetRaidTargetIndex("target") == nil"#)
.unwrap());
}
// ── The ready-check timeout (decision 1989) ─────────────────────────────────────────────
/// A raid we lead: us, Alice and Bob on the roster; the two strings the summary reads raw.
fn raid_we_lead(s: &UiScript) -> PartyState {
s.run(
r#"RAID_MEMBERS_AFK = "The following players are AFK: %s"
READY_CHECK_NO_AFK = "No players are AFK""#,
)
.unwrap();
let row = |name: &str, guid: u64| crate::script::RaidMemberInfo {
name: name.into(),
guid,
..Default::default()
};
PartyState {
members: vec![
PartyMemberInfo {
name: "Alice".into(),
guid: 0xA11CE,
},
PartyMemberInfo {
name: "Bob".into(),
guid: 0xB0B,
},
],
leader_index: 0,
leader_guid: 0x5E1F,
own_guid: 0x5E1F,
raid: vec![
row("Probefour", 0x5E1F),
row("Alice", 0xA11CE),
row("Bob", 0xB0B),
],
loot_method: "group".into(),
master_looter: None,
loot_threshold: 2,
}
}
/// `DoReadyCheck` flags every roster member but the caller and arms 30 s; the tick is a no-op
/// before the deadline, prints the `", "`-joined unanswered names on it, and — the only
/// clear-to-zero — disarms, so the next tick prints nothing.
#[test]
fn the_leaders_check_times_out_into_the_afk_list_and_disarms() {
let mut s = UiScript::new().unwrap();
let party = raid_we_lead(&s);
s.set_party(party);
s.run("__benilla_now = 100 DoReadyCheck()").unwrap();
assert_eq!(s.take_party_requests(), vec![PartyRequest::ReadyCheckStart]);
s.run("__benilla_now = 129.9 CheckReadyCheckTime()")
.unwrap();
assert!(
s.take_ready_check_lines().is_empty(),
"not before the deadline"
);
s.run("__benilla_now = 130 CheckReadyCheckTime()").unwrap();
assert_eq!(
s.take_ready_check_lines(),
vec!["The following players are AFK: Alice, Bob".to_string()]
);
s.run("__benilla_now = 200 CheckReadyCheckTime()").unwrap();
assert!(
s.take_ready_check_lines().is_empty(),
"disarmed by the summary"
);
}
/// An answer — ready or not — clears its member's flag; the answer that leaves nobody pending
/// closes the check at once (`0x4ba4d9`), well before the deadline.
#[test]
fn an_answer_clears_its_member_and_the_last_one_closes_the_check_at_once() {
let mut s = UiScript::new().unwrap();
let party = raid_we_lead(&s);
s.set_party(party);
s.run("__benilla_now = 100 DoReadyCheck()").unwrap();
s.ready_check_answered(0xA11CE, false);
assert!(
s.take_ready_check_lines().is_empty(),
"Bob is still pending"
);
s.ready_check_answered(0xB0B, true);
assert_eq!(
s.take_ready_check_lines(),
vec!["No players are AFK".to_string()],
"nobody left pending ⇒ the summary now, not at 130"
);
s.run("__benilla_now = 130 CheckReadyCheckTime()").unwrap();
assert!(s.take_ready_check_lines().is_empty());
}
/// The tick is leader-gated: a member's client arms the same 30 s on the open form and the
/// four conjuncts fail on the guid compare, every frame, forever — the deadline stays armed
/// (nothing on the member's side ever clears it) and nothing is ever printed.
#[test]
fn the_tick_is_inert_for_a_non_leader() {
let mut s = UiScript::new().unwrap();
s.run(r#"READY_CHECK_NO_AFK = "No players are AFK""#)
.unwrap();
s.set_party(two_member_party()); // Alice leads; we are 0x5E1F
s.run("__benilla_now = 100").unwrap();
s.ready_check_request(false);
s.run("__benilla_now = 130 CheckReadyCheckTime()").unwrap();
assert!(s.take_ready_check_lines().is_empty());
assert_eq!(s.model_mut().ready_check.deadline, Some(130.0));
}
/// `SMSG_GROUP_LIST`'s leg: a member who left took their pending flag with them, and a roster
/// scan that finds nobody pending force-closes the check.
#[test]
fn a_roster_change_with_nobody_left_pending_closes_the_check() {
let mut s = UiScript::new().unwrap();
let party = raid_we_lead(&s);
s.set_party(party.clone());
s.run("__benilla_now = 100 DoReadyCheck()").unwrap();
s.ready_check_answered(0xA11CE, true);
// Bob leaves: the roster without him.
let mut without_bob = party;
without_bob.raid.retain(|m| m.guid != 0xB0B);
without_bob.members.retain(|m| m.guid != 0xB0B);
s.set_party(without_bob);
assert_eq!(
s.take_ready_check_lines(),
vec!["No players are AFK".to_string()]
);
}
/// A plain party has no raid roster, so `DoReadyCheck` flags nobody — and the leader's own
/// echo of the open form, taking the response-collection arm, closes it on the spot.
#[test]
fn the_leaders_own_echo_closes_an_empty_check_at_once() {
let mut s = UiScript::new().unwrap();
let mut party = raid_we_lead(&s);
party.raid.clear();
s.set_party(party);
s.run("__benilla_now = 100 DoReadyCheck()").unwrap();
s.ready_check_request(true);
assert_eq!(
s.take_ready_check_lines(),
vec!["No players are AFK".to_string()]
);
}
/// The two keys are read raw off `_G` with an empty-string default — a missing GlobalStrings
/// prints an empty line and never raises.
#[test]
fn a_missing_summary_string_prints_an_empty_line() {
let mut s = UiScript::new().unwrap();
let party = raid_we_lead(&s);
s.set_party(party);
s.run("RAID_MEMBERS_AFK = nil __benilla_now = 100 DoReadyCheck()")
.unwrap();
s.run("__benilla_now = 130 CheckReadyCheckTime()").unwrap();
assert_eq!(s.take_ready_check_lines(), vec![String::new()]);
}
}
+28 -11
View File
@@ -86,10 +86,13 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| Ok(measured_wh(lua, &this)?.1))?,
)?;
// GetLeft/GetRight/GetTop/GetBottom — the region's RESOLVED edges (y-up UI units; frame twin
// in object.rs). Every drawable region carries anchors (authored or the creation-path
// implicit anchor, decision 1310) and reads its resolved rect; a templateless Lua region
// nobody anchored never resolves → nil, same as pre-resolve.
// GetLeft/GetRight/GetTop/GetBottom — the region's RESOLVED edges in its OWNER's units (y-up;
// screen ÷ the owner's effective scale, the frame twin's law in `object/layout_methods.rs` —
// a region shares its owner's scale, and a texture inside the scaled world map answered
// screen units here while its owner answered local ones, decision 1985). Every drawable
// region carries anchors (authored or the creation-path implicit anchor, decision 1310) and
// reads its resolved rect; a templateless Lua region nobody anchored never resolves → nil,
// same as pre-resolve.
for (name, pick) in [
("GetLeft", 0u8),
("GetRight", 1u8),
@@ -101,11 +104,14 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(move |lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.region_resolved.get(&rh).map(|r| match pick {
0 => r.left,
1 => r.right,
2 => r.top,
_ => r.bottom,
let inv = 1.0 / owner_scale(&model, rh);
Ok(model.region_resolved.get(&rh).map(|r| {
inv * match pick {
0 => r.left,
1 => r.right,
2 => r.top,
_ => r.bottom,
}
}))
})?,
)?;
@@ -201,10 +207,11 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let inv = 1.0 / owner_scale(&model, rh);
Ok(match model.region_resolved.get(&rh) {
Some(r) => (
Value::Number(f64::from((r.left + r.right) * 0.5)),
Value::Number(f64::from((r.bottom + r.top) * 0.5)),
Value::Number(f64::from(inv * (r.left + r.right) * 0.5)),
Value::Number(f64::from(inv * (r.bottom + r.top) * 0.5)),
),
None => (Value::Nil, Value::Nil),
})
@@ -273,3 +280,13 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
)?;
Ok(())
}
/// The region's owner frame's effective scale (1 for an orphan) — the divisor that turns a
/// resolved screen rect into the owner's units, the space every region getter answers in.
fn owner_scale(model: &Model, rh: crate::widget::RegionHandle) -> f32 {
model
.arena
.region(rh)
.map(|r| crate::script::object::eff_scale(model, r.owner))
.unwrap_or(1.0)
}
@@ -588,3 +588,34 @@ fn a_title_region_drag_swallows_the_press_and_ends_on_release() {
// that tells it apart from `StartMoving`, which raises "Frame %s is not movable".
assert!(!s.eval::<bool>("return TP:IsMovable()").unwrap());
}
/// A region's rect getters answer in its OWNER's units — screen ÷ the owner's effective scale —
/// exactly as the frame getters do (decision 1985): a texture inside a frame scaled to 0.5 that
/// covers the frame answers the frame's own width, not half of it.
#[test]
fn region_getters_answer_in_the_owners_units_under_scale() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"f = CreateFrame("Frame", "ScaledOwner") f:SetWidth(200) f:SetHeight(100)
f:SetPoint("BOTTOMLEFT", 100, 50) f:SetScale(0.5)
t = f:CreateTexture("ScaledTex") t:SetAllPoints(f)"#,
)
.unwrap();
s.resolve();
let (fl, fr, tl, tr, cx) = s
.eval::<(f64, f64, f64, f64, f64)>(
"local cx = t:GetCenter() return f:GetLeft(), f:GetRight(), t:GetLeft(), t:GetRight(), cx",
)
.unwrap();
assert!(
(fl - tl).abs() < 1e-3 && (fr - tr).abs() < 1e-3,
"frame {fl}..{fr} vs region {tl}..{tr}"
);
assert!(
(fr - fl - 200.0).abs() < 1e-3,
"the owner's own width, {}",
fr - fl
);
assert!((cx - (fl + fr) * 0.5).abs() < 1e-3);
}
@@ -1262,6 +1262,7 @@ fn unit_is_party_leader_ors_two_legs_and_answers_one_when_solo() {
// comparison against our group's leader could ever express.
s.set_party(PartyState {
leader_guid: 0x9999,
own_guid: 0,
..Default::default()
});
s.set_unit(
@@ -1285,6 +1286,7 @@ fn unit_is_party_leader_ors_two_legs_and_answers_one_when_solo() {
// out-of-range member — the client holds no object, so leg 1 has nothing to read.
s.set_party(PartyState {
leader_guid: THEM,
own_guid: 0,
..Default::default()
});
s.set_unit(
+44 -1
View File
@@ -578,7 +578,12 @@ pub(crate) fn face_billboards(
// when neither moved this frame, the placement below would come out bit-identical
// (the write guards at the end already knew that; the recompute did not — 1979's
// floor, every resident card re-placed on a still frame).
let tracked = card.scale_anim.is_some() || card.seq_translation.is_some();
// …and a card that has never been placed (the first-pass stamp is what says so): it
// spawned unrotated at scale one, and a still frame must not leave it that way
// (review of 2026-09-04 — a tile bursting in under a parked camera).
let tracked = card.scale_anim.is_some()
|| card.seq_translation.is_some()
|| card.gseq_attach_ms.is_none();
if let Some(owner) = card.follow {
match owners.get(owner) {
Ok(gt) => {
@@ -930,6 +935,44 @@ mod tests {
/// nails it to a fixed model-space point, so what the pad occludes changes with every camera
/// move — the reported "way too strong and off position". Both halves are asserted: the offset's
/// magnitude/direction at one camera, and that it FOLLOWS the camera to the next.
/// A card that spawns under a parked camera must still be placed on its first pass: the
/// still-frame skip (1979) has no view of "never placed", and a tile bursting in under a
/// parked camera left every glow card unrotated at scale one until the mouse moved
/// (review 2026-09-04).
#[test]
fn a_card_born_on_a_still_frame_is_placed() {
use benilla_assets::coords::wow_to_bevy;
let mut app = App::new();
app.init_resource::<Time>();
app.add_systems(Update, face_billboards);
app.world_mut()
.spawn((crate::view::WorldCamera, GlobalTransform::IDENTITY));
// Two quiet frames: the camera's change tick ages past the still-frame window.
app.update();
app.update();
let root = app
.world_mut()
.spawn(GlobalTransform::from_translation(Vec3::new(3.0, 1.5, 0.0)))
.id();
app.update();
let pivot = wow_to_bevy([-0.012, 0.162, -0.060]);
let frame = app
.world_mut()
.spawn((
BillboardCard::frame_following(BillboardKind::Spherical, pivot, root),
Transform::IDENTITY,
))
.id();
app.update();
let tf = *app.world().entity(frame).get::<Transform>().unwrap();
let pivot_world = Vec3::new(3.0, 1.5, 0.0) + pivot;
assert!(
(tf.translation - pivot_world).length() < 1e-5,
"a card born on a still frame sits at its pivot, not at the origin: {:?}",
tf.translation
);
}
#[test]
fn an_item_emitters_billboard_frame_puts_it_behind_the_pivot() {
use benilla_assets::coords::wow_to_bevy;
+9 -5
View File
@@ -476,7 +476,7 @@ fn gate_doodad_anim(
(
Ref<GlobalTransform>,
&bevy::camera::primitives::Frustum,
&bevy::camera::Projection,
Ref<bevy::camera::Projection>,
// The seat's own write, visible THIS frame: `GlobalTransform` only changes after
// propagation, which runs after this system — so on a teleport frame the global
// reads still while the camera has already moved. Reading the local too is what
@@ -516,17 +516,21 @@ fn gate_doodad_anim(
let world_cam = cam.single().ok();
let exterior_gate = crate::exterior_cull::ExteriorGate::build(
&exterior_windows,
world_cam.as_ref().map(|(tf, _, proj, _)| (&**tf, *proj)),
world_cam.as_ref().map(|(tf, _, proj, _)| (&**tf, &**proj)),
);
let camera_instance = camera_claim.0.map(|c| c.room.instance);
let verdicts_still = world_cam.as_ref().is_some_and(|(tf, _, _, local)| {
!tf.is_changed() && !local.as_ref().is_some_and(|l| l.is_changed())
let verdicts_still = world_cam.as_ref().is_some_and(|(tf, _, proj, local)| {
!tf.is_changed() && !proj.is_changed() && !local.as_ref().is_some_and(|l| l.is_changed())
}) && !exterior_windows.is_changed()
&& !camera_claim.is_changed()
&& !view.is_changed()
&& changed_vis.is_empty();
for (entity, mut host, lazy, pose, has_rig, player, drive) in &mut hosts {
let drawn = if verdicts_still {
// A host born this frame has no verdict to reuse (`active` starts false): a meshless one
// spawned under a parked camera stayed parked until the camera moved (review
// 2026-09-04). Read off the host's own tick — a second query on the component would
// conflict with this one's `&mut`.
let drawn = if verdicts_still && !host.is_added() {
host.active
} else if host.meshes.is_empty() {
// Meshless (particles-only) host: the emitters' own draw-set law (see the `fade`
+5 -1
View File
@@ -329,9 +329,13 @@ pub(crate) fn publish_model_alpha(
>,
// A declaration that went away is a change the `Ref` cannot see: those units are due.
mut undeclared: RemovedComponents<ModelFade>,
// The appear ramp's RETIREMENT is a removal too — the frame it goes, the alpha must land
// on its final value, not hold the ramp's last sample (review 2026-09-04).
mut ramp_done: RemovedComponents<UnitAppearFade>,
) {
let now = time.elapsed_secs();
let undeclared: bevy::platform::collections::HashSet<Entity> = undeclared.read().collect();
let mut undeclared: bevy::platform::collections::HashSet<Entity> = undeclared.read().collect();
undeclared.extend(ramp_done.read());
for (entity, appear, despawn, is_self, declared, current) in &mut units {
// With no ramp in flight and not the self body, the alpha is the declared fade alone —
// a published value that cannot have moved unless the declaration did. Every resident
@@ -30,11 +30,23 @@ use benilla_assets::materials::WowModelMaterial;
pub(super) fn apply_model_visibility(
debug: Res<DebugState>,
view: Res<ViewDistance>,
cam: Query<(Ref<GlobalTransform>, &Projection, Option<Ref<Transform>>), With<WorldCamera>>,
cam: Query<
(
Ref<GlobalTransform>,
Ref<Projection>,
Option<Ref<Transform>>,
),
With<WorldCamera>,
>,
// The two edges the per-part skip below cannot read off its own row: a building streaming
// in (its portal set is new), and any owner's inherited verdict flipping (a card follows it).
new_portals: Query<(), Added<WmoPortalInstance>>,
// `Changed`, not `Added`: the PVS writer sets the tick only on a real change of the
// visible set, and that set moves when a building's asset lands frames after its spawn.
new_portals: Query<(), Changed<WmoPortalInstance>>,
owner_flips: Query<(), Changed<InheritedVisibility>>,
// A part whose far-side mark was REMOVED reads as never marked; the classifier's full walk
// can strip it under a still camera (review 2026-09-04), and the strip must un-skip it.
mut unmarked: RemovedComponents<super::FarSideOfWater>,
// The per-frame WMO portal PVS (computed by `crate::wmo_portal`), read here so the cull composes
// with the toggles + far-clip in this single Visibility authority rather than fighting it.
instances: Query<&WmoPortalInstance>,
@@ -60,6 +72,7 @@ pub(super) fn apply_model_visibility(
// late to survive (decision 1409).
card_owners: Query<&InheritedVisibility>,
mut q: Query<(
Entity,
&ModelPart,
Ref<GlobalTransform>,
&mut Visibility,
@@ -101,10 +114,9 @@ pub(super) fn apply_model_visibility(
// floor: ~8.7 k resident parts re-verdicted on every still frame at the Stormwind pin).
// Both the propagated frame and the seat's own write: this system runs before propagation,
// so a teleport frame's move is only visible on the local `Transform` (see doodad_anim).
let scene_still = cam_view
.as_ref()
.is_some_and(|(t, _, l)| !t.is_changed() && !l.as_ref().is_some_and(|l| l.is_changed()))
&& !debug.is_changed()
let scene_still = cam_view.as_ref().is_some_and(|(t, p, l)| {
!t.is_changed() && !p.is_changed() && !l.as_ref().is_some_and(|l| l.is_changed())
}) && !debug.is_changed()
&& !view.is_changed()
&& !windows.is_changed()
&& !claim.is_changed()
@@ -117,15 +129,18 @@ pub(super) fn apply_model_visibility(
// asked per submesh below — the same value `crate::exterior_cull` asks for the objects it owns.
let gate = crate::exterior_cull::ExteriorGate::build(
&windows,
cam_view.as_ref().map(|(t, p, _)| (&**t, *p)),
cam_view.as_ref().map(|(t, p, _)| (&**t, &**p)),
);
// The placement the camera is standing in, exempt from its own window gate (see the param).
let own_instance = claim.0.map(|c| c.room.instance);
// Parallel: this walks EVERY model submesh in residency — ~100k in a city — and at that N a
// serial walk alone blew half the 16.7 ms budget (the Stormwind fps hunt). Every write below
// is change-gated, so the steady state is a pure read fan-out.
let unmarked: bevy::platform::collections::HashSet<Entity> = unmarked.read().collect();
let unmarked = &unmarked;
q.par_iter_mut().for_each(
|(
entity,
part,
xf,
mut vis,
@@ -140,6 +155,7 @@ pub(super) fn apply_model_visibility(
card,
)| {
if scene_still
&& !unmarked.contains(&entity)
&& !xf.is_changed()
&& !group_vis.as_ref().is_some_and(|g| g.is_changed())
&& !mat_anim.as_ref().is_some_and(|a| a.is_changed())
+19 -15
View File
@@ -243,7 +243,7 @@ pub(crate) struct SceneGates<'w, 's> {
portals: Query<'w, 's, &'static crate::wmo_portal::WmoPortalInstance>,
/// Streamed-in buildings this frame: a room admit can only change with the camera, a
/// window, a claim, a surface, or a new portal set — the last is this.
new_portals: Query<'w, 's, (), Added<crate::wmo_portal::WmoPortalInstance>>,
new_portals: Query<'w, 's, (), Changed<crate::wmo_portal::WmoPortalInstance>>,
}
impl SceneGates<'_, '_> {
@@ -452,7 +452,7 @@ pub(super) fn simulate_particles(
Ref<GlobalTransform>,
&Frustum,
&Camera,
&Projection,
Ref<Projection>,
Option<Ref<Transform>>,
),
With<WorldCamera>,
@@ -529,6 +529,7 @@ pub(super) fn simulate_particles(
// still too, and a gated emitter can skip its sphere/window/room tests outright — 2.4 k
// resident emitters against 11 active in a parked city (decision 1979's floor).
let gate_inputs_still = !cam_tf.is_changed()
&& !projection.is_changed()
&& !cam_local.as_ref().is_some_and(|l| l.is_changed())
&& !gates.changed()
&& !interleave.surfaces_changed();
@@ -544,7 +545,7 @@ pub(super) fn simulate_particles(
// The far-clip wall, the exterior gate and the camera's own room — built once for the whole
// emitter walk, the same values the model visibility authority and `exterior_cull` ask
// (0784/0786: one spelling of the window test).
let (farclip, exterior_gate, camera_instance) = gates.scene(Some((&*cam_tf, projection)));
let (farclip, exterior_gate, camera_instance) = gates.scene(Some((&*cam_tf, &*projection)));
// `$WOW_PARTICLE_DEPTHDUMP` (B16): is this a dump frame? Decided once per run.
let dump_frame = dumps.depth_frame(time.elapsed_secs());
// `$WOW_EMIT_DUMP`: is this a dump tick? Decided once per frame, for the whole walk.
@@ -778,17 +779,6 @@ pub(super) fn simulate_particles(
// The instance's gseq cursor (0856/0858): the spawn age IS `sceneNow − attach` — the
// emitter spawns with its instance, and every lane's instance is fresh per play.
let gseq_now = f64::from(*age);
// Dormant (decision 1979): nothing alive, nothing draining, and the timing tracks say
// the emitter is not emitting at this clock — every placement, ride and water step
// below would compute state for zero particles. The clock derivation above already
// ran (it is what `emitting` reads, and `seq` must keep following the host).
if particles.is_empty()
&& !*draining
&& children.iter().all(|c| c.particles.is_empty())
&& !def.timing.emitting(clock_seq, elapsed_s, gseq_now)
{
continue;
}
// Anchored mode (see [`Particle`]): positions are emitter-relative, so tracking a moving
// owner needs nothing beyond refreshing `placement` — the cloud rides the anchor for free
// (the reference's per-frame `translate(−emitterPos)` draw-matrix rebuild).
@@ -848,6 +838,20 @@ pub(super) fn simulate_particles(
commands.entity(entity).despawn();
continue;
}
// Dormant (decision 1979) — judged AFTER the owner-loss and drain-complete blocks above,
// which are the emitter's lifetime and must run every frame (review of 2026-09-04: a
// one-shot whose owner despawned never drained and lived for the session). Nothing
// alive, nothing draining, and the timing tracks say
// the emitter is not emitting at this clock — every placement, ride and water step
// below would compute state for zero particles. The clock derivation above already
// ran (it is what `emitting` reads, and `seq` must keep following the host).
if particles.is_empty()
&& !*draining
&& children.iter().all(|c| c.particles.is_empty())
&& !def.timing.emitting(clock_seq, elapsed_s, gseq_now)
{
continue;
}
// The MODEL's render alpha for this frame (decision 0827 — the reference's per-frame
// `emitter+0x1a8 = Model+0x19c` copy at `0x718960` @`0x719073`). Two disjoint sources, the
// same slot the reference writes both through: an entity-owned cloud takes its OWN model
@@ -1290,7 +1294,7 @@ pub(super) fn simulate_particles(
&cam,
&cam_tf,
camera,
projection,
&projection,
images.contains(&*texture),
&quads.verts[start as usize..],
);
+15
View File
@@ -296,6 +296,21 @@ impl RigPalettes {
} else {
self.free_ranges[i] = (base + bones, len - bones);
}
// A fresh range starts at zero rows: the spare-buffer reuse (`rows_make_mut`) means the
// CPU mirror above the watermark is a previous occupant's pose, and three readers
// (`write_rig`'s torn-joint keep, `write_rider`'s unchanged compare, the computed-rig
// readbacks) assume an unallocated row is zero (review 2026-09-04).
let (r0, r1) = (3 * base as usize, 3 * (base + bones) as usize);
let wm = self.bone_watermark();
let rows = rows_make_mut(
&mut self.rows,
&mut self.spare,
wm,
&mut self.cost_copies,
&mut self.cost_copy_us,
);
let r1 = r1.min(rows.len());
rows[r0..r1].fill([0.0; 4]);
Arc::make_mut(&mut self.table)[slot as usize] = base;
self.slot_len[slot as usize] = bones;
self.mirrored[slot as usize] = false;