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