the select-screen ghost is four mechanisms, and a click raises windows
- the select screen's ghost is four mechanisms off one roster-row bit, and the rest of them land: the FFX pass swap — the reference builds TWO pass pairs, world and glue, and a ghost row installs the glue DEATH pass over the whole diorama while the character list and buttons composite clean above it; the ghost-gated sun — the scene's light gather is wiped and one hardcoded overhead directional injected, its colours turning out to be Light.dbc's death preset (diffuse (94,153,198), ambient (26,56,85)) rather than the live capture we had parked them as, with the sign settled at the bytes (an authored light already carries one negation, so reusing that convention lights the ghost from underneath); and three glow weights corrected — the glue screens pin 0.30 login / 0.40 living select / 0.15 ghost where we rendered a flat 0.5 everywhere, so login and create get slightly less glow too, a fidelity correction to a value we had never verified - a press brings its window to the front: the mouse-DOWN handler raises the capture-or-hover frame unguarded, the toplevel gate living inside the worker — which is what makes a click on a child raise the window that owns it — and the release never raises; behind it, a frame-by-frame diff against the reference put back what the transcription dropped: toplevel on 47 frames (without it every raise trigger was a silent no-op across the whole interface), the mouse on 16 window bodies (a click on the bank's or merchant's own background was turning the camera or dropping your target), and id= on 81 frames — the slot index a 1.12 addon reads, where GetID() answered 0; a new gate reads our flags off the LOADED engine against the reference's, both directions with a reason each, so the next drift can't hide behind renamed templates - the jump stops being frame-rate dependent: the fall step becomes the reference's closed form (the mean of the endpoint velocities is the exact displacement rate, and both our movers had the error in opposite directions), taking the apex from 1.668 yd at 144 fps and 1.844 at 20 to a flat 1.640 against the analytic 1.640 — a live run apexed at 1.6410; and the knockback's freeze is its PLANTED direction bit, not a velocity test — air control opens exactly while the direction nibble is clear, the launch plants forward whatever its speed, and the bit now rides the wire, which we had never sent at all - the walk-away leash belongs to the OBJECT: the reference's watchdog overwrites the opener's stored range on the player typemask bit alone with (10+4)², so a player-shared quest panel is leashed at 14 yards — not at the 5.56-yard NPC reach, and not never, as two earlier readings had it — while a census of all 14 arming sites confirms every other NPC window already closed at the right distance; the walk-away also sends strictly LESS than the button: an NPC panel closes silently while a share still owes its decline Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1715,7 +1715,7 @@
|
||||
<CheckButton name="BenillaActionButtonTemplate" inherits="ActionBarButtonTemplate" virtual="true"/>
|
||||
|
||||
<!-- The bar: 1024x53 anchored BOTTOM of the screen (ref-MainMenuBar.xml l.4-10). -->
|
||||
<Frame name="MainMenuBar" parent="UIParent">
|
||||
<Frame name="MainMenuBar" enableMouse="true" toplevel="true" parent="UIParent">
|
||||
<Size><AbsDimension x="1024" y="53"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOM" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
|
||||
@@ -1956,51 +1956,51 @@
|
||||
<!-- The 12 buttons: Button1 at the art frame BOTTOMLEFT (8,4); 2..12 chain LEFT of
|
||||
the previous's RIGHT +6,0 (ref-ActionBarFrame.xml l.51-158). Replaces our old
|
||||
fixed 38px stride with the real 42px chain (36 wide + 6 gap). -->
|
||||
<CheckButton name="ActionButton1" inherits="BenillaActionButtonTemplate" parent="MainMenuBarArtFrame">
|
||||
<CheckButton name="ActionButton1" inherits="BenillaActionButtonTemplate" id="1" parent="MainMenuBarArtFrame">
|
||||
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="8" y="4"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 1)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="ActionButton2" inherits="BenillaActionButtonTemplate" parent="MainMenuBarArtFrame">
|
||||
<CheckButton name="ActionButton2" inherits="BenillaActionButtonTemplate" id="2" parent="MainMenuBarArtFrame">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="ActionButton1" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 2)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="ActionButton3" inherits="BenillaActionButtonTemplate" parent="MainMenuBarArtFrame">
|
||||
<CheckButton name="ActionButton3" inherits="BenillaActionButtonTemplate" id="3" parent="MainMenuBarArtFrame">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="ActionButton2" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 3)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="ActionButton4" inherits="BenillaActionButtonTemplate" parent="MainMenuBarArtFrame">
|
||||
<CheckButton name="ActionButton4" inherits="BenillaActionButtonTemplate" id="4" parent="MainMenuBarArtFrame">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="ActionButton3" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 4)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="ActionButton5" inherits="BenillaActionButtonTemplate" parent="MainMenuBarArtFrame">
|
||||
<CheckButton name="ActionButton5" inherits="BenillaActionButtonTemplate" id="5" parent="MainMenuBarArtFrame">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="ActionButton4" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 5)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="ActionButton6" inherits="BenillaActionButtonTemplate" parent="MainMenuBarArtFrame">
|
||||
<CheckButton name="ActionButton6" inherits="BenillaActionButtonTemplate" id="6" parent="MainMenuBarArtFrame">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="ActionButton5" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 6)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="ActionButton7" inherits="BenillaActionButtonTemplate" parent="MainMenuBarArtFrame">
|
||||
<CheckButton name="ActionButton7" inherits="BenillaActionButtonTemplate" id="7" parent="MainMenuBarArtFrame">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="ActionButton6" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 7)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="ActionButton8" inherits="BenillaActionButtonTemplate" parent="MainMenuBarArtFrame">
|
||||
<CheckButton name="ActionButton8" inherits="BenillaActionButtonTemplate" id="8" parent="MainMenuBarArtFrame">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="ActionButton7" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 8)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="ActionButton9" inherits="BenillaActionButtonTemplate" parent="MainMenuBarArtFrame">
|
||||
<CheckButton name="ActionButton9" inherits="BenillaActionButtonTemplate" id="9" parent="MainMenuBarArtFrame">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="ActionButton8" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 9)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="ActionButton10" inherits="BenillaActionButtonTemplate" parent="MainMenuBarArtFrame">
|
||||
<CheckButton name="ActionButton10" inherits="BenillaActionButtonTemplate" id="10" parent="MainMenuBarArtFrame">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="ActionButton9" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 10)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="ActionButton11" inherits="BenillaActionButtonTemplate" parent="MainMenuBarArtFrame">
|
||||
<CheckButton name="ActionButton11" inherits="BenillaActionButtonTemplate" id="11" parent="MainMenuBarArtFrame">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="ActionButton10" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 11)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="ActionButton12" inherits="BenillaActionButtonTemplate" parent="MainMenuBarArtFrame">
|
||||
<CheckButton name="ActionButton12" inherits="BenillaActionButtonTemplate" id="12" parent="MainMenuBarArtFrame">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="ActionButton11" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 12)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
|
||||
@@ -1856,7 +1856,7 @@
|
||||
the bar. (MainMenuBarArtFrame is defined in ActionBar.xml, which the loader loads before
|
||||
this file; the anchor resolves by name.)
|
||||
-->
|
||||
<CheckButton name="MainMenuBarBackpackButton" parent="MainMenuBarArtFrame">
|
||||
<CheckButton name="MainMenuBarBackpackButton" id="0" parent="MainMenuBarArtFrame">
|
||||
<Size><AbsDimension x="37" y="37"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMRIGHT" relativeTo="MainMenuBarArtFrame" relativePoint="BOTTOMRIGHT">
|
||||
|
||||
@@ -296,7 +296,7 @@
|
||||
</Scripts>
|
||||
</CheckButton>
|
||||
|
||||
<Frame name="BankFrame" parent="UIParent" id="-1" hidden="true">
|
||||
<Frame name="BankFrame" enableMouse="true" toplevel="true" parent="UIParent" id="-1" hidden="true">
|
||||
<!-- The real 384×512 window (ref l.77-90). No <Anchors> — ShowUIPanel/SetLeftFrame SetPoints
|
||||
this to the left slot on open, the same NPC-session convention as Merchant/Trainer/Mail.
|
||||
id="-1" = BANK_CONTAINER: the 24 item buttons below read it via GetParent():GetID(), the
|
||||
|
||||
@@ -380,13 +380,16 @@
|
||||
-- Hide-twin — UpdateMicroButtons live (MicroMenu.xml), the bar-text calls live too (1146),
|
||||
-- and the reputation one live since the watch bar started being driven. The subframe hide-loop is OUR OWN addition
|
||||
-- (decision 0437 phase 4): PaperDollFrame is a true engine child of this frame (its
|
||||
-- own header comment) so it hides for free on the parent's Hide-cascade, but
|
||||
-- SkillFrame is a SEPARATE top-level file — this engine has no cross-file `parent=`
|
||||
-- attachment (grepped `benilla-ui/src/loader`: the XML `parent=` attribute is read nowhere,
|
||||
-- unlike `relativeTo`, which resolves by name regardless of tree position — see
|
||||
-- SkillFrame.xml's own header comment) — so nothing else would hide it when the WHOLE
|
||||
-- character window closes (ESC, the close X, or another left-slot window bumping it via
|
||||
-- UIPanel exclusivity). Looping every BENILLA_CHARACTERFRAME_SUBFRAMES entry here, once, is
|
||||
-- own header comment) so it hides for free on the parent's Hide-cascade, while
|
||||
-- SkillFrame is a SEPARATE top-level file, and when this loop was written nothing else
|
||||
-- would have closed that page with the WHOLE character window (ESC, the close X, or another
|
||||
-- left-slot window bumping it via UIPanel exclusivity) — the loader read no XML `parent=`
|
||||
-- then. It reads one now: decision 1734 restored the attribute across the shipped tree, and
|
||||
-- the loader resolves it by global name at load, winning over the enclosing element — so
|
||||
-- SkillFrame's own `parent="CharacterFrame"`, and Pet's, Reputation's and Honor's, really do
|
||||
-- attach to this frame. The loop is kept as it stands: it closes the same five entries by
|
||||
-- name, and it is what the window was built and approved against.
|
||||
-- Looping every BENILLA_CHARACTERFRAME_SUBFRAMES entry here, once, is
|
||||
-- what carried that correctness through every non-nested tab that followed — Pet,
|
||||
-- Reputation and now Honor, all four of the five pages that are separate files.
|
||||
function CharacterFrame_OnHide()
|
||||
@@ -1371,7 +1374,7 @@
|
||||
</Scripts>
|
||||
</Frame>
|
||||
|
||||
<Frame name="CharacterFrame" parent="UIParent" hidden="true">
|
||||
<Frame name="CharacterFrame" toplevel="true" parent="UIParent" hidden="true">
|
||||
<!-- The real 384x512 window (ref-CharacterFrame.xml:5-7). No <Anchors> — ShowUIPanel/
|
||||
SetLeftFrame SetPoints this to the left slot on open (UiPanels.xml's own convention). -->
|
||||
<Size><AbsDimension x="384" y="512"/></Size>
|
||||
@@ -1449,23 +1452,23 @@
|
||||
the loader resolves a key-shaped `text=` through the shipped strings, so the row is
|
||||
localized like the reference's. (Tabs 1/4 carried the enUS literals from 0437;
|
||||
same words either way in enUS, and now the same mechanism.) -->
|
||||
<Button name="CharacterFrameTab1" inherits="CharacterFrameTabButtonTemplate" text="CHARACTER">
|
||||
<Button name="CharacterFrameTab1" inherits="CharacterFrameTabButtonTemplate" id="1" text="CHARACTER">
|
||||
<Anchors><Anchor point="CENTER" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="60" y="62"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnClick>BenillaCharacterFrameTab_OnClick(1)</OnClick></Scripts>
|
||||
</Button>
|
||||
<Button name="CharacterFrameTab2" inherits="CharacterFrameTabButtonTemplate" text="PET" hidden="true">
|
||||
<Button name="CharacterFrameTab2" inherits="CharacterFrameTabButtonTemplate" id="2" text="PET" hidden="true">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="CharacterFrameTab1" relativePoint="RIGHT"><Offset><AbsDimension x="-16" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnClick>BenillaCharacterFrameTab_OnClick(2)</OnClick></Scripts>
|
||||
</Button>
|
||||
<Button name="CharacterFrameTab3" inherits="CharacterFrameTabButtonTemplate" text="REPUTATION">
|
||||
<Button name="CharacterFrameTab3" inherits="CharacterFrameTabButtonTemplate" id="3" text="REPUTATION">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="CharacterFrameTab2" relativePoint="RIGHT"><Offset><AbsDimension x="-16" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnClick>BenillaCharacterFrameTab_OnClick(3)</OnClick></Scripts>
|
||||
</Button>
|
||||
<Button name="CharacterFrameTab4" inherits="CharacterFrameTabButtonTemplate" text="SKILLS">
|
||||
<Button name="CharacterFrameTab4" inherits="CharacterFrameTabButtonTemplate" id="4" text="SKILLS">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="CharacterFrameTab3" relativePoint="RIGHT"><Offset><AbsDimension x="-16" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnClick>BenillaCharacterFrameTab_OnClick(4)</OnClick></Scripts>
|
||||
</Button>
|
||||
<Button name="CharacterFrameTab5" inherits="CharacterFrameTabButtonTemplate" text="HONOR">
|
||||
<Button name="CharacterFrameTab5" inherits="CharacterFrameTabButtonTemplate" id="5" text="HONOR">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="CharacterFrameTab4" relativePoint="RIGHT"><Offset><AbsDimension x="-16" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnClick>BenillaCharacterFrameTab_OnClick(5)</OnClick></Scripts>
|
||||
</Button>
|
||||
@@ -1474,7 +1477,7 @@
|
||||
exactly fill the parent instead of the ref's setAllPoints="true" — the same effect
|
||||
(see this file's header: frame-level setAllPoints was a no-op when this was written
|
||||
and is wired now; the explicit size is equivalent, so it stays). -->
|
||||
<Frame name="PaperDollFrame" parent="CharacterFrame" id="1">
|
||||
<Frame name="PaperDollFrame" enableMouse="true" parent="CharacterFrame" id="1">
|
||||
<Size><AbsDimension x="384" y="512"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
|
||||
<HitRectInsets><AbsInset left="0" right="30" top="0" bottom="45"/></HitRectInsets>
|
||||
@@ -1632,7 +1635,7 @@
|
||||
filled from Lua exactly like the ref (PaperDollFrame_OnLoad /
|
||||
_SetStats — see the former's header comment); instances declare no
|
||||
regions of their own, same as the ref's. -->
|
||||
<Frame name="CharacterStatFrame1" inherits="BenillaStatRowTemplate">
|
||||
<Frame name="CharacterStatFrame1" inherits="BenillaStatRowTemplate" id="1">
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="6" y="-3"/></Offset></Anchor></Anchors>
|
||||
<Scripts>
|
||||
<OnLoad>self:EnableMouse(true)</OnLoad>
|
||||
@@ -1640,7 +1643,7 @@
|
||||
<OnLeave>GameTooltip:Hide()</OnLeave>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
<Frame name="CharacterStatFrame2" inherits="BenillaStatRowTemplate">
|
||||
<Frame name="CharacterStatFrame2" inherits="BenillaStatRowTemplate" id="2">
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="CharacterStatFrame1" relativePoint="BOTTOMLEFT"/></Anchors>
|
||||
<Scripts>
|
||||
<OnLoad>self:EnableMouse(true)</OnLoad>
|
||||
@@ -1648,7 +1651,7 @@
|
||||
<OnLeave>GameTooltip:Hide()</OnLeave>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
<Frame name="CharacterStatFrame3" inherits="BenillaStatRowTemplate">
|
||||
<Frame name="CharacterStatFrame3" inherits="BenillaStatRowTemplate" id="3">
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="CharacterStatFrame2" relativePoint="BOTTOMLEFT"/></Anchors>
|
||||
<Scripts>
|
||||
<OnLoad>self:EnableMouse(true)</OnLoad>
|
||||
@@ -1656,7 +1659,7 @@
|
||||
<OnLeave>GameTooltip:Hide()</OnLeave>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
<Frame name="CharacterStatFrame4" inherits="BenillaStatRowTemplate">
|
||||
<Frame name="CharacterStatFrame4" inherits="BenillaStatRowTemplate" id="4">
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="CharacterStatFrame3" relativePoint="BOTTOMLEFT"/></Anchors>
|
||||
<Scripts>
|
||||
<OnLoad>self:EnableMouse(true)</OnLoad>
|
||||
@@ -1664,7 +1667,7 @@
|
||||
<OnLeave>GameTooltip:Hide()</OnLeave>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
<Frame name="CharacterStatFrame5" inherits="BenillaStatRowTemplate">
|
||||
<Frame name="CharacterStatFrame5" inherits="BenillaStatRowTemplate" id="5">
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="CharacterStatFrame4" relativePoint="BOTTOMLEFT"/></Anchors>
|
||||
<Scripts>
|
||||
<OnLoad>self:EnableMouse(true)</OnLoad>
|
||||
@@ -1778,7 +1781,7 @@
|
||||
<Anchor point="TOPRIGHT" relativeTo="PaperDollFrame" relativePoint="TOPLEFT"><Offset><AbsDimension x="297" y="-77"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Frames>
|
||||
<Frame name="MagicResFrame1" inherits="BenillaMagicResistanceFrameTemplate">
|
||||
<Frame name="MagicResFrame1" inherits="BenillaMagicResistanceFrameTemplate" id="6">
|
||||
<Anchors><Anchor point="TOP" relativeTo="CharacterResistanceFrame" relativePoint="TOP"/></Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
@@ -1791,7 +1794,7 @@
|
||||
</Layer>
|
||||
</Layers>
|
||||
</Frame>
|
||||
<Frame name="MagicResFrame2" inherits="BenillaMagicResistanceFrameTemplate">
|
||||
<Frame name="MagicResFrame2" inherits="BenillaMagicResistanceFrameTemplate" id="2">
|
||||
<Anchors><Anchor point="TOP" relativeTo="MagicResFrame1" relativePoint="BOTTOM"/></Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
@@ -1804,7 +1807,7 @@
|
||||
</Layer>
|
||||
</Layers>
|
||||
</Frame>
|
||||
<Frame name="MagicResFrame3" inherits="BenillaMagicResistanceFrameTemplate">
|
||||
<Frame name="MagicResFrame3" inherits="BenillaMagicResistanceFrameTemplate" id="3">
|
||||
<Anchors><Anchor point="TOP" relativeTo="MagicResFrame2" relativePoint="BOTTOM"/></Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
@@ -1817,7 +1820,7 @@
|
||||
</Layer>
|
||||
</Layers>
|
||||
</Frame>
|
||||
<Frame name="MagicResFrame4" inherits="BenillaMagicResistanceFrameTemplate">
|
||||
<Frame name="MagicResFrame4" inherits="BenillaMagicResistanceFrameTemplate" id="4">
|
||||
<Anchors><Anchor point="TOP" relativeTo="MagicResFrame3" relativePoint="BOTTOM"/></Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
@@ -1830,7 +1833,7 @@
|
||||
</Layer>
|
||||
</Layers>
|
||||
</Frame>
|
||||
<Frame name="MagicResFrame5" inherits="BenillaMagicResistanceFrameTemplate">
|
||||
<Frame name="MagicResFrame5" inherits="BenillaMagicResistanceFrameTemplate" id="5">
|
||||
<Anchors><Anchor point="TOP" relativeTo="MagicResFrame4" relativePoint="BOTTOM"/></Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
|
||||
@@ -1412,7 +1412,7 @@
|
||||
one script here that is genuinely ChatFrame1's rather than the dock's, so the template
|
||||
carries the gate rather than the registration.
|
||||
-->
|
||||
<ScrollingMessageFrame name="BenillaChatFrameTemplate" virtual="true" parent="UIParent"
|
||||
<ScrollingMessageFrame name="BenillaChatFrameTemplate" toplevel="true" virtual="true" parent="UIParent"
|
||||
frameStrata="BACKGROUND" displayDuration="120.0" maxLines="128"
|
||||
movable="true" resizable="true">
|
||||
<!-- ref FloatingChatFrame.xml l.222-230, verbatim. The only `<ResizeBounds>` in the whole
|
||||
@@ -2143,7 +2143,7 @@
|
||||
<HighlightFont inherits="GameFontHighlight" justifyH="LEFT"/>
|
||||
<DisabledFont inherits="GameFontDisable" justifyH="LEFT"/>
|
||||
</Button>
|
||||
<Frame name="UIMenuTemplate" frameStrata="DIALOG" virtual="true">
|
||||
<Frame name="UIMenuTemplate" toplevel="true" frameStrata="DIALOG" virtual="true">
|
||||
<Backdrop bgFile="Interface\Tooltips\UI-Tooltip-Background" edgeFile="Interface\Tooltips\UI-Tooltip-Border" tile="true">
|
||||
<BackgroundInsets><AbsInset left="4" right="4" top="4" bottom="4"/></BackgroundInsets>
|
||||
<TileSize><AbsValue val="32"/></TileSize>
|
||||
@@ -2271,7 +2271,7 @@
|
||||
arrow-steering working and the keys being dead. -->
|
||||
<!-- historyLines: the ref template ships 32; 64 at the director's call ("at least 50",
|
||||
2026-07-26) — the recall walk is the modern law already (decision 0301). -->
|
||||
<EditBox name="ChatFrameEditBox" parent="UIParent" frameStrata="DIALOG" hidden="true" historyLines="64" letters="255">
|
||||
<EditBox name="ChatFrameEditBox" toplevel="true" parent="UIParent" frameStrata="DIALOG" hidden="true" historyLines="64" letters="255">
|
||||
<Size><AbsDimension x="0" y="32"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="ChatFrame1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="-5" y="-2"/></Offset></Anchor>
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></HighlightTexture>
|
||||
</Button>
|
||||
|
||||
<Frame name="DressUpFrame" parent="UIParent" toplevel="true" hidden="true">
|
||||
<Frame name="DressUpFrame" enableMouse="true" parent="UIParent" toplevel="true" hidden="true">
|
||||
<!-- The ref's 384×512 window. No <Anchors>: ShowUIPanel seats it in the left slot, this
|
||||
house's convention for every panel window (MerchantFrame.xml/CraftFrame.xml). -->
|
||||
<Size><AbsDimension x="384" y="512"/></Size>
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
end
|
||||
]]></Script>
|
||||
|
||||
<MessageFrame name="UIErrorsFrame" parent="UIParent" displayDuration="5" insertMode="TOP" frameStrata="HIGH">
|
||||
<MessageFrame name="UIErrorsFrame" toplevel="true" parent="UIParent" displayDuration="5" insertMode="TOP" frameStrata="HIGH">
|
||||
<Size><AbsDimension x="512" y="60"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOP" relativePoint="TOP">
|
||||
|
||||
@@ -2433,7 +2433,7 @@
|
||||
</Scripts>
|
||||
</Button>
|
||||
|
||||
<EditBox name="WhoFrameEditBox" autoFocus="false" historyLines="32">
|
||||
<EditBox name="WhoFrameEditBox" frameStrata="HIGH" autoFocus="false" historyLines="32">
|
||||
<Size><AbsDimension x="296" y="32"/></Size>
|
||||
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="-20" y="100"/></Offset></Anchor></Anchors>
|
||||
<Scripts>
|
||||
|
||||
@@ -87,9 +87,11 @@
|
||||
by deleting one `GameMenuButton_Pending` call and writing its OnClick.
|
||||
- The Options label stays a literal "Options": 1.12 GlobalStrings has no era
|
||||
GAMEMENU_OPTIONS (checked; its OPTIONS_MENU is "Options Menu", a different string).
|
||||
- The ref frame is `toplevel="true" movable="true" parent="UIParent"`. Dropped: this engine has
|
||||
no cross-file `parent=` attachment (SkillFrame.xml's header comment), and `movable` is inert
|
||||
in the ref too (GameMenuFrame defines no drag scripts, so it never actually moves).
|
||||
- The ref frame is `toplevel="true" movable="true" parent="UIParent"`. `parent="UIParent"` is
|
||||
ours too, and it attaches for real: the loader does read a cross-file `parent=`, resolving
|
||||
the name by global lookup at load (decision 1734), and the frame below declares it. Only
|
||||
`movable` is dropped, and it is inert in the ref too (GameMenuFrame defines no drag
|
||||
scripts, so it never actually moves).
|
||||
`frameStrata="DIALOG"` is what puts it over the world UI, and that is kept.
|
||||
**The "no frame-level raise" half of that reason is gone**: the raise law is built
|
||||
(`script::object::toplevel`, wow-re `ui/scratch/toplevel-raise.md`) and the loader honours
|
||||
|
||||
@@ -292,7 +292,7 @@
|
||||
</Scripts>
|
||||
</Button>
|
||||
|
||||
<Frame name="GossipFrame" parent="UIParent" hidden="true">
|
||||
<Frame name="GossipFrame" enableMouse="true" toplevel="true" parent="UIParent" hidden="true">
|
||||
<!-- The real 384×512 window (ref l.128). No <Anchors> — ShowUIPanel/SetLeftFrame SetPoints this
|
||||
to the left slot on open. -->
|
||||
<Size><AbsDimension x="384" y="512"/></Size>
|
||||
|
||||
@@ -63,24 +63,22 @@
|
||||
while the item template is still in flight. `GetLootRollItemLink` (decision 1059) is the
|
||||
click's, not the hover's: the tooltip wants the roll, not the markup.
|
||||
|
||||
`toplevel="true"` (ref l.243) IS STILL OMITTED, but for a NEW reason — the old one ("`SetToplevel`
|
||||
isn't in the v1 object model yet ... trivially re-added the day `SetToplevel` lands") has expired.
|
||||
`SetToplevel` landed: the loader calls the real method and the raise law — occlusion-gated,
|
||||
confined to this frame's own stratum — is `script::object::toplevel` (wow-re
|
||||
`ui/scratch/toplevel-raise.md`).
|
||||
`toplevel="true"` (ref l.243) IS CARRIED, since decision 1739. Two earlier reasons for
|
||||
omitting it have both expired: `SetToplevel` landed (the loader calls the real method, and
|
||||
the raise law — occlusion-gated, confined to this frame's own stratum — is
|
||||
`script::object::toplevel`, wow-re `ui/scratch/toplevel-raise.md`), and the one blocker
|
||||
left after that was a test fixture, not a mechanism.
|
||||
|
||||
What adding it back does, measured rather than guessed: `GroupLootFrame1`'s rect
|
||||
(390.5,60)-(633.5,144) overlaps `ExhaustionTick` — `ActionBar.xml` l.1111, `frameStrata="DIALOG"`
|
||||
transcribed verbatim from ref-MainMenuBar.xml l.415-442 — in a ~2.5 px band, so showing roll
|
||||
frame 1 raises its whole subtree to DIALOG level 3 while frame 2 (which overlaps nothing, the
|
||||
four stack with a 15 px gap) stays at level 0. That is exactly what the reference does, and the
|
||||
only pixels it moves are the sliver where the roll frame meets the tick. It does, however, break
|
||||
`group_loot_tests::start_loot_roll_claims_frames_in_order_and_paints_the_roll`, whose
|
||||
`quad_center("UI-GroupLoot-Dice-Up")` picks the FIRST dice quad in painter order as a stand-in
|
||||
for "frame 1's Need button" — an unstated assumption that declaration order is draw order among
|
||||
the four, which the raise legitimately ends. That is a fixture proxy, not the test's assertion
|
||||
(the BoP-confirm gate it proves is untouched), so re-adding the attribute is one word plus a
|
||||
two-line fix to that helper — a director's call on the look, not a blocked mechanism.
|
||||
What it does, measured rather than guessed: `GroupLootFrame1`'s rect (390.5,60)-(633.5,144)
|
||||
overlaps `ExhaustionTick` — `ActionBar.xml` l.1111, `frameStrata="DIALOG"` transcribed
|
||||
verbatim from ref-MainMenuBar.xml l.415-442 — in a ~2.5 px band, so showing a roll frame
|
||||
raises its whole subtree within DIALOG. The only pixels that move are the sliver where the
|
||||
roll frame meets the tick, and it is exactly what the reference does. It did cost
|
||||
`group_loot_tests::start_loot_roll_claims_frames_in_order_and_paints_the_roll` its
|
||||
`quad_center("UI-GroupLoot-Dice-Up")` stand-in for "frame 1's Need button" — an unstated
|
||||
assumption that declaration order is draw order among the four, which the raise
|
||||
legitimately ends; that test now takes the button by NAME and asserts the hit before
|
||||
clicking.
|
||||
|
||||
NAMED FONT OBJECTS ARE SUPPORTED on this branch (the loader's top-level `<Font>` handling,
|
||||
`loader/mod.rs`'s `do_font`, plus the full `Fonts.xml` transcription, both landed since
|
||||
@@ -321,7 +319,7 @@
|
||||
`CreateFrame("Frame", "BRollBarFrame"..i, bar, "GroupLootFrameTemplate")`, which resolves on
|
||||
the real client and missed here for no reason but our prefix.
|
||||
-->
|
||||
<Frame name="GroupLootFrameTemplate" frameStrata="DIALOG" hidden="true" virtual="true">
|
||||
<Frame name="GroupLootFrameTemplate" toplevel="true" frameStrata="DIALOG" hidden="true" virtual="true">
|
||||
<Size><AbsDimension x="243" y="84"/></Size>
|
||||
<Layers>
|
||||
<Layer level="ARTWORK">
|
||||
|
||||
@@ -659,9 +659,10 @@
|
||||
13px of the plate out of the mouse rect — the art bleeds past the drawn frame there.
|
||||
|
||||
`parent="UIParent"` is the reference's and is kept verbatim (ItemTextFrame.xml's
|
||||
precedent); it is inert in this engine, which has no cross-file parent attachment and
|
||||
screen-parents a bare toplevel anyway. `movable` is the reference's too and is equally
|
||||
inert: this window declares no drag scripts, so it never actually moves. -->
|
||||
precedent). It is NOT inert — the loader resolves a cross-file `parent=` by name (1734), so
|
||||
this window really hangs under `UIParent` and hides with it. `movable` is the reference's
|
||||
too and IS inert, on both sides: this window declares no drag scripts and neither does the
|
||||
reference's, which never calls `StartMoving` on it. -->
|
||||
<Frame name="HelpFrame" toplevel="true" parent="UIParent" movable="true" enableMouse="true" hidden="true">
|
||||
<Size><AbsDimension x="640" y="512"/></Size>
|
||||
<Anchors><Anchor point="CENTER"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
|
||||
|
||||
@@ -15,11 +15,16 @@
|
||||
Today, Yesterday, This Week, Last Week, Lifetime.
|
||||
|
||||
A CHILD PAGE of `CharacterFrame` (ref l.5: `parent="CharacterFrame"`, `id="5"`), shown and
|
||||
hidden by `CharacterFrame_ShowSubFrame` — but a SEPARATE top-level file, the
|
||||
SkillFrame.xml / PetPaperDollFrame.xml / ReputationFrame.xml shape: this engine reads no XML
|
||||
`parent=` attribute (only physical `<Frames>` nesting creates a real parent), so the page is
|
||||
pinned to `CharacterFrame`'s own TOPLEFT by NAME and loaded right after its siblings so it
|
||||
paints on top (later file = higher z). Joining `BENILLA_CHARACTERFRAME_SUBFRAMES` as entry 5
|
||||
hidden by `CharacterFrame_ShowSubFrame` — a SEPARATE top-level file, the SkillFrame.xml /
|
||||
PetPaperDollFrame.xml / ReputationFrame.xml shape, and it carries the reference's
|
||||
`parent="CharacterFrame"` for real: the loader does read a cross-file `parent=`, resolving
|
||||
the name by global lookup at load (decision 1734). That name has to exist by the time this
|
||||
file loads — an unresolvable one is not an error, it warns and falls back to the enclosing
|
||||
element — which the manifest order gives it. The page is still sized and pinned to
|
||||
`CharacterFrame`'s own TOPLEFT by NAME and still loaded right after its siblings so it paints
|
||||
on top (later file = higher z): that arrangement produces the identical rect and is what the
|
||||
page was built and approved against, not a workaround for something the engine cannot do.
|
||||
Joining `BENILLA_CHARACTERFRAME_SUBFRAMES` as entry 5
|
||||
is all it has to do to be closed by `CharacterFrame_OnHide`'s hide-loop.
|
||||
|
||||
THE TAB ROW IS NOW WHOLE. Four of the reference's five tabs shipped before this page and
|
||||
@@ -401,10 +406,12 @@
|
||||
page's id against its slot in BENILLA_CHARACTERFRAME_SUBFRAMES, and with this page the two
|
||||
cover the real client's whole row. -->
|
||||
<Frame name="HonorFrame" enableMouse="true" parent="CharacterFrame" hidden="true" id="5">
|
||||
<!-- The real 384x512 page. The reference's `setAllPoints="true"` needs the true parenting
|
||||
this engine's cross-file `parent=` does not give (this file's header comment), so the
|
||||
page is sized and pinned to CharacterFrame's TOPLEFT by name — ReputationFrame.xml,
|
||||
SkillFrame.xml and PetPaperDollFrame.xml all ship the identical substitute. -->
|
||||
<!-- The real 384x512 page. The explicit `<Size>` + TOPLEFT `<Anchor>` below is an exact
|
||||
equivalent of the reference's `setAllPoints="true"` here — CharacterFrame, the parent
|
||||
this frame declares, is itself 384x512 (ref CharacterFrame.xml l.4-7) — and it is kept
|
||||
rather than swapped because it is the geometry the page ships with, not because the
|
||||
attribute would not work. ReputationFrame.xml, SkillFrame.xml and
|
||||
PetPaperDollFrame.xml all carry the identical pair. -->
|
||||
<Size><AbsDimension x="384" y="512"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="CharacterFrame" relativePoint="TOPLEFT"/></Anchors>
|
||||
<HitRectInsets><AbsInset left="0" right="30" top="0" bottom="75"/></HitRectInsets>
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
|
||||
<!-- `toplevel` is the ref's (ItemRef.xml l.3) — a linked-item tooltip is a window that must
|
||||
land above the HIGH-strata frames it is opened over (decision 1318). -->
|
||||
<GameTooltip name="ItemRefTooltip" inherits="GameTooltipTemplate" parent="UIParent" frameStrata="HIGH" toplevel="true" hidden="true">
|
||||
<GameTooltip name="ItemRefTooltip" inherits="GameTooltipTemplate" enableMouse="true" parent="UIParent" frameStrata="HIGH" toplevel="true" hidden="true">
|
||||
<Size><AbsDimension x="128" y="64"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOM"><Offset><AbsDimension x="0" y="80"/></Offset></Anchor>
|
||||
|
||||
@@ -558,7 +558,7 @@
|
||||
</Scripts>
|
||||
</Button>
|
||||
|
||||
<Frame name="LootFrame" parent="UIParent" hidden="true">
|
||||
<Frame name="LootFrame" enableMouse="true" toplevel="true" parent="UIParent" hidden="true">
|
||||
<!-- The real 256×256 window (ref l.59-60). No <Anchors> here — ShowUIPanel SetPoints this to
|
||||
its slot. -->
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
|
||||
@@ -792,7 +792,7 @@
|
||||
</Layers>
|
||||
</CheckButton>
|
||||
|
||||
<Frame name="MailFrame" parent="UIParent" hidden="true">
|
||||
<Frame name="MailFrame" enableMouse="true" toplevel="true" parent="UIParent" hidden="true">
|
||||
<!-- ref MailFrame l.242-255: 384×512; ShowUIPanel slots it left. HitRectInsets quoted for
|
||||
fidelity (currently inert — the loader ignores it). -->
|
||||
<Size><AbsDimension x="384" y="512"/></Size>
|
||||
@@ -1270,14 +1270,14 @@
|
||||
</Frames>
|
||||
</Frame>
|
||||
<!-- ref SendMailSendMoneyButton l.906-919 / SendMailCODButton l.920-933. -->
|
||||
<CheckButton name="SendMailSendMoneyButton" inherits="BenillaSendMailRadioButtonTemplate">
|
||||
<CheckButton name="SendMailSendMoneyButton" inherits="BenillaSendMailRadioButtonTemplate" id="1">
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="185" y="-6"/></Offset></Anchor></Anchors>
|
||||
<Scripts>
|
||||
<OnLoad>getglobal(this:GetName().."Text"):SetText("Send Money")</OnLoad>
|
||||
<OnClick>SendMailRadioButton_OnClick(1)</OnClick>
|
||||
</Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="SendMailCODButton" inherits="BenillaSendMailRadioButtonTemplate">
|
||||
<CheckButton name="SendMailCODButton" inherits="BenillaSendMailRadioButtonTemplate" id="2">
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="SendMailSendMoneyButton" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="1"/></Offset></Anchor></Anchors>
|
||||
<Scripts>
|
||||
<OnLoad>getglobal(this:GetName().."Text"):SetText("C.O.D.")</OnLoad>
|
||||
@@ -1414,7 +1414,7 @@
|
||||
</Frame>
|
||||
|
||||
<!-- ── Open Mail (ref OpenMailFrame l.1306-1915) ──────────────────────────────────────────── -->
|
||||
<Frame name="OpenMailFrame" parent="UIParent" hidden="true" enableMouse="true">
|
||||
<Frame name="OpenMailFrame" toplevel="true" parent="UIParent" hidden="true" enableMouse="true">
|
||||
<Size><AbsDimension x="384" y="512"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="InboxFrame" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-10" y="0"/></Offset></Anchor>
|
||||
|
||||
@@ -776,7 +776,7 @@
|
||||
</Scripts>
|
||||
</Button>
|
||||
|
||||
<Frame name="MerchantFrame" parent="UIParent" hidden="true">
|
||||
<Frame name="MerchantFrame" enableMouse="true" toplevel="true" parent="UIParent" hidden="true">
|
||||
<!-- The real 384×512 window (ref l.117-119). No <Anchors> — ShowUIPanel/SetLeftFrame SetPoints
|
||||
this to the left slot on open. -->
|
||||
<Size><AbsDimension x="384" y="512"/></Size>
|
||||
|
||||
@@ -23,10 +23,14 @@
|
||||
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 TOP-LEVEL frames anchored by name into MainMenuBarArtFrame
|
||||
(this engine has no cross-file `parent=` attachment — SkillFrame.xml's header comment), then
|
||||
seated one frame level above the art by BenillaActionBarArt_SeatAbove (ActionBar.xml) so the
|
||||
bar plate doesn't paint over them. Same shape the bag bar next door already uses.
|
||||
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
|
||||
|
||||
@@ -278,7 +278,7 @@
|
||||
them (its HorizontalMultiBar1/2 templates carry it too, l.36/l.151), and the shipped state
|
||||
a character with a zero toggle byte lands in. MultiActionBar_Update is the only thing that
|
||||
opens one. -->
|
||||
<Frame name="MultiBarBottomLeft" parent="UIParent" frameStrata="HIGH" hidden="true">
|
||||
<Frame name="MultiBarBottomLeft" toplevel="true" parent="UIParent" frameStrata="HIGH" hidden="true">
|
||||
<Size><AbsDimension x="500" y="38"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMLEFT" relativeTo="ActionButton1" relativePoint="TOPLEFT"><Offset><AbsDimension x="0" y="17"/></Offset></Anchor>
|
||||
@@ -336,7 +336,7 @@
|
||||
</Frame>
|
||||
|
||||
<!-- HIGH and hidden, same as BottomLeft above (ref `MultiActionBars.xml` l.151). -->
|
||||
<Frame name="MultiBarBottomRight" parent="UIParent" frameStrata="HIGH" hidden="true">
|
||||
<Frame name="MultiBarBottomRight" toplevel="true" parent="UIParent" frameStrata="HIGH" hidden="true">
|
||||
<Size><AbsDimension x="500" y="38"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="LEFT" relativeTo="MultiBarBottomLeft" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor>
|
||||
@@ -416,7 +416,7 @@
|
||||
RIGHT_ACTIONBAR_PAGE = 3 (actions 25..36). Geometry from ref-MultiActionBars.xml: a 38x500
|
||||
frame, button 1 at TOPRIGHT, each next TOP-to-BOTTOM of the previous at -6. -->
|
||||
|
||||
<Frame name="MultiBarRight" parent="UIParent" frameStrata="HIGH" hidden="true">
|
||||
<Frame name="MultiBarRight" toplevel="true" parent="UIParent" frameStrata="HIGH" hidden="true">
|
||||
<Size><AbsDimension x="38" y="500"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-7" y="98"/></Offset></Anchor>
|
||||
@@ -473,7 +473,7 @@
|
||||
</Frames>
|
||||
</Frame>
|
||||
|
||||
<Frame name="MultiBarLeft" parent="UIParent" frameStrata="HIGH" hidden="true">
|
||||
<Frame name="MultiBarLeft" toplevel="true" parent="UIParent" frameStrata="HIGH" hidden="true">
|
||||
<Size><AbsDimension x="38" y="500"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPRIGHT" relativeTo="MultiBarRight" relativePoint="TOPLEFT"><Offset><AbsDimension x="-5" y="0"/></Offset></Anchor>
|
||||
|
||||
@@ -420,16 +420,16 @@
|
||||
|
||||
<!-- The four instances — frame 1 at the ref's (10,-128); 2-4 cascade at the no-pet stride
|
||||
(see the header note; the pet-frame anchor law returns with phase 4). -->
|
||||
<Frame name="PartyMemberFrame1" inherits="BenillaPartyMemberTemplate" parent="UIParent" id="1">
|
||||
<Frame name="PartyMemberFrame1" inherits="BenillaPartyMemberTemplate" toplevel="true" parent="UIParent" id="1">
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="10" y="-128"/></Offset></Anchor></Anchors>
|
||||
</Frame>
|
||||
<Frame name="PartyMemberFrame2" inherits="BenillaPartyMemberTemplate" parent="UIParent" id="2">
|
||||
<Frame name="PartyMemberFrame2" inherits="BenillaPartyMemberTemplate" toplevel="true" parent="UIParent" id="2">
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="PartyMemberFrame1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
|
||||
</Frame>
|
||||
<Frame name="PartyMemberFrame3" inherits="BenillaPartyMemberTemplate" parent="UIParent" id="3">
|
||||
<Frame name="PartyMemberFrame3" inherits="BenillaPartyMemberTemplate" toplevel="true" parent="UIParent" id="3">
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="PartyMemberFrame2" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
|
||||
</Frame>
|
||||
<Frame name="PartyMemberFrame4" inherits="BenillaPartyMemberTemplate" parent="UIParent" id="4">
|
||||
<Frame name="PartyMemberFrame4" inherits="BenillaPartyMemberTemplate" toplevel="true" parent="UIParent" id="4">
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="PartyMemberFrame3" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
|
||||
</Frame>
|
||||
|
||||
|
||||
@@ -476,7 +476,7 @@
|
||||
</Scripts>
|
||||
</CheckButton>
|
||||
|
||||
<Frame name="PetActionBarFrame" parent="MainMenuBar" hidden="true">
|
||||
<Frame name="PetActionBarFrame" enableMouse="true" parent="MainMenuBar" hidden="true">
|
||||
<Size><AbsDimension x="509" y="43"/></Size>
|
||||
<!-- This anchor is the LOAD-TIME value only: UIParent_ManageFramePositions owns the real
|
||||
one and re-seats the frame on every pass (OnShow/OnHide below fire it), because the
|
||||
@@ -506,44 +506,44 @@
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Frames>
|
||||
<CheckButton name="PetActionButton1" inherits="BenillaPetActionButtonTemplate">
|
||||
<CheckButton name="PetActionButton1" inherits="BenillaPetActionButtonTemplate" id="1">
|
||||
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="36" y="2"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 1)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="PetActionButton2" inherits="BenillaPetActionButtonTemplate">
|
||||
<CheckButton name="PetActionButton2" inherits="BenillaPetActionButtonTemplate" id="2">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="PetActionButton1" relativePoint="RIGHT"><Offset><AbsDimension x="8" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 2)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="PetActionButton3" inherits="BenillaPetActionButtonTemplate">
|
||||
<CheckButton name="PetActionButton3" inherits="BenillaPetActionButtonTemplate" id="3">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="PetActionButton2" relativePoint="RIGHT"><Offset><AbsDimension x="8" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 3)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="PetActionButton4" inherits="BenillaPetActionButtonTemplate">
|
||||
<CheckButton name="PetActionButton4" inherits="BenillaPetActionButtonTemplate" id="4">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="PetActionButton3" relativePoint="RIGHT"><Offset><AbsDimension x="8" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 4)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="PetActionButton5" inherits="BenillaPetActionButtonTemplate">
|
||||
<CheckButton name="PetActionButton5" inherits="BenillaPetActionButtonTemplate" id="5">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="PetActionButton4" relativePoint="RIGHT"><Offset><AbsDimension x="8" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 5)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="PetActionButton6" inherits="BenillaPetActionButtonTemplate">
|
||||
<CheckButton name="PetActionButton6" inherits="BenillaPetActionButtonTemplate" id="6">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="PetActionButton5" relativePoint="RIGHT"><Offset><AbsDimension x="8" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 6)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<!-- The ref's own +7 here, not +8 (PetActionBarFrame.xml l.158-166). -->
|
||||
<CheckButton name="PetActionButton7" inherits="BenillaPetActionButtonTemplate">
|
||||
<CheckButton name="PetActionButton7" inherits="BenillaPetActionButtonTemplate" id="7">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="PetActionButton6" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 7)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="PetActionButton8" inherits="BenillaPetActionButtonTemplate">
|
||||
<CheckButton name="PetActionButton8" inherits="BenillaPetActionButtonTemplate" id="8">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="PetActionButton7" relativePoint="RIGHT"><Offset><AbsDimension x="8" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 8)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="PetActionButton9" inherits="BenillaPetActionButtonTemplate">
|
||||
<CheckButton name="PetActionButton9" inherits="BenillaPetActionButtonTemplate" id="9">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="PetActionButton8" relativePoint="RIGHT"><Offset><AbsDimension x="8" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 9)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="PetActionButton10" inherits="BenillaPetActionButtonTemplate">
|
||||
<CheckButton name="PetActionButton10" inherits="BenillaPetActionButtonTemplate" id="10">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="PetActionButton9" relativePoint="RIGHT"><Offset><AbsDimension x="8" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 10)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
`ref-CharacterFrame.lua` for the tab dispatch).
|
||||
|
||||
**Its own top-level file, loaded straight after `CharacterFrame.xml`** — the SkillFrame.xml
|
||||
precedent: this engine reads no XML `parent=` attribute, so a page ships as a sibling pinned
|
||||
to `CharacterFrame`'s TOPLEFT by name, and `BENILLA_CHARACTERFRAME_SUBFRAMES` (which
|
||||
precedent. It carries the reference's `parent="CharacterFrame"`, and that attaches for real:
|
||||
the loader does read a cross-file `parent=`, resolving the name by global lookup at load
|
||||
(decision 1734), which is why the load order matters — an unresolvable name is not an error,
|
||||
it warns and falls back to the enclosing element. The page is still sized and pinned to
|
||||
`CharacterFrame`'s TOPLEFT by name, which gives the identical rect and is what the page was
|
||||
built and approved against, and `BENILLA_CHARACTERFRAME_SUBFRAMES` (which
|
||||
this page joins as entry 2) is what hides it when the whole window closes.
|
||||
|
||||
**Almost none of the page's Lua lives here, and that is the point.** The reference's own
|
||||
@@ -327,8 +331,10 @@
|
||||
]]></Script>
|
||||
|
||||
<Frame name="PetPaperDollFrame" enableMouse="true" parent="CharacterFrame" hidden="true" id="2">
|
||||
<!-- The real 384×512 page (ref l.4, `setAllPoints="true"`); pinned to the window's TOPLEFT
|
||||
by name, the SkillFrame.xml precedent (no cross-file `parent=` in this engine). -->
|
||||
<!-- The real 384×512 page (ref l.4, `setAllPoints="true"`). The explicit `<Size>` + TOPLEFT
|
||||
`<Anchor>` is an exact equivalent of that here — CharacterFrame, the parent this frame
|
||||
declares, is itself 384x512 (ref CharacterFrame.xml l.4-7) — and it is kept rather than
|
||||
swapped because it is the geometry the page ships with, the SkillFrame.xml precedent. -->
|
||||
<Size><AbsDimension x="384" y="512"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="CharacterFrame" relativePoint="TOPLEFT"/></Anchors>
|
||||
<HitRectInsets><AbsInset left="0" right="30" top="0" bottom="75"/></HitRectInsets>
|
||||
@@ -488,7 +494,7 @@
|
||||
(its tooltip lists what it eats), not a happiness readout; the happiness face that
|
||||
does move lives on the pet unit frame (decision 1005/0990). Shown for hunter pets
|
||||
only. -->
|
||||
<Frame name="PetPaperDollPetInfo">
|
||||
<Frame name="PetPaperDollPetInfo" enableMouse="true">
|
||||
<Size><AbsDimension x="24" y="23"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="PetModelFrameRotateLeftButton" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor>
|
||||
@@ -568,7 +574,7 @@
|
||||
<Frames>
|
||||
<!-- The 5 primaries (ref l.335-419): TOPLEFT (6,-5), chained BOTTOMLEFT with a
|
||||
1px gap. Labels are the template's $parentLabel, filled by _SetStats. -->
|
||||
<Frame name="PetStatFrame1" inherits="BenillaStatRowTemplate">
|
||||
<Frame name="PetStatFrame1" inherits="BenillaStatRowTemplate" id="1">
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="6" y="-5"/></Offset></Anchor></Anchors>
|
||||
<Scripts>
|
||||
<OnLoad>self:EnableMouse(true)</OnLoad>
|
||||
@@ -576,7 +582,7 @@
|
||||
<OnLeave>GameTooltip:Hide()</OnLeave>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
<Frame name="PetStatFrame2" inherits="BenillaStatRowTemplate">
|
||||
<Frame name="PetStatFrame2" inherits="BenillaStatRowTemplate" id="2">
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="PetStatFrame1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
|
||||
<Scripts>
|
||||
<OnLoad>self:EnableMouse(true)</OnLoad>
|
||||
@@ -584,7 +590,7 @@
|
||||
<OnLeave>GameTooltip:Hide()</OnLeave>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
<Frame name="PetStatFrame3" inherits="BenillaStatRowTemplate">
|
||||
<Frame name="PetStatFrame3" inherits="BenillaStatRowTemplate" id="3">
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="PetStatFrame2" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
|
||||
<Scripts>
|
||||
<OnLoad>self:EnableMouse(true)</OnLoad>
|
||||
@@ -592,7 +598,7 @@
|
||||
<OnLeave>GameTooltip:Hide()</OnLeave>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
<Frame name="PetStatFrame4" inherits="BenillaStatRowTemplate">
|
||||
<Frame name="PetStatFrame4" inherits="BenillaStatRowTemplate" id="4">
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="PetStatFrame3" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
|
||||
<Scripts>
|
||||
<OnLoad>self:EnableMouse(true)</OnLoad>
|
||||
@@ -600,7 +606,7 @@
|
||||
<OnLeave>GameTooltip:Hide()</OnLeave>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
<Frame name="PetStatFrame5" inherits="BenillaStatRowTemplate">
|
||||
<Frame name="PetStatFrame5" inherits="BenillaStatRowTemplate" id="5">
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="PetStatFrame4" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
|
||||
<Scripts>
|
||||
<OnLoad>self:EnableMouse(true)</OnLoad>
|
||||
@@ -689,7 +695,7 @@
|
||||
<Anchor point="TOPRIGHT" relativeTo="PetPaperDollFrame" relativePoint="TOPLEFT"><Offset><AbsDimension x="347" y="-77"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Frames>
|
||||
<Frame name="PetMagicResFrame1" inherits="BenillaMagicResistanceFrameTemplate">
|
||||
<Frame name="PetMagicResFrame1" inherits="BenillaMagicResistanceFrameTemplate" id="6">
|
||||
<Anchors><Anchor point="TOP" relativeTo="PetResistanceFrame" relativePoint="TOP"/></Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
@@ -702,7 +708,7 @@
|
||||
</Layer>
|
||||
</Layers>
|
||||
</Frame>
|
||||
<Frame name="PetMagicResFrame2" inherits="BenillaMagicResistanceFrameTemplate">
|
||||
<Frame name="PetMagicResFrame2" inherits="BenillaMagicResistanceFrameTemplate" id="2">
|
||||
<Anchors><Anchor point="TOP" relativeTo="PetMagicResFrame1" relativePoint="BOTTOM"/></Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
@@ -715,7 +721,7 @@
|
||||
</Layer>
|
||||
</Layers>
|
||||
</Frame>
|
||||
<Frame name="PetMagicResFrame3" inherits="BenillaMagicResistanceFrameTemplate">
|
||||
<Frame name="PetMagicResFrame3" inherits="BenillaMagicResistanceFrameTemplate" id="3">
|
||||
<Anchors><Anchor point="TOP" relativeTo="PetMagicResFrame2" relativePoint="BOTTOM"/></Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
@@ -728,7 +734,7 @@
|
||||
</Layer>
|
||||
</Layers>
|
||||
</Frame>
|
||||
<Frame name="PetMagicResFrame4" inherits="BenillaMagicResistanceFrameTemplate">
|
||||
<Frame name="PetMagicResFrame4" inherits="BenillaMagicResistanceFrameTemplate" id="4">
|
||||
<Anchors><Anchor point="TOP" relativeTo="PetMagicResFrame3" relativePoint="BOTTOM"/></Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
@@ -741,7 +747,7 @@
|
||||
</Layer>
|
||||
</Layers>
|
||||
</Frame>
|
||||
<Frame name="PetMagicResFrame5" inherits="BenillaMagicResistanceFrameTemplate">
|
||||
<Frame name="PetMagicResFrame5" inherits="BenillaMagicResistanceFrameTemplate" id="5">
|
||||
<Anchors><Anchor point="TOP" relativeTo="PetMagicResFrame4" relativePoint="BOTTOM"/></Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
|
||||
@@ -42,8 +42,9 @@
|
||||
and l.161-162 and it is transcribed verbatim rather than guarded: the window's current
|
||||
pet slot cannot paint without it, and a guard here would hide a missing binding instead
|
||||
of reporting it.
|
||||
- `parent="UIParent"` (xml l.72) is dropped: this engine reads no XML `parent=` attribute
|
||||
(`PetPaperDollFrame.xml`'s header). The frame's own `<Anchors>` are kept verbatim even
|
||||
- `parent="UIParent"` (xml l.72) is ours too, and it attaches for real: the loader does
|
||||
read a cross-file `parent=`, resolving the name by global lookup at load (decision
|
||||
1734), and the frame below declares it. The frame's own `<Anchors>` are kept verbatim even
|
||||
though `ShowUIPanel` re-`SetPoint`s it into the left slot — they name the same
|
||||
TOPLEFT (0,-104) the slot manager writes, so the two agree (ItemTextFrame.xml keeps its
|
||||
reference anchor for the same reason).
|
||||
|
||||
@@ -868,7 +868,7 @@
|
||||
</Layers>
|
||||
</Frame>
|
||||
|
||||
<Frame name="QuestFrame" parent="UIParent" hidden="true">
|
||||
<Frame name="QuestFrame" enableMouse="true" toplevel="true" parent="UIParent" hidden="true">
|
||||
<Size><AbsDimension x="384" y="512"/></Size>
|
||||
<Layers>
|
||||
<!-- NPC portrait 60×60 TOPLEFT (7,-6) (ref QuestFramePortrait l.20-33). Drawn on the OUTER
|
||||
|
||||
@@ -1370,7 +1370,7 @@
|
||||
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></HighlightTexture>
|
||||
</Button>
|
||||
|
||||
<Frame name="QuestLogFrame" parent="UIParent" hidden="true">
|
||||
<Frame name="QuestLogFrame" enableMouse="true" toplevel="true" parent="UIParent" hidden="true">
|
||||
<!-- The real 384×512 window (pin §1). No <Anchors> — ShowUIPanel/SetLeftFrame SetPoints this
|
||||
to the left slot on open, exactly like Gossip/Merchant/Quest. -->
|
||||
<Size><AbsDimension x="384" y="512"/></Size>
|
||||
@@ -1564,14 +1564,14 @@
|
||||
<!-- pin §2, geometry byte-for-byte (ref QuestLogFrame.xml:535-588): 6 title rows,
|
||||
300×16, row1 TOPLEFT of the frame at (19,-75); rows 2-6 chain TOPLEFT→prior BOTTOMLEFT
|
||||
at (0,+1) — a TIGHT 1px OVERLAP, not a gap (the ref's own offset is positive). -->
|
||||
<Button name="QuestLogTitle1" inherits="BenillaQuestLogTitleTemplate">
|
||||
<Button name="QuestLogTitle1" inherits="BenillaQuestLogTitleTemplate" id="1">
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="19" y="-75"/></Offset></Anchor></Anchors>
|
||||
</Button>
|
||||
<Button name="QuestLogTitle2" inherits="BenillaQuestLogTitleTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogTitle1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="1"/></Offset></Anchor></Anchors></Button>
|
||||
<Button name="QuestLogTitle3" inherits="BenillaQuestLogTitleTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogTitle2" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="1"/></Offset></Anchor></Anchors></Button>
|
||||
<Button name="QuestLogTitle4" inherits="BenillaQuestLogTitleTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogTitle3" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="1"/></Offset></Anchor></Anchors></Button>
|
||||
<Button name="QuestLogTitle5" inherits="BenillaQuestLogTitleTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogTitle4" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="1"/></Offset></Anchor></Anchors></Button>
|
||||
<Button name="QuestLogTitle6" inherits="BenillaQuestLogTitleTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogTitle5" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="1"/></Offset></Anchor></Anchors></Button>
|
||||
<Button name="QuestLogTitle2" inherits="BenillaQuestLogTitleTemplate" id="2"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogTitle1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="1"/></Offset></Anchor></Anchors></Button>
|
||||
<Button name="QuestLogTitle3" inherits="BenillaQuestLogTitleTemplate" id="3"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogTitle2" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="1"/></Offset></Anchor></Anchors></Button>
|
||||
<Button name="QuestLogTitle4" inherits="BenillaQuestLogTitleTemplate" id="4"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogTitle3" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="1"/></Offset></Anchor></Anchors></Button>
|
||||
<Button name="QuestLogTitle5" inherits="BenillaQuestLogTitleTemplate" id="5"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogTitle4" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="1"/></Offset></Anchor></Anchors></Button>
|
||||
<Button name="QuestLogTitle6" inherits="BenillaQuestLogTitleTemplate" id="6"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogTitle5" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="1"/></Offset></Anchor></Anchors></Button>
|
||||
|
||||
<!-- The detail pane's real ScrollFrame (decision 0112's mechanism): 300×261, TOPLEFT of
|
||||
this window at (19,-175) — the ref's own `QuestLogDetailScrollFrame` position, this
|
||||
@@ -1940,7 +1940,7 @@
|
||||
lines*13) so the left-aligned column hugs the minimap's right edge like the ref; the
|
||||
GetWidth reads are extract-measured (one frame stale), so the sizing tail re-runs from
|
||||
OnUpdate for two frames after each repaint (`resize_pending`) and converges next frame. -->
|
||||
<Frame name="QuestWatchFrame" parent="UIParent">
|
||||
<Frame name="QuestWatchFrame" toplevel="true" parent="UIParent">
|
||||
<Size><AbsDimension x="280" y="20"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPRIGHT" relativeTo="MinimapCluster" relativePoint="BOTTOMRIGHT">
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
</Scripts>
|
||||
</Button>
|
||||
|
||||
<Frame name="QuestTimerFrame" parent="UIParent" enableMouse="true" frameStrata="BACKGROUND" hidden="true">
|
||||
<Frame name="QuestTimerFrame" toplevel="true" parent="UIParent" enableMouse="true" frameStrata="BACKGROUND" hidden="true">
|
||||
<Size>
|
||||
<AbsDimension x="160" y="72"/>
|
||||
</Size>
|
||||
|
||||
@@ -13,10 +13,15 @@
|
||||
addon-visible contract and neither is ours to vary.
|
||||
|
||||
A CHILD PAGE of `CharacterFrame` (ref l.211: `parent="CharacterFrame"`, `id="3"`), shown and
|
||||
hidden by `CharacterFrame_ShowSubFrame` — but a SEPARATE top-level file, the SkillFrame.xml /
|
||||
PetPaperDollFrame.xml shape: this engine reads no XML `parent=` attribute (only physical
|
||||
`<Frames>` nesting creates a real parent), so the page is pinned to `CharacterFrame`'s own
|
||||
TOPLEFT by NAME and loaded right after it so it paints on top (later file = higher z).
|
||||
hidden by `CharacterFrame_ShowSubFrame` — a SEPARATE top-level file, the SkillFrame.xml /
|
||||
PetPaperDollFrame.xml shape, and it carries the reference's `parent="CharacterFrame"` for
|
||||
real: the loader does read a cross-file `parent=`, resolving the name by global lookup at
|
||||
load (decision 1734). That name must exist by the time this file loads — an unresolvable one
|
||||
warns and falls back to the enclosing element rather than erroring — which the manifest order
|
||||
gives it. The page is still pinned to `CharacterFrame`'s own TOPLEFT by NAME and still loaded
|
||||
right after it so it paints on top (later file = higher z): that arrangement produces the
|
||||
identical rect and is what the page was built and approved against, not a workaround for
|
||||
something the engine cannot do.
|
||||
`CharacterFrame.xml`'s `CharacterFrame_OnHide` hide-loop over
|
||||
`BENILLA_CHARACTERFRAME_SUBFRAMES` is what closes it with the window; joining that table as
|
||||
entry 3 is all this page has to do to get that.
|
||||
@@ -584,10 +589,12 @@
|
||||
`character_tests.rs` pins every page's id against its slot in
|
||||
BENILLA_CHARACTERFRAME_SUBFRAMES. -->
|
||||
<Frame name="ReputationFrame" enableMouse="true" parent="CharacterFrame" hidden="true" id="3">
|
||||
<!-- The real 384x512 page. The reference's `setAllPoints="true"` needs the true parenting
|
||||
this engine's cross-file `parent=` does not give (this file's header comment), so the
|
||||
page is sized and pinned to CharacterFrame's TOPLEFT by name — SkillFrame.xml and
|
||||
PetPaperDollFrame.xml both ship the identical substitute. -->
|
||||
<!-- The real 384x512 page. The explicit `<Size>` + TOPLEFT `<Anchor>` below is an exact
|
||||
equivalent of the reference's `setAllPoints="true"` here — CharacterFrame, the parent
|
||||
this frame declares, is itself 384x512 (ref CharacterFrame.xml l.4-7) — and it is kept
|
||||
rather than swapped because it is the geometry the page ships with, not because the
|
||||
attribute would not work. SkillFrame.xml and PetPaperDollFrame.xml both carry the
|
||||
identical pair. -->
|
||||
<Size><AbsDimension x="384" y="512"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="CharacterFrame" relativePoint="TOPLEFT"/></Anchors>
|
||||
<HitRectInsets><AbsInset left="0" right="30" top="0" bottom="75"/></HitRectInsets>
|
||||
|
||||
@@ -14,12 +14,14 @@
|
||||
physically nested `<Frame>` the way PaperDollFrame is: PaperDollFrame is declared INSIDE
|
||||
CharacterFrame.xml's own `<Frames>` tree (a real engine parent/child), while this page is a
|
||||
SEPARATE file's top-level `<Frame>` (CharacterFrame.xml is shared-tree with another session
|
||||
this pass, so only its tab machinery is touched here — not its frame tree). `parent=` is
|
||||
transcribed for fidelity but is a NO-OP in this engine (grepped `benilla-ui/src/loader`: the
|
||||
XML `parent` attribute is read nowhere — only physical `<Frames>` nesting creates a real
|
||||
parent). Position instead rides `<Anchors relativeTo="CharacterFrame">` (a NAMED
|
||||
lookup, works regardless of tree position, the same mechanism every sibling-anchor in this
|
||||
codebase already uses) — loaded right after `CharacterFrame.xml` in the load list so it
|
||||
this pass, so only its tab machinery is touched here — not its frame tree). `parent=` really attaches
|
||||
(decision 1734; the loader resolves the name globally at load and it wins over the enclosing
|
||||
element — `shipped_xml_tests::every_declared_parent_really_attaches` asserts it on the loaded
|
||||
tree). The line above used to say the attribute was read nowhere, which was true once and had
|
||||
outlived itself. Position still rides `<Anchors relativeTo="CharacterFrame">` — a NAMED
|
||||
lookup that works regardless of tree position, the same mechanism every sibling-anchor in
|
||||
this codebase uses, kept because it is the geometry this page ships with — loaded right after
|
||||
`CharacterFrame.xml` in the load list so it
|
||||
paints ON TOP of it (later file = higher z, the established house convention every top-level
|
||||
window already relies on implicitly). Because a true engine Hide-cascade doesn't reach a
|
||||
non-child, `CharacterFrame.xml`'s own `CharacterFrame_OnHide` was extended (its own
|
||||
@@ -687,20 +689,20 @@
|
||||
<!-- 12 rank-bar slots (ref SkillRankFrame1..12, l.746-853): 271×15, row1 TOPLEFT(38,-79);
|
||||
rows 2-12 chain BOTTOMLEFT→TOPLEFT by (0,-3) — the SAME 18px pitch as the label chain
|
||||
(15 bar height + 3 gap). -->
|
||||
<StatusBar name="SkillRankFrame1" inherits="BenillaSkillRankTemplate">
|
||||
<StatusBar name="SkillRankFrame1" inherits="BenillaSkillRankTemplate" id="1">
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="38" y="-79"/></Offset></Anchor></Anchors>
|
||||
</StatusBar>
|
||||
<StatusBar name="SkillRankFrame2" inherits="BenillaSkillRankTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
|
||||
<StatusBar name="SkillRankFrame3" inherits="BenillaSkillRankTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame2" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
|
||||
<StatusBar name="SkillRankFrame4" inherits="BenillaSkillRankTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame3" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
|
||||
<StatusBar name="SkillRankFrame5" inherits="BenillaSkillRankTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame4" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
|
||||
<StatusBar name="SkillRankFrame6" inherits="BenillaSkillRankTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame5" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
|
||||
<StatusBar name="SkillRankFrame7" inherits="BenillaSkillRankTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame6" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
|
||||
<StatusBar name="SkillRankFrame8" inherits="BenillaSkillRankTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame7" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
|
||||
<StatusBar name="SkillRankFrame9" inherits="BenillaSkillRankTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame8" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
|
||||
<StatusBar name="SkillRankFrame10" inherits="BenillaSkillRankTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame9" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
|
||||
<StatusBar name="SkillRankFrame11" inherits="BenillaSkillRankTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame10" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
|
||||
<StatusBar name="SkillRankFrame12" inherits="BenillaSkillRankTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame11" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
|
||||
<StatusBar name="SkillRankFrame2" inherits="BenillaSkillRankTemplate" id="2"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
|
||||
<StatusBar name="SkillRankFrame3" inherits="BenillaSkillRankTemplate" id="3"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame2" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
|
||||
<StatusBar name="SkillRankFrame4" inherits="BenillaSkillRankTemplate" id="4"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame3" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
|
||||
<StatusBar name="SkillRankFrame5" inherits="BenillaSkillRankTemplate" id="5"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame4" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
|
||||
<StatusBar name="SkillRankFrame6" inherits="BenillaSkillRankTemplate" id="6"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame5" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
|
||||
<StatusBar name="SkillRankFrame7" inherits="BenillaSkillRankTemplate" id="7"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame6" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
|
||||
<StatusBar name="SkillRankFrame8" inherits="BenillaSkillRankTemplate" id="8"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame7" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
|
||||
<StatusBar name="SkillRankFrame9" inherits="BenillaSkillRankTemplate" id="9"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame8" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
|
||||
<StatusBar name="SkillRankFrame10" inherits="BenillaSkillRankTemplate" id="10"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame9" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
|
||||
<StatusBar name="SkillRankFrame11" inherits="BenillaSkillRankTemplate" id="11"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame10" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
|
||||
<StatusBar name="SkillRankFrame12" inherits="BenillaSkillRankTemplate" id="12"><Anchors><Anchor point="TOPLEFT" relativeTo="SkillRankFrame11" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors></StatusBar>
|
||||
|
||||
<!-- The faux-scroll frame (FauxScrollFrameTemplate, ScrollTemplates.xml): invisible
|
||||
container over the 12 rows; only its right-hand Slider scroll bar renders. Sized to the
|
||||
|
||||
@@ -758,7 +758,7 @@
|
||||
<HighlightTexture file="Interface\SpellBook\UI-SpellbookPanel-Tab-Highlight" alphaMode="ADD"/>
|
||||
</Button>
|
||||
|
||||
<Frame name="SpellBookFrame" parent="UIParent" hidden="true">
|
||||
<Frame name="SpellBookFrame" enableMouse="true" toplevel="true" parent="UIParent" hidden="true">
|
||||
<!-- The real 384x512 window (ref l.202-205). No <Anchors> — ShowUIPanel/SetLeftFrame
|
||||
SetPoints this to the left slot on open (UiPanels.xml's own convention). -->
|
||||
<Size><AbsDimension x="384" y="512"/></Size>
|
||||
|
||||
@@ -267,7 +267,7 @@
|
||||
</Scripts>
|
||||
</CheckButton>
|
||||
|
||||
<Frame name="ShapeshiftBarFrame" parent="MainMenuBar" hidden="true">
|
||||
<Frame name="ShapeshiftBarFrame" enableMouse="true" parent="MainMenuBar" hidden="true">
|
||||
<Size><AbsDimension x="29" y="32"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMLEFT" relativeTo="MainMenuBar" relativePoint="TOPLEFT"><Offset><AbsDimension x="30" y="45"/></Offset></Anchor>
|
||||
@@ -320,43 +320,43 @@
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Frames>
|
||||
<CheckButton name="ShapeshiftButton1" inherits="BenillaShapeshiftButtonTemplate">
|
||||
<CheckButton name="ShapeshiftButton1" inherits="BenillaShapeshiftButtonTemplate" id="1">
|
||||
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="11" y="3"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 1)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="ShapeshiftButton2" inherits="BenillaShapeshiftButtonTemplate">
|
||||
<CheckButton name="ShapeshiftButton2" inherits="BenillaShapeshiftButtonTemplate" id="2">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton1" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 2)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="ShapeshiftButton3" inherits="BenillaShapeshiftButtonTemplate">
|
||||
<CheckButton name="ShapeshiftButton3" inherits="BenillaShapeshiftButtonTemplate" id="3">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton2" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 3)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="ShapeshiftButton4" inherits="BenillaShapeshiftButtonTemplate">
|
||||
<CheckButton name="ShapeshiftButton4" inherits="BenillaShapeshiftButtonTemplate" id="4">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton3" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 4)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="ShapeshiftButton5" inherits="BenillaShapeshiftButtonTemplate">
|
||||
<CheckButton name="ShapeshiftButton5" inherits="BenillaShapeshiftButtonTemplate" id="5">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton4" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 5)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="ShapeshiftButton6" inherits="BenillaShapeshiftButtonTemplate">
|
||||
<CheckButton name="ShapeshiftButton6" inherits="BenillaShapeshiftButtonTemplate" id="6">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton5" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 6)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="ShapeshiftButton7" inherits="BenillaShapeshiftButtonTemplate">
|
||||
<CheckButton name="ShapeshiftButton7" inherits="BenillaShapeshiftButtonTemplate" id="7">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton6" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 7)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="ShapeshiftButton8" inherits="BenillaShapeshiftButtonTemplate">
|
||||
<CheckButton name="ShapeshiftButton8" inherits="BenillaShapeshiftButtonTemplate" id="8">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton7" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 8)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="ShapeshiftButton9" inherits="BenillaShapeshiftButtonTemplate">
|
||||
<CheckButton name="ShapeshiftButton9" inherits="BenillaShapeshiftButtonTemplate" id="9">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton8" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 9)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="ShapeshiftButton10" inherits="BenillaShapeshiftButtonTemplate">
|
||||
<CheckButton name="ShapeshiftButton10" inherits="BenillaShapeshiftButtonTemplate" id="10">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton9" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 10)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
|
||||
@@ -406,7 +406,7 @@
|
||||
</HighlightTexture>
|
||||
</Button>
|
||||
|
||||
<Frame name="TaxiFrame" parent="UIParent" hidden="true">
|
||||
<Frame name="TaxiFrame" enableMouse="true" toplevel="true" parent="UIParent" hidden="true">
|
||||
<!-- The real 384×512 window (ref l.34-36). No <Anchors> — ShowUIPanel/SetLeftFrame
|
||||
SetPoints this to the left slot on open, exactly like Gossip/Merchant/Trainer. -->
|
||||
<Size><AbsDimension x="384" y="512"/></Size>
|
||||
|
||||
@@ -951,7 +951,7 @@
|
||||
world map is FULLSCREEN strata — DIALOG would render the list underneath it (the strata
|
||||
escalation later reference versions added to ToggleDropDownMenu, applied statically
|
||||
here since our customer set is known). -->
|
||||
<Button name="UIDropDownListTemplate" frameStrata="FULLSCREEN_DIALOG" virtual="true" hidden="true">
|
||||
<Button name="UIDropDownListTemplate" enableMouse="true" frameStrata="FULLSCREEN_DIALOG" virtual="true" hidden="true">
|
||||
<Size><AbsDimension x="180" y="32"/></Size>
|
||||
<Frames>
|
||||
<Frame name="$parentBackdrop" setAllPoints="true">
|
||||
|
||||
@@ -1179,7 +1179,7 @@
|
||||
BELOW the MEDIUM panel windows on purpose, and the player frame shares the top-left corner
|
||||
with the left-slot panel. See the note on PartyFrame.xml's template (decision 0597) for why
|
||||
the default MEDIUM makes a unit frame's bar children draw through a window's background. -->
|
||||
<Frame name="PlayerFrame" parent="UIParent" frameStrata="BACKGROUND">
|
||||
<Frame name="PlayerFrame" toplevel="true" parent="UIParent" frameStrata="BACKGROUND">
|
||||
<Size><AbsDimension x="232" y="100"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="-19" y="-4"/></Offset></Anchor>
|
||||
@@ -1461,7 +1461,7 @@
|
||||
|
||||
<!-- The SELF-menu dropdown host (ref $parentDropDown, PlayerFrame.xml): hidden state-holder
|
||||
only — the list anchors to the player frame by name, never to this. -->
|
||||
<Frame name="PlayerFrameDropDown" parent="PlayerFrame" inherits="UIDropDownMenuTemplate" hidden="true">
|
||||
<Frame name="PlayerFrameDropDown" parent="PlayerFrame" inherits="UIDropDownMenuTemplate" id="1" hidden="true">
|
||||
<Size><AbsDimension x="10" y="10"/></Size>
|
||||
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="-20" y="-10"/></Offset></Anchor></Anchors>
|
||||
<Scripts>
|
||||
@@ -1519,7 +1519,7 @@
|
||||
every login. Steady-state behavior is identical — Update Shows/Hides on target changes. -->
|
||||
<!-- frameStrata LOW is the ref's own (`TargetFrame.xml` l.88) — one above the player frame's
|
||||
BACKGROUND, still below the MEDIUM panels. Same reasoning as PlayerFrame above. -->
|
||||
<Frame name="TargetFrame" parent="UIParent" hidden="true" frameStrata="LOW">
|
||||
<Frame name="TargetFrame" toplevel="true" parent="UIParent" hidden="true" frameStrata="LOW">
|
||||
<Size><AbsDimension x="232" y="100"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="250" y="-4"/></Offset></Anchor>
|
||||
@@ -1772,7 +1772,7 @@
|
||||
|
||||
<!-- The target-menu dropdown host (ref $parentDropDown, TargetFrame.xml): hidden
|
||||
state-holder only, like the player's above. -->
|
||||
<Frame name="TargetFrameDropDown" parent="TargetFrame" inherits="UIDropDownMenuTemplate" hidden="true">
|
||||
<Frame name="TargetFrameDropDown" parent="TargetFrame" inherits="UIDropDownMenuTemplate" id="1" hidden="true">
|
||||
<Size><AbsDimension x="10" y="10"/></Size>
|
||||
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="-20" y="-10"/></Offset></Anchor></Anchors>
|
||||
<Scripts>
|
||||
@@ -2064,7 +2064,7 @@
|
||||
<!-- 93×45 at the target frame's BOTTOMRIGHT (-35,-10) — ref TargetFrame.xml l.515-525. A
|
||||
Frame, not the reference's Button: every unit frame in this file is one, and this engine
|
||||
dispatches OnClick on a mouse-enabled frame (see TargetFrame/PetFrame above). -->
|
||||
<Frame name="TargetofTargetFrame" parent="TargetFrame" hidden="true" frameStrata="LOW">
|
||||
<Frame name="TargetofTargetFrame" enableMouse="true" toplevel="true" parent="TargetFrame" hidden="true" frameStrata="LOW">
|
||||
<Size><AbsDimension x="93" y="45"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMRIGHT" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-35" y="-10"/></Offset></Anchor>
|
||||
@@ -2504,7 +2504,7 @@
|
||||
|
||||
<!-- frameStrata LOW matches the party frames' and the target's — above the player frame's
|
||||
BACKGROUND (which it overlaps), still below the MEDIUM panels. -->
|
||||
<Frame name="PetFrame" parent="PlayerFrame" hidden="true" frameStrata="LOW">
|
||||
<Frame name="PetFrame" toplevel="true" parent="PlayerFrame" hidden="true" frameStrata="LOW">
|
||||
<Size><AbsDimension x="128" y="53"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT"><Offset><AbsDimension x="80" y="-60"/></Offset></Anchor>
|
||||
|
||||
@@ -440,7 +440,7 @@
|
||||
restored that line, and this was the frame it broke. `CinematicFrame` is unparented for
|
||||
exactly the same reason — every `full` panel must be.
|
||||
-->
|
||||
<Frame name="WorldMapFrame" frameStrata="FULLSCREEN_DIALOG" hidden="true" enableMouse="true">
|
||||
<Frame name="WorldMapFrame" enableKeyboard="true" frameStrata="FULLSCREEN_DIALOG" hidden="true" enableMouse="true">
|
||||
<Size><AbsDimension x="1024" y="768"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="CENTER"/>
|
||||
@@ -662,7 +662,7 @@
|
||||
<Size><AbsDimension x="1002" y="668"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="WorldMapDetailFrame"/></Anchors>
|
||||
<Frames>
|
||||
<Frame name="WorldMapPlayer">
|
||||
<Frame name="WorldMapPlayer" id="0">
|
||||
<Size><AbsDimension x="32" y="32"/></Size>
|
||||
<Anchors><Anchor point="CENTER"/></Anchors>
|
||||
<Layers>
|
||||
|
||||
@@ -293,7 +293,7 @@ the territory line is asked for on NEW_AREA alone.
|
||||
and its real trigger is Show, not a click, so "click-raise is meaningless on mouse-less
|
||||
splash text" was the wrong argument. What holds now: these are LOW-strata splash frames
|
||||
that overlap nothing in LOW, so the occlusion gate declines. One word to re-add.) -->
|
||||
<Frame name="ZoneTextFrame" parent="UIParent" frameStrata="LOW">
|
||||
<Frame name="ZoneTextFrame" toplevel="true" parent="UIParent" frameStrata="LOW">
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOM">
|
||||
<Offset>
|
||||
@@ -336,7 +336,7 @@ the territory line is asked for on NEW_AREA alone.
|
||||
</OnUpdate>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
<Frame name="SubZoneTextFrame" parent="UIParent" frameStrata="LOW">
|
||||
<Frame name="SubZoneTextFrame" toplevel="true" parent="UIParent" frameStrata="LOW">
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOM">
|
||||
<Offset>
|
||||
|
||||
@@ -272,9 +272,9 @@ PetPaperDollFrame.xml
|
||||
# "Show as Experience Bar" box.
|
||||
ReputationFrame.xml
|
||||
# The Skills tab (decision 0437 phase 4): a CharacterFrame child page, but a separate
|
||||
# top-level file (SkillFrame.xml's own header comment — this engine has no cross-file
|
||||
# `parent=` attachment, so it's positioned by name via `<Anchors relativeTo=
|
||||
# "CharacterFrame">` instead). Loaded after CharacterFrame.xml so it paints ON TOP of
|
||||
# top-level file — it declares `parent="CharacterFrame"`, which really attaches (1734), and
|
||||
# is positioned by name via `<Anchors relativeTo="CharacterFrame">`, the geometry it ships
|
||||
# with. Loaded after CharacterFrame.xml so it paints ON TOP of
|
||||
# it (later file = higher z) and so `CharacterFrame` already exists for that anchor to
|
||||
# resolve against — seated at the real client's own tab 4, between Reputation above and
|
||||
# Honor below.
|
||||
|
||||
@@ -766,8 +766,15 @@ impl RemoteMotion {
|
||||
let mut pos = self.wow_pos;
|
||||
pos[0] += self.jump_xy_vel[0] * dt;
|
||||
pos[1] += self.jump_xy_vel[1] * dt;
|
||||
pos[2] += self.vertical_velocity * dt;
|
||||
let vertical = (self.vertical_velocity - GRAVITY * dt).max(-TERMINAL_VELOCITY);
|
||||
// **The same exact fall step the local mover runs** ([`crate::player::mover::fall_step`],
|
||||
// decision 1740) — one shared machine, which is what the reference has. This used to
|
||||
// move at the START-of-step speed and only then apply gravity (explicit Euler), so a
|
||||
// watched player's jump overshot by `½·g·dt²` every frame and floated; the local mover
|
||||
// had the mirror-image bug in the other direction. The mean velocity is the exact
|
||||
// displacement rate under constant acceleration, so neither drifts now.
|
||||
let (vertical, mean_vy) =
|
||||
crate::player::mover::fall_step(self.vertical_velocity, dt, TERMINAL_VELOCITY);
|
||||
pos[2] += mean_vy * dt;
|
||||
let speed = self.jump_xy_vel[0].hypot(self.jump_xy_vel[1]);
|
||||
return (pos, self.orientation, vertical, speed);
|
||||
}
|
||||
|
||||
@@ -274,9 +274,16 @@ fn remote_motion_jump_is_a_parabola_not_flag_walking() {
|
||||
"horizontal coasts at the frozen 7 yd/s: {pos:?}"
|
||||
);
|
||||
assert!(pos[1].abs() < 1e-3, "no lateral drift: {pos:?}");
|
||||
// **The analytic height, `v₀·t − ½g·t²`** (decision 1740). This asserted `v₀·t` until the
|
||||
// airborne-integrator round — explicit Euler, moving the whole step at the START-of-step speed
|
||||
// with no gravity in the displacement at all, which over this 0.5 s step is 3.98 yd against a
|
||||
// true 1.57. The local mover had the mirror-image error in the other direction. Both now run
|
||||
// the one exact step ([`crate::player::mover::fall_step`]), so this is the closed form and it
|
||||
// holds at any `dt`.
|
||||
let analytic = 7.955_547 * 0.5 - 0.5 * GRAVITY * 0.5 * 0.5;
|
||||
assert!(
|
||||
(pos[2] - 7.955_547 * 0.5).abs() < 1e-3,
|
||||
"height integrates v·dt: {pos:?}"
|
||||
(pos[2] - analytic).abs() < 1e-3,
|
||||
"height follows the closed form {analytic}: {pos:?}"
|
||||
);
|
||||
assert!(
|
||||
(vz - (7.955_547 - GRAVITY * 0.5)).abs() < 1e-3,
|
||||
|
||||
@@ -61,11 +61,21 @@ impl Player {
|
||||
let new_arc = airborne && (!was_airborne || jumped);
|
||||
if new_arc {
|
||||
self.airborne_since = Some(now);
|
||||
self.jump_zspeed = if jumped { self.vel_y } else { 0.0 };
|
||||
// From `launch_vz`, NOT `vel_y` (decision 1740): the mover now integrates gravity on
|
||||
// the take-off frame too, so `vel_y` has already moved `g·dt` down the parabola by the
|
||||
// time this runs and would seat the arc's launch speed a step short — visible on the
|
||||
// wire, where the jump tail sends this value on every packet of the arc, and in the
|
||||
// FALLINGFAR leg split. The reference writes `+0xa0` once, in `StartFalling`.
|
||||
self.jump_zspeed = if jumped { self.launch_vz } else { 0.0 };
|
||||
self.fall_start_y = launch_y;
|
||||
self.fall_far = false;
|
||||
} else if !airborne {
|
||||
self.airborne_since = None;
|
||||
// The arc is over: its direction nibble and its knockback provenance go with it, so
|
||||
// the next standstill jump gets its one steer back and the wire stops planting FORWARD
|
||||
// (decision 1740). The reference's `StopFalling` is the same edge.
|
||||
self.arc_dirs_set = false;
|
||||
self.knock_arc = false;
|
||||
}
|
||||
if airborne {
|
||||
let far = if self.jump_zspeed != 0.0 {
|
||||
@@ -125,6 +135,10 @@ mod tests {
|
||||
fn grounded_at(ground: f32) -> Player {
|
||||
Player {
|
||||
vel_y: JUMP_SPEED,
|
||||
// The mover records this the instant the take-off is decided, and the arc snapshot
|
||||
// reads it rather than `vel_y` (decision 1740) — by the time the bookkeeping runs, the
|
||||
// real mover has already stepped `vel_y` one `g·dt` down the parabola.
|
||||
launch_vz: JUMP_SPEED,
|
||||
pos: Vec3::new(0.0, ground, 0.0),
|
||||
..Default::default()
|
||||
}
|
||||
@@ -140,6 +154,46 @@ mod tests {
|
||||
p
|
||||
}
|
||||
|
||||
/// **The launch speed on the wire is the LAUNCH's, not the post-step velocity** (decision
|
||||
/// 1740). The mover integrates gravity on the take-off frame too, so `vel_y` is already
|
||||
/// `g·dt` down by the time the arc bookkeeping runs; reading it would send observers a jump
|
||||
/// tail one step short (0.32 yd/s at 60 fps, 0.64 at 30 — and frame-rate dependent, so two
|
||||
/// clients watching the same jump would disagree). The reference writes `+0xa0` once, in
|
||||
/// `StartFalling`, and never touches it again for the arc.
|
||||
#[test]
|
||||
fn the_arc_snapshots_the_launch_speed_not_the_already_integrated_one() {
|
||||
let mut p = grounded_at(100.0);
|
||||
// What the mover leaves behind on the take-off frame: the launch recorded, `vel_y` already
|
||||
// advanced one step by the exact fall step.
|
||||
let dt = 1.0 / 60.0;
|
||||
p.vel_y = super::super::mover::fall_step(JUMP_SPEED, dt, 60.148_003).0;
|
||||
assert!(
|
||||
p.vel_y < JUMP_SPEED,
|
||||
"the take-off frame really did integrate"
|
||||
);
|
||||
let edges = p.advance_airborne_arc(true, true, 0.0, 100.0);
|
||||
assert!(edges.new_arc);
|
||||
assert_eq!(
|
||||
p.jump_zspeed, JUMP_SPEED,
|
||||
"the wire tail carries the launch speed for the whole arc, not this frame's velocity"
|
||||
);
|
||||
}
|
||||
|
||||
/// The arc's direction nibble and its knockback provenance are **arc-scoped**: a landing
|
||||
/// clears both, so the next standstill jump gets its one steer back and the wire stops
|
||||
/// planting FORWARD (decision 1740 — the reference's `StopFalling` edge).
|
||||
#[test]
|
||||
fn landing_clears_the_arc_nibble_and_the_knockback_provenance() {
|
||||
let mut p = grounded_at(100.0);
|
||||
p.advance_airborne_arc(true, true, 0.0, 100.0);
|
||||
p.arc_dirs_set = true;
|
||||
p.knock_arc = true;
|
||||
let edges = p.advance_airborne_arc(false, false, 0.5, 100.0);
|
||||
assert!(edges.landed, "the arc closed");
|
||||
assert!(!p.arc_dirs_set, "the nibble goes with the arc");
|
||||
assert!(!p.knock_arc, "so does the knockback provenance");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_jump_snapshots_the_true_launch_ground_not_the_risen_pos() {
|
||||
let mut p = grounded_at(100.0);
|
||||
|
||||
@@ -745,6 +745,13 @@ pub(super) fn control(
|
||||
wire_jump,
|
||||
knock_launch,
|
||||
water_floor,
|
||||
// **The air-control nudge speed is `min(MOVE_WALK, MOVE_RUN)`, read live**
|
||||
// (decision 1740) — the walk override inside the reference's `0x7c4c90(1)`
|
||||
// (`0x7c4d19`/`0x7c4d1b`), not a constant. `AIR_NUDGE_SPEED`'s 2.5 is the default
|
||||
// `MOVE_WALK` and stays as the fallback for before the server has sent us speeds;
|
||||
// once it has, a walk aura, a Slow or a daze moves this with them, and the min is
|
||||
// why a *slowed* run still cannot out-steer a walk.
|
||||
mover_speeds.map_or(super::AIR_NUDGE_SPEED, |s| s.walk.min(s.run)),
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
@@ -165,6 +165,16 @@ pub(super) fn this_frame(
|
||||
move_flags_now = 0;
|
||||
}
|
||||
}
|
||||
// **A knockback arc streams FORWARD for its whole length** (decision 1740). The reference's
|
||||
// apply plants it as part of the launch (`0x6179c0`'s `0x617a18 or edx,0x8001` — set bit 0,
|
||||
// clear bit 1) and nothing clears it until the arc ends, and the send mask
|
||||
// (`0x618909 and edx,0x75a07dff`) keeps bit 0, so observers see it. It is also the mechanism
|
||||
// that makes the arc unsteerable, so the bit and the freeze are the same fact, set in one
|
||||
// place. Ours never sent it at all: a knocked-back body streamed a bare FALLING and observers
|
||||
// replayed it as a plain drop.
|
||||
if player.knock_arc {
|
||||
move_flags_now = (move_flags_now & !move_flags::BACKWARD) | move_flags::FORWARD;
|
||||
}
|
||||
// The two incapacitate suppressions — rooted drops the direction bits, stunned drops the
|
||||
// turn bits — applied to the whole word in one place, whichever branch built it, and with
|
||||
// the reference's byte trail in [`state::incapacitated_flags`] (decision 0880).
|
||||
@@ -194,3 +204,84 @@ pub(super) fn this_frame(
|
||||
fall_time: wire_fall_time,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::player::input::MoveAxes;
|
||||
|
||||
fn still() -> MoveAxes {
|
||||
MoveAxes {
|
||||
fwd: 0,
|
||||
side: 0,
|
||||
mouselook: false,
|
||||
turning: false,
|
||||
translating: false,
|
||||
autorun_armed: false,
|
||||
strafe_left: false,
|
||||
strafe_right: false,
|
||||
turn_left: false,
|
||||
turn_right: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// **A knockback arc streams FORWARD for its whole length** (decision 1740). The reference's
|
||||
/// apply plants the bit as part of the launch (`0x6179c0`'s `0x617a18 or edx,0x8001` — set bit
|
||||
/// 0, clear bit 1) and only the arc's end clears it; the send mask
|
||||
/// (`0x618909 and edx,0x75a07dff`) keeps bit 0, so observers get it on every packet of the
|
||||
/// flight. We sent a bare FALLING instead, and observers replayed a knockback as a plain drop.
|
||||
///
|
||||
/// It is the same bit that makes the arc unsteerable, which is why the two are one fact set in
|
||||
/// one place — see [`super::mover`]'s air-control gate.
|
||||
#[test]
|
||||
fn a_knockback_arc_plants_forward_on_the_wire() {
|
||||
let mut player = Player {
|
||||
knock_arc: true,
|
||||
..Default::default()
|
||||
};
|
||||
// Airborne, no keys held at all — nothing else could produce a direction bit.
|
||||
let f = this_frame(
|
||||
&mut player,
|
||||
&still(),
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
1.0,
|
||||
0.0,
|
||||
);
|
||||
assert!(
|
||||
f.wire & move_flags::FORWARD != 0,
|
||||
"the launch's planted FORWARD rides the wire: {:#x}",
|
||||
f.wire
|
||||
);
|
||||
assert!(
|
||||
f.wire & move_flags::BACKWARD == 0,
|
||||
"and it clears BACKWARD, as the apply does: {:#x}",
|
||||
f.wire
|
||||
);
|
||||
assert!(f.wire & move_flags::FALLING != 0, "still an airborne arc");
|
||||
|
||||
// The same frame without the knockback provenance streams no direction at all.
|
||||
let mut plain = Player::default();
|
||||
let f = this_frame(
|
||||
&mut plain,
|
||||
&still(),
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
1.0,
|
||||
0.0,
|
||||
);
|
||||
assert!(
|
||||
f.wire & move_flags::ANY_MOVE == 0,
|
||||
"an ordinary fall with no keys held streams no direction: {:#x}",
|
||||
f.wire
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,11 +37,10 @@ use avian3d::prelude::*;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::{
|
||||
move_trace, Player, AIR_NUDGE_SPEED, CAPSULE_HEIGHT, FEATHER_TERMINAL_VELOCITY,
|
||||
FOOT_CONE_HEIGHT, GRAVITY, GROUND_COS, GROUND_PROBE, HOVER_CLIMB_RATE, HOVER_HEIGHT,
|
||||
JUMP_SPEED, LAND_PROBE, SKIN_WIDTH, STEP_SLOPE_RATIO, STEP_SNAP_SLACK, STEP_UP_ADVANCE,
|
||||
STEP_UP_HEIGHT, TERMINAL_VELOCITY, WATER_WALK_PITCH_FLOOR, WEDGE_MIN_FALL, WEDGE_STALL_RATIO,
|
||||
WEDGE_STILL_FRAMES,
|
||||
move_trace, Player, CAPSULE_HEIGHT, FEATHER_TERMINAL_VELOCITY, FOOT_CONE_HEIGHT, GRAVITY,
|
||||
GROUND_COS, GROUND_PROBE, HOVER_CLIMB_RATE, HOVER_HEIGHT, JUMP_SPEED, LAND_PROBE, SKIN_WIDTH,
|
||||
STEP_SLOPE_RATIO, STEP_SNAP_SLACK, STEP_UP_ADVANCE, STEP_UP_HEIGHT, TERMINAL_VELOCITY,
|
||||
WATER_WALK_PITCH_FLOOR, WEDGE_MIN_FALL, WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES,
|
||||
};
|
||||
|
||||
/// **The trace-mask arm `0x6315f0`, as a predicate** — the liquid surface [`step`] may stand on
|
||||
@@ -125,6 +124,7 @@ pub(super) fn step(
|
||||
wire_jump: bool,
|
||||
knockback: Option<Vec3>,
|
||||
water_floor: Option<f32>,
|
||||
nudge_speed: f32,
|
||||
) -> Outcome {
|
||||
let dt = time.delta_secs();
|
||||
let input_horiz = if moving {
|
||||
@@ -272,11 +272,20 @@ pub(super) fn step(
|
||||
// 1620), and being knocked back mid-air is exactly what a knockback is for; the server's own
|
||||
// gate (`Unit::KnockBack`) tests stun, root and a live spline, never FALLING.
|
||||
player.vel_y = launch.y;
|
||||
player.launch_vz = launch.y;
|
||||
player.horiz_vel = Vec3::new(launch.x, 0.0, launch.z);
|
||||
player.wedged = false;
|
||||
player.steep_support = false;
|
||||
jumped = true;
|
||||
knocked = true;
|
||||
// **The launch plants the direction nibble, and that is what freezes the arc** — the
|
||||
// reference's `0x617a18 or edx,0x8001` sets FORWARD and clears BACKWARD as part of the
|
||||
// apply (decision 1740). It is set regardless of `xy_speed`, so a purely vertical knockback
|
||||
// is just as unsteerable as a 25 yd/s one; reading the freeze off the *velocity* instead
|
||||
// (what we did until 1740) let that case steer from the second frame. `knock_arc` also
|
||||
// rides the wire for the arc's length, which is the half we were not sending at all.
|
||||
player.arc_dirs_set = true;
|
||||
player.knock_arc = true;
|
||||
} else if grounded {
|
||||
player.vel_y = 0.0;
|
||||
// **HOVER refuses the jump** — the FIRST test in `CMovement::Jump 0x7c6230`
|
||||
@@ -295,33 +304,49 @@ pub(super) fn step(
|
||||
// it is the *same* jump, entered through the door the wire is given.
|
||||
if (want_jump && !player.modes.hover) || wire_jump {
|
||||
player.vel_y = JUMP_SPEED;
|
||||
player.launch_vz = JUMP_SPEED;
|
||||
player.wedged = false;
|
||||
player.steep_support = false;
|
||||
jumped = true;
|
||||
// The arc's nibble starts as the keys are: a jump taken with a direction held is
|
||||
// locked to its momentum, a jump from a standstill has one steer in hand (1740).
|
||||
player.arc_dirs_set = moving;
|
||||
}
|
||||
} else {
|
||||
// **Feather fall is a terminal-velocity substitution, and nothing else** (decision 0866).
|
||||
// The reference's fall-velocity query `0x7c5d20` picks its clamp from one flag test
|
||||
// (`0x7c5d23 test [ecx+0x40], 0x20000000`) — the ordinary 60.148 or 7.0 under
|
||||
// `MOVEFLAG_SAFE_FALL`. Gravity itself is unchanged, so a Slow Fall still *accelerates*
|
||||
// normally for the first ~0.36 s and only then rides the cap: the drop starts like any
|
||||
// other and settles into a drift, which is what Slow Fall looks like.
|
||||
//
|
||||
// **This is semi-implicit Euler; the reference is not** (decision 1736). It evaluates a
|
||||
// CLOSED FORM every substep — `+0x7c − D(+0x78)` via `0x7c5e70`, anchored at the launch —
|
||||
// so its arc is frame-rate independent and cannot drift, while ours accumulates and lands
|
||||
// low by `½·g·dt·t`: **0.066 yd at the jump apex at 60 fps (4.0% of the 1.640 yd apex),
|
||||
// 0.133 at 30 fps**, saturating near 0.50/1.00 yd at terminal. Our jump is exactly one
|
||||
// frame short, and the error is frame-rate dependent, which a jump arc must not be.
|
||||
// (`0x7c5d20`, cited above, is the velocity *query* that serves the clamp select — it
|
||||
// writes nothing. Both wow-re's ledger and this file had it as the integrator until the
|
||||
// 1736 round; the constants it yields are still bit-exact.)
|
||||
} else if !player.airborne_prev {
|
||||
// **A step-off — the arc nobody launched.** The walk election calls `StartFalling(0)`, so
|
||||
// the launch speed is exactly 0 (not the gravity-polluted first tick), which is what the
|
||||
// wire tail sends and what splits the FALLINGFAR legs (0179). The nibble seeds from the
|
||||
// keys like any other take-off: you walked off this ledge, so you keep your momentum.
|
||||
player.launch_vz = 0.0;
|
||||
player.arc_dirs_set = moving;
|
||||
}
|
||||
|
||||
// **The fall step, for every airborne frame INCLUDING the take-off's** (decision 1740). It used
|
||||
// to sit inside the `else` above, which meant a launch frame moved a whole `v₀·dt` with no
|
||||
// gravity at all while every later frame moved at its END-of-step speed — one overshoot
|
||||
// followed by a systematic undershoot. The reference has no such seam: `StartFalling` anchors
|
||||
// the arc and the closed form is evaluated from `t = 0`, the launch frame included.
|
||||
//
|
||||
// **Feather fall is a terminal-velocity substitution, and nothing else** (decision 0866). The
|
||||
// reference's fall-velocity query `0x7c5d20` picks the clamp from one flag test
|
||||
// (`0x7c5d23 test [ecx+0x40], 0x20000000`) — the ordinary 60.148 or 7.0 under
|
||||
// `MOVEFLAG_SAFE_FALL`. Gravity itself is unchanged, so a Slow Fall still *accelerates*
|
||||
// normally for the first ~0.36 s and only then rides the cap: the drop starts like any other
|
||||
// and settles into a drift, which is what Slow Fall looks like.
|
||||
//
|
||||
// `player.vel_y` keeps the END-of-step speed (the state the next frame and the wire read);
|
||||
// `fall_mean_vy` is what this frame actually MOVES at, and for constant acceleration the mean
|
||||
// is exact — see [`fall_step`].
|
||||
let mut fall_mean_vy = player.vel_y;
|
||||
if !held && !anchored && (jumped || !grounded) {
|
||||
let terminal = if player.modes.feather_fall {
|
||||
FEATHER_TERMINAL_VELOCITY
|
||||
} else {
|
||||
TERMINAL_VELOCITY
|
||||
};
|
||||
player.vel_y = (player.vel_y - GRAVITY * dt).max(-terminal);
|
||||
let (end_vy, mean_vy) = fall_step(player.vel_y, dt, terminal);
|
||||
player.vel_y = end_vy;
|
||||
fall_mean_vy = mean_vy;
|
||||
}
|
||||
let mut air_nudged = false;
|
||||
// The anchor owns the horizontal too — the wipe leaves the basis recompute nothing to build a
|
||||
@@ -334,10 +359,10 @@ pub(super) fn step(
|
||||
// this frame, and the walk arm below would hand our momentum straight back to the keys.
|
||||
} else if grounded && !anchored {
|
||||
player.horiz_vel = input_horiz;
|
||||
} else if !held && !anchored && moving && player.horiz_vel.length_squared() < 0.01 {
|
||||
} else if !held && !anchored && moving && !player.arc_dirs_set {
|
||||
// Air control: one nudge to steer a jump that took off from a standstill (a moving jump
|
||||
// keeps its momentum locked, since horiz_vel is already non-zero). The pressed direction
|
||||
// *really* moves us, so it re-seeds the frozen airborne direction flags.
|
||||
// keeps its momentum locked, because its nibble was already set at take-off). The pressed
|
||||
// direction *really* moves us, so it re-seeds the frozen airborne direction flags.
|
||||
//
|
||||
// **The guard is a velocity proxy for a flag test, and the reference's is the flag**
|
||||
// (decision 1736, wow-re `airborne-steerability.md`). `0x7c5a20`/`0x7c5c20` do not bail on
|
||||
@@ -351,12 +376,16 @@ pub(super) fn step(
|
||||
// Fixing it means carrying the nibble as arc state and seeding it at the launch — 1736
|
||||
// §Plan, deliberately not done in the round that found it.
|
||||
//
|
||||
// [`AIR_NUDGE_SPEED`] is likewise the right number for the wrong reason: the reference
|
||||
// takes `min(MOVE_WALK, MOVE_RUN)` from the unit's live speeds (`0x7c4c90(1)`'s walk
|
||||
// override), and 2.5 is merely the DEFAULT `MOVE_WALK`. Under a walk aura, a Slow or a
|
||||
// daze it is wrong.
|
||||
player.horiz_vel = dir.normalize_or_zero() * AIR_NUDGE_SPEED;
|
||||
// [`super::AIR_NUDGE_SPEED`] is likewise the right number for the wrong reason: the
|
||||
// reference takes `min(MOVE_WALK, MOVE_RUN)` from the unit's live speeds (`0x7c4c90(1)`'s
|
||||
// walk override), and 2.5 is merely the DEFAULT `MOVE_WALK` — so it is now the fallback
|
||||
// `nudge_speed` the caller passes when the server has told us no speeds yet, not the law.
|
||||
player.horiz_vel = dir.normalize_or_zero() * nudge_speed;
|
||||
air_nudged = true;
|
||||
// The press sets the nibble, which is what makes this one-shot on the reference: the next
|
||||
// press finds `+0x40 & 0xf` non-zero, `arg = 0` goes to the basis rebuild, and the bail
|
||||
// fires. No second steer, and no drifting back toward the keys.
|
||||
player.arc_dirs_set = true;
|
||||
}
|
||||
|
||||
let pre_move = center;
|
||||
@@ -402,7 +431,8 @@ pub(super) fn step(
|
||||
let velocity = if held || anchored {
|
||||
Vec3::ZERO
|
||||
} else {
|
||||
player.horiz_vel + Vec3::Y * player.vel_y
|
||||
// `fall_mean_vy`, not `player.vel_y` — the exact displacement rate for this step.
|
||||
player.horiz_vel + Vec3::Y * fall_mean_vy
|
||||
};
|
||||
// The airborne slide is the OTHER shared resolve ([`airborne_step`]) — the same code a
|
||||
// remote mover's arc runs, so a jump meets our walls whoever is jumping (decision 0627).
|
||||
@@ -530,6 +560,14 @@ pub(super) fn step(
|
||||
anchored,
|
||||
});
|
||||
|
||||
// The mover's own airborne record for the next step's arc-start test — see
|
||||
// [`Player::airborne_prev`]. Held and anchored bodies are not airborne: the anchor ends the arc
|
||||
// (0880) and the settle hold has no arc at all.
|
||||
// `jumped ||`, not just `!grounded`: a launch frame IS the arc's first airborne frame even
|
||||
// though the body is still standing on the floor it pushed off. Without that, the frame after a
|
||||
// jump looks like a fresh step-off and re-seeds the arc state the launch just set — which is
|
||||
// exactly how a standstill jump lost the one steer it is owed.
|
||||
player.airborne_prev = !held && !anchored && (jumped || !grounded);
|
||||
Outcome {
|
||||
held,
|
||||
grounded,
|
||||
@@ -948,6 +986,43 @@ pub(crate) fn grounded_step(
|
||||
/// ([`crate::net::motion::remote`]). Without that, a watched player who jumps into a building is
|
||||
/// drawn *inside* it for the length of the jump and pops back out on the landing packet — the
|
||||
/// airborne half of the very defect 0626 fixed on the ground (decision 0627).
|
||||
/// **One frame of the fall, integrated exactly** — the end-of-step vertical speed and the *mean*
|
||||
/// speed to move at, given the speed at the step's start.
|
||||
///
|
||||
/// Under constant acceleration the mean velocity over a step IS the exact displacement rate, so
|
||||
/// `mean · dt` is the closed form `v₀·dt − ½g·dt²`, not an approximation of it. That matters
|
||||
/// because the reference does not integrate at all: it evaluates a closed form anchored at the
|
||||
/// launch every substep (`+0x7c − D(+0x78)` via `0x7c5e70`), so its arc is frame-rate independent
|
||||
/// and cannot drift (decision 1740, wow-re `airborne-steerability.md`). Stepping at either
|
||||
/// endpoint's velocity instead of the mean loses `½·g·dt²` per frame — and both of ours did, in
|
||||
/// opposite directions:
|
||||
///
|
||||
/// - the local mover applied gravity *before* moving (semi-implicit), so it moved at the END-of-step
|
||||
/// speed and fell **low**: `½·g·dt·t` by elapsed time, = 0.066 yd at the 1.640 yd jump apex at
|
||||
/// 60 fps (4.0%), 0.133 at 30 fps. The jump was exactly one frame short, and short by *more* the
|
||||
/// worse the frame rate — which a jump arc must never be;
|
||||
/// - the remote dead-reckon moved *before* applying gravity (explicit Euler), so it moved at the
|
||||
/// START-of-step speed and overshot by the same amount, floating every other player's jump.
|
||||
///
|
||||
/// The terminal clamp is handled piecewise rather than by clamping the mean, because a step that
|
||||
/// *reaches* terminal partway is two regimes: accelerating to `−terminal` at `t_c`, then coasting.
|
||||
/// Clamping the mean would under-move that one step by up to `½·g·dt²`.
|
||||
pub(crate) fn fall_step(v0: f32, dt: f32, terminal: f32) -> (f32, f32) {
|
||||
let unclamped = v0 - GRAVITY * dt;
|
||||
if unclamped >= -terminal {
|
||||
// Wholly inside the clamp: mean of the endpoints, exact.
|
||||
return (unclamped, 0.5 * (v0 + unclamped));
|
||||
}
|
||||
if v0 <= -terminal {
|
||||
// Already at (or past) terminal for the whole step — constant velocity.
|
||||
return (-terminal, -terminal);
|
||||
}
|
||||
// The clamp engages partway: accelerate for `t_c`, then coast the remainder.
|
||||
let t_c = (v0 + terminal) / GRAVITY;
|
||||
let displacement = 0.5 * (v0 - terminal) * t_c - terminal * (dt - t_c);
|
||||
(-terminal, displacement / dt)
|
||||
}
|
||||
|
||||
pub(crate) fn airborne_step(
|
||||
world: &benilla_world::collision::WorldCollision<'_, '_>,
|
||||
capsule: &Collider,
|
||||
@@ -1275,6 +1350,7 @@ pub(crate) fn step_up(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::player::AIR_NUDGE_SPEED;
|
||||
use crate::player::CAPSULE_RADIUS;
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
@@ -2149,6 +2225,7 @@ mod tests {
|
||||
false,
|
||||
None,
|
||||
Some(SURFACE),
|
||||
AIR_NUDGE_SPEED,
|
||||
);
|
||||
}
|
||||
player.pos.y
|
||||
@@ -2221,6 +2298,7 @@ mod tests {
|
||||
false,
|
||||
None,
|
||||
Some(SURFACE),
|
||||
AIR_NUDGE_SPEED,
|
||||
);
|
||||
}
|
||||
player.pos.y
|
||||
@@ -2304,6 +2382,7 @@ mod tests {
|
||||
false,
|
||||
None,
|
||||
Some(SURFACE),
|
||||
AIR_NUDGE_SPEED,
|
||||
);
|
||||
}
|
||||
player.pos
|
||||
@@ -2381,8 +2460,9 @@ mod tests {
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
AIR_NUDGE_SPEED,
|
||||
);
|
||||
(out.jumped, player.vel_y)
|
||||
(out.jumped, player.launch_vz)
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
@@ -2431,8 +2511,9 @@ mod tests {
|
||||
wire_jump,
|
||||
None,
|
||||
None,
|
||||
AIR_NUDGE_SPEED,
|
||||
);
|
||||
(out.jumped, player.vel_y)
|
||||
(out.jumped, player.launch_vz)
|
||||
};
|
||||
(press(true, false), press(false, true))
|
||||
})
|
||||
@@ -2445,7 +2526,245 @@ mod tests {
|
||||
assert_eq!(
|
||||
wire,
|
||||
(true, JUMP_SPEED),
|
||||
"`force = 0` skips that test and takes off on the land seed `0xc0fe93d8`"
|
||||
"`force = 0` skips that test and takes off on the land seed `0xc0fe93d8` (the launch \
|
||||
speed, not the post-gravity `vel_y` — 1740)"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The fall step is EXACT, not merely close** (decision 1740). Under constant acceleration
|
||||
/// the mean of the endpoint velocities is the true displacement rate, so one step of `dt` and
|
||||
/// `n` steps of `dt/n` must land on the same height — that is the whole property the reference
|
||||
/// gets for free by evaluating a closed form, and the one both of our integrators lacked.
|
||||
///
|
||||
/// The old semi-implicit form fails this: it lost `½·g·dt²` per step, so a coarser step landed
|
||||
/// lower, which is a jump arc that changes shape with the frame rate.
|
||||
#[test]
|
||||
fn the_fall_step_is_frame_rate_independent() {
|
||||
let total = 0.5_f32;
|
||||
let drop = |steps: u32| {
|
||||
let dt = total / steps as f32;
|
||||
let (mut v, mut y) = (JUMP_SPEED, 0.0_f32);
|
||||
for _ in 0..steps {
|
||||
let (end, mean) = fall_step(v, dt, TERMINAL_VELOCITY);
|
||||
y += mean * dt;
|
||||
v = end;
|
||||
}
|
||||
y
|
||||
};
|
||||
// The analytic height after `total`: v₀·t − ½g·t².
|
||||
let analytic = JUMP_SPEED * total - 0.5 * GRAVITY * total * total;
|
||||
for steps in [1_u32, 2, 8, 30, 120] {
|
||||
let got = drop(steps);
|
||||
assert!(
|
||||
(got - analytic).abs() < 1.0e-4,
|
||||
"{steps} steps must reproduce the closed form {analytic}, got {got}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The jump apex is the reference's, to the millimetre: `v₀²/2g` = 1.640 yd. The old
|
||||
/// integrator undershot it by 4.0% at 60 fps and 8% at 30 — visible as a jump that could not
|
||||
/// quite clear what it should (decision 1740).
|
||||
#[test]
|
||||
fn the_jump_apex_is_the_analytic_one_at_any_frame_rate() {
|
||||
let apex = JUMP_SPEED * JUMP_SPEED / (2.0 * GRAVITY);
|
||||
for fps in [30.0_f32, 60.0, 144.0] {
|
||||
let dt = 1.0 / fps;
|
||||
let (mut v, mut y, mut high) = (JUMP_SPEED, 0.0_f32, 0.0_f32);
|
||||
while v > -1.0 {
|
||||
let (end, mean) = fall_step(v, dt, TERMINAL_VELOCITY);
|
||||
y += mean * dt;
|
||||
high = high.max(y);
|
||||
v = end;
|
||||
}
|
||||
// Within one step's worth of sampling — the apex falls between frames, and that is
|
||||
// sampling, not drift.
|
||||
assert!(
|
||||
(high - apex).abs() < 0.5 * GRAVITY * dt * dt + 1.0e-4,
|
||||
"at {fps} fps the apex should be ~{apex}, got {high}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **Terminal is a piecewise clamp, not a clamped mean** (decision 1740). A step that reaches
|
||||
/// terminal partway is two regimes, and clamping the mean instead would under-move it by up to
|
||||
/// `½·g·dt²`. Below terminal the clamp must not perturb the exact answer at all.
|
||||
#[test]
|
||||
fn the_terminal_clamp_is_exact_on_the_step_that_reaches_it() {
|
||||
let dt = 1.0 / 60.0;
|
||||
// Well below terminal: untouched by the clamp.
|
||||
let (end, mean) = fall_step(0.0, dt, TERMINAL_VELOCITY);
|
||||
assert!((end - -GRAVITY * dt).abs() < 1.0e-6);
|
||||
assert!((mean - -0.5 * GRAVITY * dt).abs() < 1.0e-6);
|
||||
// Straddling terminal: reach it at `t_c`, coast the rest.
|
||||
let v0 = -TERMINAL_VELOCITY + 0.5 * GRAVITY * dt;
|
||||
let (end, mean) = fall_step(v0, dt, TERMINAL_VELOCITY);
|
||||
assert_eq!(end, -TERMINAL_VELOCITY, "the step ends at terminal");
|
||||
let t_c = (v0 + TERMINAL_VELOCITY) / GRAVITY;
|
||||
let want = (0.5 * (v0 - TERMINAL_VELOCITY) * t_c - TERMINAL_VELOCITY * (dt - t_c)) / dt;
|
||||
assert!(
|
||||
(mean - want).abs() < 1.0e-6,
|
||||
"piecewise, not a clamped mean"
|
||||
);
|
||||
// Already at terminal: constant velocity, no acceleration at all.
|
||||
let (end, mean) = fall_step(-TERMINAL_VELOCITY, dt, TERMINAL_VELOCITY);
|
||||
assert_eq!((end, mean), (-TERMINAL_VELOCITY, -TERMINAL_VELOCITY));
|
||||
}
|
||||
|
||||
/// **A knockback is unsteerable because its launch plants a direction bit — not because it is
|
||||
/// fast** (decision 1740, wow-re `airborne-steerability.md`).
|
||||
///
|
||||
/// This is the case the old gate got wrong, and it is the reason the gate changed. Air control
|
||||
/// opens on the reference exactly while the direction nibble `[CMovement+0x40] & 0xf` is clear
|
||||
/// (`0x7c6afc test al,0xf` guarding the only two `arg = 1` openers), and `0x6179c0`'s
|
||||
/// `0x617a18 or edx,0x8001` plants FORWARD as part of every knockback apply — *regardless of
|
||||
/// `speedXY`*. We tested `horiz_vel.length_squared() < 0.01` instead, a velocity proxy that
|
||||
/// agrees for every ordinary jump and disagrees for exactly this input: a knockback with no
|
||||
/// horizontal component at all, which the proxy reads as "standing still, may steer". Since
|
||||
/// `Outcome::knocked` is true on the launch frame only, nothing else covered it either — the
|
||||
/// player got 2.5 yd/s of free steering from the second frame of a purely vertical toss.
|
||||
#[test]
|
||||
fn a_vertical_knockback_is_no_more_steerable_than_a_fast_one() {
|
||||
let flat: [(f32, f32); 2] = [(-3.0, 0.0), (3.0, 0.0)];
|
||||
// Straight up, no horizontal at all — the input the old velocity proxy could not see.
|
||||
const STRAIGHT_UP: Vec3 = Vec3::new(0.0, 12.0, 0.0);
|
||||
let horiz = world_from_profile(&flat)
|
||||
.world_mut()
|
||||
.run_system_once(|world: benilla_world::collision::WorldCollision| {
|
||||
let capsule = player_capsule();
|
||||
let mut time = Time::default();
|
||||
time.advance_by(std::time::Duration::from_secs_f32(1.0 / 60.0));
|
||||
let mut player = Player::default();
|
||||
// Frame 1: the launch.
|
||||
let out = step(
|
||||
&mut player,
|
||||
&time,
|
||||
&world,
|
||||
&capsule,
|
||||
false,
|
||||
Vec3::ZERO,
|
||||
0.0,
|
||||
false,
|
||||
false,
|
||||
Some(STRAIGHT_UP),
|
||||
None,
|
||||
AIR_NUDGE_SPEED,
|
||||
);
|
||||
assert!(out.knocked && out.jumped, "the toss opened an arc");
|
||||
assert_eq!(player.horiz_vel, Vec3::ZERO, "and it is purely vertical");
|
||||
// Frames 2..6: airborne, and now the player leans on a movement key.
|
||||
let mut nudged = false;
|
||||
for _ in 0..5 {
|
||||
let out = step(
|
||||
&mut player,
|
||||
&time,
|
||||
&world,
|
||||
&capsule,
|
||||
true,
|
||||
Vec3::NEG_X,
|
||||
7.0,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
AIR_NUDGE_SPEED,
|
||||
);
|
||||
nudged |= out.air_nudged;
|
||||
}
|
||||
(player.horiz_vel, nudged)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
horiz,
|
||||
(Vec3::ZERO, false),
|
||||
"the planted nibble locks the arc: no nudge fires and the body does not drift, however \
|
||||
hard the key is held"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The air-control nudge is `min(MOVE_WALK, MOVE_RUN)` off the unit's live speeds, not a
|
||||
/// constant** (decision 1740). `0x7c5c20(1)` → `0x7c4c90(1)`, whose non-zero argument is a walk
|
||||
/// override returning `min(+0x88, +0x8c)`. `AIR_NUDGE_SPEED`'s 2.5 is merely the *default*
|
||||
/// `MOVE_WALK`, so the constant is right until a walk aura, a Slow or a daze moves either
|
||||
/// speed — and the `min` is why a slowed run still cannot out-steer a walk.
|
||||
#[test]
|
||||
fn the_standstill_nudge_takes_the_speed_it_is_given() {
|
||||
let flat: [(f32, f32); 2] = [(-3.0, 0.0), (3.0, 0.0)];
|
||||
let steer = |nudge: f32| {
|
||||
world_from_profile(&flat)
|
||||
.world_mut()
|
||||
.run_system_once(move |world: benilla_world::collision::WorldCollision| {
|
||||
let capsule = player_capsule();
|
||||
let mut time = Time::default();
|
||||
time.advance_by(std::time::Duration::from_secs_f32(1.0 / 60.0));
|
||||
let mut player = Player::default();
|
||||
// Jump from a dead standstill: the nibble is clear, so one steer is in hand.
|
||||
step(
|
||||
&mut player,
|
||||
&time,
|
||||
&world,
|
||||
&capsule,
|
||||
false,
|
||||
Vec3::ZERO,
|
||||
0.0,
|
||||
true,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
nudge,
|
||||
);
|
||||
// Airborne, and now a key goes down.
|
||||
let out = step(
|
||||
&mut player,
|
||||
&time,
|
||||
&world,
|
||||
&capsule,
|
||||
true,
|
||||
Vec3::NEG_X,
|
||||
7.0,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
nudge,
|
||||
);
|
||||
// …and a second press must change nothing: the nibble is set now.
|
||||
step(
|
||||
&mut player,
|
||||
&time,
|
||||
&world,
|
||||
&capsule,
|
||||
true,
|
||||
Vec3::NEG_Z,
|
||||
7.0,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
nudge,
|
||||
);
|
||||
(out.air_nudged, player.horiz_vel)
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let (fired, v) = steer(2.5);
|
||||
assert!(fired, "a standstill jump has one steer in hand");
|
||||
assert!(
|
||||
(v.length() - 2.5).abs() < 1.0e-4,
|
||||
"at the speed given: {v:?}"
|
||||
);
|
||||
assert!(v.x < 0.0, "and in the pressed direction: {v:?}");
|
||||
// A walk aura: the same jump steers slower, because the law reads the live speeds.
|
||||
let (fired, v) = steer(1.0);
|
||||
assert!(fired);
|
||||
assert!(
|
||||
(v.length() - 1.0).abs() < 1.0e-4,
|
||||
"a slowed body steers slower — the constant is not the law: {v:?}"
|
||||
);
|
||||
// One-shot in both cases: the second press found the nibble set and did nothing.
|
||||
assert!(
|
||||
v.z.abs() < 1.0e-4,
|
||||
"the second press must not steer again: {v:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2491,8 +2810,9 @@ mod tests {
|
||||
false,
|
||||
Some(PUSH),
|
||||
None,
|
||||
AIR_NUDGE_SPEED,
|
||||
);
|
||||
(out.jumped, out.knocked, player.vel_y, player.horiz_vel)
|
||||
(out.jumped, out.knocked, player.launch_vz, player.horiz_vel)
|
||||
};
|
||||
(knock(false), knock(true))
|
||||
})
|
||||
@@ -2505,7 +2825,8 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
vel_y, PUSH.y,
|
||||
"the vertical is the server's take-off speed, not JUMP_SPEED ({JUMP_SPEED})"
|
||||
"the vertical is the server's take-off speed, not JUMP_SPEED ({JUMP_SPEED}) — read off \
|
||||
`launch_vz`, because `vel_y` has already taken this frame's gravity step (1740)"
|
||||
);
|
||||
assert_eq!(
|
||||
horiz,
|
||||
@@ -2602,6 +2923,7 @@ mod tests {
|
||||
false,
|
||||
None,
|
||||
Some(SURFACE),
|
||||
AIR_NUDGE_SPEED,
|
||||
);
|
||||
(player.pos.y, player.vel_y)
|
||||
};
|
||||
@@ -2679,6 +3001,7 @@ mod tests {
|
||||
false,
|
||||
None,
|
||||
Some(SURFACE),
|
||||
AIR_NUDGE_SPEED,
|
||||
);
|
||||
low = low.min(player.pos.y);
|
||||
}
|
||||
|
||||
@@ -722,6 +722,38 @@ pub(crate) struct Player {
|
||||
/// `StartFalling(0)`). Observers replay the parabola from it, and the FALLINGFAR latch splits
|
||||
/// its distance/timer legs on it (decision 0179); held constant while `fall_time` advances.
|
||||
pub(super) jump_zspeed: f32,
|
||||
/// **The launch vertical speed, recorded the instant a take-off is decided** — before this
|
||||
/// frame's gravity touches it. [`super::arc`] snapshots [`Self::jump_zspeed`] from *this*, not
|
||||
/// from `vel_y`: the mover now integrates gravity on the take-off frame too (decision 1740), so
|
||||
/// by the time the arc bookkeeping runs `vel_y` is already one step down the parabola and would
|
||||
/// seat a launch speed `g·dt` short. The reference has no such ambiguity — `+0xa0` is written
|
||||
/// once by `StartFalling` and never touched again for the arc.
|
||||
pub(super) launch_vz: f32,
|
||||
/// **The arc's direction nibble — the reference's `[CMovement+0x40] & 0xf`** (decision 1740).
|
||||
/// Air control opens exactly while this is CLEAR: `0x7c5a20`/`0x7c5c20` bail on
|
||||
/// `FALLING && arg == 0`, and the only two openers that pass `arg = 1` sit behind
|
||||
/// `0x7c6afc test al,0xf`. So a jump from a standstill can be steered once, a jump taken with a
|
||||
/// direction already held cannot, and a **knockback never can** — its launch plants FORWARD
|
||||
/// itself (`0x6179c0`'s `0x617a18 or edx,0x8001`), which is the real freeze mechanism and has
|
||||
/// nothing to do with the arc's speed. Seeded at take-off, set by the one nudge that fires,
|
||||
/// cleared when the arc ends.
|
||||
///
|
||||
/// This replaced a `horiz_vel.length_squared() < 0.01` proxy that agreed with the reference for
|
||||
/// every ordinary jump and disagreed for one input: a knockback with `xy_speed ≈ 0`, which the
|
||||
/// proxy read as "standing still, may steer".
|
||||
pub(super) arc_dirs_set: bool,
|
||||
/// This airborne arc was launched by a **knockback**, so its planted FORWARD bit rides the wire
|
||||
/// for the arc's whole length (decision 1740) — the reference's `0x617a18` sets FORWARD and
|
||||
/// clears BACKWARD, and the send mask `0x618909 and edx,0x75a07dff` keeps bit 0. Distinct from
|
||||
/// [`super::mover::Outcome::knocked`], which is true on the launch frame only.
|
||||
pub(super) knock_arc: bool,
|
||||
/// **Was the body airborne at the end of the previous mover step** — the mover's own record,
|
||||
/// so the arc-start seeding does not have to read [`Self::airborne_since`] (decision 1740).
|
||||
/// That field belongs to the *wire* lifecycle in [`super::arc`] and is written by
|
||||
/// [`super::flags`], which runs AFTER the mover: reading it here made a physics decision
|
||||
/// depend on a system further down the frame, so a step-off's launch state was seeded a frame
|
||||
/// late whenever the mover ran on its own. Separate owners, separate fields.
|
||||
pub(super) airborne_prev: bool,
|
||||
/// The translation-direction move-flag bits ([`crate::creature_anim::move_flags::ANY_MOVE`]) the
|
||||
/// current airborne arc launched with. Mid-air these are the *actual* motion — momentum is frozen at
|
||||
/// takeoff, so held keys move nothing — and the live flags (animation, pose, wire) read them instead
|
||||
|
||||
@@ -270,6 +270,14 @@ pub(super) fn breach_step(
|
||||
capsule: &Collider,
|
||||
) -> Outcome {
|
||||
player.vel_y = SWIM_JUMP_SPEED;
|
||||
// The breach is a take-off like any other, so it records its launch speed for the arc
|
||||
// bookkeeping to snapshot (decision 1740). Without this the jump tail would send `zspeed = 0`
|
||||
// for every water exit and observers would replay the breach as a step-off rather than a hop.
|
||||
player.launch_vz = SWIM_JUMP_SPEED;
|
||||
// Leaving the water starts a fresh arc, and it starts it with the swim keys still down — the
|
||||
// nibble seeds from them exactly as a ledge step-off does.
|
||||
player.arc_dirs_set = player.horiz_vel.length_squared() > 0.0;
|
||||
player.knock_arc = false;
|
||||
let half_h = Vec3::Y * (CAPSULE_HEIGHT * 0.5);
|
||||
let out = world.slide_body(
|
||||
capsule,
|
||||
|
||||
@@ -235,6 +235,11 @@ pub(crate) struct CreateScene {
|
||||
/// Whether the current spawn wrote the fog rows enabled (the create law) — a screen hop with
|
||||
/// the same scene race still rebuilds when this flips.
|
||||
fog: bool,
|
||||
/// Whether the light buffer currently holds the **ghost** rig ([`ghost_rig`]) rather than the
|
||||
/// scene's authored one. Unlike [`Self::fog`] a change here does NOT rebuild the scene: the
|
||||
/// reference's fork is a per-frame fill callback on models that are already standing, so the
|
||||
/// buffer is rewritten in place and the stage's braziers keep burning across the click.
|
||||
ghost: bool,
|
||||
/// The scene's own light buffer — the scene's **authored M2 light rig** folded per the world
|
||||
/// law (see [`SceneRig`]: directionals → ambient + SH probe slot 0, points → the per-vertex
|
||||
/// point table) plus the ref's per-race fog (`CharModelFogInfo`) — **create only**: at select
|
||||
@@ -323,6 +328,88 @@ fn scene_rig(lights: &[benilla_assets::ModelLight]) -> SceneRig {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The GHOST select screen's hardcoded sun** — the one directional the reference injects when the
|
||||
/// selected roster record carries `CHARSELECT+0xfc & 0x2000`, replacing the authored rig entirely
|
||||
/// (wow-re `glue-select-model.md` §C1–C4 + `glue-select-ghost-treatment.md`, VERIFIED).
|
||||
///
|
||||
/// `0x472150` is a per-model per-frame **fill callback** (`[model+0x3bc]`, registered by `0x7134b0`)
|
||||
/// whose *entire body* is gated on that bit (`47218e mov ecx,[record+0xfc]; 472194 test ch,0x20;
|
||||
/// 472197 je 0x4722b2`). It is registered on **three** models — the background scene M2
|
||||
/// (`0x472144`), the character (`0x472bac`) and the **pet** (`0x472ebd`) — so all three fork
|
||||
/// together off one `[0x83856c]`-keyed read. For a living selection the callback is a complete
|
||||
/// no-op and the authored rig stands, exactly as at create (decision 0435's law, select-confirmed).
|
||||
///
|
||||
/// For a ghost it does two things, and this function is both:
|
||||
///
|
||||
/// - **the collector is WIPED** — `0x71bc30` opens `mov ecx,0x6d; rep stosd`, 436 bytes zeroed, so
|
||||
/// the scene-DB gather is discarded: no authored ambient, and **no point lights** (which is why
|
||||
/// the ghost stage loses the NightElf/Scourge stage-local lamps that make those two scenes);
|
||||
/// - **one hardcoded directional is injected** (built by `0x71b4a0`, forced directional by
|
||||
/// `0x71b620(0)`, colours by two `0x6d6ad0()→0x6d62e0()` DN reads, specular left 0).
|
||||
///
|
||||
/// **The direction, and the trap in it.** `0x472280 mov [ebp-4],0xbf800000` writes the raw literal
|
||||
/// `(0,0,-1)` into `CGLight+0x24`, which is a **from-light** vector: `0x71b6a0` stores it verbatim
|
||||
/// (normalize only, no `fchs`) and `0x71bce0` negates all three components (`0x71be7c`/`0x71be81`/
|
||||
/// `0x71be86`) before accumulating the SH lobe, so the lobe is centred on `-u` = **`(0,0,+1)`, i.e.
|
||||
/// lit from directly overhead** (WoW Z up). The fixed-function lane agrees — `0x59c820` negates
|
||||
/// too, and GL's directional `GL_POSITION` *is* the to-light vector — so the sign does not depend
|
||||
/// on which lane you emulate. The trap: an **authored** M2 light has TWO negations (`0x718960`
|
||||
/// already stores `-Bz`, and [`scene_rig`] feeds `+bone_z` as its to-light to match), while this one
|
||||
/// has exactly ONE. Carrying the authored convention across double-negates and lights the ghost
|
||||
/// from below.
|
||||
///
|
||||
/// **The colours are DBC data, not a live capture.** `0x4721e2 mov esi,[edx+0xc]` hard-codes
|
||||
/// **`LightParams` row 3** — `Light.dbc`'s **death** preset column (id 3 in 59 of 374 rows and in no
|
||||
/// other column; the world's ghost arm selects the same slot, `0x5de9df mov ecx,4; call 0x6d4620`)
|
||||
/// — and `0x6d6ad0` maps it to `LightIntBand` ids `18·3−17+sub`: **37 diffuse** (colour sub 0),
|
||||
/// **38 ambient** (sub 1), sampled at a hardcoded time key of `0` (`xor edx,edx`; half-minutes,
|
||||
/// `[0, 2880)`). Both rows are **single-key in the shipped 1.12.1 chain**, hence time-invariant,
|
||||
/// hence these literals — 0469 parked this as "pending a live capture" on the earlier reading that
|
||||
/// the tables were runtime-only, and that park is withdrawn.
|
||||
///
|
||||
/// The values are stated rather than read back out of our own `Light.dbc` reader for one reason:
|
||||
/// the glue screens run **pre-world**, where no map, no zone and no `LightParams` state exist at
|
||||
/// all — the reference reaches them through the DN singleton it keeps alive across the glue, and we
|
||||
/// have no such thing to ask. A chain whose bands are keyed (a patched install) would diverge here;
|
||||
/// that is the stated bound.
|
||||
fn ghost_rig() -> SceneRig {
|
||||
SceneRig {
|
||||
// `LightIntBand` 38 = 0x001A3855 → (26, 56, 85).
|
||||
ambient: [26.0 / 255.0, 56.0 / 255.0, 85.0 / 255.0],
|
||||
// `LightIntBand` 37 = 0x005E99C6 → (94, 153, 198), on the to-light `(0,0,+1)`.
|
||||
lobes: vec![(
|
||||
benilla_assets::coords::wow_to_bevy([0.0, 0.0, 1.0]),
|
||||
[94.0 / 255.0, 153.0 / 255.0, 198.0 / 255.0],
|
||||
)],
|
||||
// Wiped: the gather is discarded whole, so the stage's own lamps do not survive.
|
||||
points: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pack a [`SceneRig`] into the glue scene's light buffer contents.
|
||||
///
|
||||
/// Rig materials read the probe + point table; the core rows still carry the plain ambient (with a
|
||||
/// zero sun) so any non-rig lane that ever lands in the booth degrades sanely. The directional fold
|
||||
/// lands in probe slot 0 — booth instances carry `MeshTag` 0, the rig lane's read side. The dial is
|
||||
/// 1.0: the rig lane ignores it, so byte levels stand.
|
||||
///
|
||||
/// One function for both rigs, so the authored and the ghost light cannot drift into two packings.
|
||||
fn scene_light_blob(
|
||||
rig: &SceneRig,
|
||||
fog_rgb: [f32; 3],
|
||||
fog_far: f32,
|
||||
fog: bool,
|
||||
) -> benilla_world::lighting::LightBlob {
|
||||
let mut blob = benilla_world::lighting::LightBlob::model(rig.ambient, [0.0; 3], Vec3::NEG_Y)
|
||||
.probe(rig.ambient, &rig.lobes)
|
||||
.fog(fog_rgb, fog_far, fog)
|
||||
.dial(1.0);
|
||||
for (pos, color) in &rig.points {
|
||||
blob = blob.point(*pos, SCENE_POINT_RANGE, *color);
|
||||
}
|
||||
blob
|
||||
}
|
||||
|
||||
/// The ref's per-scene fog: the race rows are `CharModelFogInfo` (GlueParent.lua) — color + far,
|
||||
/// near always 0; the main menu's is `AccountLogin.xml`'s own `<FogColor>` + `fogFar` attribute.
|
||||
fn scene_fog(token: &str) -> ([f32; 3], f32) {
|
||||
@@ -561,7 +648,12 @@ pub(super) fn spawn_glue_booth(
|
||||
..default()
|
||||
},
|
||||
bevy::camera::RenderTarget::Image(image.clone().into()),
|
||||
benilla_world::ffx_glow::FfxGlow::BOOTH,
|
||||
// **The glue screens run the reference's own glue pass pair**, not a bake's nothing
|
||||
// (decision 1731): this camera draws the fullscreen `UI_<Race>` diorama the reference
|
||||
// brackets its glue FFX around (`0x46fad3`/`0x46fae0`), and the GlueXML frames composite
|
||||
// over the result afterwards — which is why a ghost selection washes the scene steel-blue
|
||||
// while the character list, the buttons and the logo stay clean.
|
||||
benilla_world::ffx_glow::FfxGlow::GLUE_SCENE,
|
||||
// Placeholder — `sync_glue_booth` overwrites transform + projection from the model's bounds
|
||||
// on the first bake (the same `body_frame` law as the paper doll).
|
||||
Projection::from(bevy::camera::PerspectiveProjection {
|
||||
@@ -628,12 +720,52 @@ pub(super) fn spawn_glue_booth(
|
||||
pet_stage_scale: 1.0,
|
||||
pet_baked: None,
|
||||
fog: true,
|
||||
ghost: false,
|
||||
light: None,
|
||||
variants: default(),
|
||||
rev: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/// Drive the glue screens' **FFX state** ([`benilla_world::ffx_glow::GlueFfx`]) off the live look —
|
||||
/// which pass the glue pair has installed, and what `LightParams.glow` is pinned to.
|
||||
///
|
||||
/// One writer for all three glue screens, because the reference's three writers are all reached
|
||||
/// through the same widget and the same build (wow-re `glue-select-ghost-treatment.md`):
|
||||
///
|
||||
/// | look | reference | pass | glow |
|
||||
/// |---|---|---|---|
|
||||
/// | none (login, empty account) | `CSimpleModelFFX::OnShow 0x46fa60` | glow | `*0x8380b4` 0.30 |
|
||||
/// | a create look | same — no select build runs | glow | 0.30 |
|
||||
/// | a living select record | `0x472ff7`/`0x473002` | glow | `*0x838574` 0.40 |
|
||||
/// | a ghost select record | `0x472fde`/`0x472fe9` | **death** | `*0x838570` 0.15 |
|
||||
///
|
||||
/// An empty account really is the OnShow state and not the living arm: `0x472950` is gated
|
||||
/// `-1 ≤ [0x83856c] < [0xb42140]`, so with no roster nothing re-pins anything.
|
||||
///
|
||||
/// It reads [`GluePreview`] rather than the roster because that is what every glue screen already
|
||||
/// writes — the login screen's `None`, the create screen's `Create` look, the select screen's
|
||||
/// `Select` record — so no screen can forget to reset it on the way out.
|
||||
pub(super) fn sync_glue_ffx(
|
||||
preview: Res<GluePreview>,
|
||||
mut ffx: ResMut<benilla_world::ffx_glow::GlueFfx>,
|
||||
) {
|
||||
use benilla_world::ffx_glow::GlueFfx;
|
||||
let want = match preview.look {
|
||||
Some(GlueLook::Select(l)) => {
|
||||
if l.flags & benilla_protocol::CHARACTER_FLAG_GHOST != 0 {
|
||||
GlueFfx::SelectGhost
|
||||
} else {
|
||||
GlueFfx::SelectLiving
|
||||
}
|
||||
}
|
||||
Some(GlueLook::Create(_)) | None => GlueFfx::Shown,
|
||||
};
|
||||
if *ffx != want {
|
||||
*ffx = want;
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive the background scene (see [`CreateScene`]): load the selected race's `UI_*` model, spawn
|
||||
/// it looping sequence 0, resize the booth target to the window (the scene is a fullscreen render),
|
||||
/// seat the character on the stage spot, and hold the booth camera on the scene's authored camera 0
|
||||
@@ -714,6 +846,14 @@ pub(super) fn sync_glue_scene(
|
||||
GlueScene::MainMenu => true,
|
||||
GlueScene::Race(_) => matches!(preview.look, Some(GlueLook::Create(_))),
|
||||
};
|
||||
// The GHOST light fork (wow-re `glue-select-model.md` §C1, and [`ghost_rig`]): the selected
|
||||
// roster record's `CHARSELECT+0xfc & 0x2000`, which reaches the background scene, the character
|
||||
// and the pet through one `[0x83856c]`-keyed read in the reference and through one light buffer
|
||||
// here — all three of ours bind it.
|
||||
let ghost = matches!(
|
||||
preview.look,
|
||||
Some(GlueLook::Select(l)) if l.flags & benilla_protocol::CHARACTER_FLAG_GHOST != 0
|
||||
);
|
||||
if scene.token != Some(token) {
|
||||
scene.token = Some(token);
|
||||
scene.handle = Some(asset_server.load(m2_url(&format!(
|
||||
@@ -733,6 +873,33 @@ pub(super) fn sync_glue_scene(
|
||||
// Same scene, other screen: rebuild in place (the handle is cached — same-frame respawn).
|
||||
scene.spawned = false;
|
||||
commands.entity(scene.root).despawn_related::<Children>();
|
||||
} else if scene.spawned && scene.ghost != ghost {
|
||||
// Same scene, same screen, the selection crossed the ghost bit: **re-light, don't rebuild**.
|
||||
// The reference's fork is a per-frame fill callback on models that are already standing
|
||||
// (`0x472150` on `[model+0x3bc]`), so nothing is respawned there either — and respawning
|
||||
// here would restart the stage's own emitters, which the click has no business touching.
|
||||
if let (Some(model), Some(light)) = (
|
||||
scene.handle.as_ref().and_then(|h| m2s.get(h)),
|
||||
scene.light.clone(),
|
||||
) {
|
||||
let rig = if ghost {
|
||||
ghost_rig()
|
||||
} else {
|
||||
scene_rig(&model.lights)
|
||||
};
|
||||
let (fog_rgb, fog_far) = scene_fog(token);
|
||||
scene_light_blob(&rig, fog_rgb, fog_far, fog).write(&queue, &light);
|
||||
scene.ghost = ghost;
|
||||
// The character and the pet re-bake onto the rewritten buffer (`sync_glue_booth` and
|
||||
// `sync_glue_pet` both key on this).
|
||||
scene.rev += 1;
|
||||
info!(
|
||||
"glue scene: {} light — {} directional, {} point light(s)",
|
||||
if ghost { "GHOST" } else { "authored" },
|
||||
rig.lobes.len(),
|
||||
rig.points.len(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if !scene.spawned {
|
||||
let Some(model) = scene.handle.as_ref().and_then(|h| m2s.get(h)) else {
|
||||
@@ -752,20 +919,15 @@ pub(super) fn sync_glue_scene(
|
||||
.iter()
|
||||
.find(|a| a.id == 0)
|
||||
.map_or((Quat::IDENTITY, 1.0), |a| stage_frame(model, a.bone));
|
||||
let rig = scene_rig(&model.lights);
|
||||
// A GHOST selection replaces the gather outright (see [`ghost_rig`]); a living one keeps
|
||||
// the scene's authored rig, which is the create law select-confirmed.
|
||||
let rig = if ghost {
|
||||
ghost_rig()
|
||||
} else {
|
||||
scene_rig(&model.lights)
|
||||
};
|
||||
let (fog_rgb, fog_far) = scene_fog(token);
|
||||
// Rig materials read the probe + point table; the core rows still carry the plain ambient
|
||||
// (with a zero sun) so any non-rig lane that ever lands in the booth degrades sanely. The
|
||||
// directional fold lands in probe slot 0 — booth instances carry MeshTag 0, the rig lane's
|
||||
// read side. The dial is 1.0: the rig lane ignores it, so byte levels stand.
|
||||
let mut blob =
|
||||
benilla_world::lighting::LightBlob::model(rig.ambient, [0.0; 3], Vec3::NEG_Y)
|
||||
.probe(rig.ambient, &rig.lobes)
|
||||
.fog(fog_rgb, fog_far, fog)
|
||||
.dial(1.0);
|
||||
for (pos, color) in &rig.points {
|
||||
blob = blob.point(*pos, SCENE_POINT_RANGE, *color);
|
||||
}
|
||||
let blob = scene_light_blob(&rig, fog_rgb, fog_far, fog);
|
||||
let light = scene
|
||||
.light
|
||||
.get_or_insert_with(|| blob.create(&device, "wow_create_scene_light"))
|
||||
@@ -780,6 +942,7 @@ pub(super) fn sync_glue_scene(
|
||||
tint_mirrors.0.insert("glue_scene", light.clone());
|
||||
blob.write(&queue, &light);
|
||||
scene.fog = fog;
|
||||
scene.ghost = ghost;
|
||||
let dc = blob.probe_dc();
|
||||
info!(
|
||||
"create scene: UI_{token} rig — ambient {:?}, {} point light(s), probe dc ({:.2},{:.2},{:.2})",
|
||||
@@ -1739,6 +1902,41 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// GOLDEN — the ghost select screen's hardcoded sun, every number byte-VERIFIED (wow-re
|
||||
/// `glue-select-ghost-treatment.md`; see [`ghost_rig`] for the citations).
|
||||
///
|
||||
/// The **sign** is the assertion that matters. wow-re's earlier §C4 was internally
|
||||
/// contradictory about it — it called `CGLight+0x24` a to-light vector with no sign flip in the
|
||||
/// SH chain, then concluded "lit from directly overhead" via the fixed-function commit, which
|
||||
/// negates. Those differ by 180°: a character lit from above versus from below. The round that
|
||||
/// settled it found `0x71bce0` negating all three components (`0x71be7c`/`0x71be81`/`0x71be86`)
|
||||
/// before it accumulates, so both lanes agree on `(0,0,+1)`. The trap it also found is why this
|
||||
/// is pinned rather than trusted to a comment: an AUTHORED M2 light already carries one
|
||||
/// negation (`0x718960` stores `-Bz`, which is why [`scene_rig`] feeds `+bone_z`), so reusing
|
||||
/// the authored convention here double-negates and lights the ghost from underneath.
|
||||
#[test]
|
||||
fn the_ghost_sun_is_one_overhead_directional_over_a_wiped_gather() {
|
||||
let rig = ghost_rig();
|
||||
// The gather is WIPED — `0x71bc30`'s `mov ecx,0x6d; rep stosd`. No stage lamps survive.
|
||||
assert!(
|
||||
rig.points.is_empty(),
|
||||
"a ghost selection discards the scene-DB gather whole"
|
||||
);
|
||||
// Exactly one directional.
|
||||
assert_eq!(rig.lobes.len(), 1);
|
||||
let (to_light, diffuse) = rig.lobes[0];
|
||||
// Bevy +Y is up: `wow_to_bevy([0,0,1])`. Overhead, not underfoot.
|
||||
assert_eq!(to_light, Vec3::Y, "lit from directly overhead (WoW Z up)");
|
||||
// `LightIntBand` 37 = 0x005E99C6 → (94,153,198); 38 = 0x001A3855 → (26,56,85).
|
||||
let byte = |c: f32| (c * 255.0).round() as u32;
|
||||
assert_eq!(
|
||||
diffuse.map(byte),
|
||||
[94, 153, 198],
|
||||
"LightParams row 3, sub 0"
|
||||
);
|
||||
assert_eq!(rig.ambient.map(byte), [26, 56, 85], "…and sub 1");
|
||||
}
|
||||
|
||||
/// GOLDEN — the rig fold's classification law (wow-re `m2-dynamic-lights.md` +
|
||||
/// `glue-model-lighting.md`): directionals accumulate (ambient tracks summed; diffuse×intensity
|
||||
/// as per-light SH lobes whose to-light is the **bone's +Z axis**, never the def position),
|
||||
|
||||
@@ -1165,6 +1165,8 @@ impl Plugin for PortraitPlugin {
|
||||
sync_stable_booth,
|
||||
glue_booth::sync_glue_booth,
|
||||
glue_booth::sync_glue_scene,
|
||||
// The glue screens' FFX state, off the same look every screen already writes.
|
||||
glue_booth::sync_glue_ffx,
|
||||
// After the scene's framing: the viewport/clear its law decided (1619).
|
||||
glue_booth::pillarbox_glue_scene,
|
||||
// After the scene: the pet's seat and its light are the scene's to publish.
|
||||
|
||||
@@ -251,16 +251,21 @@ impl NpcSession for QuestGiver {
|
||||
self.npc.filter(|g| !benilla_protocol::guid::is_item(*g))
|
||||
}
|
||||
|
||||
/// **A party share is not range-guarded** (decision 1733). Its giver is a live player with a
|
||||
/// position, a name and a portrait, so it stays a full session for [`crate::ui_session`]'s
|
||||
/// *other* consumer — but the guard's threshold is the NPC **service** range
|
||||
/// ([`crate::target::SERVICE_RANGE_SQ`], 5.56 yd), a rule about reaching an NPC's counter, and
|
||||
/// a share has no counter to reach. The server's own bound is `QUEST_SHARE_DISTANCE`, 14.0 yd
|
||||
/// (vmangos `Object.h:72`), tested at ACCEPT and answered with `QUEST_PARTY_MSG_TOO_FAR` —
|
||||
/// so a sharer standing six yards off would have had the panel yanked out from under a quest
|
||||
/// the server was perfectly willing to grant.
|
||||
fn range_guarded(&self) -> bool {
|
||||
!self.npc.is_some_and(benilla_protocol::guid::is_player)
|
||||
/// Walking away from a party member's SHARED quest still owes them the decline
|
||||
/// (decision 1741). The distance that triggers it is not this method's business — the guard
|
||||
/// takes the leash from the giver's own type (`crate::ui_session::leash_sq`: 14.0 yd for a
|
||||
/// player, the 5.56 yd service range for an NPC), which is what 1733 got wrong by exempting
|
||||
/// the share panel outright.
|
||||
///
|
||||
/// **The walk-away sends strictly LESS than the button does**, and that is the reference's,
|
||||
/// not a simplification: its watchdog calls the session end directly (`0x4933da` → `0x501130`)
|
||||
/// rather than going through `DeclineQuest`, so an NPC's panel closes silently here — no
|
||||
/// giver re-open — while the button's path still re-opens it.
|
||||
fn walk_away_send(&self, npc: u64) -> Option<ClientCommand> {
|
||||
benilla_protocol::guid::is_player(npc).then_some(ClientCommand::QuestPushResult {
|
||||
sharer: npc,
|
||||
msg: QuestShareMsg::DECLINE_QUEST,
|
||||
})
|
||||
}
|
||||
|
||||
fn close(&mut self) {
|
||||
|
||||
@@ -0,0 +1,632 @@
|
||||
//! **The reference's frame FLAGS are the test oracle** — the flag half of decision 0675's "the
|
||||
//! reference file is the test oracle", and the gate for the class decision 1739 closed.
|
||||
//!
|
||||
//! `framexml_diff` guards the *numbers* a transcribed window carries. Nothing guarded its *flags*,
|
||||
//! and they went quietly wrong at scale: on 2026-08-30 a sweep found **47 frames** the reference
|
||||
//! marks `toplevel` and ours did not (so no window ever came to the front), **23** the reference
|
||||
//! makes mouse-interactive and ours did not (so a click on a window's own background fell through
|
||||
//! to the 3D world), and **81** carrying a reference `id=` ours dropped (so `GetID()` — the
|
||||
//! contract every 1.12 addon reads a slot index out of — answered 0). Every gate was green
|
||||
//! throughout, because a flag is invisible to a behavioural test that never presses on the frame.
|
||||
//!
|
||||
//! ## Two properties make this a guard rather than a comfort
|
||||
//!
|
||||
//! **Our side is read from the ENGINE, never from our XML.** An attribute-vs-attribute diff is
|
||||
//! precisely what let the class hide, twice over: benilla renames templates (`ChatFrameTemplate` →
|
||||
//! `BenillaChatFrameTemplate`), so a name-keyed text diff reports a gap of zero on a window that is
|
||||
//! entirely missing the flag; and the `<Scripts>` **auto-enable** law (wow-re
|
||||
//! `ui/scratch/scripts-auto-enable.md` §1, VERIFIED — an `<OnEnter>`/`<OnLeave>`/`<OnMouseDown>`/
|
||||
//! `<OnMouseUp>`/`<OnDragStart>` reaches the same enable primitive `0x76af00(2,-1)` the attribute
|
||||
//! does) makes `enableMouse=` a poor proxy for whether the frame actually takes the mouse. Asking
|
||||
//! the loaded engine — `IsToplevel()`, `IsMouseEnabled()`, `GetID()` — is immune to both.
|
||||
//!
|
||||
//! **Divergences are an explicit list with a reason each, never a pattern.** [`KNOWN`] carries
|
||||
//! them, in both directions; a new one cannot hide inside a tolerance. The list is where the
|
||||
//! seven frames the reference makes interactive through *handlers we do not carry* are recorded —
|
||||
//! those want the handler (and its tooltip), never a bare `enableMouse="true"` that would swallow
|
||||
//! the click and give nothing back.
|
||||
//!
|
||||
//! ## Three flags, and only three
|
||||
//!
|
||||
//! `toplevel`, the effective mouse enable, and `id` are mechanical: the reference's value is right
|
||||
//! for any frame we transcribe, and a difference is a defect. The neighbours are **not**, and are
|
||||
//! deliberately out of scope rather than silently tolerated — `movable` is an inert flag for all
|
||||
//! but two reference frames (nothing calls `StartMoving` on the rest, so copying it would be
|
||||
//! cargo-cult), `frameStrata` decides what draws over what and is the director's call,
|
||||
//! `setAllPoints` has an exact `<Size>`+`<Anchors>` equivalent that five of our pages deliberately
|
||||
//! use, and `hidden` is equivalent whenever the reference hides in `OnLoad` instead. Decision 1739
|
||||
//! carries the reasoning per attribute.
|
||||
//!
|
||||
//! ## What it can and cannot see
|
||||
//!
|
||||
//! The population is the shipped tree's **named, published frames**, so a virtual template is not
|
||||
//! compared directly — but every instance of one is, carrying the template's resolved flags, which
|
||||
//! is the same coverage by a different route (a `KNOWN` entry naming a template therefore reads as
|
||||
//! stale and is refused).
|
||||
//!
|
||||
//! The reference side is **XML only**: a `SetID` or `EnableMouse` the reference makes from Lua at
|
||||
//! `OnLoad` is invisible here and reads as absent. That blind spot only ever under-reports — it
|
||||
//! can hide a divergence, never invent one — so nothing it misses turns into a false failure; the
|
||||
//! handful of frames it does hide are in `KNOWN` with that reason.
|
||||
//!
|
||||
//! The whole module skips cleanly with no install — `_extracted_framexml/` is a gitignored
|
||||
//! Blizzard asset, like every other client-data test here.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use benilla_ui::framexml::{self, Element, TopLevel};
|
||||
|
||||
/// Element tags that are *frames* — the ones with flags to compare. Regions (`<Texture>`,
|
||||
/// `<FontString>`) carry names too and must not shadow a frame of the same name.
|
||||
const FRAME_TAGS: &[&str] = &[
|
||||
"Frame",
|
||||
"Button",
|
||||
"CheckButton",
|
||||
"EditBox",
|
||||
"ScrollFrame",
|
||||
"Slider",
|
||||
"StatusBar",
|
||||
"MessageFrame",
|
||||
"ScrollingMessageFrame",
|
||||
"Model",
|
||||
"PlayerModel",
|
||||
"DressUpModel",
|
||||
"TabardModel",
|
||||
"ColorSelect",
|
||||
"SimpleHTML",
|
||||
"GameTooltip",
|
||||
"Minimap",
|
||||
"MovieFrame",
|
||||
"WorldFrame",
|
||||
"Cooldown",
|
||||
];
|
||||
|
||||
/// The five `<Scripts>` handler names that auto-enable the MOUSE kind, and only those five
|
||||
/// (wow-re `ui/scratch/scripts-auto-enable.md` §1's kind-2 OR-chain, `0x769fb7`..`0x76a022`).
|
||||
/// `OnDragStop`/`OnReceiveDrag` bind a slot and trip no enable — they are deliberately absent.
|
||||
const MOUSE_HANDLERS: &[&str] = &[
|
||||
"OnEnter",
|
||||
"OnLeave",
|
||||
"OnMouseDown",
|
||||
"OnMouseUp",
|
||||
"OnDragStart",
|
||||
];
|
||||
|
||||
/// Widget kinds whose **constructor** enables the mouse, so an element of that tag needs no
|
||||
/// `enableMouse` and no handler to be clickable.
|
||||
///
|
||||
/// This is deliberately the SAME list [`benilla_ui::widget::WidgetArena::create`] uses, and it has
|
||||
/// to be: both are one claim about the client's ctors (`CSimpleButton`/`CSimpleEditBox` enable
|
||||
/// input in theirs; a `Slider` thumb must be draggable; a scroll frame takes the wheel — each
|
||||
/// carries its own citation there). Reading the engine's list here means a wrong entry is wrong in
|
||||
/// one place rather than two, and the sweep never reports a divergence that is only the two models
|
||||
/// disagreeing with each other.
|
||||
///
|
||||
/// It is also what answers the chat window: `ChatFrame1` is a `ScrollingMessageFrame`, so the
|
||||
/// reference's takes the mouse from its ctor even though `FloatingChatFrameTemplate` writes
|
||||
/// `enableMouse="false"` — which is a no-op, the XML path only ever ENABLES
|
||||
/// (`scripts-auto-enable.md` §1.3). That is how a 1.12 chat link is clickable at all.
|
||||
const MOUSE_BY_CTOR: &[&str] = &[
|
||||
"Button",
|
||||
"CheckButton",
|
||||
"EditBox",
|
||||
"ScrollingMessageFrame",
|
||||
"ScrollFrame",
|
||||
"Slider",
|
||||
];
|
||||
|
||||
/// One accepted difference between benilla and the reference, with the reason it is accepted.
|
||||
///
|
||||
/// `frame` is benilla's name for it; `flag` is which of the three; `why` is why the difference is
|
||||
/// right (or, for the handler gaps, why the honest fix is not this flag).
|
||||
struct Known {
|
||||
frame: &'static str,
|
||||
flag: Flag,
|
||||
why: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Flag {
|
||||
Toplevel,
|
||||
Mouse,
|
||||
Id,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Flag {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Flag::Toplevel => "toplevel",
|
||||
Flag::Mouse => "mouse",
|
||||
Flag::Id => "id",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The accepted differences, in four groups: **seven frames the reference makes mouse-interactive
|
||||
/// through HANDLERS we do not carry**, the merchant rows whose mouse we take where it does not,
|
||||
/// the faux scroll panes we build from a different widget kind, and the ids the reference sets
|
||||
/// from Lua. Every entry is a judgement someone made; none of them is a tolerance.
|
||||
const KNOWN: &[Known] = &[
|
||||
// ── The reference has it, we do not: all seven are handler gaps, not flag gaps ─────────────
|
||||
//
|
||||
// In each of these the reference's mouse comes from an `<OnEnter>`/`<OnLeave>` pair whose body
|
||||
// we have not built, and the interaction the player would notice is the TOOLTIP those handlers
|
||||
// show — not the click-blocking the flag gives. Declaring `enableMouse="true"` here would make
|
||||
// the frame swallow the click and hand back nothing, which is worse than the gap.
|
||||
Known {
|
||||
frame: "PetPaperDollFrameExpBar",
|
||||
flag: Flag::Mouse,
|
||||
why:
|
||||
"the reference bar inherits TextStatusBar, whose OnEnter/OnLeave show the value text; \
|
||||
ours is a plain StatusBar. Wants the status-text arc, not the flag.",
|
||||
},
|
||||
Known {
|
||||
frame: "TargetofTargetHealthBar",
|
||||
flag: Flag::Mouse,
|
||||
why: "same as PetPaperDollFrameExpBar — a plain StatusBar where the reference inherits \
|
||||
TextStatusBar.",
|
||||
},
|
||||
Known {
|
||||
frame: "TargetofTargetManaBar",
|
||||
flag: Flag::Mouse,
|
||||
why: "same as PetPaperDollFrameExpBar — a plain StatusBar where the reference inherits \
|
||||
TextStatusBar.",
|
||||
},
|
||||
Known {
|
||||
frame: "TradePlayerItem7",
|
||||
flag: Flag::Mouse,
|
||||
why:
|
||||
"the enchant slot: ours inherits BenillaTradeEnchantItemTemplate, the reference's the \
|
||||
ordinary PlayerTradeItemTemplate. A structural difference in our own trade window.",
|
||||
},
|
||||
Known {
|
||||
frame: "WhoFrameDropDown",
|
||||
flag: Flag::Mouse,
|
||||
why: "the reference's /who sort dropdown carries its own handlers over the shared \
|
||||
UIDropDownMenuTemplate; ours takes the template alone.",
|
||||
},
|
||||
Known {
|
||||
frame: "WorldMapPlayer",
|
||||
flag: Flag::Mouse,
|
||||
why: "the reference's map dot has OnEnter -> WorldMapUnit_OnEnter, the name-on-hover \
|
||||
tooltip. Wants that handler, not the flag.",
|
||||
},
|
||||
Known {
|
||||
frame: "WorldStateAlwaysUpFrame",
|
||||
flag: Flag::Mouse,
|
||||
why:
|
||||
"the reference's PvP objective banner has OnEnter/OnLeave for its tooltip. Wants that \
|
||||
handler, not the flag.",
|
||||
},
|
||||
// ── We take the mouse where the reference does not ─────────────────────────────────────────
|
||||
//
|
||||
// The merchant rows: ours are one mouse-taking row each; the reference splits every row into
|
||||
// an inert container plus a `$parentItemButton` that takes the mouse on its behalf. Same
|
||||
// clicks, a different frame under them.
|
||||
Known {
|
||||
frame: "MerchantItem1",
|
||||
flag: Flag::Mouse,
|
||||
why: "inherits BenillaMerchantItemTemplate",
|
||||
},
|
||||
Known {
|
||||
frame: "MerchantItem2",
|
||||
flag: Flag::Mouse,
|
||||
why: "inherits BenillaMerchantItemTemplate",
|
||||
},
|
||||
Known {
|
||||
frame: "MerchantItem3",
|
||||
flag: Flag::Mouse,
|
||||
why: "inherits BenillaMerchantItemTemplate",
|
||||
},
|
||||
Known {
|
||||
frame: "MerchantItem4",
|
||||
flag: Flag::Mouse,
|
||||
why: "inherits BenillaMerchantItemTemplate",
|
||||
},
|
||||
Known {
|
||||
frame: "MerchantItem5",
|
||||
flag: Flag::Mouse,
|
||||
why: "inherits BenillaMerchantItemTemplate",
|
||||
},
|
||||
Known {
|
||||
frame: "MerchantItem6",
|
||||
flag: Flag::Mouse,
|
||||
why: "inherits BenillaMerchantItemTemplate",
|
||||
},
|
||||
Known {
|
||||
frame: "MerchantItem7",
|
||||
flag: Flag::Mouse,
|
||||
why: "inherits BenillaMerchantItemTemplate",
|
||||
},
|
||||
Known {
|
||||
frame: "MerchantItem8",
|
||||
flag: Flag::Mouse,
|
||||
why: "inherits BenillaMerchantItemTemplate",
|
||||
},
|
||||
Known {
|
||||
frame: "MerchantItem9",
|
||||
flag: Flag::Mouse,
|
||||
why: "inherits BenillaMerchantItemTemplate",
|
||||
},
|
||||
Known {
|
||||
frame: "MerchantItem10",
|
||||
flag: Flag::Mouse,
|
||||
why: "inherits BenillaMerchantItemTemplate",
|
||||
},
|
||||
Known {
|
||||
frame: "MerchantItem11",
|
||||
flag: Flag::Mouse,
|
||||
why: "inherits BenillaMerchantItemTemplate",
|
||||
},
|
||||
Known {
|
||||
frame: "MerchantItem12",
|
||||
flag: Flag::Mouse,
|
||||
why: "inherits BenillaMerchantItemTemplate",
|
||||
},
|
||||
Known {
|
||||
frame: "MerchantBuyBackItem",
|
||||
flag: Flag::Mouse,
|
||||
why: "inherits BenillaMerchantItemTemplate",
|
||||
},
|
||||
Known {
|
||||
frame: "HonorFrameProgressBar",
|
||||
flag: Flag::Mouse,
|
||||
why: "our honour bar carries its own hover tooltip; the reference's page has none",
|
||||
},
|
||||
Known {
|
||||
frame: "WorldMapFrame",
|
||||
flag: Flag::Mouse,
|
||||
why: "our map body takes the mouse so a click on it cannot reach the world behind a \
|
||||
FULLSCREEN_DIALOG window; the reference relies on WorldMapButton alone",
|
||||
},
|
||||
// ── The faux scroll panes: a different WIDGET KIND, not a missing flag ─────────────────────
|
||||
//
|
||||
// The reference declares each of these `<ScrollFrame …inherits="FauxScrollFrameTemplate">` and
|
||||
// takes the mouse from that kind's constructor, even though a faux pane never really scrolls —
|
||||
// the kind is chosen for the wheel and the scrollbar plumbing. benilla's `FauxScrollFrameTemplate`
|
||||
// is a plain `<Frame>` and wires the wheel explicitly, with an `<OnMouseWheel>` on the pane
|
||||
// routed through `BenillaFauxScrollFrame_OnMouseWheel` (FriendsFrame.xml's own note at the
|
||||
// friends pane). Same behaviour — the list scrolls under the wheel — reached a different way,
|
||||
// and the reference's own faux panes use nothing else the mouse flag gives.
|
||||
//
|
||||
// The two mail panes are a further step out: ours are flat art with a FontString body, a
|
||||
// render approximation MailFrame.xml names at the site, so neither side scrolls them.
|
||||
Known {
|
||||
frame: "FriendsFrameFriendsScrollFrame",
|
||||
flag: Flag::Mouse,
|
||||
why: "our faux scroll pane is a Frame with an explicit <OnMouseWheel>, not a ScrollFrame",
|
||||
},
|
||||
Known {
|
||||
frame: "FriendsFrameIgnoreScrollFrame",
|
||||
flag: Flag::Mouse,
|
||||
why: "as FriendsFrameFriendsScrollFrame",
|
||||
},
|
||||
Known {
|
||||
frame: "GuildListScrollFrame",
|
||||
flag: Flag::Mouse,
|
||||
why: "as FriendsFrameFriendsScrollFrame",
|
||||
},
|
||||
Known {
|
||||
frame: "WhoListScrollFrame",
|
||||
flag: Flag::Mouse,
|
||||
why: "as FriendsFrameFriendsScrollFrame",
|
||||
},
|
||||
Known {
|
||||
frame: "SkillListScrollFrame",
|
||||
flag: Flag::Mouse,
|
||||
why: "as FriendsFrameFriendsScrollFrame",
|
||||
},
|
||||
Known {
|
||||
frame: "ReputationListScrollFrame",
|
||||
flag: Flag::Mouse,
|
||||
why: "as FriendsFrameFriendsScrollFrame",
|
||||
},
|
||||
Known {
|
||||
frame: "OpenMailScrollFrame",
|
||||
flag: Flag::Mouse,
|
||||
why: "our open-mail body is flat art plus a FontString — the render approximation \
|
||||
MailFrame.xml names; nothing scrolls it on either side",
|
||||
},
|
||||
Known {
|
||||
frame: "SendMailScrollFrame",
|
||||
flag: Flag::Mouse,
|
||||
why: "as OpenMailScrollFrame",
|
||||
},
|
||||
// ── id ─────────────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The reference side of this comparison is XML only: the sweep cannot see a `SetID` the
|
||||
// reference makes from Lua at `OnLoad`, so a frame the reference numbers *there* reads as 0
|
||||
// here. Every entry below is that case, and each ends at the same runtime number ours declares
|
||||
// — which is why they are accepted rather than fixed. (The blind spot only ever under-reports:
|
||||
// it can hide a divergence, never invent one, so nothing it misses is a false failure.)
|
||||
Known {
|
||||
frame: "CharacterBag0Slot",
|
||||
flag: Flag::Id,
|
||||
why: "the bag slots are numbered from Lua in the reference (ContainerFrame.lua's bag-slot \
|
||||
OnLoad); ours declares the same inventory slot in XML",
|
||||
},
|
||||
Known {
|
||||
frame: "CharacterBag1Slot",
|
||||
flag: Flag::Id,
|
||||
why: "as CharacterBag0Slot",
|
||||
},
|
||||
Known {
|
||||
frame: "CharacterBag2Slot",
|
||||
flag: Flag::Id,
|
||||
why: "as CharacterBag0Slot",
|
||||
},
|
||||
Known {
|
||||
frame: "CharacterBag3Slot",
|
||||
flag: Flag::Id,
|
||||
why: "as CharacterBag0Slot",
|
||||
},
|
||||
Known {
|
||||
frame: "KeyRingButton",
|
||||
flag: Flag::Id,
|
||||
why: "-2 is KEYRING_CONTAINER, which the reference also arrives at — from Lua, not XML",
|
||||
},
|
||||
Known {
|
||||
frame: "BankFrame",
|
||||
flag: Flag::Id,
|
||||
why: "id=\"-1\" is ours: the bank window's own sentinel for \"not a container slot\", \
|
||||
which the reference has no equivalent of",
|
||||
},
|
||||
];
|
||||
|
||||
/// The extracted reference FrameXML directory, or `None` when the install isn't there.
|
||||
fn reference_dir() -> Option<PathBuf> {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../WoW/_extracted_framexml");
|
||||
dir.is_dir().then_some(dir)
|
||||
}
|
||||
|
||||
/// Every named *frame* element in the reference corpus, keyed by name — templates and instances
|
||||
/// alike, nested `<Frames>` included, `$parent`-relative names excluded (they repeat across
|
||||
/// templates and name nothing on their own).
|
||||
fn reference_frames() -> Option<HashMap<String, Element>> {
|
||||
let dir = reference_dir()?;
|
||||
let mut paths: Vec<PathBuf> = std::fs::read_dir(&dir)
|
||||
.ok()?
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("xml")))
|
||||
.collect();
|
||||
paths.sort();
|
||||
|
||||
let mut out: HashMap<String, Element> = HashMap::new();
|
||||
for path in paths {
|
||||
// Blizzard ships a UTF-8 BOM on some of these and stray high bytes in comments; the parse
|
||||
// is what matters, so read lossily rather than refusing the file.
|
||||
let bytes = std::fs::read(&path).ok()?;
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
let Ok(doc) = framexml::parse(text.trim_start_matches('\u{feff}')) else {
|
||||
continue;
|
||||
};
|
||||
for item in &doc.items {
|
||||
if let TopLevel::Template(el) | TopLevel::Instance(el) = item {
|
||||
collect_named(el, &mut out);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Recurse an element tree, publishing every named frame. First name wins, matching the client's
|
||||
/// auto-publish rule.
|
||||
fn collect_named(el: &Element, out: &mut HashMap<String, Element>) {
|
||||
if FRAME_TAGS.iter().any(|t| t.eq_ignore_ascii_case(&el.tag)) {
|
||||
if let Some(name) = el.attr("name") {
|
||||
if !name.contains("$parent") {
|
||||
out.entry(name.to_string()).or_insert_with(|| el.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
for child in &el.children {
|
||||
collect_named(child, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// The templates `el` inherits, **right to left** — a later name in the list overrides an earlier
|
||||
/// one, so the search for an inherited value has to try the last one first.
|
||||
fn inherits(el: &Element) -> impl Iterator<Item = &str> {
|
||||
el.attr("inherits")
|
||||
.unwrap_or("")
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
}
|
||||
|
||||
/// An attribute's value on `name`, following `inherits=`; the element's own value wins over any
|
||||
/// template's. `depth` guards a malformed cycle (the shipped corpus has none).
|
||||
fn resolved_attr<'a>(
|
||||
name: &str,
|
||||
frames: &'a HashMap<String, Element>,
|
||||
attr: &str,
|
||||
depth: u32,
|
||||
) -> Option<&'a str> {
|
||||
if depth > 16 {
|
||||
return None;
|
||||
}
|
||||
let el = frames.get(name)?;
|
||||
if let Some(v) = el.attr(attr) {
|
||||
return Some(v);
|
||||
}
|
||||
inherits(el).find_map(|t| resolved_attr(t, frames, attr, depth + 1))
|
||||
}
|
||||
|
||||
/// Whether any element in `name`'s inherits chain declares one of `handlers` inside `<Scripts>`.
|
||||
fn declares_handler(
|
||||
name: &str,
|
||||
frames: &HashMap<String, Element>,
|
||||
handlers: &[&str],
|
||||
depth: u32,
|
||||
) -> bool {
|
||||
if depth > 16 {
|
||||
return false;
|
||||
}
|
||||
let Some(el) = frames.get(name) else {
|
||||
return false;
|
||||
};
|
||||
let own = el
|
||||
.children
|
||||
.iter()
|
||||
.filter(|c| c.tag.eq_ignore_ascii_case("Scripts"))
|
||||
.flat_map(|s| s.children.iter())
|
||||
.any(|h| handlers.iter().any(|w| w.eq_ignore_ascii_case(&h.tag)));
|
||||
own || inherits(el).any(|t| declares_handler(t, frames, handlers, depth + 1))
|
||||
}
|
||||
|
||||
/// Whether the reference's frame of this name **takes the mouse** once loaded — the three ways
|
||||
/// `0x76af00(2, -1)` is reached, per `scripts-auto-enable.md` §1.3: the widget's own ctor, the
|
||||
/// `enableMouse` attribute, or an auto-enabling `<Scripts>` handler.
|
||||
fn reference_takes_mouse(name: &str, frames: &HashMap<String, Element>) -> bool {
|
||||
let Some(el) = frames.get(name) else {
|
||||
return false;
|
||||
};
|
||||
if MOUSE_BY_CTOR
|
||||
.iter()
|
||||
.any(|t| t.eq_ignore_ascii_case(&el.tag))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if resolved_attr(name, frames, "enableMouse", 0).is_some_and(|v| v.eq_ignore_ascii_case("true"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
declares_handler(name, frames, MOUSE_HANDLERS, 0)
|
||||
}
|
||||
|
||||
/// The reference name for one of ours: the same name, or the one behind our `Benilla` prefix
|
||||
/// (`BenillaChatFrameTemplate` is the reference's `ChatFrameTemplate` — the rename that made a
|
||||
/// text diff report a gap of zero on a window missing every flag).
|
||||
fn reference_name<'a>(ours: &str, frames: &'a HashMap<String, Element>) -> Option<&'a str> {
|
||||
if let Some((k, _)) = frames.get_key_value(ours) {
|
||||
return Some(k);
|
||||
}
|
||||
let bare = ours.strip_prefix("Benilla")?;
|
||||
frames.get_key_value(bare).map(|(k, _)| k.as_str())
|
||||
}
|
||||
|
||||
/// One divergence found by the sweep, rendered for the failure message.
|
||||
fn describe(frame: &str, flag: Flag, ours: &str, theirs: &str) -> String {
|
||||
format!(" {frame} — {flag}: ours {ours}, reference {theirs}")
|
||||
}
|
||||
|
||||
/// **Every frame both UIs name carries the reference's `toplevel`, mouse enable and `id`** — read
|
||||
/// off the loaded engine, with [`KNOWN`] the only accepted differences.
|
||||
///
|
||||
/// Verified to fail: delete `toplevel="true"` from `CharacterFrame.xml` and this names
|
||||
/// `CharacterFrame`; delete `id="1"` from `ActionButton1` and it names that.
|
||||
#[test]
|
||||
fn the_shipped_frames_carry_the_references_flags() {
|
||||
let Some(reference) = reference_frames() else {
|
||||
return; // no install — the same skip every client-data test here takes
|
||||
};
|
||||
assert!(
|
||||
reference.len() > 500,
|
||||
"only {} reference frames parsed — the corpus scan broke, and a sweep over nothing \
|
||||
passes whatever we did",
|
||||
reference.len()
|
||||
);
|
||||
|
||||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
let failures = super::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||||
s.resolve();
|
||||
|
||||
// Read the three flags off the ENGINE, not off our XML — the whole point of the module.
|
||||
let flags = |n: &str| -> Option<(bool, bool, i64)> {
|
||||
s.eval::<i64>(&format!(
|
||||
"local f = getglobal(\"{n}\") \
|
||||
if not f or not f.IsToplevel then return -1 end \
|
||||
return (f:IsToplevel() and 1 or 0) + (f:IsMouseEnabled() and 2 or 0)"
|
||||
))
|
||||
.ok()
|
||||
.filter(|v| *v >= 0)
|
||||
.map(|v| {
|
||||
let id = s
|
||||
.eval::<i64>(&format!("return getglobal(\"{n}\"):GetID()"))
|
||||
.unwrap_or(0);
|
||||
(v & 1 == 1, v & 2 == 2, id)
|
||||
})
|
||||
};
|
||||
|
||||
let mut divergences: Vec<String> = Vec::new();
|
||||
let mut compared = 0usize;
|
||||
// Every KNOWN entry starts unclaimed; a real divergence claims it. What is left at the end
|
||||
// is an entry describing a difference that no longer exists.
|
||||
let mut unused: Vec<&Known> = KNOWN.iter().collect();
|
||||
|
||||
let mut ours: Vec<String> = super::shipped_xml_tests::shipped_frame_names();
|
||||
ours.sort();
|
||||
for name in &ours {
|
||||
let Some(theirs) = reference_name(name, &reference) else {
|
||||
continue;
|
||||
};
|
||||
let Some((toplevel, mouse, id)) = flags(name) else {
|
||||
continue; // not a frame in the loaded tree (a template, or a region name)
|
||||
};
|
||||
compared += 1;
|
||||
|
||||
let want_toplevel = resolved_attr(theirs, &reference, "toplevel", 0)
|
||||
.is_some_and(|v| v.eq_ignore_ascii_case("true"));
|
||||
let want_mouse = reference_takes_mouse(theirs, &reference);
|
||||
let want_id: i64 = resolved_attr(theirs, &reference, "id", 0)
|
||||
.and_then(|v| v.trim().parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
for (flag, differs, ours_s, theirs_s) in [
|
||||
(
|
||||
Flag::Toplevel,
|
||||
toplevel != want_toplevel,
|
||||
toplevel.to_string(),
|
||||
want_toplevel.to_string(),
|
||||
),
|
||||
(
|
||||
Flag::Mouse,
|
||||
mouse != want_mouse,
|
||||
mouse.to_string(),
|
||||
want_mouse.to_string(),
|
||||
),
|
||||
(Flag::Id, id != want_id, id.to_string(), want_id.to_string()),
|
||||
] {
|
||||
if !differs {
|
||||
continue;
|
||||
}
|
||||
if KNOWN.iter().any(|k| k.frame == name && k.flag == flag) {
|
||||
unused.retain(|u| !(u.frame == name && u.flag == flag));
|
||||
continue;
|
||||
}
|
||||
divergences.push(describe(name, flag, &ours_s, &theirs_s));
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
compared > 400,
|
||||
"only {compared} frames compared — the pairing broke, and the sweep guards nothing"
|
||||
);
|
||||
assert!(
|
||||
divergences.is_empty(),
|
||||
"{} frame flag(s) diverge from the reference. Each is either a defect to fix or an entry \
|
||||
to add to KNOWN with the reason it is right — never a tolerance:\n{}",
|
||||
divergences.len(),
|
||||
divergences.join("\n")
|
||||
);
|
||||
// A KNOWN entry that no longer describes a real difference is stale documentation claiming a
|
||||
// divergence that has been fixed — the exact rot this module exists to prevent elsewhere.
|
||||
let stale: Vec<String> = unused
|
||||
.iter()
|
||||
.map(|k| format!(" {} ({}) — claimed: {}", k.frame, k.flag, k.why))
|
||||
.collect();
|
||||
assert!(
|
||||
stale.is_empty(),
|
||||
"{} KNOWN entr(y/ies) no longer describe a real difference — delete them, an accepted \
|
||||
divergence that has been fixed is documentation claiming a defect we do not have:\n{}",
|
||||
stale.len(),
|
||||
stale.join("\n")
|
||||
);
|
||||
}
|
||||
@@ -239,7 +239,25 @@ fn start_loot_roll_claims_frames_in_order_and_paints_the_roll() {
|
||||
// proof loot_tests.rs runs for LootSlot. Roll 7 is BIND-ON-PICKUP, so the seam's gate holds
|
||||
// the vote back and asks instead (decision 0594): nothing on the wire until the popup is
|
||||
// accepted.
|
||||
let (nx, ny) = quad_center(&quads, "UI-GroupLoot-Dice-Up");
|
||||
//
|
||||
// Taken BY NAME, not by painter order. This used to be
|
||||
// `quad_center(&quads, "UI-GroupLoot-Dice-Up")` — the first dice quad — which silently assumed
|
||||
// declaration order is draw order among the four roll frames. `toplevel="true"` (decision
|
||||
// 1739) ends that: the frame a roll claims raises above its siblings on Show, so the first
|
||||
// dice belonged to frame 2 and the click landed on the BoE roll, which has no confirm gate and
|
||||
// went straight to the wire. The proxy was the fixture, never the assertion.
|
||||
let (nx, ny) = s
|
||||
.eval::<(f64, f64)>(
|
||||
"return (GroupLootFrame1RollButton:GetLeft() + GroupLootFrame1RollButton:GetRight()) \
|
||||
/ 2, (GroupLootFrame1RollButton:GetBottom() + GroupLootFrame1RollButton:GetTop()) / 2",
|
||||
)
|
||||
.unwrap();
|
||||
let (nx, ny) = (nx as f32, ny as f32);
|
||||
assert_eq!(
|
||||
s.hit_test_name(nx, ny).as_deref(),
|
||||
Some("GroupLootFrame1RollButton"),
|
||||
"the click has to land on frame 1's Need button for the rest of this to mean anything"
|
||||
);
|
||||
s.mouse_button(nx, ny, "LeftButton", true);
|
||||
s.mouse_button(nx, ny, "LeftButton", false);
|
||||
assert!(
|
||||
|
||||
@@ -930,6 +930,11 @@ mod enchant_confirm_tests;
|
||||
#[cfg(test)]
|
||||
mod framexml_diff;
|
||||
|
||||
/// Its FLAG twin (decision 1739): the whole-tree sweep for `toplevel`/mouse/`id` against the
|
||||
/// reference, read off the loaded engine rather than off our XML.
|
||||
#[cfg(test)]
|
||||
mod frame_flag_gate;
|
||||
|
||||
#[cfg(test)]
|
||||
mod friends_tests;
|
||||
|
||||
|
||||
@@ -1166,8 +1166,95 @@ fn a_cinematic_leaves_nothing_of_the_interface_on_screen() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **Every `parent=` the shipped tree declares REALLY attaches** — the tripwire for 1734's trap.
|
||||
///
|
||||
/// A parent name is resolved at LOAD, and a name that names nothing is **not an error**: the loader
|
||||
/// warns ("names no frame — falling back to the enclosing one") and silently reparents to whatever
|
||||
/// element encloses the declaration. So a typo, a renamed window, or a file that loads earlier in
|
||||
/// the manifest than its parent's leaves the frame attached to the wrong thing with a green suite
|
||||
/// and one log line nobody greps — which is how 1734's 41 harness failures were the *lucky*
|
||||
/// outcome and a silent half-fix was the unlucky one.
|
||||
///
|
||||
/// Asserted on the LOADED tree, not on the text: `GetParent():GetName()` is what the frame ended up
|
||||
/// attached to, which is the only form of this claim worth making. It is also the standing answer
|
||||
/// to "does this engine read a cross-file `parent=`?" — several file headers still said it does
|
||||
/// not, years after it did, and five pages carry a hand-written substitute for `setAllPoints`
|
||||
/// because of it.
|
||||
#[test]
|
||||
fn every_declared_parent_really_attaches() {
|
||||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
let failures = super::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||||
s.resolve();
|
||||
|
||||
let declared = shipped_frame_parents();
|
||||
assert!(
|
||||
declared.len() > 60,
|
||||
"only {} parent declarations found — the scan broke",
|
||||
declared.len()
|
||||
);
|
||||
let mut wrong: Vec<String> = Vec::new();
|
||||
for (child, parent) in &declared {
|
||||
let got = s
|
||||
.eval::<String>(&format!(
|
||||
"local f = getglobal(\"{child}\") \
|
||||
if not f or not f.GetParent then return \"<not a frame>\" end \
|
||||
local p = f:GetParent() \
|
||||
return p and (p:GetName() or \"<anonymous>\") or \"<none>\""
|
||||
))
|
||||
.unwrap_or_else(|_| "<error>".to_string());
|
||||
if got != *parent {
|
||||
wrong.push(format!(
|
||||
" {child}: declared parent={parent}, attached to {got}"
|
||||
));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
wrong.is_empty(),
|
||||
"{} frame(s) did not attach to the parent they declare:\n{}",
|
||||
wrong.len(),
|
||||
wrong.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
/// `(named non-virtual frame, the `parent=` it declares)` for every such declaration in the shipped
|
||||
/// tree — the population of [`every_declared_parent_really_attaches`].
|
||||
fn shipped_frame_parents() -> Vec<(String, String)> {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
|
||||
let mut out: Vec<(String, String)> = Vec::new();
|
||||
for entry in std::fs::read_dir(&dir).expect("assets/ui").flatten() {
|
||||
let path = entry.path();
|
||||
if !path.extension().is_some_and(|e| e == "xml") {
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).expect("read");
|
||||
for chunk in text.split('<').skip(1) {
|
||||
let head = &chunk[..chunk.find('>').unwrap_or(chunk.len())];
|
||||
if head.contains("virtual=\"true\"") {
|
||||
continue;
|
||||
}
|
||||
let attr = |key: &str| -> Option<String> {
|
||||
let i = head.find(key)?;
|
||||
let rest = &head[i + key.len()..];
|
||||
let j = rest.find('"')?;
|
||||
Some(rest[..j].to_string())
|
||||
};
|
||||
let (Some(name), Some(parent)) = (attr("name=\""), attr("parent=\"")) else {
|
||||
continue;
|
||||
};
|
||||
if !name.contains('$') && !parent.contains('$') {
|
||||
out.push((name, parent));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
/// Every named, non-virtual frame the shipped tree declares — the sweep's population.
|
||||
fn shipped_frame_names() -> Vec<String> {
|
||||
pub(super) fn shipped_frame_names() -> Vec<String> {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for entry in std::fs::read_dir(&dir).expect("assets/ui").flatten() {
|
||||
|
||||
@@ -7,12 +7,17 @@
|
||||
//! The threshold is the byte-verified NPC-service gate the cursor grays at
|
||||
//! ([`SERVICE_RANGE_SQ`], 5.5556 yd center-to-center — `crate::target::cursor_mode`), so a window
|
||||
//! closes exactly where the cursor says its NPC is out of service — one law for "out of service".
|
||||
//! Whether the real client's auto-close shares that exact gate is INFERRED (its close mechanism
|
||||
//! isn't RE'd); the constant is the knob if it reads too eager or too lax in play.
|
||||
//!
|
||||
//! **That last sentence used to end "…is INFERRED (its close mechanism isn't RE'd)". It is now a
|
||||
//! fact** (decision 1741): the close mechanism is `0x493230`, called per frame from
|
||||
//! `CGWorldFrame`'s layer update, and a census of all fourteen of the client's arming sites found
|
||||
//! thirteen pushing this very constant. The one thing the guess missed is that the leash is not
|
||||
//! the window's at all — it belongs to the latched OBJECT, and swaps to 14.0 yd for a player
|
||||
//! ([`leash_sq`]).
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::net::{GuidIndex, SelfPlayer};
|
||||
use crate::net::{ClientCommand, GuidIndex, NetCommands, SelfPlayer};
|
||||
use crate::target::SERVICE_RANGE_SQ;
|
||||
use benilla_world::schedule::WorldStage;
|
||||
|
||||
@@ -57,19 +62,47 @@ pub(crate) trait NpcSession: Resource {
|
||||
fn npc(&self) -> Option<u64>;
|
||||
/// The window's client-side close (no packet) — the same clear its close button does.
|
||||
fn close(&mut self);
|
||||
/// Whether the OPEN session is subject to the service-range guard below. Almost always yes,
|
||||
/// which is why it is defaulted — but "bound to a live unit" and "must stay inside NPC service
|
||||
/// range" are two claims, and a party quest-share separates them: its giver is a live *player*
|
||||
/// with a name and a portrait (so [`Self::npc`] must report it) whose distance rule is the
|
||||
/// server's 14 yd share bound, not this 5.56 yd counter reach (decision 1733).
|
||||
/// What the WALK-AWAY close owes the wire, if anything — sent by the guard below just before
|
||||
/// it closes, and `None` for every session but one.
|
||||
///
|
||||
/// Contrast [`crate::ui_quest::QuestGiver`]'s item filter, which answers the *other* question
|
||||
/// by returning `None`: a quest-starter item is not a unit at all (decision 0664).
|
||||
fn range_guarded(&self) -> bool {
|
||||
true
|
||||
/// It exists because the reference's watchdog does not route through the window's own close
|
||||
/// verb: `0x4933da` calls the quest session's end (`0x501130`) **directly**, skipping
|
||||
/// `DeclineQuest`'s core — so walking away from an NPC's quest panel is network-silent (no
|
||||
/// giver re-open, and the DETAILS suppression flag is not consulted) while walking away from a
|
||||
/// party member's SHARED quest still owes them `MSG_QUEST_PUSH_RESULT{DECLINE_QUEST}`
|
||||
/// (decision 1741). A hook rather than a call to [`Self::close`] because that difference —
|
||||
/// send *less* on the walk-away than on the button — is exactly what the binary does.
|
||||
fn walk_away_send(&self, _npc: u64) -> Option<ClientCommand> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The leash the walk-away guard measures against, **chosen by the latched object's type, not by
|
||||
/// the window** — the reference's `0x4930d0`, which stores the opener's own range and then
|
||||
/// overwrites it when the object carries the PLAYER typemask bit (`0x493156`).
|
||||
///
|
||||
/// Thirteen of the client's fourteen arming sites push the same `50/9` yd
|
||||
/// ([`SERVICE_RANGE_SQ`]) — mailbox, loot, stable master, auctioneer, trainer, flight master,
|
||||
/// gossip, item text, petition, bank, vendor, questgiver — so benilla having one constant for all
|
||||
/// of them is right, and byte-exact. What benilla did not have is the swap: a session latched onto
|
||||
/// a **player** is leashed at `10.0 + 4.0 = 14.0` yd instead (`[0x8044b0] + [0x80306c]`, squared
|
||||
/// into the cell at `0x49316d`). The only such session that registers this guard is a shared
|
||||
/// quest — and 1733 read that case as "not range-guarded at all", which is wrong in the same
|
||||
/// direction a plain revert to 5.56 yd would have been wrong (decision 1741).
|
||||
///
|
||||
/// The 14.0 coinciding with vmangos's `QUEST_SHARE_DISTANCE` is corroboration, not the derivation:
|
||||
/// the client's number is two `.rdata` floats summed.
|
||||
fn leash_sq(npc: u64) -> f32 {
|
||||
if benilla_protocol::guid::is_player(npc) {
|
||||
PLAYER_LEASH_SQ
|
||||
} else {
|
||||
SERVICE_RANGE_SQ
|
||||
}
|
||||
}
|
||||
|
||||
/// `14.0²` — the player-typemask leash (see [`leash_sq`]).
|
||||
const PLAYER_LEASH_SQ: f32 = 196.0;
|
||||
|
||||
/// Did an NPC-bound window switch to a **different** NPC of the same kind while it was already open
|
||||
/// (a `Some(a) → Some(b)`, `a != b`)? The real client can't just repaint: its `ShowUIPanel` early-
|
||||
/// returns when the frame is already visible, so the only way MERCHANT_SHOW (etc.) re-plays the open
|
||||
@@ -85,22 +118,26 @@ pub(crate) fn npc_switched(prev: Option<u64>, now: Option<u64>) -> bool {
|
||||
pub(crate) fn close_npc_session_out_of_range<T: NpcSession>(
|
||||
mut session: ResMut<T>,
|
||||
index: Res<GuidIndex>,
|
||||
commands: Res<NetCommands>,
|
||||
self_q: Query<&Transform, With<SelfPlayer>>,
|
||||
transforms: Query<&Transform>,
|
||||
) {
|
||||
let Some(npc) = session.npc() else { return };
|
||||
if !session.range_guarded() {
|
||||
return;
|
||||
}
|
||||
let Some(self_tf) = self_q.iter().next() else {
|
||||
return;
|
||||
};
|
||||
// Boundary-INCLUSIVE, centre-to-centre, no bounding radii — the reference's own compare
|
||||
// (`0x4932f2`/`0x4932fe`: `dist² <= leash²` keeps). The leash is the object's, not the
|
||||
// window's ([`leash_sq`]).
|
||||
let out = match index.0.get(&npc).and_then(|e| transforms.get(*e).ok()) {
|
||||
Some(tf) => tf.translation.distance_squared(self_tf.translation) > SERVICE_RANGE_SQ,
|
||||
Some(tf) => tf.translation.distance_squared(self_tf.translation) > leash_sq(npc),
|
||||
None => true, // the NPC despawned out from under the window
|
||||
};
|
||||
if out {
|
||||
debug!("ui_session: NPC {npc:#x} out of range/gone — client-side close");
|
||||
if let Some(cmd) = session.walk_away_send(npc) {
|
||||
let _ = commands.0.send(cmd);
|
||||
}
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
@@ -178,14 +215,28 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::ui_merchant::MerchantOpen;
|
||||
|
||||
/// A realistic NPC guid — `HIGHGUID_UNIT` in the high word. Not a bare low guid: the high word
|
||||
/// is what makes a guid a *player* (`guid::is_player`), and since 1741 the walk-away leash
|
||||
/// depends on that, so a "vendor" numbered `0x42` is silently a player and gets 14 yd.
|
||||
const VENDOR: u64 = 0xF130_0000_0000_0042;
|
||||
|
||||
/// Drive the guard in a minimal Bevy app over the merchant session (the trait's first
|
||||
/// consumer): in range keeps the window, stepping past the service gate closes it, and a
|
||||
/// despawned NPC closes it too.
|
||||
///
|
||||
/// The vendor guid is a real `HIGHGUID_UNIT` one. It used to be a bare `0x42`, which is a
|
||||
/// **player** guid (the high word is what decides — `guid::is_player`), and that went unnoticed
|
||||
/// until the leash started depending on the type: a "vendor" with a player guid is leashed at
|
||||
/// 14 yd and this test's 6 yd step stopped closing it (decision 1741).
|
||||
#[test]
|
||||
fn out_of_range_or_despawned_npc_closes_the_session() {
|
||||
let mut app = App::new();
|
||||
app.init_resource::<MerchantOpen>();
|
||||
app.init_resource::<GuidIndex>();
|
||||
// The guard sends the walk-away answer for the one session that owes one (1741); the
|
||||
// merchant owes none, but the channel has to exist for the system to run.
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
app.insert_resource(NetCommands(tx));
|
||||
app.add_systems(Update, close_npc_session_out_of_range::<MerchantOpen>);
|
||||
app.world_mut()
|
||||
.spawn((SelfPlayer, Transform::from_xyz(0.0, 0.0, 0.0)));
|
||||
@@ -196,12 +247,12 @@ mod tests {
|
||||
app.world_mut()
|
||||
.resource_mut::<GuidIndex>()
|
||||
.0
|
||||
.insert(0x42, vendor);
|
||||
.insert(VENDOR, vendor);
|
||||
|
||||
// 5.0 yd < the 5.5556 yd service gate: stays open.
|
||||
app.world_mut()
|
||||
.resource_mut::<MerchantOpen>()
|
||||
.open(0x42, vec![]);
|
||||
.open(VENDOR, vec![]);
|
||||
app.update();
|
||||
assert!(app.world().resource::<MerchantOpen>().is_open());
|
||||
|
||||
@@ -216,24 +267,28 @@ mod tests {
|
||||
// Re-open, then the NPC despawns out from under the window: closes too.
|
||||
app.world_mut()
|
||||
.resource_mut::<MerchantOpen>()
|
||||
.open(0x42, vec![]);
|
||||
app.world_mut().resource_mut::<GuidIndex>().0.remove(&0x42);
|
||||
.open(VENDOR, vec![]);
|
||||
app.world_mut()
|
||||
.resource_mut::<GuidIndex>()
|
||||
.0
|
||||
.remove(&VENDOR);
|
||||
app.update();
|
||||
assert!(!app.world().resource::<MerchantOpen>().is_open());
|
||||
}
|
||||
|
||||
/// **A party quest-share is exempt from the guard, and still a full session.** The two are one
|
||||
/// test because they are the two halves of decision 1733's split: the giver is a live player,
|
||||
/// so `npc()` must keep reporting it (the QuestFrame portrait and name banner read it through
|
||||
/// [`InteractNpc`]) — while the *distance* rule is the server's 14 yd share bound, not this
|
||||
/// 5.56 yd NPC-counter reach. Before the split, a sharer standing six yards off had the panel
|
||||
/// yanked away from a quest the server would have granted.
|
||||
/// **The leash belongs to the OBJECT, not the window** (decision 1741, correcting 1733): a
|
||||
/// session latched onto a player is guarded at 14.0 yd, one latched onto an NPC at 5.56 yd.
|
||||
/// 1733 read the share case as "not range-guarded at all" — wrong in the same direction that
|
||||
/// reverting it to the NPC range would have been wrong, which is why both bounds are moved
|
||||
/// here against the same panel.
|
||||
///
|
||||
/// It also pins what the walk-away SENDS: a shared quest still owes the sharer the decline,
|
||||
/// and an NPC's panel closes silently — the reference's watchdog skips the giver re-open the
|
||||
/// button's path does.
|
||||
#[test]
|
||||
fn a_shared_quest_panel_survives_the_service_range_but_stays_a_session() {
|
||||
fn the_walk_away_leash_is_the_givers_own_and_a_share_still_answers() {
|
||||
use crate::ui_quest::{QuestGiver, QuestView};
|
||||
|
||||
// The panel's wire view, whichever giver opened it — only the GUID differs between an NPC
|
||||
// offer and a share, which is the whole point.
|
||||
fn detail(npc: u64) -> QuestView {
|
||||
QuestView::Detail(benilla_protocol::messages::QuestDetails {
|
||||
npc,
|
||||
@@ -241,7 +296,7 @@ mod tests {
|
||||
title: "A Threat Within".into(),
|
||||
details: String::new(),
|
||||
objectives: String::new(),
|
||||
auto_finish: 1,
|
||||
auto_finish: 0,
|
||||
choices: Vec::new(),
|
||||
rewards: Vec::new(),
|
||||
money: 0,
|
||||
@@ -250,59 +305,67 @@ mod tests {
|
||||
}
|
||||
|
||||
const SHARER: u64 = 0x0000_0000_0000_002A; // HIGHGUID_PLAYER: a zero high word
|
||||
const VENDOR: u64 = 0xF130_0000_0000_0007; // HIGHGUID_UNIT
|
||||
const NPC: u64 = 0xF130_0000_0000_0007; // HIGHGUID_UNIT
|
||||
|
||||
let mut app = App::new();
|
||||
app.init_resource::<QuestGiver>();
|
||||
app.init_resource::<GuidIndex>();
|
||||
app.init_resource::<InteractNpc>();
|
||||
app.add_systems(
|
||||
Update,
|
||||
/// Open the panel on `giver`, stand `dist` yards away, run the guard once, and report
|
||||
/// whether the window survived and what went out.
|
||||
fn walk(giver: u64, dist: f32) -> (bool, Vec<ClientCommand>) {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let mut app = App::new();
|
||||
app.insert_resource(NetCommands(tx));
|
||||
app.init_resource::<QuestGiver>();
|
||||
app.init_resource::<GuidIndex>();
|
||||
app.add_systems(Update, close_npc_session_out_of_range::<QuestGiver>);
|
||||
app.world_mut()
|
||||
.spawn((SelfPlayer, Transform::from_xyz(0.0, 0.0, 0.0)));
|
||||
let e = app
|
||||
.world_mut()
|
||||
.spawn(Transform::from_xyz(dist, 0.0, 0.0))
|
||||
.id();
|
||||
app.world_mut()
|
||||
.resource_mut::<GuidIndex>()
|
||||
.0
|
||||
.insert(giver, e);
|
||||
app.world_mut()
|
||||
.resource_mut::<QuestGiver>()
|
||||
.open(giver, detail(giver));
|
||||
app.update();
|
||||
(
|
||||
close_npc_session_out_of_range::<QuestGiver>,
|
||||
feed_interact_npc,
|
||||
app.world().resource::<QuestGiver>().is_open(),
|
||||
rx.try_iter().collect(),
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
app.world_mut()
|
||||
.spawn((SelfPlayer, Transform::from_xyz(0.0, 0.0, 0.0)));
|
||||
// Ten yards away: well past the NPC service gate, well inside the share bound.
|
||||
let sharer = app
|
||||
.world_mut()
|
||||
.spawn(Transform::from_xyz(10.0, 0.0, 0.0))
|
||||
.id();
|
||||
app.world_mut()
|
||||
.resource_mut::<GuidIndex>()
|
||||
.0
|
||||
.insert(SHARER, sharer);
|
||||
}
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<QuestGiver>()
|
||||
.open(SHARER, detail(SHARER));
|
||||
app.update();
|
||||
// A SHARE: 10 yd is past the NPC gate and well inside the player leash — 1733 would have
|
||||
// kept this for the wrong reason, a plain revert would have closed it.
|
||||
let (open, sent) = walk(SHARER, 10.0);
|
||||
assert!(open, "a share is leashed at 14 yd, not 5.56");
|
||||
assert!(sent.is_empty(), "still in range, nothing owed: {sent:?}");
|
||||
|
||||
// Past 14 yd it does close — and answers the sharer.
|
||||
let (open, sent) = walk(SHARER, 15.0);
|
||||
assert!(!open, "past the player leash the panel closes");
|
||||
assert!(
|
||||
app.world().resource::<QuestGiver>().is_open(),
|
||||
"a shared quest's panel is not NPC-range-guarded"
|
||||
);
|
||||
assert_eq!(
|
||||
app.world().resource::<InteractNpc>().1,
|
||||
Some(SHARER),
|
||||
"it is still a session: the panel's portrait and name banner point at the sharer"
|
||||
matches!(
|
||||
sent.as_slice(),
|
||||
[ClientCommand::QuestPushResult {
|
||||
sharer: SHARER,
|
||||
msg: benilla_protocol::messages::QuestShareMsg::DECLINE_QUEST,
|
||||
}]
|
||||
),
|
||||
"walking away from a share still declines it: {sent:?}"
|
||||
);
|
||||
|
||||
// The control: the identical distance with an NPC giver closes, as it always has.
|
||||
app.world_mut()
|
||||
.resource_mut::<GuidIndex>()
|
||||
.0
|
||||
.insert(VENDOR, sharer);
|
||||
app.world_mut()
|
||||
.resource_mut::<QuestGiver>()
|
||||
.open(VENDOR, detail(VENDOR));
|
||||
app.update();
|
||||
// An NPC at the same 10 yd is long gone — the leash did not follow the window.
|
||||
let (open, sent) = walk(NPC, 10.0);
|
||||
assert!(!open, "an NPC giver is still leashed at 5.56 yd");
|
||||
assert!(
|
||||
!app.world().resource::<QuestGiver>().is_open(),
|
||||
"an NPC giver ten yards off still closes"
|
||||
sent.is_empty(),
|
||||
"the walk-away skips the giver re-open the button does: {sent:?}"
|
||||
);
|
||||
|
||||
// Boundary-inclusive: exactly on the leash keeps the window (`dist² <= leash²`).
|
||||
assert!(walk(SHARER, 14.0).0, "14.0 yd exactly is still in range");
|
||||
}
|
||||
|
||||
/// The `"npc"` portrait token's resolver ([`feed_interact_npc`]): the open NPC session's guid,
|
||||
@@ -324,7 +387,7 @@ mod tests {
|
||||
app.world_mut()
|
||||
.resource_mut::<GuidIndex>()
|
||||
.0
|
||||
.insert(0x42, npc);
|
||||
.insert(VENDOR, npc);
|
||||
|
||||
// No window open → no npc.
|
||||
app.update();
|
||||
@@ -333,13 +396,13 @@ mod tests {
|
||||
// A merchant opens on the indexed NPC → resolves to its entity.
|
||||
app.world_mut()
|
||||
.resource_mut::<MerchantOpen>()
|
||||
.open(0x42, vec![]);
|
||||
.open(VENDOR, vec![]);
|
||||
app.update();
|
||||
assert_eq!(app.world().resource::<InteractNpc>().0, Some(npc));
|
||||
|
||||
// Gossip carries the same guid (a browse-goods hop) and wins the priority chain — still the
|
||||
// one NPC's entity.
|
||||
app.world_mut().resource_mut::<GossipState>().npc = Some(0x42);
|
||||
app.world_mut().resource_mut::<GossipState>().npc = Some(VENDOR);
|
||||
app.update();
|
||||
assert_eq!(app.world().resource::<InteractNpc>().0, Some(npc));
|
||||
|
||||
@@ -358,7 +421,7 @@ mod tests {
|
||||
app.init_resource::<crate::ui_auction::AuctionOpen>();
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::ui_auction::AuctionOpen>()
|
||||
.open(0x42, 1);
|
||||
.open(VENDOR, 1);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world().resource::<InteractNpc>().0,
|
||||
@@ -380,7 +443,7 @@ mod tests {
|
||||
app.init_resource::<crate::ui_stable::StableOpen>();
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::ui_stable::StableOpen>()
|
||||
.open(0x42, 2, vec![]);
|
||||
.open(VENDOR, 2, vec![]);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world().resource::<InteractNpc>().0,
|
||||
|
||||
@@ -308,7 +308,18 @@ fn is_instrument_consumer(rel: &str) -> bool {
|
||||
/// the uploader is exactly the game opinion the wall exists to keep out. The alternative was
|
||||
/// mirroring the region into every registered buffer to keep the count flat, which re-opens B49 in
|
||||
/// the tint lane (decision 1731).
|
||||
const CEILING: usize = 164;
|
||||
/// And 164 → 165: `ffx_glow::GlueFfx`, a PUBLISH of the same shape as `rig_anim::AnimParked` and
|
||||
/// the two above — a resource the ENGINE reads and the GAME writes, and exactly ONE bit wide.
|
||||
/// Running an FFX pass pair is machinery; *which* pair a screen installs is the screen's own, and
|
||||
/// the reference says so by building two pairs (WorldFrame `0x481c46`, CGlueMgr `0x46a723`) and
|
||||
/// selecting between them at two different sites. The world's selector is already inside the
|
||||
/// engine's reach — `PLAYER_FLAGS_GHOST` on the live player — but the glue's is not and cannot be:
|
||||
/// it is the ghost bit of the **selected roster row** (`0x472fd9 test dh,0x20`), a character
|
||||
/// nobody is standing in, on a screen the engine has no concept of. The alternative was handing the
|
||||
/// renderer a roster to read to keep a count flat (decision 1731). Note the direction of travel:
|
||||
/// this replaced `FfxGlow::state_scale`, a float that let a bake inherit a player-state lane by
|
||||
/// arithmetic — the enum that took its place makes report B49's invariant structural.
|
||||
const CEILING: usize = 165;
|
||||
|
||||
/// How far under [`CEILING`] the real count may sit before this test asks for the ceiling to be
|
||||
/// lowered. Slack, not tolerance: it keeps a single closure from failing the gate, while making it
|
||||
|
||||
@@ -1164,6 +1164,9 @@ impl UiScript {
|
||||
}
|
||||
let mut model = self.model_mut();
|
||||
model.mouse_down_on.clear();
|
||||
// …and its one-slot twin `root+0x80`, which the mouse-down raise reads: a capture left
|
||||
// behind would aim the next press's raise at whatever the pointer was last holding.
|
||||
model.mouse_capture = None;
|
||||
// [`Model::last_click`] is deliberately NOT cleared here. It looks like it belongs in this
|
||||
// list, and the binary says otherwise: `[CButton+0x334]` has exactly three writers
|
||||
// image-wide (the ctor, the fired-double zero, the fired-single stamp) and none of them is
|
||||
|
||||
@@ -319,6 +319,16 @@ pub(crate) struct Model {
|
||||
/// same-frame press+release test in [`UiScript::mouse_button`]. Keyed by button so a `LeftButton`
|
||||
/// press is not cleared by a `RightButton` release.
|
||||
pub(crate) mouse_down_on: HashMap<String, FrameHandle>,
|
||||
/// The client's single mouse-capture slot **`root+0x80`** — the frame a press captured, held
|
||||
/// until every button is up. The same fact as [`Model::mouse_down_on`] in the one-slot form,
|
||||
/// and the two differ only while more than one button is held; this is the form the
|
||||
/// mouse-down **raise** reads, which is what needs the precedence to be unambiguous.
|
||||
///
|
||||
/// Written at `0x7663e6` with the resolved target — capture-**else**-hover, so an existing
|
||||
/// capture survives a chorded press over a different frame — and cleared at `0x7664bb` only
|
||||
/// when the post-event button mask is empty, so a chorded release keeps it. A title-region
|
||||
/// press never reaches `0x7663e6` and so captures nothing.
|
||||
pub(crate) mouse_capture: Option<FrameHandle>,
|
||||
/// Per **frame**, the [`UiScript::now`] second at which its last single `OnClick` fired — the
|
||||
/// double-click detector's entire state, and the faithful stand-in for the client's per-widget
|
||||
/// timestamp `[CButton+0x334]` (wow-re `ui/scratch/button-doubleclick-law.md`).
|
||||
@@ -1472,6 +1482,7 @@ impl Model {
|
||||
mouseover: None,
|
||||
hover_repick: false,
|
||||
mouse_down_on: HashMap::new(),
|
||||
mouse_capture: None,
|
||||
last_click: HashMap::new(),
|
||||
pending_size_changed: Vec::new(),
|
||||
errors: Vec::new(),
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
//! | `0x775b0a` (Lua `Frame:Raise()`) | the explicit script call | [`install`] |
|
||||
//! | `0x76aeec` in `effective_visible_show 0x76ae10` | **the Show trigger** | [`raise_on_show`], run from [`crate::script::event::fire_visibility_changes`] |
|
||||
//! | `0x7652d7` in begin-move `0x7652b0` | drag start | [`super::movable`]'s `start_moving` |
|
||||
//! | `0x766392` in the mouse-down handler `0x7662c0` | **the click trigger** | **not wired** — the pointer path is another concern's; the hook is one call in `pointer.rs`'s button-down arm |
|
||||
//! | `0x766392` in the mouse-down handler `0x7662c0` | **the click trigger** | [`crate::script::UiScript::mouse_button`]'s down arm, first thing — capture-else-hover, unguarded, ahead of the title-region swallow |
|
||||
//! | `0x764a4c` (`force = 0`) | re-applies an already-computed bit `0x10` | no counterpart (see the OVERLAPPED note above) |
|
||||
//! | `0x7650d6` | dead — zero references binary-wide | — |
|
||||
//!
|
||||
|
||||
@@ -329,6 +329,37 @@ impl UiScript {
|
||||
/// statement-position calls (`script.mouse_button(...);`) are unaffected.
|
||||
pub fn mouse_button(&mut self, x: f32, y: f32, button: &str, down: bool) -> bool {
|
||||
let hit_id = self.hit_test(x, y);
|
||||
// ── The RAISE, and it happens FIRST ─────────────────────────────────────────────────────
|
||||
//
|
||||
// wow-re ledger `0x7662c0` (VERIFIED, the `toplevel-raise.md` §5 fan-out): the
|
||||
// mouse-button-DOWN handler "resolves target = root+0x80 (existing capture) else
|
||||
// root+0x7c (hover frame), calls `0x76a5b0` Raise() **UNGUARDED** @`0x766392` (the
|
||||
// toplevel gate is inside `0x7650f0`, so the raised frame may be an ancestor of the
|
||||
// clicked one), then title-region drag or capture+OnMouseDown".
|
||||
//
|
||||
// Four clauses, each load-bearing and each visible:
|
||||
//
|
||||
// · **DOWN only.** The sibling category-`0xe` handler `0x766420` never raises — a window
|
||||
// comes forward on the press, not on the release.
|
||||
// · **Before the title-region swallow below**, so grabbing a window by its title bar
|
||||
// raises it too, even though that path dispatches no `OnMouseDown` at all.
|
||||
// · **Unguarded** — no toplevel test out here. [`toplevel::raise`] walks to the nearest
|
||||
// toplevel self-or-ancestor and does nothing when there is none, which is exactly what
|
||||
// makes a click on a *child* (a button, a tab, the window's own background texture)
|
||||
// bring the whole window forward.
|
||||
// · **Capture else hover.** [`Model::mouse_capture`] is `root+0x80`; while a button is
|
||||
// held it wins, so a chorded press over a different window raises the one being
|
||||
// dragged, not the one under the cursor.
|
||||
//
|
||||
// Nothing here is gated on the frame being mouse-enabled: the "hover frame" is the
|
||||
// hit-test result, which already applies that gate.
|
||||
if down {
|
||||
let target = self.model_ref().mouse_capture;
|
||||
if let Some(t) = target.or_else(|| self.hit_test_frame(x, y)) {
|
||||
let mut model = self.model_mut();
|
||||
super::object::toplevel::raise(&mut model, t);
|
||||
}
|
||||
}
|
||||
// ── The title region, and it SWALLOWS the press ─────────────────────────────────────────
|
||||
//
|
||||
// A mouse-down inside `frame:GetTitleRegion()` starts a mode-2 move and never reaches the
|
||||
@@ -420,6 +451,11 @@ impl UiScript {
|
||||
model.mouse_down_on.remove(button);
|
||||
}
|
||||
}
|
||||
// `0x7663e6` writes the resolved target into `root+0x80` — capture-else-hover, so
|
||||
// this is an `or`, not an assignment: an existing capture is not displaced by a
|
||||
// second button pressed elsewhere. (This is the point the reference reaches only
|
||||
// on a title-region MISS, which is why the swallow above returns before it.)
|
||||
model.mouse_capture = model.mouse_capture.or(hit_handle);
|
||||
// A press REPLACES any in-flight gesture, so a started one has to be ended first
|
||||
// — `abandon_drag`'s own doc carries why a silent drop is a stuck UI rather than a
|
||||
// cancelled drag. Its `OnDragStop` fires below, outside this borrow.
|
||||
@@ -451,6 +487,12 @@ impl UiScript {
|
||||
(click, None, false, jump, None, abandoned, color_jump)
|
||||
} else {
|
||||
let pressed = model.mouse_down_on.remove(button);
|
||||
// `root+0x80` is cleared at `0x7664bb` **only when the post-event button mask is
|
||||
// zero** — a chorded release keeps the capture for the button still held. With the
|
||||
// per-button map already drained above, "mask is zero" is "the map is empty".
|
||||
if model.mouse_down_on.is_empty() {
|
||||
model.mouse_capture = None;
|
||||
}
|
||||
// A left release ends any in-flight thumb drag (decision 0250 §5).
|
||||
if button == "LeftButton" {
|
||||
super::slider::end_drag(&mut model);
|
||||
|
||||
@@ -515,3 +515,177 @@ fn starting_a_move_raises_the_dragged_window() {
|
||||
s.run("Dialog:StopMovingOrSizing()").unwrap();
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
// ── The mouse-DOWN trigger ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The centre of `name`'s resolved rect, in the coordinate space [`UiScript::mouse_button`] takes.
|
||||
fn centre(s: &UiScript, name: &str) -> (f32, f32) {
|
||||
let (x, y) = s
|
||||
.eval::<(f64, f64)>(&format!(
|
||||
"return ({name}:GetLeft() + {name}:GetRight()) / 2, \
|
||||
({name}:GetBottom() + {name}:GetTop()) / 2"
|
||||
))
|
||||
.unwrap();
|
||||
(x as f32, y as f32)
|
||||
}
|
||||
|
||||
/// **A press brings the window forward** — the trigger at `0x766392` in the mouse-down handler
|
||||
/// `0x7662c0`, the last of the six census sites to be wired (wow-re ledger `0x7662c0`).
|
||||
///
|
||||
/// Without it `toplevel` gives only half the behaviour a player reads as "windows": a window you
|
||||
/// *open* comes to the front, and a window you *click* stays buried under whatever is on top of it.
|
||||
#[test]
|
||||
fn pressing_a_toplevel_window_brings_it_to_the_front() {
|
||||
let mut s = board_and_dialog();
|
||||
s.run(
|
||||
r#"
|
||||
Dialog:SetToplevel(true)
|
||||
Dialog:EnableMouse(true)
|
||||
Dialog:Show()
|
||||
Board:SetFrameLevel(9) -- put the dialog back underneath
|
||||
Dialog:SetFrameLevel(0)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert!(level(&mut s, "Dialog") < level(&mut s, "Board"));
|
||||
|
||||
// A point inside Dialog and clear of Board, so the hit is unambiguous.
|
||||
let (x, y) = centre(&s, "Dialog");
|
||||
assert_eq!(s.hit_test_name(x, y).as_deref(), Some("Dialog"));
|
||||
s.mouse_button(x, y, "LeftButton", true);
|
||||
assert!(
|
||||
level(&mut s, "Dialog") > level(&mut s, "Board"),
|
||||
"the press brought it to the front"
|
||||
);
|
||||
s.mouse_button(x, y, "LeftButton", false);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The trigger is **UNGUARDED** — there is no toplevel test at the call site. The worker resolves
|
||||
/// the nearest toplevel *self-or-ancestor*, so pressing a plain child raises the window that owns
|
||||
/// it. This is the clause that makes clicking a window's button, tab or background art work at all;
|
||||
/// with a guard out here, only a press landing on the window frame itself would raise.
|
||||
#[test]
|
||||
fn pressing_a_child_of_a_toplevel_window_raises_the_window() {
|
||||
let mut s = board_and_dialog();
|
||||
s.run(
|
||||
r#"
|
||||
Dialog:SetToplevel(true)
|
||||
Dialog:Show()
|
||||
Knob = CreateFrame("Frame", "Knob", Dialog)
|
||||
Knob:SetPoint("CENTER", Dialog, "CENTER")
|
||||
Knob:SetSize(40, 40)
|
||||
Knob:EnableMouse(true)
|
||||
Board:SetFrameLevel(9)
|
||||
Dialog:SetFrameLevel(0)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert!(level(&mut s, "Dialog") < level(&mut s, "Board"));
|
||||
|
||||
let (x, y) = centre(&s, "Knob");
|
||||
assert_eq!(s.hit_test_name(x, y).as_deref(), Some("Knob"));
|
||||
s.mouse_button(x, y, "LeftButton", true);
|
||||
assert!(
|
||||
level(&mut s, "Dialog") > level(&mut s, "Board"),
|
||||
"pressing the child raised its toplevel ancestor"
|
||||
);
|
||||
s.mouse_button(x, y, "LeftButton", false);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The RELEASE never raises.** The sibling category-`0xe` handler `0x766420` has no `Raise` call
|
||||
/// anywhere in it; only the down edge does. A raise on the release would be invisible in the
|
||||
/// ordinary case and wrong in the one that matters — a press that started a drag elsewhere.
|
||||
#[test]
|
||||
fn releasing_over_a_toplevel_window_does_not_raise_it() {
|
||||
let mut s = board_and_dialog();
|
||||
s.run(
|
||||
r#"
|
||||
Dialog:SetToplevel(true)
|
||||
Dialog:EnableMouse(true)
|
||||
Dialog:Show()
|
||||
Board:SetFrameLevel(9)
|
||||
Dialog:SetFrameLevel(0)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let (x, y) = centre(&s, "Dialog");
|
||||
let before = level(&mut s, "Dialog");
|
||||
// A release with no press before it: the down edge never ran, so nothing may move.
|
||||
s.mouse_button(x, y, "LeftButton", false);
|
||||
assert_eq!(
|
||||
level(&mut s, "Dialog"),
|
||||
before,
|
||||
"the release raised nothing"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **Capture else hover.** The handler resolves `root+0x80` (the existing capture) *before* falling
|
||||
/// back to `root+0x7c` (the frame under the cursor), so a second button pressed while the first is
|
||||
/// still held raises the frame being HELD, not whatever the pointer has wandered onto. The capture
|
||||
/// clears only when the last button comes up (`0x7664bb`, gated on the post-event mask), so the
|
||||
/// next press after that resolves to the hover again.
|
||||
#[test]
|
||||
fn a_chorded_press_raises_the_held_frame_not_the_one_under_the_cursor() {
|
||||
let mut s = board_and_dialog();
|
||||
s.run(
|
||||
r#"
|
||||
Dialog:SetToplevel(true)
|
||||
Dialog:EnableMouse(true)
|
||||
Dialog:Show()
|
||||
|
||||
-- A second window well clear of the first, with something of its own to raise over: the
|
||||
-- gate is occlusion, so a frame overlapping nothing would decline whatever we press.
|
||||
Other = CreateFrame("Frame", "Other")
|
||||
Other:SetPoint("BOTTOMLEFT", 500, 100)
|
||||
Other:SetSize(200, 200)
|
||||
Other:EnableMouse(true)
|
||||
Other:SetToplevel(true)
|
||||
Tile = CreateFrame("Frame", "Tile")
|
||||
Tile:SetPoint("BOTTOMLEFT", 660, 260) -- clips Other's far corner only
|
||||
Tile:SetSize(60, 60)
|
||||
Tile:SetFrameLevel(9)
|
||||
|
||||
Board:SetFrameLevel(9)
|
||||
Dialog:SetFrameLevel(0)
|
||||
Other:SetFrameLevel(0)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// Hold the left button on Dialog…
|
||||
let (dx, dy) = centre(&s, "Dialog");
|
||||
s.mouse_button(dx, dy, "LeftButton", true);
|
||||
let dialog_held = level(&mut s, "Dialog");
|
||||
let other_before = level(&mut s, "Other");
|
||||
|
||||
// …then press the right button away over Other. The capture is still Dialog's, so Other is not
|
||||
// the raise target even though it is what the cursor is on.
|
||||
let (ox, oy) = centre(&s, "Other");
|
||||
assert_eq!(s.hit_test_name(ox, oy).as_deref(), Some("Other"));
|
||||
s.mouse_button(ox, oy, "RightButton", true);
|
||||
assert_eq!(
|
||||
level(&mut s, "Other"),
|
||||
other_before,
|
||||
"the frame under the cursor is not the raise target while a capture is held"
|
||||
);
|
||||
assert_eq!(level(&mut s, "Dialog"), dialog_held);
|
||||
|
||||
// Both buttons up — only now does the capture clear.
|
||||
s.mouse_button(ox, oy, "LeftButton", false);
|
||||
s.mouse_button(ox, oy, "RightButton", false);
|
||||
let other_now = level(&mut s, "Other");
|
||||
s.mouse_button(ox, oy, "LeftButton", true);
|
||||
assert!(
|
||||
level(&mut s, "Other") > other_now,
|
||||
"with no capture held the press raises what the cursor is over"
|
||||
);
|
||||
s.mouse_button(ox, oy, "LeftButton", false);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -48,10 +48,8 @@ use crate::view::WorldCamera;
|
||||
pub struct FfxGlow {
|
||||
/// Per-view multiplier on the zone glow weight. See [`Self::WORLD`] / [`Self::UI_PANE`].
|
||||
pub(crate) gain_scale: f32,
|
||||
/// Per-view multiplier on the whole **player-state** class of screen effects: the haze mix
|
||||
/// `z` ([`FfxHazeMix`] — the drunk/underwater screen-toward-blur cross-fade) *and* the
|
||||
/// FFXDeath gate `y` ([`FfxDeathFade`] — the ghost combine). `1.0` only on the true world
|
||||
/// view.
|
||||
/// **Which FFX pass pair this view runs**, and therefore what drives the combine's `y` (the
|
||||
/// FFXDeath gate) and `z` (the haze mix) — see [`FfxState`].
|
||||
///
|
||||
/// **The law, and why it is ONE field.** The reference runs its FFX pass inside the
|
||||
/// WorldFrame's own paint — a BEGIN/END pair, `0x6cd890`/`0x6cda70`, bracketed at
|
||||
@@ -70,9 +68,40 @@ pub struct FfxGlow {
|
||||
///
|
||||
/// The haze had its own `haze_scale` and the death gate had none, so a released ghost's
|
||||
/// portraits baked through the FFXDeath combine and came back steel-blue luma (report B49,
|
||||
/// decision 1481). Naming the *class* rather than the member is the fix: the next
|
||||
/// player-state pass is gated by construction.
|
||||
pub(crate) state_scale: f32,
|
||||
/// decision 1481). Naming the *class* rather than the member was the fix; naming the **pass
|
||||
/// pair** (decision 1731) is the same fix one step further, and it is the reference's own
|
||||
/// shape — see [`FfxState`].
|
||||
pub(crate) state: FfxState,
|
||||
}
|
||||
|
||||
/// **Which of the reference's FFX pass pairs a view runs.** The binary builds *two*, and the
|
||||
/// active-pass slot `[0xce8bb4]` holds whichever the screen that is painting installed:
|
||||
///
|
||||
/// - the **WorldFrame** pair — `0x6cc130` CFFXGlow → `[0xb4b350]`, `0x6cc690` CFFXDeath →
|
||||
/// `[0xb4b39c]`, built at `0x481c46`; selected by `0x5de9c0` off `PLAYER_FLAGS_GHOST`;
|
||||
/// - the **glue** pair — `[0xb414c4]` glow / `[0xb41468]` death, built at CGlueMgr init
|
||||
/// `0x46a723`/`0x46a752`; selected by the select build's tail `0x472fd9 test dh,0x20` off the
|
||||
/// selected roster record's `CHARSELECT+0xfc & 0x2000` (wow-re `death-pass.md` §4(c) +
|
||||
/// `glue-select-model.md` §A2, both VERIFIED).
|
||||
///
|
||||
/// benilla's views coexist where the reference's screens take turns, so what the reference
|
||||
/// expresses as one global slot written by whoever paints, we express as a property of the view.
|
||||
/// That is *why* this is an enum and not a scale: a bake's [`Self::None`] cannot inherit a
|
||||
/// player-state lane by arithmetic accident, which is the invariant decision 1481 legislated after
|
||||
/// report B49, now structural.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum FfxState {
|
||||
/// The **WorldFrame** pair — the live player's own state: the ghost death combine
|
||||
/// ([`FfxDeathFade`], off `PLAYER_FLAGS_GHOST`) and the drunk/underwater haze
|
||||
/// ([`FfxHazeMix`]). The true world view only.
|
||||
Player,
|
||||
/// The **glue** pair — the death combine iff the SELECTED ROSTER ROW is a ghost
|
||||
/// ([`GlueFfx::death`]), and no haze: a glue screen has no drunk player and no camera in a
|
||||
/// liquid, and the reference's glue pass pair carries no haze lane to read.
|
||||
Glue,
|
||||
/// Neither — a bake standing in for a 1.12 UI model widget, which the reference composites
|
||||
/// *after* the WorldFrame's own FFX bracket and which therefore sees no pass state at all.
|
||||
None,
|
||||
}
|
||||
|
||||
impl FfxGlow {
|
||||
@@ -81,7 +110,17 @@ impl FfxGlow {
|
||||
/// combine). The only view that carries the latter.
|
||||
pub const WORLD: Self = Self {
|
||||
gain_scale: 1.0,
|
||||
state_scale: 1.0,
|
||||
state: FfxState::Player,
|
||||
};
|
||||
/// The **glue screens' own** fullscreen render (login / create / character select) — the
|
||||
/// reference's glue pass pair, installed by CGlueMgr and applied around the glue scene's paint
|
||||
/// (`0x46fad3 call 0x6cd890` … `0x46fae0 jmp 0x6cda70`). It is not a bake standing in for a UI
|
||||
/// widget: it is the screen, so it runs the zone glow AND, for a ghost selection, the death
|
||||
/// combine — while the GlueXML frames over it composite afterwards, untinted, exactly as the
|
||||
/// reference's character list and buttons do.
|
||||
pub const GLUE_SCENE: Self = Self {
|
||||
gain_scale: 1.0,
|
||||
state: FfxState::Glue,
|
||||
};
|
||||
/// A portrait/booth bake at world parity for *lighting and glow* (decision 0638) — but never
|
||||
/// a player-state pass: a bake stands in for a UI model widget, which the reference
|
||||
@@ -90,7 +129,7 @@ impl FfxGlow {
|
||||
/// goes steel-blue (decision 1481, report B49).
|
||||
pub const BOOTH: Self = Self {
|
||||
gain_scale: 1.0,
|
||||
state_scale: 0.0,
|
||||
state: FfxState::None,
|
||||
};
|
||||
/// **Decode only, no glow** — for a bake that stands in for a 1.12 *UI model widget*. The
|
||||
/// reference applies its FFX pass inside the WorldFrame's own paint (the `0x6cd890`/`0x6cda70`
|
||||
@@ -100,7 +139,7 @@ impl FfxGlow {
|
||||
/// text and buttons don't bloom.)
|
||||
pub const UI_PANE: Self = Self {
|
||||
gain_scale: 0.0,
|
||||
state_scale: 0.0,
|
||||
state: FfxState::None,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -124,6 +163,85 @@ pub struct FfxGlowGain(pub f32);
|
||||
#[derive(Resource, Clone, Default, ExtractResource)]
|
||||
pub struct FfxDeathFade(pub f32);
|
||||
|
||||
/// **The glue screens' FFX state** — the two things the select build's tail writes, and nothing
|
||||
/// else (`0x472fba`–`0x473007`, byte-read from wow-re's own disassembly; the fork is
|
||||
/// `472fd9 test dh,0x20` on the selected record's `CHARSELECT+0xfc`):
|
||||
///
|
||||
/// ```text
|
||||
/// 472fde mov ecx,ds:0xb41468 ; call 0x6cde60 ; ghost → install the glue DEATH pass
|
||||
/// 472fe9 mov edx,ds:0x838570 ; mov [esi+0x110],edx ; …and pin LightParams.glow
|
||||
/// 472ff7 mov ecx,ds:0xb414c4 ; call 0x6cde60 ; living → install the glue GLOW pass
|
||||
/// 473002 mov eax,ds:0x838574 ; mov [esi+0x110],eax ; …and pin LightParams.glow
|
||||
/// ```
|
||||
///
|
||||
/// `[esi+0x110]` is the DN/lighting singleton's `LightParams.glow` (`esi` = `0x6d48b0()` =
|
||||
/// `&0xce9b60`) — the same scalar `0x6cb930` reads for the death combine's **alpha byte**
|
||||
/// (`glow × 255`, `death-pass.md` §3) and the glow combine reads for its blur² weight. So on a glue
|
||||
/// screen the glow weight is not a zone value at all: it is one of two constants, chosen by the
|
||||
/// same bit that chooses the pass.
|
||||
///
|
||||
/// Written by the game's char-select feed; read by [`sync_gain`]'s off-world arm and by
|
||||
/// [`FfxState::Glue`]'s combine.
|
||||
#[derive(Resource, Clone, Copy, Default, PartialEq, Eq, Debug, ExtractResource)]
|
||||
pub enum GlueFfx {
|
||||
/// **A glue ModelFFX widget is up and nothing has re-pinned it** — the login screen, the create
|
||||
/// screen, and a character-select screen with an empty account (where no select build runs).
|
||||
/// `CSimpleModelFFX`'s own **OnShow** override `0x46fa60` pins `[+0x110] = *0x8380b4 = 0.30f`
|
||||
/// and installs the glue GLOW pass (`0x46fa74`). OnShow, not per-frame: it tail-jumps to
|
||||
/// `0x76b260`, which runs the widget's `[frame+0x130]` handler (`0x76a0d0` maps `"OnShow"` to
|
||||
/// that slot), which is why the select build's own pin below is not overwritten every frame.
|
||||
#[default]
|
||||
Shown,
|
||||
/// **The character-select screen, LIVING selection.** The select build's tail installs the glue
|
||||
/// GLOW pass and pins `*0x838574 = 0.40f` (`0x472ff7`/`0x473002`). Note what this is *not*: a
|
||||
/// living selection is not "no post-process" — it is the glow pass at a pinned weight.
|
||||
SelectLiving,
|
||||
/// **The character-select screen, GHOST selection.** The glue DEATH pass, and `*0x838570 = 0.15f`
|
||||
/// (`0x472fde`/`0x472fe9`).
|
||||
SelectGhost,
|
||||
}
|
||||
|
||||
impl GlueFfx {
|
||||
/// The glue pair's death gate — `1.0` only for [`Self::SelectGhost`]. Instant on both edges,
|
||||
/// like the world's: the reference's swap is a slot write with no time anchor in it, so
|
||||
/// clicking from a ghost row to a living one un-washes the scene on the same frame. And it runs
|
||||
/// on **every** selection, not only the first: `0x472a6d jne 0x472fba` sends an already-built
|
||||
/// record straight into the swap block.
|
||||
fn death(self) -> f32 {
|
||||
match self {
|
||||
Self::SelectGhost => 1.0,
|
||||
Self::Shown | Self::SelectLiving => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// `LightParams.glow` while a glue screen is up — the DN/lighting singleton's `0xce9c70`
|
||||
/// (`0x6d48b0` is `mov eax,0xce9b60; ret`, so `[eax+0x110]` is that field).
|
||||
///
|
||||
/// **All three values are byte-VERIFIED**, and an accessor-call-site census over all 41
|
||||
/// `call 0x6d48b0` sites found exactly these three writers and three readers (the ffx packs
|
||||
/// `0x6cb0e2`/`0x6cb557`/`0x6cb9e1`). Each constant has exactly one reference image-wide — the
|
||||
/// read itself — so they are literals, not `.data` defaults that something else moves.
|
||||
///
|
||||
/// This is the scalar the death combine turns into its **primary alpha byte** (`0x6cb9ec`:
|
||||
/// `×255.0`, `+512.0`, `fstp`, `shr eax,0xe`) — 0.15 → 38, 0.40 → 102 — and which both combines
|
||||
/// use as the blur² weight. So a ghost's select screen glows at **less than half** a living
|
||||
/// one's, which is the opposite of what "the ghost look is the loud one" would suggest.
|
||||
///
|
||||
/// (benilla keeps the float rather than the quantized byte: 38/255 = 0.14902 against 0.15 is
|
||||
/// under a thousandth, and the same scalar feeds the in-world glow lane where the reference
|
||||
/// carries a float too.)
|
||||
fn glow(self) -> f32 {
|
||||
match self {
|
||||
// `*0x8380b4`, the widget's OnShow pin.
|
||||
Self::Shown => 0.30,
|
||||
// `*0x838574`, the select build's living arm.
|
||||
Self::SelectLiving => 0.40,
|
||||
// `*0x838570`, the select build's ghost arm.
|
||||
Self::SelectGhost => 0.15,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The haze mix `z` — the combine's screen-toward-blur cross-fade
|
||||
/// (`out = lerp(screen, blur, z) + w·blur²`, the shipped FFXGlow.bls). The reference's glow
|
||||
/// render packs it per frame from the **active player's** state (`0x6cb134`/`0x6cb599`, wow-re
|
||||
@@ -166,12 +284,17 @@ fn sync_haze(
|
||||
fn sync_gain(
|
||||
lighting: Option<Res<crate::lighting::WowLighting>>,
|
||||
live: Res<crate::schedule::WorldLive>,
|
||||
glue: Res<GlueFfx>,
|
||||
mut gain: ResMut<FfxGlowGain>,
|
||||
) {
|
||||
let target = if live.0 {
|
||||
lighting.map_or(0.5, |l| l.glow)
|
||||
} else {
|
||||
0.5
|
||||
// Off-world the glue screen pins it outright — the widget's OnShow, or the select build's
|
||||
// own fork ([`GlueFfx::glow`], all three byte-verified). It is never `LightParams`' default
|
||||
// here: this arm used to read a flat 0.5 on the reasoning that an off-world client falls
|
||||
// back to the table default, and the bytes say the glue screens pin it instead.
|
||||
glue.glow()
|
||||
};
|
||||
if gain.0 != target {
|
||||
gain.0 = target;
|
||||
@@ -417,19 +540,61 @@ fn dither_armed() -> f32 {
|
||||
/// - **x** — the zone glow weight, scaled by [`FfxGlow::gain_scale`]: authored *scene* data, so a
|
||||
/// portrait bake carries it at world parity (decision 0638) and only a UI model pane drops it,
|
||||
/// keeping the combine for its gamma decode alone.
|
||||
/// - **y/z** — the FFXDeath gate and the haze mix, both scaled by [`FfxGlow::state_scale`]: these
|
||||
/// are keyed on the **viewer's own state**, which the reference paints inside the WorldFrame and
|
||||
/// every UI frame composites over, so neither may reach a bake (decision 1481).
|
||||
/// - **y/z** — the FFXDeath gate and the haze mix, both selected by [`FfxGlow::state`]: which pass
|
||||
/// pair this view runs decides what drives them, and a bake runs neither pair (decision 1481,
|
||||
/// report B49 — now structural rather than arithmetic, decision 1731).
|
||||
/// - **w** — the deband-dither arm ([`dither_armed`]), 0 or 1.
|
||||
fn combine_uniform(zone_gain: f32, death: f32, haze: f32, glow: &FfxGlow) -> [f32; 4] {
|
||||
fn combine_uniform(
|
||||
zone_gain: f32,
|
||||
world: FfxPassState,
|
||||
glue: FfxPassState,
|
||||
glow: &FfxGlow,
|
||||
) -> [f32; 4] {
|
||||
let feed = match glow.state {
|
||||
FfxState::Player => world,
|
||||
FfxState::Glue => glue,
|
||||
FfxState::None => FfxPassState::INERT,
|
||||
};
|
||||
[
|
||||
zone_gain * glow.gain_scale,
|
||||
death * glow.state_scale,
|
||||
haze * glow.state_scale,
|
||||
feed.death,
|
||||
feed.haze,
|
||||
dither_armed(),
|
||||
]
|
||||
}
|
||||
|
||||
/// One pass pair's live state, as [`combine_uniform`] consumes it: the death gate and the haze mix
|
||||
/// that pair carries. The glue pair has no haze lane at all, which is a fact about the reference
|
||||
/// and not a value we happen to leave at zero.
|
||||
#[derive(Clone, Copy)]
|
||||
struct FfxPassState {
|
||||
death: f32,
|
||||
haze: f32,
|
||||
}
|
||||
|
||||
impl FfxPassState {
|
||||
/// The **WorldFrame** pair's live state: the ghost gate off `PLAYER_FLAGS_GHOST` and the
|
||||
/// drunk/underwater haze, both of the live player.
|
||||
fn world(death: f32, haze: f32) -> Self {
|
||||
Self { death, haze }
|
||||
}
|
||||
|
||||
/// The **glue** pair's live state. Death only, and the constructor is where that is enforced:
|
||||
/// the reference's haze is `primary.z` of the *WorldFrame* glow combine `0x6cb020`, packed from
|
||||
/// the active player's inebriation and the camera-eye liquid probe (`drunk-blur-z.md`) — a glue
|
||||
/// screen has neither, and CGlueMgr's pair has no lane to read them into. Passing a haze here
|
||||
/// should be impossible rather than merely wrong.
|
||||
fn glue(death: f32) -> Self {
|
||||
Self { death, haze: 0.0 }
|
||||
}
|
||||
|
||||
/// What a view running neither pair reads — a bake.
|
||||
const INERT: Self = Self {
|
||||
death: 0.0,
|
||||
haze: 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FfxGlowNode;
|
||||
|
||||
@@ -451,8 +616,11 @@ impl ViewNode for FfxGlowNode {
|
||||
let pipeline_cache = world.resource::<PipelineCache>();
|
||||
let uniform = combine_uniform(
|
||||
world.resource::<FfxGlowGain>().0,
|
||||
world.resource::<FfxDeathFade>().0,
|
||||
world.resource::<FfxHazeMix>().0,
|
||||
FfxPassState::world(
|
||||
world.resource::<FfxDeathFade>().0,
|
||||
world.resource::<FfxHazeMix>().0,
|
||||
),
|
||||
FfxPassState::glue(world.resource::<GlueFfx>().death()),
|
||||
glow,
|
||||
);
|
||||
let (Some(downsample), Some(gauss_h), Some(gauss_v), Some(combine)) = (
|
||||
@@ -563,11 +731,13 @@ impl Plugin for FfxGlowPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.insert_resource(FfxGlowGain(0.647)) // overwritten by sync_gain from zone data
|
||||
.init_resource::<FfxDeathFade>()
|
||||
.init_resource::<GlueFfx>()
|
||||
.init_resource::<FfxHazeMix>()
|
||||
.add_plugins((
|
||||
ExtractComponentPlugin::<FfxGlow>::default(),
|
||||
ExtractResourcePlugin::<FfxGlowGain>::default(),
|
||||
ExtractResourcePlugin::<FfxDeathFade>::default(),
|
||||
ExtractResourcePlugin::<GlueFfx>::default(),
|
||||
ExtractResourcePlugin::<FfxHazeMix>::default(),
|
||||
))
|
||||
.add_systems(Update, (sync_gain, sync_haze, ensure_ffx_glow));
|
||||
@@ -596,18 +766,33 @@ impl Plugin for FfxGlowPlugin {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The world pair, live: a released ghost, sober.
|
||||
fn ghost_world() -> FfxPassState {
|
||||
FfxPassState::world(1.0, 0.0)
|
||||
}
|
||||
/// The glue pair with a living selection — the common case.
|
||||
fn living_glue() -> FfxPassState {
|
||||
FfxPassState::glue(0.0)
|
||||
}
|
||||
|
||||
/// The zone glow is scene data — a portrait bake wants it at world parity (decision 0638) —
|
||||
/// while the death and haze lanes are player state and must stop at the world view
|
||||
/// (decision 1481). One preset table, checked as a whole so a new preset can't quietly join
|
||||
/// the wrong side.
|
||||
/// while the death and haze lanes belong to a *pass pair*, and a bake runs neither
|
||||
/// (decision 1481, now structural: 1731). One preset table, checked as a whole so a new preset
|
||||
/// can't quietly join the wrong side.
|
||||
#[test]
|
||||
fn only_the_world_view_carries_the_player_state_lanes() {
|
||||
fn only_the_two_screen_views_run_a_pass_pair() {
|
||||
for (name, view) in [("BOOTH", FfxGlow::BOOTH), ("UI_PANE", FfxGlow::UI_PANE)] {
|
||||
assert_eq!(view.state_scale, 0.0, "{name} must not carry player state");
|
||||
assert_eq!(
|
||||
view.state,
|
||||
FfxState::None,
|
||||
"{name} is a bake standing in for a UI widget — it runs no pass pair"
|
||||
);
|
||||
}
|
||||
assert_eq!(FfxGlow::WORLD.state_scale, 1.0);
|
||||
assert_eq!(FfxGlow::WORLD.state, FfxState::Player);
|
||||
assert_eq!(FfxGlow::GLUE_SCENE.state, FfxState::Glue);
|
||||
// The glow half is unchanged by that law: a bake still glows like the world.
|
||||
assert_eq!(FfxGlow::BOOTH.gain_scale, 1.0);
|
||||
assert_eq!(FfxGlow::GLUE_SCENE.gain_scale, 1.0);
|
||||
assert_eq!(FfxGlow::UI_PANE.gain_scale, 0.0);
|
||||
}
|
||||
|
||||
@@ -615,18 +800,18 @@ mod tests {
|
||||
/// luma. The gate reaches the world view and nothing else — while the zone glow still does.
|
||||
#[test]
|
||||
fn a_ghosts_bake_is_not_death_combined() {
|
||||
let (zone, ghost, sober) = (0.5, 1.0, 0.0);
|
||||
let zone = 0.5;
|
||||
assert_eq!(
|
||||
combine_uniform(zone, ghost, sober, &FfxGlow::WORLD),
|
||||
combine_uniform(zone, ghost_world(), living_glue(), &FfxGlow::WORLD),
|
||||
[0.5, 1.0, 0.0, 0.0]
|
||||
);
|
||||
assert_eq!(
|
||||
combine_uniform(zone, ghost, sober, &FfxGlow::BOOTH),
|
||||
combine_uniform(zone, ghost_world(), living_glue(), &FfxGlow::BOOTH),
|
||||
[0.5, 0.0, 0.0, 0.0],
|
||||
"the booth keeps the zone glow and drops the death gate"
|
||||
);
|
||||
assert_eq!(
|
||||
combine_uniform(zone, ghost, sober, &FfxGlow::UI_PANE),
|
||||
combine_uniform(zone, ghost_world(), living_glue(), &FfxGlow::UI_PANE),
|
||||
[0.0, 0.0, 0.0, 0.0]
|
||||
);
|
||||
}
|
||||
@@ -635,14 +820,87 @@ mod tests {
|
||||
/// world-only, and still is.
|
||||
#[test]
|
||||
fn a_drunk_players_bake_stays_sharp() {
|
||||
let (zone, alive, drunk) = (0.5, 0.0, 1.0);
|
||||
let (zone, drunk) = (0.5, FfxPassState::world(0.0, 1.0));
|
||||
assert_eq!(
|
||||
combine_uniform(zone, alive, drunk, &FfxGlow::WORLD),
|
||||
combine_uniform(zone, drunk, living_glue(), &FfxGlow::WORLD),
|
||||
[0.5, 0.0, 1.0, 0.0]
|
||||
);
|
||||
assert_eq!(
|
||||
combine_uniform(zone, alive, drunk, &FfxGlow::BOOTH),
|
||||
combine_uniform(zone, drunk, living_glue(), &FfxGlow::BOOTH),
|
||||
[0.5, 0.0, 0.0, 0.0]
|
||||
);
|
||||
}
|
||||
|
||||
/// **The two pairs are independent, which is the whole point of naming them** (decision 1731).
|
||||
/// The reference has one active-pass slot because its screens take turns; ours coexist, so the
|
||||
/// invariant has to be stated: a ghost on the CHARACTER-SELECT list death-combines the glue
|
||||
/// scene and nothing else, and a released ghost in the WORLD never reaches the glue view.
|
||||
#[test]
|
||||
fn each_pass_pair_reaches_only_its_own_view() {
|
||||
let zone = 0.5;
|
||||
let ghost_glue = FfxPassState::glue(1.0);
|
||||
let alive_world = FfxPassState::world(0.0, 0.0);
|
||||
assert_eq!(
|
||||
combine_uniform(zone, alive_world, ghost_glue, &FfxGlow::GLUE_SCENE),
|
||||
[0.5, 1.0, 0.0, 0.0],
|
||||
"a ghost roster row death-combines the glue scene"
|
||||
);
|
||||
assert_eq!(
|
||||
combine_uniform(zone, alive_world, ghost_glue, &FfxGlow::WORLD),
|
||||
[0.5, 0.0, 0.0, 0.0],
|
||||
"…and never the world view, whose own player is alive"
|
||||
);
|
||||
assert_eq!(
|
||||
combine_uniform(zone, ghost_world(), living_glue(), &FfxGlow::GLUE_SCENE),
|
||||
[0.5, 0.0, 0.0, 0.0],
|
||||
"and a world ghost never reaches the glue view"
|
||||
);
|
||||
}
|
||||
|
||||
/// The glue pair carries **no haze lane** — a fact about the reference (its pair is built by
|
||||
/// CGlueMgr; the haze is `primary.z` of the *WorldFrame* glow combine), enforced by
|
||||
/// [`FfxPassState::glue`] having no way to say otherwise. Pinned so the day someone widens that
|
||||
/// constructor, a test argues back — and pinned against a fully hazed WORLD, which is the state
|
||||
/// that would leak if the two pairs were ever refolded into one.
|
||||
#[test]
|
||||
fn the_glue_pair_never_hazes() {
|
||||
let drunk_world = FfxPassState::world(0.0, 1.0);
|
||||
assert_eq!(
|
||||
combine_uniform(
|
||||
0.5,
|
||||
drunk_world,
|
||||
FfxPassState::glue(1.0),
|
||||
&FfxGlow::GLUE_SCENE
|
||||
),
|
||||
[0.5, 1.0, 0.0, 0.0],
|
||||
"the glue view death-combines on its own gate and never hazes"
|
||||
);
|
||||
}
|
||||
|
||||
/// GOLDEN — the three writers of the glue screens' `LightParams.glow`, and the alpha byte the
|
||||
/// death combine quantizes the ghost one into. Every number byte-VERIFIED (wow-re
|
||||
/// `glue-select-ghost-treatment.md`); each constant has exactly one reference image-wide.
|
||||
///
|
||||
/// The default is a shown-but-unselected glue screen — login, create, and an empty account —
|
||||
/// which is the state the login screen renders in and which is NOT either select arm.
|
||||
#[test]
|
||||
fn the_glue_screens_pin_three_verified_glow_weights() {
|
||||
assert_eq!(GlueFfx::default(), GlueFfx::Shown);
|
||||
assert_eq!(
|
||||
GlueFfx::Shown.glow(),
|
||||
0.30,
|
||||
"*0x8380b4, the widget's OnShow"
|
||||
);
|
||||
assert_eq!(GlueFfx::SelectLiving.glow(), 0.40, "*0x838574");
|
||||
assert_eq!(GlueFfx::SelectGhost.glow(), 0.15, "*0x838570");
|
||||
// Only the ghost arm installs the death pass, and it is the only arm that does.
|
||||
assert_eq!(GlueFfx::SelectGhost.death(), 1.0);
|
||||
assert_eq!(GlueFfx::Shown.death(), 0.0);
|
||||
assert_eq!(GlueFfx::SelectLiving.death(), 0.0);
|
||||
// The combine's primary alpha byte (`0x6cb9ec`: ×255, +512, fstp, shr 14) — 38 ghosted,
|
||||
// 102 living. A ghost's screen glows at less than half a living one's.
|
||||
let alpha_byte = |glow: f32| ((glow * 255.0 + 512.0).to_bits() >> 14) & 0xff;
|
||||
assert_eq!(alpha_byte(GlueFfx::SelectGhost.glow()), 38);
|
||||
assert_eq!(alpha_byte(GlueFfx::SelectLiving.glow()), 102);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user