the minimap and world map go stock, and the chat loses its white plate
- the minimap cluster and the battleground list are the reference's own `Minimap.xml` and `BattlefieldFrame.xml`, ours deleted, and the sixteen engine names the static probe found unbound are built, not stubbed: the battleground list and queue family (the as-group join refused client-side with no packet when the map's `MaxPlayers` is below the group), the bodyless `CMSG_BATTLEFIELD_STATUS` on world enter — benilla never sent it, so a queue joined before a reconnect was invisible — the 5.5556 yd `BATTLEFIELDS_CLOSED` leash, and the meeting-stone pair over the `0x295` five-way table with both asymmetries; the ping's lifetime is the stock Lua's now (held 5 s, faded 0.5 s), `GetPingPosition` answers two numbers always — our nil was a Lua error on every frame of the stock `Minimap_OnUpdate` — and the map-change drop the reference never had is gone (1974)
- the world map is the reference's own `WorldMapFrame.xml`, our transcription deleted; built for it: the arrow-frame family (an anonymous `Model` child at its 33.6 px footprint, drawn app-side as the minimap's own arrow sprite through one shared constant), the battleground position family over `MSG_BATTLEGROUND_PLAYER_POSITIONS` both ways (the reference's 5000 ms request throttle), and `GetPlayerMapPosition` resolving `raid1..raid40` where it knew only the party (B320); `SetupFullscreenScale` — the engine binding `0x48c270` the map's and the cinematic frame's OnShow call, which our map had stood in for with Lua — is bound to the carved law `min(0.75 · aspect, 1)` off the configured aspect, written through the frame's own `SetScale` (1.0 at 4:3 and wider, 0.9375 at 5:4); a hovered POI writes the map's own area label, not the tooltip; named gap: `WorldMapPing` draws nothing yet (1980, 1981)
- the tutorial frame is the reference's own `TutorialFrame.xml`, over a tutorial system benilla never had: two bit banks filled from `SMSG_TUTORIAL_FLAGS` — the fire-once bank `TriggerTutorial` reads and sets (never a send) and the acknowledged bank `FlagTutorial` writes and sends; there is no tutorial CVar in the binary (210 `CVar::Register` sites censused), so "disable tutorials" is every bit acknowledged; the sites this app can produce are wired (the world-enter Welcome cascade, the level-up arm, item pushes, the loot sparkle, the meeting-stone and battleground arms), the rest named; the loader learned `<TitleRegion>` — the stock frame's drag handle, a region that emits no quad; and the `TabardModel` widget kind arrives ahead of its window, five ints bounded by the `.rdata` table `{170, 17, 6, 17, 51}` and the constant 100 000 tabard cost (1976, 1977)
- the chat window drew as an opaque white plate: the chat-cache loader, gated on `InWorld` alone, restored into the boot VM 130 ms before the deferred entry load filled it, and its per-VM memo then blocked the restore that would have reached the real frames — the plate the stock Lua tints black stayed the XML's white; between the entry edge and the load there is now no VM in the world (the edge parks the boot VM, the load takes it back), so no in-world feed pushes into a VM with no interface; the perf pill reads `59 fps 7.0 ms 17.3 cpu` — the MAIN thread's milliseconds in full text and the process-wide sum dim, because the sum of every thread's CPU time had been read as a frame time (a raid read 17 on it at a solid 60 with the main thread at 7); and `WOW_PROBE_UI_ONE_TEX=1` is a pricing lever, never a look, for the draw-count ceiling a UI atlas would reach before one is built (1978, 1954)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1,645 +0,0 @@
|
||||
<!--
|
||||
The HUD minimap cluster — our own implementation of what the 1.12 FrameXML Minimap.xml
|
||||
specifies (decision 0203 phase 1), which is cited throughout for the names, the geometry, the
|
||||
art and the event wiring: that half is the API addons reposition and hook by name, and none of
|
||||
it is ours to invent. The <Minimap> widget itself is the engine-rendered map hole: benilla-ui carries its
|
||||
rect + zoom, and the app (`minimap.rs`) fills it with the streamed tile window, the
|
||||
MinimapMask circle, and the player arrow; everything in THIS file is the chrome the reference
|
||||
authors around that hole, at the reference's own geometry and art.
|
||||
|
||||
IN (this file): the cluster frame (192×192, TOPRIGHT), MinimapBorderTop, the zone-text button
|
||||
(label fed by MINIMAP_ZONE_CHANGED → GetMinimapZoneText()), the minimap show/hide toggle, the
|
||||
140×140 <Minimap> widget, MinimapBackdrop's round border art, the zoom in/out buttons
|
||||
driving Minimap:SetZoom() with the reference's enable/disable-at-bounds law + sounds, and the
|
||||
unread-mail icon (MiniMapMailFrame, decision 0544 P3 — the mail arc's arrival layer) driven by
|
||||
UPDATE_PENDING_MAIL + HasNewMail(), and the tracking icon (MiniMapTrackingFrame) driven by
|
||||
PLAYER_AURAS_CHANGED + GetTrackingTexture() (the aura rebuild's tracking-global law, `ui_aura`).
|
||||
|
||||
The minimap PING (decision 1596) is IN as a click entry only: `Minimap_OnClick` below, on the
|
||||
widget's OnMouseUp, exactly where the reference puts it — because one corpus addon
|
||||
(CleanMinimap) REPLACES that global and expects the client to call the replacement. Everything
|
||||
past the click is the engine's: the marker is pinned to a WORLD point and drawn by
|
||||
`minimap/ping.rs` in the same pass as the blips, so there is no `MiniMapPing` frame, no Lua
|
||||
timer and no per-frame SetPoint here. Owning the display in Lua is what made the first attempt
|
||||
flaky, and no corpus addon touches any of that surface (measured: 0 of 218 for MiniMapPing /
|
||||
Minimap_SetPing / MINIMAP_PING / PingLocation / GetPingPosition).
|
||||
|
||||
OUT (no wire / later arc slices, per 0203): meeting stone and battlefield buttons. The ping
|
||||
MODEL is not out any more and never needed to be: the reference's MiniMapPing is a <Model> on
|
||||
MinimapPing.mdx, and 1600 re-expresses its three drawn quads as flat animated sprites at the
|
||||
model's own byte-measured sizes and tracks. (The "rotate-minimap CVar" this list used to name
|
||||
does not EXIST in 5875 — a 214-site CVar::Register census found no such knob, and the address
|
||||
it was pinned on, 0xceaa60, is the inside-a-WMO flag. Nothing to omit.) The day/night indicator
|
||||
now lives in its own GameTime.xml (the reference's own file split), loaded right after this
|
||||
file. The
|
||||
reference's minimapPlayerModel/.mdx arrow renders app-side from its .blp for now (0203 flags
|
||||
the simplification). The reference's <HitRectInsets> on the cluster is dropped (it only
|
||||
enlarges the mouse-blocking hull; the loader models the tag now, so restore it if it ever
|
||||
matters).
|
||||
-->
|
||||
<Ui xmlns="http://www.blizzard.com/wow/ui/">
|
||||
<Script><![CDATA[
|
||||
-- The minimap click (decision 1596). A global, and thin on purpose: it is the seam an
|
||||
-- addon replaces (CleanMinimap swaps it out to make a modifier-drag move the cluster and
|
||||
-- calls the original otherwise), so everything it could get wrong is behind the engine.
|
||||
-- The reference's own body also range-tests the disc and divides by GetEffectiveScale
|
||||
-- here; both belong on the far side of PingLocation, which knows the map's real geometry
|
||||
-- this frame — in UI units the widget's radius is not a number this file can know.
|
||||
function Minimap_OnClick()
|
||||
local x, y = GetCursorPosition();
|
||||
local cx, cy = Minimap:GetCenter();
|
||||
Minimap:PingLocation(x - cx, y - cy);
|
||||
end
|
||||
|
||||
-- ref-Minimap.lua ToggleMinimap(), verbatim (the toggle button hides the MAP, not the chrome).
|
||||
function ToggleMinimap()
|
||||
if ( Minimap:IsVisible() ) then
|
||||
PlaySound("igMiniMapClose");
|
||||
Minimap:Hide();
|
||||
else
|
||||
PlaySound("igMiniMapOpen");
|
||||
Minimap:Show();
|
||||
end
|
||||
end
|
||||
|
||||
-- The zone label's tint, keyed by the zone's PvP disposition. Colours quoted from
|
||||
-- ref-Minimap.lua (they are measurements of the real interface, not choices of ours); the
|
||||
-- table is ours, because five branches that differ only in three floats want to be data.
|
||||
local ZONE_PVP_TINT = {
|
||||
friendly = { r = 0.1, g = 1.0, b = 0.1 },
|
||||
hostile = { r = 1.0, g = 0.1, b = 0.1 },
|
||||
contested = { r = 1.0, g = 0.7, b = 0 },
|
||||
};
|
||||
|
||||
-- Every line of the zone tooltip is drawn the same way — plain white, nothing in the
|
||||
-- right-hand column — so the stanza is written once here instead of once per line.
|
||||
local function AddZoneTooltipLine(text)
|
||||
GameTooltip:AddLine(text, "", 1.0, 1.0, 1.0);
|
||||
end
|
||||
|
||||
-- The zone's "whose land is this" line, or nil for a zone that is nobody's (sanctuary, or
|
||||
-- a disposition we don't recognise). Free-for-all is deliberately NOT decided here: the
|
||||
-- two callers below reach it by genuinely different routes and have to keep doing so — see
|
||||
-- the note in Minimap_Update.
|
||||
local function ZoneTerritoryLine(pvpType, factionName)
|
||||
if ( pvpType == "friendly" or pvpType == "hostile" ) then
|
||||
return format(FACTION_CONTROLLED_TERRITORY, factionName);
|
||||
elseif ( pvpType == "contested" ) then
|
||||
return CONTESTED_TERRITORY;
|
||||
end
|
||||
return nil;
|
||||
end
|
||||
|
||||
-- Our implementation of what ref-Minimap.lua's Minimap_Update() specifies: relabel the
|
||||
-- minimap's zone line, tint it for the zone's PVP state (decision 0287 — GetZonePVPInfo
|
||||
-- closes the "zone PVP state we don't track yet" reduction that kept the label gold), and
|
||||
-- refresh the hover tooltip if — and only if — the cursor is already parked on the label,
|
||||
-- so a zone change under the mouse updates what is on screen.
|
||||
function Minimap_Update()
|
||||
local zoneName = GetMinimapZoneText();
|
||||
MinimapZoneText:SetText(zoneName);
|
||||
|
||||
local pvpType, factionName, isArena = GetZonePVPInfo();
|
||||
-- An arena zone is free-for-all, so it wears the hostile red whatever the zone's own
|
||||
-- type says; resolving that override after the lookup keeps it in one place. A zone
|
||||
-- with no tint of its own (sanctuary, or a type we don't know) stays normal gold.
|
||||
local tint = ZONE_PVP_TINT[pvpType];
|
||||
if ( isArena ) then
|
||||
tint = ZONE_PVP_TINT.hostile;
|
||||
end
|
||||
if ( tint ) then
|
||||
MinimapZoneText:SetTextColor(tint.r, tint.g, tint.b);
|
||||
else
|
||||
MinimapZoneText:SetTextColor(NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b);
|
||||
end
|
||||
|
||||
if ( not GameTooltip:IsOwned(MinimapZoneTextButton) ) then
|
||||
return;
|
||||
end
|
||||
-- AT MOST ONE territory line on this path, and free-for-all is an ALTERNATIVE to the
|
||||
-- faction line here, reached off `pvpType` rather than off `isArena` — so with the
|
||||
-- live API (which never reports "arena" as a type) it never fires at all. That is the
|
||||
-- reference's own asymmetry with MinimapZoneTextButton_OnEnter below, where the FFA
|
||||
-- line is an EXTRA line gated on `isArena`. The two disagree, they are kept apart
|
||||
-- deliberately, and converging them would be a behaviour change wearing a tidy-up's
|
||||
-- clothes — which is why ZoneTerritoryLine stops short of this arm.
|
||||
local territory = ZoneTerritoryLine(pvpType, factionName);
|
||||
if ( pvpType == "arena" ) then
|
||||
territory = FREE_FOR_ALL_TERRITORY;
|
||||
end
|
||||
GameTooltip:SetOwner(MinimapZoneTextButton, "ANCHOR_LEFT");
|
||||
AddZoneTooltipLine(zoneName);
|
||||
if ( territory ) then
|
||||
AddZoneTooltipLine(territory);
|
||||
end
|
||||
GameTooltip:Show();
|
||||
end
|
||||
|
||||
-- The zone label's own hover tooltip. Minimap_Update above refreshes this same tooltip in
|
||||
-- place while the cursor is parked here, but the two are NOT one routine: this one adds
|
||||
-- the free-for-all line as an EXTRA line gated on `isArena` (so an arena that is also
|
||||
-- contested says both), while Minimap_Update reaches it off `pvpType == "arena"` INSTEAD
|
||||
-- of the faction line. See the note there; the difference is the reference's, it is what
|
||||
-- players actually see, and it is preserved on purpose.
|
||||
function MinimapZoneTextButton_OnEnter()
|
||||
GameTooltip:SetOwner(this, "ANCHOR_LEFT");
|
||||
AddZoneTooltipLine(GetMinimapZoneText());
|
||||
|
||||
local pvpType, factionName, isArena = GetZonePVPInfo();
|
||||
local territory = ZoneTerritoryLine(pvpType, factionName);
|
||||
if ( territory ) then
|
||||
AddZoneTooltipLine(territory);
|
||||
end
|
||||
if ( isArena ) then
|
||||
AddZoneTooltipLine(FREE_FOR_ALL_TERRITORY);
|
||||
end
|
||||
GameTooltip:Show();
|
||||
end
|
||||
|
||||
-- The end of the track a step of `step` is heading for. The two ends are not spelled the
|
||||
-- same way — index 0 is a constant, the tight end rides however many levels the widget
|
||||
-- carries — which is the only reason the +/- buttons need saying twice at all.
|
||||
local function MinimapZoomLimit(step)
|
||||
if ( step < 0 ) then
|
||||
return 0;
|
||||
end
|
||||
return Minimap:GetZoomLevels() - 1;
|
||||
end
|
||||
|
||||
-- One zoom step, either direction (ref-Minimap.lua's two click handlers say this twice in
|
||||
-- mirror image; benilla says it once and hands it the direction). Three things happen: the
|
||||
-- OPPOSITE button is re-armed, because a step this way always leaves room to come back;
|
||||
-- the widget moves; and this button greys if the step landed on its own end of the track.
|
||||
-- Note the limit is read AFTER the step, so a widget that clamps the move is still asked
|
||||
-- where it actually ended up.
|
||||
local function MinimapZoomStep(step, sound, button, opposite)
|
||||
opposite:Enable();
|
||||
PlaySound(sound);
|
||||
Minimap:SetZoom(Minimap:GetZoom() + step);
|
||||
if ( Minimap:GetZoom() == MinimapZoomLimit(step) ) then
|
||||
button:Disable();
|
||||
end
|
||||
end
|
||||
|
||||
function Minimap_ZoomInClick()
|
||||
MinimapZoomStep(1, "igMiniMapZoomIn", MinimapZoomIn, MinimapZoomOut);
|
||||
end
|
||||
|
||||
function Minimap_ZoomOutClick()
|
||||
MinimapZoomStep(-1, "igMiniMapZoomOut", MinimapZoomOut, MinimapZoomIn);
|
||||
end
|
||||
|
||||
-- Re-sync BOTH buttons to the zoom as it stands, which is what a click cannot do: a click
|
||||
-- knows which way it moved, this does not. The app fires MINIMAP_UPDATE_ZOOM when the
|
||||
-- active zoom index switches (stepping inside/outside flips to the other, independent
|
||||
-- level), so the +/- greying follows the map you are actually on instead of the one you
|
||||
-- left. Both ends are re-armed first and then at most one is greyed again — a widget with
|
||||
-- a single zoom level sits on both ends at once, and the tight end wins that tie.
|
||||
function Minimap_UpdateZoom()
|
||||
MinimapZoomIn:Enable();
|
||||
MinimapZoomOut:Enable();
|
||||
local zoom = Minimap:GetZoom();
|
||||
if ( zoom == MinimapZoomLimit(1) ) then
|
||||
MinimapZoomIn:Disable();
|
||||
elseif ( zoom == MinimapZoomLimit(-1) ) then
|
||||
MinimapZoomOut:Disable();
|
||||
end
|
||||
end
|
||||
|
||||
-- Byte-exact 1.12 enUS GlobalString (GlobalStrings.lua:2122) — the unread-mail tooltip.
|
||||
local HAVE_MAIL = "You have unread mail";
|
||||
|
||||
-- ref-Minimap.xml MiniMapMailFrame's OnEvent, verbatim logic (l.281-289): UPDATE_PENDING_MAIL
|
||||
-- is the ONLY event the reference registers on this frame — no MAIL_INBOX_UPDATE/MAIL_CLOSED
|
||||
-- exists there despite the icon logically depending on inbox state. That is not an oversight
|
||||
-- to paper over: reading your mail clears the icon through the pending-mail countdown, never
|
||||
-- through the inbox (opening a letter arms a deferred refresh; closing the mailbox re-asks
|
||||
-- the server). `ui_mail::MailPending` models that countdown and fires UPDATE_PENDING_MAIL at
|
||||
-- exactly the reference's three signal sites — decision 0913.
|
||||
function MiniMapMailFrame_OnEvent()
|
||||
if ( event == "UPDATE_PENDING_MAIL" ) then
|
||||
if ( HasNewMail() ) then
|
||||
this:Show();
|
||||
else
|
||||
this:Hide();
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ref-Minimap.xml l.290-293, verbatim.
|
||||
function MiniMapMailFrame_OnEnter()
|
||||
GameTooltip:SetOwner(this, "ANCHOR_BOTTOMLEFT");
|
||||
GameTooltip:SetText(HAVE_MAIL);
|
||||
end
|
||||
|
||||
-- The tracking icon, same shape as the mail frame above. `GetTrackingTexture()` is the
|
||||
-- WHOLE of the state: the icon path of the one tracking effect the aura rebuild diverts
|
||||
-- out of the buff bar (`ui_aura`), or nil when nothing is tracked — so there is nothing to
|
||||
-- reconcile, the answer is either an icon to wear or a reason to get out of the way.
|
||||
-- PLAYER_AURAS_CHANGED is the only signal that can change it (ref-Minimap.xml l.109-174
|
||||
-- registers nothing else on this frame). Note the icon is NOT cleared on the way out: the
|
||||
-- frame is hidden, and the next tracking effect repaints it before it is seen again.
|
||||
function MiniMapTrackingFrame_OnEvent()
|
||||
if ( event ~= "PLAYER_AURAS_CHANGED" ) then
|
||||
return;
|
||||
end
|
||||
local tracking = GetTrackingTexture();
|
||||
if ( not tracking ) then
|
||||
MiniMapTrackingFrame:Hide();
|
||||
return;
|
||||
end
|
||||
MiniMapTrackingIcon:SetTexture(tracking);
|
||||
MiniMapTrackingFrame:Show();
|
||||
end
|
||||
|
||||
-- Hovering the icon names the tracking spell itself, which the tooltip knows how to build.
|
||||
function MiniMapTrackingFrame_OnEnter()
|
||||
GameTooltip:SetOwner(this, "ANCHOR_BOTTOMLEFT");
|
||||
GameTooltip:SetTrackingSpell();
|
||||
end
|
||||
|
||||
]]></Script>
|
||||
<Frame name="MinimapCluster" frameStrata="BACKGROUND" enableMouse="true" parent="UIParent">
|
||||
<Size>
|
||||
<AbsDimension x="192" y="192"/>
|
||||
</Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPRIGHT"/>
|
||||
</Anchors>
|
||||
<Layers>
|
||||
<Layer level="ARTWORK">
|
||||
<Texture name="MinimapBorderTop" file="Interface\Minimap\UI-Minimap-Border">
|
||||
<Size>
|
||||
<AbsDimension x="192" y="32"/>
|
||||
</Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPRIGHT"/>
|
||||
</Anchors>
|
||||
<TexCoords left="0.25" right="1.0" top="0.0" bottom="0.125"/>
|
||||
</Texture>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Frames>
|
||||
<Button name="MinimapZoneTextButton">
|
||||
<Size>
|
||||
<AbsDimension x="128" y="12"/>
|
||||
</Size>
|
||||
<Anchors>
|
||||
<Anchor point="CENTER">
|
||||
<Offset>
|
||||
<AbsDimension x="-3" y="83"/>
|
||||
</Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
<FontString name="MinimapZoneText" inherits="GameFontNormal" nonspacewrap="true">
|
||||
<Size>
|
||||
<AbsDimension x="128" y="12"/>
|
||||
</Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOP"/>
|
||||
</Anchors>
|
||||
</FontString>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Scripts>
|
||||
<OnEnter>
|
||||
MinimapZoneTextButton_OnEnter();
|
||||
</OnEnter>
|
||||
<OnLeave>
|
||||
GameTooltip:Hide();
|
||||
</OnLeave>
|
||||
</Scripts>
|
||||
</Button>
|
||||
<Button name="MinimapToggleButton">
|
||||
<Size>
|
||||
<AbsDimension x="32" y="32"/>
|
||||
</Size>
|
||||
<Anchors>
|
||||
<Anchor point="CENTER" relativePoint="TOPRIGHT">
|
||||
<Offset>
|
||||
<AbsDimension x="-15" y="-13"/>
|
||||
</Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<Scripts>
|
||||
<OnClick>
|
||||
ToggleMinimap();
|
||||
</OnClick>
|
||||
</Scripts>
|
||||
<NormalTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Up"/>
|
||||
<PushedTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Down"/>
|
||||
<HighlightTexture alphaMode="ADD" file="Interface\Buttons\UI-Panel-MinimizeButton-Highlight"/>
|
||||
</Button>
|
||||
<Minimap name="Minimap" enableMouse="true">
|
||||
<Size>
|
||||
<AbsDimension x="140" y="140"/>
|
||||
</Size>
|
||||
<Anchors>
|
||||
<Anchor point="CENTER" relativePoint="TOP">
|
||||
<Offset>
|
||||
<AbsDimension x="9" y="-92"/>
|
||||
</Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<Frames>
|
||||
<!-- ref-Minimap.xml l.109-174 for the names, geometry and art — the tracking
|
||||
icon (the mail frame's nesting precedent: a child of the round Minimap
|
||||
WIDGET, whose TOPLEFT the (-15,0) anchor hangs off). Driven by
|
||||
PLAYER_AURAS_CHANGED →
|
||||
GetTrackingTexture(): the reference's aura-cache rebuild diverts a
|
||||
tracking-effect aura (Find Minerals, Track Beasts, …) out of the buff bar
|
||||
into a tracking global this icon shows (byte-verified — the {0x2c,0x2d,0x97}
|
||||
effect exclusion; `ui_aura` mirrors the law). Right-click cancels the
|
||||
tracking aura, exactly the reference's CancelTrackingBuff. -->
|
||||
<Frame name="MiniMapTrackingFrame" enableMouse="true" hidden="true">
|
||||
<Size>
|
||||
<AbsDimension x="33" y="33"/>
|
||||
</Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT">
|
||||
<Offset>
|
||||
<AbsDimension x="-15" y="0"/>
|
||||
</Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
<Texture name="MiniMapTrackingIcon">
|
||||
<Size>
|
||||
<AbsDimension x="26" y="26"/>
|
||||
</Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT">
|
||||
<Offset>
|
||||
<AbsDimension x="7" y="-6"/>
|
||||
</Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
</Texture>
|
||||
</Layer>
|
||||
<Layer level="OVERLAY">
|
||||
<Texture name="MiniMapTrackingBorder" file="Interface\Minimap\MiniMap-TrackingBorder">
|
||||
<Size>
|
||||
<AbsDimension x="64" y="64"/>
|
||||
</Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT"/>
|
||||
</Anchors>
|
||||
</Texture>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Scripts>
|
||||
<OnLoad>
|
||||
this:RegisterEvent("PLAYER_AURAS_CHANGED");
|
||||
</OnLoad>
|
||||
<OnEvent>
|
||||
MiniMapTrackingFrame_OnEvent();
|
||||
</OnEvent>
|
||||
<OnMouseUp>
|
||||
if ( arg1 == "RightButton" ) then
|
||||
CancelTrackingBuff();
|
||||
end
|
||||
</OnMouseUp>
|
||||
<OnEnter>
|
||||
MiniMapTrackingFrame_OnEnter();
|
||||
</OnEnter>
|
||||
<OnLeave>
|
||||
GameTooltip:Hide();
|
||||
</OnLeave>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
<!-- ref-Minimap.xml l.239-298, verbatim (decision 0544 P3) — the unread-mail
|
||||
icon. A child of the round Minimap WIDGET, not the cluster: its TOPRIGHT
|
||||
(21,-38) hangs off the 140×140 map's right edge, which sits well inside the
|
||||
cluster — hoisted to the cluster the same offset lands past the screen edge.
|
||||
Unprefixed (this file's own convention: every frame here keeps the
|
||||
reference's bare name, unlike the newer Benilla*-prefixed windows). -->
|
||||
<!-- **Two more of the reference's minimap icons, built and hidden** — the
|
||||
fourth time this posture has been the answer (1219's vertical multibars,
|
||||
1223's bonus bar, ReputationWatchBar, these). `ref-Minimap.xml` declares
|
||||
both `hidden="true"`: real on the client from load, shown only when there
|
||||
is a battleground queue or a meeting stone to advertise.
|
||||
|
||||
benilla drives neither, so they stay hidden — but addons reposition the
|
||||
whole icon set by NAME. `CleanMinimap.lua:209` is
|
||||
`getglobal(frameName):ClearAllPoints()` over a list that includes both, and
|
||||
it died on the nil; `MiniMapBattlefieldFrame` alone is 5 addons in the
|
||||
corrected ranking (1218). Their icon regions are declared with them, since
|
||||
an addon that moves a frame expects its named children to exist.
|
||||
|
||||
Geometry quoted from the reference: both 33x33 with an 18x18 icon at
|
||||
TOPLEFT (7,-6), Battlefield at BOTTOMLEFT (13,-13) of the cluster and
|
||||
MeetingStone at TOPRIGHT (23,-65). -->
|
||||
<Button name="MiniMapBattlefieldFrame" enableMouse="true" hidden="true">
|
||||
<Size><AbsDimension x="33" y="33"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="13" y="-13"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<!-- **All THREE of the reference's regions, on the reference's layers**
|
||||
(ref-Minimap.xml l.311-349). This block used to carry only the icon,
|
||||
which was the same half-step the note above warns against: "an addon
|
||||
that moves a frame expects its named children to exist" is right, and
|
||||
then one of the three was declared. pfUI's `modules/minimap.lua:80` is
|
||||
`MiniMapBattlefieldBorder:Hide()`, unguarded — it hides the ring so the
|
||||
icon sits flush on its square minimap — and pfQuest, pfQuest-turtle and
|
||||
ShaguDPS all embed that module.
|
||||
|
||||
The LAYERS are the reference's too, and they are not decoration: the
|
||||
icon is BACKGROUND and the border ARTWORK above it, which is the order
|
||||
a ring around an icon has to draw in. Ours had the icon on ARTWORK, so
|
||||
adding the border there would have raced it. -->
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
<Texture name="MiniMapBattlefieldIcon" file="Interface\BattlefieldFrame\UI-Battlefield-Icon">
|
||||
<Size><AbsDimension x="32" y="32"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="CENTER"><Offset><AbsDimension x="-1" y="0"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
</Texture>
|
||||
</Layer>
|
||||
<Layer level="ARTWORK">
|
||||
<Texture name="MiniMapBattlefieldBorder" file="Interface\Minimap\MiniMap-TrackingBorder">
|
||||
<Size><AbsDimension x="52" y="52"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"/></Anchors>
|
||||
</Texture>
|
||||
</Layer>
|
||||
<Layer level="BORDER">
|
||||
<!-- The queue-ready pulse. Nothing here drives it (benilla has no
|
||||
battleground queue), and it is declared for the same reason its
|
||||
two siblings are: `BattlegroundShine` is a global an addon can
|
||||
read, and its absence is a nil rather than a missing effect. -->
|
||||
<Texture name="BattlegroundShine" file="Interface\ComboFrame\ComboPoint" alphaMode="ADD">
|
||||
<Size><AbsDimension x="32" y="32"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="CENTER"><Offset><AbsDimension x="3" y="3"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<TexCoords left="0.5625" right="1" top="0" bottom="1"/>
|
||||
</Texture>
|
||||
</Layer>
|
||||
</Layers>
|
||||
</Button>
|
||||
|
||||
<Button name="MiniMapMeetingStoneFrame" enableMouse="true" hidden="true">
|
||||
<Size><AbsDimension x="33" y="33"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="23" y="-65"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Layers>
|
||||
<Layer level="ARTWORK">
|
||||
<Texture name="MiniMapMeetingStoneIcon" file="Interface\Icons\INV_Misc_Rune_06">
|
||||
<Size><AbsDimension x="18" y="18"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT"><Offset><AbsDimension x="7" y="-6"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
</Texture>
|
||||
</Layer>
|
||||
</Layers>
|
||||
</Button>
|
||||
|
||||
<Frame name="MiniMapMailFrame" enableMouse="true" hidden="true">
|
||||
<Size>
|
||||
<AbsDimension x="33" y="33"/>
|
||||
</Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPRIGHT">
|
||||
<Offset>
|
||||
<AbsDimension x="21" y="-38"/>
|
||||
</Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<Layers>
|
||||
<Layer level="ARTWORK">
|
||||
<Texture name="MiniMapMailIcon" file="Interface\Icons\INV_Letter_15">
|
||||
<Size>
|
||||
<AbsDimension x="18" y="18"/>
|
||||
</Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT">
|
||||
<Offset>
|
||||
<AbsDimension x="7" y="-6"/>
|
||||
</Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
</Texture>
|
||||
</Layer>
|
||||
<Layer level="OVERLAY">
|
||||
<Texture name="MiniMapMailBorder" file="Interface\Minimap\MiniMap-TrackingBorder">
|
||||
<Size>
|
||||
<AbsDimension x="52" y="52"/>
|
||||
</Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT"/>
|
||||
</Anchors>
|
||||
</Texture>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Scripts>
|
||||
<OnLoad>
|
||||
this:RegisterEvent("UPDATE_PENDING_MAIL");
|
||||
this:SetFrameLevel(this:GetFrameLevel()+1)
|
||||
</OnLoad>
|
||||
<OnEvent>
|
||||
MiniMapMailFrame_OnEvent();
|
||||
</OnEvent>
|
||||
<OnEnter>
|
||||
MiniMapMailFrame_OnEnter();
|
||||
</OnEnter>
|
||||
<OnLeave>
|
||||
GameTooltip:Hide();
|
||||
</OnLeave>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
<Frame name="MinimapBackdrop">
|
||||
<Size>
|
||||
<AbsDimension x="192" y="192"/>
|
||||
</Size>
|
||||
<Anchors>
|
||||
<Anchor point="CENTER" relativeTo="MinimapCluster">
|
||||
<Offset>
|
||||
<AbsDimension x="0" y="-20"/>
|
||||
</Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<Layers>
|
||||
<Layer level="ARTWORK">
|
||||
<Texture name="MinimapBorder" file="Interface\Minimap\UI-Minimap-Border">
|
||||
<TexCoords left="0.25" right="1.0" top="0.125" bottom="0.875"/>
|
||||
</Texture>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Frames>
|
||||
<Button name="MinimapZoomIn">
|
||||
<Size>
|
||||
<AbsDimension x="32" y="32"/>
|
||||
</Size>
|
||||
<Anchors>
|
||||
<Anchor point="CENTER">
|
||||
<Offset>
|
||||
<AbsDimension x="77" y="-13"/>
|
||||
</Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<Scripts>
|
||||
<OnClick>
|
||||
Minimap_ZoomInClick();
|
||||
</OnClick>
|
||||
</Scripts>
|
||||
<NormalTexture file="Interface\Minimap\UI-Minimap-ZoomInButton-Up"/>
|
||||
<PushedTexture file="Interface\Minimap\UI-Minimap-ZoomInButton-Down"/>
|
||||
<DisabledTexture file="Interface\Minimap\UI-Minimap-ZoomInButton-Disabled"/>
|
||||
<HighlightTexture alphaMode="ADD" file="Interface\Minimap\UI-Minimap-ZoomButton-Highlight"/>
|
||||
</Button>
|
||||
<Button name="MinimapZoomOut">
|
||||
<Size>
|
||||
<AbsDimension x="32" y="32"/>
|
||||
</Size>
|
||||
<Anchors>
|
||||
<Anchor point="CENTER">
|
||||
<Offset>
|
||||
<AbsDimension x="51" y="-41"/>
|
||||
</Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<Scripts>
|
||||
<OnClick>
|
||||
Minimap_ZoomOutClick();
|
||||
</OnClick>
|
||||
</Scripts>
|
||||
<NormalTexture file="Interface\Minimap\UI-Minimap-ZoomOutButton-Up"/>
|
||||
<PushedTexture file="Interface\Minimap\UI-Minimap-ZoomOutButton-Down"/>
|
||||
<DisabledTexture file="Interface\Minimap\UI-Minimap-ZoomOutButton-Disabled"/>
|
||||
<HighlightTexture alphaMode="ADD" file="Interface\Minimap\UI-Minimap-ZoomButton-Highlight"/>
|
||||
</Button>
|
||||
</Frames>
|
||||
</Frame>
|
||||
</Frames>
|
||||
<Scripts>
|
||||
<!-- ref-Minimap.xml l.498: the ping is the widget's own OnMouseUp, on any
|
||||
button (the reference tests no button here either). -->
|
||||
<OnMouseUp>
|
||||
Minimap_OnClick();
|
||||
</OnMouseUp>
|
||||
</Scripts>
|
||||
</Minimap>
|
||||
</Frames>
|
||||
<Scripts>
|
||||
<OnLoad>
|
||||
Minimap_Update();
|
||||
-- Seed the +/- state from the live zoom (default index 3 ⇒ both enabled) rather than
|
||||
-- hardcoding a zoom-0 "can't zoom out" — the client's default is 3, not the widest.
|
||||
Minimap_UpdateZoom();
|
||||
this:RegisterEvent("MINIMAP_ZONE_CHANGED");
|
||||
this:RegisterEvent("MINIMAP_UPDATE_ZOOM");
|
||||
</OnLoad>
|
||||
<OnEvent>
|
||||
if ( event == "MINIMAP_UPDATE_ZOOM" ) then
|
||||
Minimap_UpdateZoom();
|
||||
else
|
||||
Minimap_Update();
|
||||
end
|
||||
</OnEvent>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
</Ui>
|
||||
@@ -70,6 +70,8 @@
|
||||
-- arrived on both clients, yet no window). pushable=1 lets a pushable=0 panel (a merchant)
|
||||
-- shove it to center, the reference's browse-while-trading arbitration.
|
||||
UIPanelWindows["TradeFrame"] = { area = "left", pushable = 1 }
|
||||
-- UIParent.lua l.38, verbatim — the battlemaster's instance list (decision 1974).
|
||||
UIPanelWindows["BattlefieldFrame"] = { area = "left", pushable = 0 }
|
||||
-- The trainer window (decision 0237 phase 3, TrainerFrame.xml): an NPC session exactly like
|
||||
-- Gossip/Merchant above (area "left", pushable 0) — competes for/replaces them at the slot.
|
||||
UIPanelWindows["BenillaTrainerFrame"] = { area = "left", pushable = 0 }
|
||||
|
||||
@@ -1,953 +0,0 @@
|
||||
<!--
|
||||
The fullscreen world map — our implementation of the behaviour the 1.12 FrameXML
|
||||
WorldMapFrame.xml/.lua specifies (decision 0203 phase 2; decision 0221 settled the layout on
|
||||
the fullscreen sheet — the windowed / 1.14-header direction of 0214/0217/0219 is parked, and
|
||||
this faithful fullscreen sheet is the baseline we return to). The reference is the SPEC, not
|
||||
the source of this text: the names are its contract (globals addons call, frame and template
|
||||
names, art paths, every constant and texcoord) and the code that uses them is ours. The map
|
||||
is pure FrameXML over the worldmap host API (benilla-ui script/worldmap.rs): detail tiles are
|
||||
plain textures whose art the Lua swaps per selection (GetMapInfo →
|
||||
Interface\WorldMap\<name>\<name>1..12), the player blip is positioned from
|
||||
GetPlayerMapPosition in the same 0..1 map space the reference uses, navigation is
|
||||
SetMapZoom/-ToCurrentZone/ProcessMapClick.
|
||||
|
||||
IN (this file): the FULLSCREEN frame + blackout, the 12-piece parchment chrome, the title,
|
||||
the 1002×668 detail frame with its 12 tiles + ADD highlight, the discovery-overlay pool
|
||||
(0222 — the explored sub-areas paint their detail over the base parchment), the click/hover
|
||||
button layer, the continent/zone dropdowns (over UIDropDownMenu.xml's kit), the zoom-out
|
||||
button (panel-button shape — our engine ships no UIPanelButtonTemplate), the close X (the
|
||||
QuestLog/BagFrame hand-drawn shape), the area label pair, and the player arrow (MinimapArrow
|
||||
art spun by SetRotation(GetPlayerFacing()) — the reference renders an engine arrow model;
|
||||
0203 flags the stand-in).
|
||||
|
||||
OUT (later slices, per 0203): the ping child; the magnifying-glass zoom-out shortcut (redundant
|
||||
with the button); SetupFullscreenScale (the map renders at its native 1024×768 centered; a scale
|
||||
pass is a taste knob).
|
||||
|
||||
The raid pool moved half-way out of that list and the halves are worth separating. The 40
|
||||
`WorldMapRaid` frames are now DECLARED, as inherits= instances of WorldMapRaidUnitTemplate
|
||||
alongside the four party ones, because the published names and their `$parentIcon` children are
|
||||
what addons index — pfUI's mapcolors walks all 44 and died on the first missing icon. What is
|
||||
still OUT is POSITIONING them: `GetPlayerMapPosition` answers (0,0) for every `raid` token, and
|
||||
the reference's second arm (team members in a battleground) needs GetNumBattlefieldPositions and
|
||||
GetBattlefieldPosition, neither of which we bind. So they exist, hidden, and WorldMapParty_Update
|
||||
drives the party arm alone.
|
||||
|
||||
WorldMapTooltip was in this OUT list — "only unit blips used it" — and is now declared at the
|
||||
foot of the file. That reason was about the reference's own consumers and never about the
|
||||
published NAME, which six corpus addons index directly and pfUI dies on at file scope.
|
||||
|
||||
Deviations from the reference, deliberate: BlackoutWorld fills via anchors instead of the
|
||||
GetScreenWidth resize dance (same pixels); OnShow calls WorldMapFrame_Update() directly after
|
||||
SetMapToCurrentZone (the reference repaints off the synchronous WORLD_MAP_UPDATE; ours fires
|
||||
next tick — the direct call keeps the open paint flicker-free); ESC/M close via the app key
|
||||
arm, not OnKeyDown+RunBinding (no binding system).
|
||||
-->
|
||||
<Ui xmlns="http://www.blizzard.com/wow/ui/">
|
||||
<Script><![CDATA[
|
||||
NUM_WORLDMAP_DETAIL_TILES = 12;
|
||||
NUM_WORLDMAP_OVERLAYS = 0;
|
||||
NUM_WORLDMAP_POIS = 0;
|
||||
|
||||
-- ref WorldMapFrame.lua l.509-515 is the spec. Through the PANEL MANAGER, not a bare
|
||||
-- Show/Hide: the map is registered `area = "full"` (UiPanels.xml), which is what makes it
|
||||
-- take the screen from the other windows on the way in and — the reason this changed —
|
||||
-- what stops it opening from behind the game menu, the one window that still could
|
||||
-- (director-reported, decision 0674).
|
||||
function ToggleWorldMap()
|
||||
if ( WorldMapFrame:IsVisible() ) then
|
||||
HideUIPanel(WorldMapFrame);
|
||||
else
|
||||
ShowUIPanel(WorldMapFrame);
|
||||
end
|
||||
end
|
||||
|
||||
-- Fill the screen: scale the native 1024×768 sheet up until it touches the constraining
|
||||
-- screen edge — full height on any screen wider than 4:3 (the common case), full width on
|
||||
-- a narrower one (ref SetupFullscreenScale, deferred at 0203, wired at 0224). The blackout
|
||||
-- is a SEPARATE unscaled frame behind the sheet, so only the sheet grows.
|
||||
function SetupFullscreenScale()
|
||||
local xScale = GetScreenWidth() / 1024;
|
||||
local yScale = GetScreenHeight() / 768;
|
||||
local scale = xScale;
|
||||
if ( yScale < xScale ) then
|
||||
scale = yScale;
|
||||
end
|
||||
WorldMapFrame:SetScale(scale);
|
||||
end
|
||||
|
||||
-- One overlay tile's extent along one axis: how many pixels of art it draws, and how wide
|
||||
-- the FILE holding them is. Every tile is a full 256 except the last of a row or column,
|
||||
-- which carries whatever remains of the overlay — and its file is padded up to the next
|
||||
-- power of two (16 the smallest the art uses), so that tile shows only the leading
|
||||
-- pixels/file fraction of it. Both axes want the same answer, so they ask the same way.
|
||||
local function overlayTileExtent(index, count, span)
|
||||
if ( index < count ) then
|
||||
return 256, 256;
|
||||
end
|
||||
local pixels = mod(span, 256);
|
||||
if ( pixels == 0 ) then
|
||||
pixels = 256;
|
||||
end
|
||||
local file = 16;
|
||||
while ( file < pixels ) do
|
||||
file = file * 2;
|
||||
end
|
||||
return pixels, file;
|
||||
end
|
||||
|
||||
-- The repaint (spec: ref WorldMapFrame.lua WorldMapFrame_Update): the tile swap, the
|
||||
-- discovery-overlay pool, and the zoom-out arming (POI landmarks are still deferred). The
|
||||
-- "World" fallback is the reference's own hack for the nil GetMapInfo at the world level,
|
||||
-- kept because our host answers nil at that level for the same reason.
|
||||
function WorldMapFrame_Update()
|
||||
local mapFileName = GetMapInfo();
|
||||
if ( not mapFileName ) then
|
||||
mapFileName = "World";
|
||||
end
|
||||
for i=1, NUM_WORLDMAP_DETAIL_TILES, 1 do
|
||||
getglobal("WorldMapDetailTile"..i):SetTexture("Interface\\WorldMap\\"..mapFileName.."\\"..mapFileName..i);
|
||||
end
|
||||
|
||||
-- The discovery overlays (spec: ref WorldMapFrame.lua, "Setup the overlays"): the base
|
||||
-- tiles are the unexplored parchment, and each REVEALED sub-area (GetNumMapOverlays /
|
||||
-- GetMapOverlayInfo, filtered host-side by the explored bitset) paints its detailed
|
||||
-- art over them. That art is cut into a grid of 256-px tiles, numbered 1..n across
|
||||
-- then down, which we lay onto ARTWORK textures pooled on WorldMapDetailFrame — the
|
||||
-- pool grows on demand, never shrinks, and the tail left over from a busier map is
|
||||
-- parked hidden below. Offsets are native pixels: the map renders at its native
|
||||
-- 1024×768 (no WORLDMAP_SX/SY scale, unlike the parked windowed layout), so the
|
||||
-- overlays share the detail tiles' coordinate space untouched.
|
||||
local used = 0;
|
||||
for overlay = 1, GetNumMapOverlays() do
|
||||
local art, artWidth, artHeight, originX, originY = GetMapOverlayInfo(overlay);
|
||||
local columns = ceil(artWidth/256);
|
||||
local rows = ceil(artHeight/256);
|
||||
local wanted = used + (columns * rows);
|
||||
if ( wanted > NUM_WORLDMAP_OVERLAYS ) then
|
||||
for slot = NUM_WORLDMAP_OVERLAYS + 1, wanted do
|
||||
WorldMapDetailFrame:CreateTexture("WorldMapOverlay"..slot, "ARTWORK");
|
||||
end
|
||||
NUM_WORLDMAP_OVERLAYS = wanted;
|
||||
end
|
||||
-- Tile n of the overlay is file <art>n, and its place in the grid follows from n.
|
||||
for tile = 1, columns * rows do
|
||||
local column = mod(tile - 1, columns) + 1;
|
||||
local row = ceil(tile / columns);
|
||||
local wide, fileWide = overlayTileExtent(column, columns, artWidth);
|
||||
local tall, fileTall = overlayTileExtent(row, rows, artHeight);
|
||||
used = used + 1;
|
||||
local texture = getglobal("WorldMapOverlay"..used);
|
||||
texture:SetWidth(wide);
|
||||
texture:SetHeight(tall);
|
||||
texture:SetTexCoord(0, wide/fileWide, 0, tall/fileTall);
|
||||
texture:ClearAllPoints();
|
||||
texture:SetPoint("TOPLEFT", originX + (256 * (column - 1)), -(originY + (256 * (row - 1))));
|
||||
texture:SetTexture(art..tile);
|
||||
texture:Show();
|
||||
end
|
||||
end
|
||||
for slot = used + 1, NUM_WORLDMAP_OVERLAYS do
|
||||
getglobal("WorldMapOverlay"..slot):Hide();
|
||||
end
|
||||
|
||||
-- The POI icons (spec: ref WorldMapFrame.lua's landmark pass). One frame per landmark
|
||||
-- the host reports for the displayed map, grown on demand from WorldMapPOITemplate and
|
||||
-- never shrunk — the tail from a busier map is parked hidden, like the overlay pool
|
||||
-- above. The host reports the AreaPOI.dbc rows the displayed level admits (town and
|
||||
-- city icons, a capital's "Under Attack" marker, the Eastern Plaguelands towers),
|
||||
-- then the guard-directions marker (SMSG_GOSSIP_POI) last.
|
||||
local numPOIs = GetNumMapLandmarks();
|
||||
if ( numPOIs > NUM_WORLDMAP_POIS ) then
|
||||
for slot = NUM_WORLDMAP_POIS + 1, numPOIs do
|
||||
CreateFrame("Button", "WorldMapFramePOI"..slot, WorldMapButton, "WorldMapPOITemplate");
|
||||
end
|
||||
NUM_WORLDMAP_POIS = numPOIs;
|
||||
end
|
||||
for slot = 1, NUM_WORLDMAP_POIS do
|
||||
local poi = getglobal("WorldMapFramePOI"..slot);
|
||||
if ( slot > numPOIs ) then
|
||||
poi:Hide();
|
||||
else
|
||||
local name, description, textureIndex, x, y = GetMapLandmarkInfo(slot);
|
||||
WorldMapPOI_SetTexture(poi, textureIndex);
|
||||
poi.name = name;
|
||||
poi.description = description;
|
||||
poi:ClearAllPoints();
|
||||
poi:SetPoint("CENTER", "WorldMapDetailFrame", "TOPLEFT",
|
||||
x * WorldMapDetailFrame:GetWidth(), -y * WorldMapDetailFrame:GetHeight());
|
||||
poi:Show();
|
||||
end
|
||||
end
|
||||
|
||||
if ( GetCurrentMapContinent() == 0 ) then
|
||||
WorldMapZoomOutButton:Disable();
|
||||
else
|
||||
WorldMapZoomOutButton:Enable();
|
||||
end
|
||||
end
|
||||
|
||||
-- Point a POI frame's texture at its cell of Interface\Minimap\POIIcons — an 8x8 grid of
|
||||
-- icons indexed across then down, the same atlas and the same indexing the minimap's
|
||||
-- landmark blips use (the corpse marker below hardcodes its own cell of it).
|
||||
function WorldMapPOI_SetTexture(poi, textureIndex)
|
||||
local column = mod(textureIndex, 8);
|
||||
local row = floor(textureIndex / 8);
|
||||
getglobal(poi:GetName().."Texture"):SetTexCoord(
|
||||
column * 0.125, (column + 1) * 0.125, row * 0.125, (row + 1) * 0.125);
|
||||
end
|
||||
|
||||
-- Every marker on the sheet — the corpse, a POI — names itself in the shared GameTooltip,
|
||||
-- and every one of them wants the plate opening AWAY from the edge it sits nearest, so the
|
||||
-- side pick lives here once instead of in each OnEnter. `subtitle` is the second line the
|
||||
-- landmarks that carry a live status use; the rest pass nothing.
|
||||
-- The party blips (ref WorldMapFrame.lua:391-403's party arm, report B320): the same
|
||||
-- (0,0)-means-hide law as the player and the corpse, one frame per slot.
|
||||
--
|
||||
-- It rides the per-frame pass beside those two rather than WorldMapFrame_Update, where the
|
||||
-- reference keeps it: a member's position moves continuously and the map has no event that
|
||||
-- fires when it does, so placing it on the repaint alone would freeze every blip between
|
||||
-- roster changes. Its own function so the live pass and the tests enter it identically.
|
||||
--
|
||||
-- The reference's RAID arm is not taken — see the blip frames' note.
|
||||
function WorldMapParty_Update()
|
||||
for i = 1, 4 do
|
||||
local blip = getglobal("WorldMapParty" .. i)
|
||||
local px, py = GetPlayerMapPosition("party" .. i)
|
||||
if ( px == 0 and py == 0 ) then
|
||||
blip:Hide()
|
||||
else
|
||||
px = px * WorldMapDetailFrame:GetWidth()
|
||||
py = -py * WorldMapDetailFrame:GetHeight()
|
||||
blip:SetPoint("CENTER", "WorldMapDetailFrame", "TOPLEFT", px, py)
|
||||
blip:Show()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Ref WorldMapUnit_OnEnter (WorldMapFrame.lua l.457-499) transcribed. Three things it does
|
||||
-- that the one-line version it replaces did not: it takes NO argument and reads `this` (the
|
||||
-- reference's calling convention, and now ours, because the name is the reference's and an
|
||||
-- addon may call it); it drives WorldMapTooltip, which is what the blip templates' OnLeave
|
||||
-- hides; and it stacks the name of EVERY blip under the cursor rather than one, because
|
||||
-- blips overlap whenever a group stands together.
|
||||
--
|
||||
-- No `or UNKNOWN` fallback, deliberately: a blip is only reached here when it is VISIBLE,
|
||||
-- and it is only visible when GetPlayerMapPosition answered real coordinates for its unit,
|
||||
-- so UnitName cannot be nil without a contract already being broken. A guard there would
|
||||
-- turn that into a silently wrong tooltip instead of a raise (1203).
|
||||
--
|
||||
-- `unitButton.name` is the battleground-team label the reference sets for someone on your
|
||||
-- team but in neither your party nor your raid; our positioning pass never sets it (no
|
||||
-- GetBattlefieldPosition binding), so that branch is dead here and kept because it is the
|
||||
-- reference's own fallback order.
|
||||
function WorldMapUnit_OnEnter()
|
||||
local x = this:GetCenter()
|
||||
local parentX = this:GetParent():GetCenter()
|
||||
if ( x > parentX ) then
|
||||
WorldMapTooltip:SetOwner(this, "ANCHOR_LEFT")
|
||||
else
|
||||
WorldMapTooltip:SetOwner(this, "ANCHOR_RIGHT")
|
||||
end
|
||||
|
||||
local unitButton
|
||||
local newLineString = ""
|
||||
local tooltipText = ""
|
||||
if ( MouseIsOver(WorldMapPlayer) ) then
|
||||
tooltipText = UnitName(WorldMapPlayer.unit)
|
||||
newLineString = "\n"
|
||||
end
|
||||
for i = 1, MAX_PARTY_MEMBERS do
|
||||
unitButton = getglobal("WorldMapParty" .. i)
|
||||
if ( unitButton:IsVisible() and MouseIsOver(unitButton) ) then
|
||||
tooltipText = tooltipText .. newLineString .. UnitName(unitButton.unit)
|
||||
newLineString = "\n"
|
||||
end
|
||||
end
|
||||
for i = 1, MAX_RAID_MEMBERS do
|
||||
unitButton = getglobal("WorldMapRaid" .. i)
|
||||
if ( unitButton:IsVisible() and MouseIsOver(unitButton) ) then
|
||||
if ( unitButton.name ) then
|
||||
tooltipText = tooltipText .. newLineString .. unitButton.name
|
||||
else
|
||||
tooltipText = tooltipText .. newLineString .. UnitName(unitButton.unit)
|
||||
end
|
||||
newLineString = "\n"
|
||||
end
|
||||
end
|
||||
WorldMapTooltip:SetText(tooltipText)
|
||||
WorldMapTooltip:Show()
|
||||
end
|
||||
|
||||
function WorldMapMarker_ShowTooltip(marker, title, subtitle)
|
||||
local markerX = marker:GetCenter();
|
||||
local sheetX = marker:GetParent():GetCenter();
|
||||
if ( markerX > sheetX ) then
|
||||
GameTooltip:SetOwner(marker, "ANCHOR_LEFT");
|
||||
else
|
||||
GameTooltip:SetOwner(marker, "ANCHOR_RIGHT");
|
||||
end
|
||||
GameTooltip:SetText(title);
|
||||
if ( subtitle and subtitle ~= "" ) then
|
||||
GameTooltip:AddLine(subtitle, 1, 1, 1);
|
||||
end
|
||||
GameTooltip:Show();
|
||||
end
|
||||
|
||||
-- The reference's rule: zone view → its continent; continent view → the world.
|
||||
function WorldMapZoomOutButton_OnClick()
|
||||
if ( GetCurrentMapZone() ~= 0 ) then
|
||||
SetMapZoom(GetCurrentMapContinent());
|
||||
else
|
||||
SetMapZoom(0);
|
||||
end
|
||||
end
|
||||
|
||||
-- ── The continent/zone dropdowns (the reference's wiring; its `arg.n` vararg walk is
|
||||
-- written here as select("#", ...), the 5.1-native spelling and this tree's house style
|
||||
-- for it). ────────────────────────────────────────────────────────────────────────────
|
||||
function WorldMapContinentsDropDown_OnLoad()
|
||||
UIDropDownMenu_Initialize(this, WorldMapContinentsDropDown_Initialize);
|
||||
UIDropDownMenu_SetWidth(130);
|
||||
end
|
||||
|
||||
function WorldMapContinentsDropDown_Initialize()
|
||||
WorldMapFrame_LoadContinents(GetMapContinents());
|
||||
end
|
||||
|
||||
function WorldMapFrame_LoadContinents(...)
|
||||
local info;
|
||||
for i=1, select("#", ...), 1 do
|
||||
info = {};
|
||||
info.text = select(i, ...);
|
||||
info.func = WorldMapContinentButton_OnClick;
|
||||
UIDropDownMenu_AddButton(info);
|
||||
end
|
||||
end
|
||||
|
||||
function WorldMapZoneDropDown_OnLoad()
|
||||
UIDropDownMenu_Initialize(this, WorldMapZoneDropDown_Initialize);
|
||||
UIDropDownMenu_SetWidth(130);
|
||||
end
|
||||
|
||||
function WorldMapZoneDropDown_Initialize()
|
||||
WorldMapFrame_LoadZones(GetMapZones(GetCurrentMapContinent()));
|
||||
end
|
||||
|
||||
function WorldMapFrame_LoadZones(...)
|
||||
local info;
|
||||
for i=1, select("#", ...), 1 do
|
||||
info = {};
|
||||
info.text = select(i, ...);
|
||||
info.func = WorldMapZoneButton_OnClick;
|
||||
UIDropDownMenu_AddButton(info);
|
||||
end
|
||||
end
|
||||
|
||||
function WorldMapContinentButton_OnClick()
|
||||
UIDropDownMenu_SetSelectedID(WorldMapContinentDropDown, this:GetID());
|
||||
SetMapZoom(this:GetID());
|
||||
end
|
||||
|
||||
function WorldMapZoneButton_OnClick()
|
||||
UIDropDownMenu_SetSelectedID(WorldMapZoneDropDown, this:GetID());
|
||||
SetMapZoom(GetCurrentMapContinent(), this:GetID());
|
||||
end
|
||||
|
||||
function WorldMap_UpdateZoneDropDownText()
|
||||
if ( GetCurrentMapZone() == 0 ) then
|
||||
UIDropDownMenu_ClearAll(WorldMapZoneDropDown);
|
||||
else
|
||||
UIDropDownMenu_SetSelectedID(WorldMapZoneDropDown, GetCurrentMapZone());
|
||||
end
|
||||
end
|
||||
|
||||
function WorldMap_UpdateContinentDropDownText()
|
||||
if ( GetCurrentMapContinent() == 0 ) then
|
||||
UIDropDownMenu_ClearAll(WorldMapContinentDropDown);
|
||||
else
|
||||
UIDropDownMenu_SetSelectedID(WorldMapContinentDropDown, GetCurrentMapContinent());
|
||||
end
|
||||
end
|
||||
|
||||
-- Where the cursor sits inside a map-sized frame, as a fraction of it measured from the
|
||||
-- TOPLEFT: (0,0) the top-left corner of the map art, (1,1) the bottom-right. Both sides of
|
||||
-- the host's map API — the click (ProcessMapClick) and the hover (UpdateMapHighlight) —
|
||||
-- take a position in exactly that space, and the cursor arrives in screen pixels with the
|
||||
-- y axis pointing the other way, so this is the one conversion between them: undo the
|
||||
-- frame's effective scale, then measure from its edges, which a frame gives up as its
|
||||
-- center and its size (each edge being half the size away from the center).
|
||||
local function cursorMapPosition(frame)
|
||||
local cursorX, cursorY = GetCursorPosition();
|
||||
local scale = frame:GetEffectiveScale();
|
||||
local midX, midY = frame:GetCenter();
|
||||
local frameWidth = frame:GetWidth();
|
||||
local frameHeight = frame:GetHeight();
|
||||
local fromLeft = (cursorX / scale) - (midX - (frameWidth/2));
|
||||
local fromTop = (midY + (frameHeight/2)) - (cursorY / scale);
|
||||
return fromLeft / frameWidth, fromTop / frameHeight;
|
||||
end
|
||||
|
||||
-- Left click drills into whatever the host finds at that map position; every other button
|
||||
-- is the zoom-out shortcut. ref WorldMapFrame.lua l.277-296. The SECOND argument is the button the click landed on,
|
||||
-- defaulting to `this` — restored in 1793's sweep, having been dropped in favour of naming
|
||||
-- `WorldMapButton` directly.
|
||||
--
|
||||
-- Equivalent for 1.12's own caller, which passes exactly that button
|
||||
-- (`WorldMapFrame.lua:174`) — and not equivalent for anyone else, which is the whole
|
||||
-- reason the reference takes it: a caller aiming the click at a different map surface was
|
||||
-- silently redirected to the main one, because Lua discards a surplus argument without
|
||||
-- complaint.
|
||||
function WorldMapButton_OnClick(mouseButton, button)
|
||||
CloseDropDownMenus();
|
||||
if ( mouseButton == "LeftButton" ) then
|
||||
if ( not button ) then
|
||||
button = this
|
||||
end
|
||||
ProcessMapClick(cursorMapPosition(button));
|
||||
else
|
||||
WorldMapZoomOutButton_OnClick();
|
||||
end
|
||||
end
|
||||
|
||||
-- The per-frame hover pass (spec: ref WorldMapButton_OnUpdate), reduced to the area
|
||||
-- highlight + the player blip (raid/flags/ping are still deferred; the party blips
|
||||
-- landed with B320 and ride WorldMapFrame_Update, not this pass).
|
||||
-- UpdateMapHighlight is a stub until the area-bitmap source lands (wow-re dispatch) — a
|
||||
-- nil area/art takes the hide paths.
|
||||
function WorldMapButton_OnUpdate(elapsed)
|
||||
local width = this:GetWidth();
|
||||
local height = this:GetHeight();
|
||||
local area, art, cropX, cropY, sizeX, sizeY, atX, atY =
|
||||
UpdateMapHighlight(cursorMapPosition(this));
|
||||
WorldMapFrameAreaLabel:SetText(area or "");
|
||||
if ( art ) then
|
||||
-- The sub-area under the cursor lights up: <zone>Highlight holds one shape per
|
||||
-- sub-area, of which crop names the fraction actually used (the file is padded to
|
||||
-- a power of two), while size and at are the shape's extent and top-left corner as
|
||||
-- fractions of the map — so both scale by the button's pixel size. Nothing to draw
|
||||
-- for a degenerate shape, but the art is still swapped in for the next frame.
|
||||
WorldMapHighlight:SetTexCoord(0, cropX, 0, cropY);
|
||||
WorldMapHighlight:SetTexture("Interface\\WorldMap\\"..art.."\\"..art.."Highlight");
|
||||
local drawWidth = sizeX * width;
|
||||
local drawHeight = sizeY * height;
|
||||
if ( drawWidth > 0 and drawHeight > 0 ) then
|
||||
WorldMapHighlight:SetWidth(drawWidth);
|
||||
WorldMapHighlight:SetHeight(drawHeight);
|
||||
WorldMapHighlight:SetPoint("TOPLEFT", "WorldMapDetailFrame", "TOPLEFT", atX * width, -atY * height);
|
||||
WorldMapHighlight:Show();
|
||||
end
|
||||
else
|
||||
WorldMapHighlight:Hide();
|
||||
end
|
||||
-- The player blip: the reference's engine arrow, as our rotating texture stand-in.
|
||||
WorldMapPlayerIcon:SetRotation(GetPlayerFacing());
|
||||
local playerX, playerY = GetPlayerMapPosition("player");
|
||||
if ( playerX == 0 and playerY == 0 ) then
|
||||
WorldMapPlayer:Hide();
|
||||
else
|
||||
playerX = playerX * WorldMapDetailFrame:GetWidth();
|
||||
playerY = -playerY * WorldMapDetailFrame:GetHeight();
|
||||
WorldMapPlayer:SetPoint("CENTER", "WorldMapDetailFrame", "TOPLEFT", playerX, playerY);
|
||||
WorldMapPlayer:Show();
|
||||
end
|
||||
WorldMapParty_Update()
|
||||
-- Position corpse (ref WorldMapFrame.lua:443-452 — the law is that (0,0) means hide).
|
||||
local corpseX, corpseY = GetCorpseMapPosition();
|
||||
if ( corpseX == 0 and corpseY == 0 ) then
|
||||
WorldMapCorpse:Hide();
|
||||
else
|
||||
corpseX = corpseX * WorldMapDetailFrame:GetWidth();
|
||||
corpseY = -corpseY * WorldMapDetailFrame:GetHeight();
|
||||
WorldMapCorpse:SetPoint("CENTER", "WorldMapDetailFrame", "TOPLEFT", corpseX, corpseY);
|
||||
WorldMapCorpse:Show();
|
||||
end
|
||||
end
|
||||
]]></Script>
|
||||
<!-- The fullscreen blackout — a separate, UNSCALED frame behind the map sheet (0224). The map
|
||||
itself (WorldMapFrame) is now the native 1024×768 sheet that SetupFullscreenScale grows to
|
||||
fill the screen; the blackout stays screen-sized so the world never shows through the
|
||||
margins, and eats clicks so nothing leaks to the world behind it. -->
|
||||
<!-- BlackoutWorld — **the reference's name**, which this frame did not carry until now.
|
||||
|
||||
The reference declares it as a `<Texture>` in WorldMapFrame's own BACKGROUND layer
|
||||
(WorldMapFrame.xml l.8: 1024x768, BOTTOMLEFT, solid black); ours is a separate
|
||||
mouse-enabled fullscreen Frame, which is the deliberate deviation the header records —
|
||||
it blacks the world out AND eats the clicks the reference's fullscreen map frame would
|
||||
have taken. That structural choice stands.
|
||||
|
||||
What was wrong was the NAME. `WorldMapBlackout` was ours; `BlackoutWorld` is the published
|
||||
global, and four corpus addons index it directly — pfUI calls `BlackoutWorld:Hide()` at
|
||||
`modules/map.lua:109` and died there. This file's own header has been calling it
|
||||
"BlackoutWorld" in prose the whole time while the code said otherwise.
|
||||
|
||||
**The kind still differs and is not papered over**: `GetObjectType()` answers "Frame"
|
||||
here and "Texture" in the reference. Every corpus caller only Shows/Hides it, which both
|
||||
kinds answer identically; an addon that asked its type would see the deviation, and that
|
||||
is the honest cost of the structural choice above.
|
||||
|
||||
**And it carries NO `parent=`, for the reason WorldMapFrame does not** (see that frame's
|
||||
note below). The reference's blackout is a region *inside* WorldMapFrame, so it belongs to
|
||||
the very panel `SetFullScreenFrame` shows and cannot be reached by the `UIParent:Hide()`
|
||||
that showing it performs. Ours is a separate frame — the structural deviation above — and
|
||||
while that frame hung off `UIParent` it inherited the opposite property: opening the map
|
||||
hid the blackout, and the 3D world came back through the margins beside the 4:3 sheet
|
||||
(director-reported, decision 1757). Top-level restores the reference's own relationship;
|
||||
the TOPLEFT/BOTTOMRIGHT pair then pins to the screen root, which is the same rect
|
||||
`parent="UIParent"` was resolving to. -->
|
||||
<Frame name="BlackoutWorld" frameStrata="FULLSCREEN" hidden="true" enableMouse="true">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT"/>
|
||||
<Anchor point="BOTTOMRIGHT"/>
|
||||
</Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
<Texture setAllPoints="true"><Color r="0.0" g="0.0" b="0.0"/></Texture>
|
||||
</Layer>
|
||||
</Layers>
|
||||
</Frame>
|
||||
<!-- One POI icon on the map (spec: ref WorldMapPOITemplate, WorldMapFrameTemplates.xml): a
|
||||
16x16 cell of Interface\Minimap\POIIcons picked by the landmark's own textureIndex, with a
|
||||
name/description tooltip. Grown on demand by WorldMapFrame_Update into WorldMapButton. -->
|
||||
<Button name="WorldMapPOITemplate" virtual="true" enableMouse="true">
|
||||
<Size><AbsDimension x="16" y="16"/></Size>
|
||||
<Layers>
|
||||
<Layer level="ARTWORK">
|
||||
<Texture name="$parentTexture" file="Interface\Minimap\POIIcons" setAllPoints="true"/>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Scripts>
|
||||
<OnEnter>
|
||||
WorldMapMarker_ShowTooltip(this, this.name, this.description);
|
||||
</OnEnter>
|
||||
<OnLeave>
|
||||
GameTooltip:Hide();
|
||||
</OnLeave>
|
||||
</Scripts>
|
||||
</Button>
|
||||
<!--
|
||||
**No `parent=`, deliberately, and the reference is the same.** `WorldMapFrame` is an
|
||||
`area = "full"` panel, so showing it runs `SetFullScreenFrame` — which hides `UIParent`.
|
||||
A world map parented to `UIParent` is therefore hidden by the very call that shows it:
|
||||
`Show()` sets its own shown flag and the cascade keeps `IsVisible()` false.
|
||||
|
||||
benilla carried `parent="UIParent"` here (the reference does not) and got away with it for
|
||||
as long as `SetFullScreenFrame` had its `UIParent:Hide()` line dropped. Decision 1734
|
||||
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" enableKeyboard="true" frameStrata="FULLSCREEN_DIALOG" hidden="true" enableMouse="true">
|
||||
<Size><AbsDimension x="1024" y="768"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="CENTER"/>
|
||||
</Anchors>
|
||||
<Layers>
|
||||
<Layer level="ARTWORK">
|
||||
<Texture file="Interface\WorldMap\UI-WorldMap-Top1">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="CENTER"><Offset><AbsDimension x="-512" y="384"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
<Texture file="Interface\WorldMap\UI-WorldMap-Top2">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="CENTER"><Offset><AbsDimension x="-256" y="384"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
<Texture file="Interface\WorldMap\UI-WorldMap-Top3">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="CENTER"><Offset><AbsDimension x="0" y="384"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
<Texture file="Interface\WorldMap\UI-WorldMap-Top4">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="CENTER"><Offset><AbsDimension x="256" y="384"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
<Texture file="Interface\WorldMap\UI-WorldMap-Middle1">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="CENTER"><Offset><AbsDimension x="-512" y="128"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
<Texture file="Interface\WorldMap\UI-WorldMap-Middle2">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="CENTER"><Offset><AbsDimension x="-256" y="128"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
<Texture file="Interface\WorldMap\UI-WorldMap-Middle3">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="CENTER"><Offset><AbsDimension x="0" y="128"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
<Texture file="Interface\WorldMap\UI-WorldMap-Middle4">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="CENTER"><Offset><AbsDimension x="256" y="128"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
<Texture file="Interface\WorldMap\UI-WorldMap-Bottom1">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="CENTER"><Offset><AbsDimension x="-512" y="-128"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
<Texture file="Interface\WorldMap\UI-WorldMap-Bottom2">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="CENTER"><Offset><AbsDimension x="-256" y="-128"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
<Texture file="Interface\WorldMap\UI-WorldMap-Bottom3">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="CENTER"><Offset><AbsDimension x="0" y="-128"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
<Texture file="Interface\WorldMap\UI-WorldMap-Bottom4">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="CENTER"><Offset><AbsDimension x="256" y="-128"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
<FontString inherits="GameFontNormal" text="World Map">
|
||||
<Anchors><Anchor point="CENTER"><Offset><AbsDimension x="0" y="372"/></Offset></Anchor></Anchors>
|
||||
</FontString>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Frames>
|
||||
<Frame name="WorldMapPositioningGuide">
|
||||
<Size><AbsDimension x="1024" y="768"/></Size>
|
||||
<Anchors><Anchor point="CENTER"/></Anchors>
|
||||
</Frame>
|
||||
<!-- ref: the continent picker (id 1) at the top row's left, the zone picker (id 2)
|
||||
chained to its right, the zoom-out button closing the row. OnLoad instead of the
|
||||
ref's OnShow (our engine fires OnShow only on the frame itself, not children of
|
||||
a shown parent; OnLoad + the WORLD_MAP_UPDATE re-init below cover every open). -->
|
||||
<Frame name="WorldMapContinentDropDown" inherits="UIDropDownMenuTemplate" id="1">
|
||||
<Anchors>
|
||||
<Anchor point="TOP" relativePoint="TOP" relativeTo="WorldMapPositioningGuide">
|
||||
<Offset><AbsDimension x="-80" y="-35"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<Layers>
|
||||
<Layer level="OVERLAY">
|
||||
<FontString inherits="GameFontNormalSmall" text="Continent">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT"><Offset><AbsDimension x="20" y="10"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
</FontString>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Scripts>
|
||||
<OnLoad>
|
||||
WorldMapContinentsDropDown_OnLoad();
|
||||
</OnLoad>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
<Frame name="WorldMapZoneDropDown" inherits="UIDropDownMenuTemplate" id="2">
|
||||
<Anchors>
|
||||
<Anchor point="LEFT" relativeTo="WorldMapContinentDropDown" relativePoint="RIGHT">
|
||||
<Offset><AbsDimension x="-33" y="0"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<Layers>
|
||||
<Layer level="OVERLAY">
|
||||
<FontString inherits="GameFontNormalSmall" text="Zone">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT"><Offset><AbsDimension x="20" y="10"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
</FontString>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Scripts>
|
||||
<OnLoad>
|
||||
WorldMapZoneDropDown_OnLoad();
|
||||
this:RegisterEvent("WORLD_MAP_UPDATE");
|
||||
</OnLoad>
|
||||
<OnEvent>
|
||||
if ( this:IsVisible() ) then
|
||||
WorldMapContinentsDropDown_OnLoad();
|
||||
WorldMap_UpdateContinentDropDownText();
|
||||
WorldMapZoneDropDown_OnLoad();
|
||||
WorldMap_UpdateZoneDropDownText();
|
||||
end
|
||||
</OnEvent>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
<Button name="WorldMapZoomOutButton" text="Zoom Out">
|
||||
<Size><AbsDimension x="110" y="22"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="LEFT" relativeTo="WorldMapZoneDropDown" relativePoint="RIGHT">
|
||||
<Offset><AbsDimension x="-10" y="3"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<NormalFont inherits="GameFontNormal"/>
|
||||
<HighlightFont inherits="GameFontHighlight"/>
|
||||
<DisabledFont inherits="GameFontDisable"/>
|
||||
<NormalTexture file="Interface\Buttons\UI-Panel-Button-Up"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></NormalTexture>
|
||||
<PushedTexture file="Interface\Buttons\UI-Panel-Button-Down"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></PushedTexture>
|
||||
<DisabledTexture file="Interface\Buttons\UI-Panel-Button-Disabled"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></DisabledTexture>
|
||||
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></HighlightTexture>
|
||||
<Scripts>
|
||||
<OnClick>WorldMapZoomOutButton_OnClick();</OnClick>
|
||||
</Scripts>
|
||||
</Button>
|
||||
<Button name="WorldMapFrameCloseButton">
|
||||
<Size><AbsDimension x="32" y="32"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPRIGHT" relativePoint="TOP" relativeTo="WorldMapPositioningGuide">
|
||||
<Offset><AbsDimension x="516" y="4"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<NormalTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Up"/>
|
||||
<PushedTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Down"/>
|
||||
<HighlightTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Highlight" alphaMode="ADD"/>
|
||||
<Scripts>
|
||||
<OnClick>HideUIPanel(WorldMapFrame);</OnClick>
|
||||
</Scripts>
|
||||
</Button>
|
||||
<Frame name="WorldMapDetailFrame">
|
||||
<Size><AbsDimension x="1002" y="668"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativePoint="TOP" relativeTo="WorldMapPositioningGuide">
|
||||
<Offset><AbsDimension x="-502" y="-69"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
<Texture name="WorldMapDetailTile1">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"/></Anchors>
|
||||
</Texture>
|
||||
<Texture name="WorldMapDetailTile2">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="WorldMapDetailTile1" relativePoint="TOPRIGHT"/></Anchors>
|
||||
</Texture>
|
||||
<Texture name="WorldMapDetailTile3">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="WorldMapDetailTile2" relativePoint="TOPRIGHT"/></Anchors>
|
||||
</Texture>
|
||||
<Texture name="WorldMapDetailTile4">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="WorldMapDetailTile3" relativePoint="TOPRIGHT"/></Anchors>
|
||||
</Texture>
|
||||
<Texture name="WorldMapDetailTile5">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="WorldMapDetailTile1" relativePoint="BOTTOMLEFT"/></Anchors>
|
||||
</Texture>
|
||||
<Texture name="WorldMapDetailTile6">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="WorldMapDetailTile5" relativePoint="TOPRIGHT"/></Anchors>
|
||||
</Texture>
|
||||
<Texture name="WorldMapDetailTile7">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="WorldMapDetailTile6" relativePoint="TOPRIGHT"/></Anchors>
|
||||
</Texture>
|
||||
<Texture name="WorldMapDetailTile8">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="WorldMapDetailTile7" relativePoint="TOPRIGHT"/></Anchors>
|
||||
</Texture>
|
||||
<Texture name="WorldMapDetailTile9">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="WorldMapDetailTile5" relativePoint="BOTTOMLEFT"/></Anchors>
|
||||
</Texture>
|
||||
<Texture name="WorldMapDetailTile10">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="WorldMapDetailTile9" relativePoint="TOPRIGHT"/></Anchors>
|
||||
</Texture>
|
||||
<Texture name="WorldMapDetailTile11">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="WorldMapDetailTile10" relativePoint="TOPRIGHT"/></Anchors>
|
||||
</Texture>
|
||||
<Texture name="WorldMapDetailTile12">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="WorldMapDetailTile11" relativePoint="TOPRIGHT"/></Anchors>
|
||||
</Texture>
|
||||
</Layer>
|
||||
<Layer level="ARTWORK">
|
||||
<Texture name="WorldMapHighlight" alphaMode="ADD" hidden="true">
|
||||
<Size><AbsDimension x="128" y="128"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"/></Anchors>
|
||||
</Texture>
|
||||
</Layer>
|
||||
</Layers>
|
||||
</Frame>
|
||||
<Button name="WorldMapButton" enableMouse="true">
|
||||
<Size><AbsDimension x="1002" y="668"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="WorldMapDetailFrame"/></Anchors>
|
||||
<Frames>
|
||||
<Frame name="WorldMapPlayer" id="0">
|
||||
<Size><AbsDimension x="32" y="32"/></Size>
|
||||
<Anchors><Anchor point="CENTER"/></Anchors>
|
||||
<Layers>
|
||||
<Layer level="OVERLAY">
|
||||
<Texture name="WorldMapPlayerIcon" file="Interface\Minimap\MinimapArrow" setAllPoints="true"/>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<!-- Ref WorldMapFrame.xml l.473-484. The OnLoad is load-bearing and not
|
||||
decoration: WorldMapUnit_OnEnter reads `WorldMapPlayer.unit` when the
|
||||
cursor is over the arrow, and this is the only place it is ever set.
|
||||
The hover pair is transcribed with it and is LIVE, which is the
|
||||
opposite of what an earlier draft of this comment claimed. Neither the
|
||||
reference's frame nor ours carries enableMouse, but a declared OnEnter
|
||||
AUTO-ENABLES the mouse (wow-re ui/scratch/scripts-auto-enable.md §1,
|
||||
VERIFIED: a handler reaches the same enable primitive 0x76af00(2,-1)
|
||||
as the attribute). So the arrow takes the mouse and names the player on
|
||||
hover, exactly as the reference's does — and frame_flag_gate's KNOWN
|
||||
entry for this frame, which existed only because we had the flag-gap
|
||||
without the handler, went stale the moment this landed. -->
|
||||
<Scripts>
|
||||
<OnLoad>
|
||||
this.unit = "player";
|
||||
this:SetFrameLevel(this:GetFrameLevel() + 1);
|
||||
</OnLoad>
|
||||
<OnEnter>WorldMapUnit_OnEnter()</OnEnter>
|
||||
<OnLeave>WorldMapTooltip:Hide()</OnLeave>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
<!-- The corpse marker (decision 0308 §5) — ref WorldMapCorpseTemplate
|
||||
(WorldMapFrameTemplates.xml l.55-88) inlined: 16x16, the POIIcons skull
|
||||
cell (0.875-1.0 / 0-0.125), hover names it CORPSE_RED. The ref anchors its
|
||||
tooltip to a WorldMapTooltip; ours rides the shared GameTooltip through
|
||||
the side-pick every marker on the sheet shares. That parenthetical used to
|
||||
read "this map ships no separate tooltip" and is no longer true —
|
||||
WorldMapTooltip is declared below. The UNIT blips moved onto it with
|
||||
WorldMapUnit_OnEnter; the sheet MARKERS (this one, the POIs) deliberately
|
||||
did not, because they share WorldMapMarker_ShowTooltip and moving one
|
||||
moves all of them. That is a deviation left standing, not an oversight. -->
|
||||
<Frame name="WorldMapCorpse" hidden="true" enableMouse="true">
|
||||
<Size><AbsDimension x="16" y="16"/></Size>
|
||||
<Anchors><Anchor point="CENTER"/></Anchors>
|
||||
<Layers>
|
||||
<Layer level="ARTWORK">
|
||||
<Texture file="Interface\Minimap\POIIcons" setAllPoints="true">
|
||||
<TexCoords left="0.875" right="1.0" top="0" bottom="0.125"/>
|
||||
</Texture>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Scripts>
|
||||
<OnEnter>
|
||||
WorldMapMarker_ShowTooltip(this, "|cffff2020Corpse|r")
|
||||
</OnEnter>
|
||||
<OnLeave>
|
||||
GameTooltip:Hide()
|
||||
</OnLeave>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
<!-- The group blips — ref WorldMapFrame.xml l.486-529, which declares all 44 of them as
|
||||
inherits= instances of WorldMapUnitTemplate / WorldMapRaidUnitTemplate. Ours now does the
|
||||
same; they used to be four hand-written Frames carrying an ANONYMOUS texture, which cost
|
||||
the `$parentIcon` name every addon recolouring a blip reaches for (see
|
||||
WorldMapFrameTemplates.xml's header for the pfUI raise that found it).
|
||||
|
||||
The RAID forty are declared here and positioned NOWHERE: WorldMapParty_Update below drives
|
||||
the party arm only, because `GetPlayerMapPosition` answers (0,0) for every `raid` token and
|
||||
the reference's battleground arm needs GetNumBattlefieldPositions / GetBattlefieldPosition,
|
||||
which we do not bind. Declared-and-hidden is what the reference does with them out of a
|
||||
raid anyway; the difference is that ours stay that way. Named in the OUT list. -->
|
||||
<Frame name="WorldMapParty1" inherits="WorldMapUnitTemplate" id="1"/>
|
||||
<Frame name="WorldMapParty2" inherits="WorldMapUnitTemplate" id="2"/>
|
||||
<Frame name="WorldMapParty3" inherits="WorldMapUnitTemplate" id="3"/>
|
||||
<Frame name="WorldMapParty4" inherits="WorldMapUnitTemplate" id="4"/>
|
||||
<Frame name="WorldMapRaid1" inherits="WorldMapRaidUnitTemplate" id="1"/>
|
||||
<Frame name="WorldMapRaid2" inherits="WorldMapRaidUnitTemplate" id="2"/>
|
||||
<Frame name="WorldMapRaid3" inherits="WorldMapRaidUnitTemplate" id="3"/>
|
||||
<Frame name="WorldMapRaid4" inherits="WorldMapRaidUnitTemplate" id="4"/>
|
||||
<Frame name="WorldMapRaid5" inherits="WorldMapRaidUnitTemplate" id="5"/>
|
||||
<Frame name="WorldMapRaid6" inherits="WorldMapRaidUnitTemplate" id="6"/>
|
||||
<Frame name="WorldMapRaid7" inherits="WorldMapRaidUnitTemplate" id="7"/>
|
||||
<Frame name="WorldMapRaid8" inherits="WorldMapRaidUnitTemplate" id="8"/>
|
||||
<Frame name="WorldMapRaid9" inherits="WorldMapRaidUnitTemplate" id="9"/>
|
||||
<Frame name="WorldMapRaid10" inherits="WorldMapRaidUnitTemplate" id="10"/>
|
||||
<Frame name="WorldMapRaid11" inherits="WorldMapRaidUnitTemplate" id="11"/>
|
||||
<Frame name="WorldMapRaid12" inherits="WorldMapRaidUnitTemplate" id="12"/>
|
||||
<Frame name="WorldMapRaid13" inherits="WorldMapRaidUnitTemplate" id="13"/>
|
||||
<Frame name="WorldMapRaid14" inherits="WorldMapRaidUnitTemplate" id="14"/>
|
||||
<Frame name="WorldMapRaid15" inherits="WorldMapRaidUnitTemplate" id="15"/>
|
||||
<Frame name="WorldMapRaid16" inherits="WorldMapRaidUnitTemplate" id="16"/>
|
||||
<Frame name="WorldMapRaid17" inherits="WorldMapRaidUnitTemplate" id="17"/>
|
||||
<Frame name="WorldMapRaid18" inherits="WorldMapRaidUnitTemplate" id="18"/>
|
||||
<Frame name="WorldMapRaid19" inherits="WorldMapRaidUnitTemplate" id="19"/>
|
||||
<Frame name="WorldMapRaid20" inherits="WorldMapRaidUnitTemplate" id="20"/>
|
||||
<Frame name="WorldMapRaid21" inherits="WorldMapRaidUnitTemplate" id="21"/>
|
||||
<Frame name="WorldMapRaid22" inherits="WorldMapRaidUnitTemplate" id="22"/>
|
||||
<Frame name="WorldMapRaid23" inherits="WorldMapRaidUnitTemplate" id="23"/>
|
||||
<Frame name="WorldMapRaid24" inherits="WorldMapRaidUnitTemplate" id="24"/>
|
||||
<Frame name="WorldMapRaid25" inherits="WorldMapRaidUnitTemplate" id="25"/>
|
||||
<Frame name="WorldMapRaid26" inherits="WorldMapRaidUnitTemplate" id="26"/>
|
||||
<Frame name="WorldMapRaid27" inherits="WorldMapRaidUnitTemplate" id="27"/>
|
||||
<Frame name="WorldMapRaid28" inherits="WorldMapRaidUnitTemplate" id="28"/>
|
||||
<Frame name="WorldMapRaid29" inherits="WorldMapRaidUnitTemplate" id="29"/>
|
||||
<Frame name="WorldMapRaid30" inherits="WorldMapRaidUnitTemplate" id="30"/>
|
||||
<Frame name="WorldMapRaid31" inherits="WorldMapRaidUnitTemplate" id="31"/>
|
||||
<Frame name="WorldMapRaid32" inherits="WorldMapRaidUnitTemplate" id="32"/>
|
||||
<Frame name="WorldMapRaid33" inherits="WorldMapRaidUnitTemplate" id="33"/>
|
||||
<Frame name="WorldMapRaid34" inherits="WorldMapRaidUnitTemplate" id="34"/>
|
||||
<Frame name="WorldMapRaid35" inherits="WorldMapRaidUnitTemplate" id="35"/>
|
||||
<Frame name="WorldMapRaid36" inherits="WorldMapRaidUnitTemplate" id="36"/>
|
||||
<Frame name="WorldMapRaid37" inherits="WorldMapRaidUnitTemplate" id="37"/>
|
||||
<Frame name="WorldMapRaid38" inherits="WorldMapRaidUnitTemplate" id="38"/>
|
||||
<Frame name="WorldMapRaid39" inherits="WorldMapRaidUnitTemplate" id="39"/>
|
||||
<Frame name="WorldMapRaid40" inherits="WorldMapRaidUnitTemplate" id="40"/>
|
||||
<Frame name="WorldMapFrameAreaFrame">
|
||||
<Size><AbsDimension x="400" y="128"/></Size>
|
||||
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
|
||||
<Layers>
|
||||
<Layer level="OVERLAY">
|
||||
<FontString name="WorldMapFrameAreaLabel" inherits="WorldMapTextFont">
|
||||
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="20" y="-20"/></Offset></Anchor></Anchors>
|
||||
</FontString>
|
||||
<FontString name="WorldMapFrameAreaDescription" inherits="SubZoneTextFont">
|
||||
<Anchors><Anchor point="TOP" relativeTo="WorldMapFrameAreaLabel" relativePoint="BOTTOM"/></Anchors>
|
||||
</FontString>
|
||||
</Layer>
|
||||
</Layers>
|
||||
</Frame>
|
||||
</Frames>
|
||||
<Scripts>
|
||||
<OnLoad>
|
||||
this:RegisterForClicks("LeftButtonUp", "RightButtonUp");
|
||||
</OnLoad>
|
||||
<OnClick>
|
||||
WorldMapButton_OnClick(arg1);
|
||||
</OnClick>
|
||||
<OnUpdate>
|
||||
WorldMapButton_OnUpdate(arg1);
|
||||
</OnUpdate>
|
||||
</Scripts>
|
||||
</Button>
|
||||
</Frames>
|
||||
<Scripts>
|
||||
<OnLoad>
|
||||
this:RegisterEvent("WORLD_MAP_UPDATE");
|
||||
-- The hover area label inherits WorldMapTextFont (nominal 102). 33 was the
|
||||
-- director's taste call (0235) — which the one-to-one raster cap (the fold-back
|
||||
-- record; wow-re fontstring-overflow.md) now clamps to the byte law's 32, one
|
||||
-- unit under that call and closer to it than the 36 the atlas snap used to
|
||||
-- deliver. The real client draws this SetFont(33) at 32 too. Still a taste dial:
|
||||
-- any value ≤ 32 is honored exactly.
|
||||
WorldMapFrameAreaLabel:SetFont("Fonts\\FRIZQT__.TTF", 33);
|
||||
WorldMapFrame_Update();
|
||||
</OnLoad>
|
||||
<!-- UpdateMicroButtons leads both, as in the ref (WorldMapFrame.xml l.599/606) — live
|
||||
since the micro-menu slice (MicroMenu.xml). -->
|
||||
<OnShow>
|
||||
BlackoutWorld:Show();
|
||||
SetupFullscreenScale();
|
||||
SetMapToCurrentZone();
|
||||
WorldMapFrame_Update();
|
||||
if UpdateMicroButtons then UpdateMicroButtons() end;
|
||||
PlaySound("igQuestLogOpen");
|
||||
CloseDropDownMenus();
|
||||
</OnShow>
|
||||
<OnHide>
|
||||
BlackoutWorld:Hide();
|
||||
if UpdateMicroButtons then UpdateMicroButtons() end;
|
||||
PlaySound("igQuestLogClose");
|
||||
</OnHide>
|
||||
<OnEvent>
|
||||
if ( WorldMapFrame:IsVisible() ) then
|
||||
WorldMapFrame_Update();
|
||||
end
|
||||
</OnEvent>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
|
||||
<!-- WorldMapTooltip (ref WorldMapFrame.xml l.636, verbatim attributes).
|
||||
|
||||
**This file's header listed it OUT, and the reason has expired.** The note said "only unit
|
||||
blips used it" — true of the REFERENCE's own consumers, and never the question. The name is
|
||||
a published global that addons index directly, and six in the corpus do: !Questie,
|
||||
Cartographer, CT_MapMod, Gatherer, pfQuest and pfUI. Three of those are on the failing
|
||||
roster, and for pfUI it is a load-time blocker — `modules/map.lua:13` indexes it at file
|
||||
scope, so the addon dies there whatever the map is drawing.
|
||||
|
||||
Declared, not driven: this is the widget under its reference name and geometry, so an
|
||||
addon that seats its own lines in it works. Our OWN map still routes its hovers through
|
||||
the shared GameTooltip (the two notes at l.666/l.692 say so and stay true) — adopting the
|
||||
reference's separate-tooltip routing is a different change, and doing half of it silently
|
||||
would be worse than either. -->
|
||||
<GameTooltip name="WorldMapTooltip" frameStrata="TOOLTIP" hidden="true" parent="WorldMapFrame" inherits="GameTooltipTemplate"/>
|
||||
</Ui>
|
||||
@@ -1,72 +0,0 @@
|
||||
<!--
|
||||
The world map's per-unit blip templates, transcribed from the reference's own
|
||||
`Interface\FrameXML\WorldMapFrameTemplates.xml` (l.3-35). Two virtual Frames and nothing else:
|
||||
`WorldMapUnitTemplate` (a party member) and `WorldMapRaidUnitTemplate` (a raid member), which
|
||||
inherits it and re-states its OnLoad without the `party` token.
|
||||
|
||||
**Why they are a file of their own rather than four hand-written copies inside WorldMapFrame.xml.**
|
||||
They used to be exactly that: `WorldMapParty1..4` written out four times, each carrying an
|
||||
ANONYMOUS `<Texture>`. Anonymous is the bug. The reference's blip names its icon `$parentIcon`,
|
||||
and that published name is load-bearing for addons — pfUI's `modules/mapcolors.lua` recolours
|
||||
party and raid blips by class and reaches them as
|
||||
|
||||
frame.icon = _G[frame_name.."Icon"]
|
||||
frame.icon:SetTexture(pfUI.media["img:circleparty"])
|
||||
|
||||
so against four anonymous textures line 11 raised `attempt to index field 'icon' (a nil value)`
|
||||
and took pfUI's whole module chain down with it. A template gets the naming right once.
|
||||
|
||||
**The raid pool is DECLARED but not DRIVEN, and that is the honest half-step.** `mapcolors`
|
||||
loops `1, MAX_RAID_MEMBERS` (40) as well as `1, MAX_PARTY_MEMBERS` (4), so all 44 frames have to
|
||||
exist for it to survive; the reference declares all 44 too (WorldMapFrame.xml l.486-529). What
|
||||
ours cannot do yet is POSITION the raid forty: `GetPlayerMapPosition` resolves `player` and
|
||||
`party1..4` and answers (0,0) for every `raid` token (worldmap.rs l.487-505), and the
|
||||
reference's second raid arm — battleground team members — needs `GetNumBattlefieldPositions`
|
||||
and `GetBattlefieldPosition`, neither of which we bind. So the forty are declared, hidden, and
|
||||
left where the reference leaves them until a raid actually has coordinates to place them at.
|
||||
Declaring a frame the reference declares is not stubbing behaviour (1134 §4): the frames and
|
||||
their art are real, and the missing piece is a data source, named here and in WorldMapFrame.xml's
|
||||
OUT list rather than faked with an empty setter.
|
||||
|
||||
The hover is the reference's `WorldMapUnit_OnEnter`, which reads `this` and owns
|
||||
`WorldMapTooltip` — both of which now exist, so the template's `WorldMapTooltip:Hide()` OnLeave
|
||||
has something to hide. Our body is the single-unit case; the reference additionally walks every
|
||||
overlapping blip with MouseIsOver and stacks their names into one tooltip, which we do not.
|
||||
-->
|
||||
<Ui xmlns="http://www.blizzard.com/wow/ui/">
|
||||
<!-- No enableMouse, exactly as the reference declares it: the OnEnter/OnLeave below
|
||||
AUTO-ENABLE the mouse on their own (wow-re ui/scratch/scripts-auto-enable.md §1,
|
||||
VERIFIED — a script handler reaches the same enable primitive 0x76af00(2,-1) the
|
||||
attribute does). Adding the attribute would be redundant, and frame_flag_gate reads
|
||||
the loaded ENGINE rather than our XML precisely so the two spellings cannot drift. -->
|
||||
<Frame name="WorldMapUnitTemplate" hidden="true" virtual="true">
|
||||
<Size><AbsDimension x="16" y="16"/></Size>
|
||||
<Anchors><Anchor point="CENTER"/></Anchors>
|
||||
<Layers>
|
||||
<Layer level="ARTWORK">
|
||||
<Texture name="$parentIcon" setAllPoints="true"/>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Scripts>
|
||||
<OnLoad>
|
||||
this:SetFrameLevel(this:GetFrameLevel() + 1);
|
||||
this.unit = "party" .. this:GetID();
|
||||
getglobal(this:GetName() .. "Icon"):SetTexture("Interface\\WorldMap\\WorldMapPartyIcon");
|
||||
</OnLoad>
|
||||
<OnEnter>WorldMapUnit_OnEnter()</OnEnter>
|
||||
<OnLeave>WorldMapTooltip:Hide()</OnLeave>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
|
||||
<!-- Ref l.29-35 verbatim: the same frame WITHOUT the `party` token, because a raid blip's unit
|
||||
is assigned by the positioning pass (`partyMemberFrame.unit = unit`, WorldMapFrame.lua
|
||||
l.386) rather than derived from its id. -->
|
||||
<Frame name="WorldMapRaidUnitTemplate" inherits="WorldMapUnitTemplate" virtual="true">
|
||||
<Scripts>
|
||||
<OnLoad>
|
||||
this:SetFrameLevel(this:GetFrameLevel() + 1);
|
||||
getglobal(this:GetName() .. "Icon"):SetTexture("Interface\\WorldMap\\WorldMapPartyIcon");
|
||||
</OnLoad>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
</Ui>
|
||||
@@ -441,10 +441,23 @@ Interface\FrameXML\BonusActionBarFrame.xml
|
||||
# their OnShow/OnHide calls the `UpdateMicroButtons` this file defines (the panel frames
|
||||
# themselves are read back through getglobal, so the reverse dependency is call-time).
|
||||
MicroMenu.xml
|
||||
# The HUD minimap cluster (decision 0203 phase 1): the chrome around the engine-rendered
|
||||
# <Minimap> widget hole (`crate::minimap` fills it). After GameTooltip (its zone-text
|
||||
# hover uses the shared tooltip).
|
||||
MinimapCluster.xml
|
||||
# ══ THE BATTLEGROUND LIST + THE MINIMAP CLUSTER — the reference's own, off the player's chain
|
||||
# (1751; decision 1974) ══
|
||||
# BattlefieldFrame.xml + BattlefieldFrame.lua: the battlemaster's instance list (the "First
|
||||
# Available" row, the join and join-as-group buttons) over `crate::ui_battlefield`'s feed and
|
||||
# the engine's queue family (GetBattlefieldStatus & co.), and — defined here, consumed by the
|
||||
# minimap file below at LOAD time — the minimap queue icon's status pass and its right-click
|
||||
# dropdown. The reference's toc seats it right before Minimap.xml (l.29-30) for that reason.
|
||||
# After UIDropDownMenu.xml (the dropdown template) and UIPanelTemplates (the buttons, the faux
|
||||
# scroll frame).
|
||||
Interface\FrameXML\BattlefieldFrame.xml
|
||||
# Minimap.xml + Minimap.lua: the cluster around the engine-rendered <Minimap> widget hole
|
||||
# (`crate::minimap` fills it), the zone text, the zoom buttons, the tracking / mail / meeting
|
||||
# stone / battlefield icons, and `MiniMapPing` — a <Model> this engine draws no pixels for; the
|
||||
# app's ping sprite (`minimap/ping.rs`) follows that frame's own show/hide and alpha, so the
|
||||
# 5 s lifetime is this file's, as the reference's is. After GameTooltip (the zone-text hover).
|
||||
# 0203's transcription is gone.
|
||||
Interface\FrameXML\Minimap.xml
|
||||
# ══ THE TIME-OF-DAY INDICATOR — the reference's own, off the player's chain (1751, window 5) ══
|
||||
# `GameTimeFrame`, `GameTimeTexture`, and `GameTime_GetTime` / `GameTimeFrame_Update` /
|
||||
# `_UpdateTooltip` / `GAMETIME_DAWN`/`_DUSK` out of `GameTime.lua`. Ours was a re-implementation of
|
||||
@@ -528,13 +541,16 @@ OptionsFrameTemplates.xml
|
||||
# entry and the ESC rung that cancel-clicks it. UIDropDownMenu.xml sits above too, but that
|
||||
# direction is call-time only, so the two could sit either way round.
|
||||
Interface\FrameXML\ColorPickerFrame.xml
|
||||
# The fullscreen world map (decision 0203 phase 2), over the worldmap host API
|
||||
# (`crate::ui_world_map` feeds it); the 'M' binding (ui_script/input.rs) calls its
|
||||
# ToggleWorldMap().
|
||||
# Its per-unit blip templates load FIRST: WorldMapFrame.xml instantiates WorldMapParty1..4 and
|
||||
# WorldMapRaid1..40 with inherits=, so the virtual Frames have to be registered before it.
|
||||
WorldMapFrameTemplates.xml
|
||||
WorldMapFrame.xml
|
||||
# ══ THE WORLD MAP — the reference's own, off the player's chain (1751, migrated 1980) ══
|
||||
# The fullscreen map over the worldmap host API (`crate::ui_world_map` feeds the player, corpse,
|
||||
# party and landmark positions; `crate::ui_battlefield_positions` the battleground teammates and
|
||||
# the flag carrier); the 'M' binding (ui_script/input.rs) calls its ToggleWorldMap(). The file
|
||||
# <Include>s its own blip templates (WorldMapFrameTemplates.xml) before the frames that inherit
|
||||
# them. Its player arrow is the Create/Position/Show/UpdateWorldMapArrowFrames family
|
||||
# (`script/worldmap_arrow.rs`), an anonymous Model pane the app draws as the minimap's arrow
|
||||
# sprite; its WorldMapPing is a MinimapPing.mdx Model pane this engine holds and draws nothing
|
||||
# into (named gap, 1980).
|
||||
Interface\FrameXML\WorldMapFrame.xml
|
||||
# ══ THE CAST BAR — the reference's own, off the player's chain (1751) ══
|
||||
# `CastingBarFrame` + its Border/Flash/Spark/Text children, driven by the SPELLCAST_* events
|
||||
# `crate::ui_cast` fires (decision 0137 phase 1). Our `CastingBar.xml` was a transcription of this
|
||||
@@ -1091,6 +1107,15 @@ Interface\FrameXML\HelpFrame.xml
|
||||
# nothing else, and nothing needs it — the engine reaches it purely by firing CINEMATIC_START.
|
||||
# Seated late because it is an outermost, screen-owning layer, like the two windows below it.
|
||||
Interface\FrameXML\CinematicFrame.xml
|
||||
# ══ THE TUTORIAL FRAME — the reference's own, off the player's chain (1751; decision 1976) ══
|
||||
# TutorialFrame.xml + TutorialFrame.lua: the alert buttons `TUTORIAL_TRIGGER` queues, the window
|
||||
# a click opens over `TUTORIAL_TITLE<n>`/`TUTORIAL<n>`, the "Display Tips" checkbox whose unticked
|
||||
# hide calls ClearTutorials, and the CINEMATIC_STOP arm that clicks the Welcome alert when the
|
||||
# intro ends. Right after CinematicFrame.xml as the reference's toc has it (l.76-80); its
|
||||
# engine — the two flag banks, the fire-once trigger, the four verbs — is `crate::tutorial`.
|
||||
# Needs UIPanelTemplates (UICheckButtonTemplate), BasicControls (the dialog button textures),
|
||||
# GameTooltip, and UiPanels' SetButtonPulse, all far above.
|
||||
Interface\FrameXML\TutorialFrame.xml
|
||||
# The game menu (decision 0674): the frame ESC opens. LAST on purpose — it is the outermost
|
||||
# layer of the shell, it depends on everything it touches being loaded (UiPanels' popup engine
|
||||
# + panel manager, MicroMenu's UpdateMicroButtons, BagFrame's Disable_BagButtons), and nothing
|
||||
|
||||
@@ -1068,6 +1068,7 @@ mod tests {
|
||||
// One frame carrying both halves of the race, in the order the drain produces them.
|
||||
app.world_mut().write_message(EnteredWorldMessage {
|
||||
billing_time_rested: 0,
|
||||
tutorial_flags: None,
|
||||
});
|
||||
app.world_mut()
|
||||
.write_message(crate::net::DisconnectedMessage {
|
||||
@@ -1109,6 +1110,7 @@ mod tests {
|
||||
|
||||
app.world_mut().write_message(EnteredWorldMessage {
|
||||
billing_time_rested: 0,
|
||||
tutorial_flags: None,
|
||||
});
|
||||
app.world_mut()
|
||||
.write_message(crate::net::DisconnectedMessage {
|
||||
|
||||
@@ -803,7 +803,7 @@ pub(crate) use impact::{DefenseAnim, PendingImpacts, SwingFlush, SwingImpact, Sw
|
||||
/// one-shots ([`EmoteAnim`]) and kit sounds ([`spell_visual::SpellKitSound`]).
|
||||
mod blood;
|
||||
mod env_damage;
|
||||
mod spell_visual;
|
||||
pub(crate) mod spell_visual;
|
||||
use blood::{blood_spurts, load_blood_tables};
|
||||
use env_damage::{hard_landing_dust, load_env_damage_table};
|
||||
pub(crate) use env_damage::{EnvDamageTable, HardLanding, HARD_LANDING_DESCENT};
|
||||
|
||||
@@ -55,6 +55,25 @@ const LEVEL_UP_EFFECT: &str = "HARDCODED Unit Level Up";
|
||||
/// 1185 → `Spells\DruidMorph_Impact_Base.mdx`, i.e. literally the druid-morph puff (wow-re
|
||||
/// `mount-composition.md` Q4b, decision 0927).
|
||||
const MOUNT_POOF_EFFECT: &str = "HARDCODED Mount Poof";
|
||||
/// The meeting-stone join — hardcoded index **0xc** (`0x861808`), spawned on the local player by
|
||||
/// the `SMSG 0x295` status-1 arm with the same `Effect_C` shape as the mount poof (wow-re
|
||||
/// `meeting-stone-status.md` §8; decision 1974).
|
||||
const MEETING_STONE_JOIN_EFFECT: &str = "HARDCODED Meeting Stone Join";
|
||||
|
||||
/// The meeting-stone join visual for `entity`, or `None` without client data or the row —
|
||||
/// `0x61fae0(kind = 0xc, &guid, completion = 0x5fbf50)`: one-shot, non-persistent, the
|
||||
/// end-of-clip terminator, attached at `0x13` like every `HARDCODED` effect.
|
||||
pub(crate) fn meeting_stone_join_fx(visuals: &SpellVisuals, entity: Entity) -> Option<SpellKitFx> {
|
||||
let path = visuals.0.hardcoded_effect(MEETING_STONE_JOIN_EFFECT)?;
|
||||
Some(SpellKitFx::Begin {
|
||||
entity,
|
||||
spell_id: 0,
|
||||
persistent: false,
|
||||
class: FxClass::Hold,
|
||||
stage: FxStage::OneShot,
|
||||
effects: vec![(HARDCODED_FX_ATTACH, path.to_string())],
|
||||
})
|
||||
}
|
||||
|
||||
/// `SpellVisual.dbc` × `SpellVisualKit.dbc` — the stage-kit chain + each kit's anim/sound
|
||||
/// (`crate::creature_anim::spell_visual` module docs). Optional like every other DBC-backed
|
||||
@@ -1296,6 +1315,7 @@ pub(super) fn arm_loot_fx(
|
||||
visuals: Option<Res<SpellVisuals>>,
|
||||
mut fx: MessageWriter<SpellKitFx>,
|
||||
mut armed: Local<EntityHashSet>,
|
||||
mut tutorials: Option<MessageWriter<crate::tutorial::TutorialEvent>>,
|
||||
) {
|
||||
let full_sweep = visuals.as_ref().is_some_and(|v| v.is_changed());
|
||||
let Some(path) = visuals.as_ref().and_then(|v| v.0.loot_art_path()) else {
|
||||
@@ -1317,6 +1337,12 @@ pub(super) fn arm_loot_fx(
|
||||
store.0.unit_is_dead() && store.0.unit_lootable()
|
||||
};
|
||||
if lootable && armed.insert(entity) {
|
||||
// `OnDynamicFlagsChanged`'s rise (`0x60049b`): the Looting tutorial (1976).
|
||||
if let Some(t) = tutorials.as_mut() {
|
||||
t.write(crate::tutorial::TutorialEvent::trigger(
|
||||
crate::tutorial::id::LOOTING,
|
||||
));
|
||||
}
|
||||
fx.write(SpellKitFx::Begin {
|
||||
entity,
|
||||
spell_id: LOOT_FX_KEY,
|
||||
|
||||
@@ -116,10 +116,13 @@ mod sound;
|
||||
mod target;
|
||||
mod textinput;
|
||||
mod transport;
|
||||
mod tutorial;
|
||||
mod ui_action;
|
||||
mod ui_auction;
|
||||
mod ui_aura;
|
||||
mod ui_bank;
|
||||
mod ui_battlefield;
|
||||
mod ui_battlefield_positions;
|
||||
mod ui_battlefield_score;
|
||||
mod ui_bind_confirm;
|
||||
mod ui_binder;
|
||||
@@ -206,10 +209,13 @@ use sound::SoundPlugin;
|
||||
use target::TargetPlugin;
|
||||
use textinput::TextInputPlugin;
|
||||
use transport::TransportPlugin;
|
||||
use tutorial::TutorialPlugin;
|
||||
use ui_action::UiActionPlugin;
|
||||
use ui_auction::UiAuctionPlugin;
|
||||
use ui_aura::UiAuraPlugin;
|
||||
use ui_bank::UiBankPlugin;
|
||||
use ui_battlefield::BattlefieldPlugin;
|
||||
use ui_battlefield_positions::BattlefieldPositionsPlugin;
|
||||
use ui_battlefield_score::BattlefieldScorePlugin;
|
||||
use ui_binder::UiBinderPlugin;
|
||||
use ui_cast::UiCastPlugin;
|
||||
@@ -644,6 +650,9 @@ pub fn run(build: BuildId) -> AppExit {
|
||||
// boot clock, the area spirit healer, the battleground queue, the meeting stone.
|
||||
.add_plugins(UiDialogVerbsPlugin)
|
||||
.add_plugins(BattlefieldScorePlugin)
|
||||
.add_plugins(BattlefieldPlugin)
|
||||
.add_plugins(BattlefieldPositionsPlugin)
|
||||
.add_plugins(TutorialPlugin)
|
||||
// Being summoned (decision 1747): SMSG_SUMMON_REQUEST's latch, the CONFIRM_SUMMON dialog it
|
||||
// raises, and the CMSG_SUMMON_RESPONSE its Accept sends. The binder's twin one line up — a
|
||||
// server-asked question whose only wire answer is yes — and here for that reason.
|
||||
|
||||
@@ -116,8 +116,12 @@ pub(super) const BLIP_BASIS_PX: f32 = 140.8;
|
||||
/// quad rendered at 1280 px per model unit — 33.6 px at stock. Its authored centre sits
|
||||
/// (+0.0004, +0.00135) model units off the frame origin: ≈(+0.5 right, 1.7 up) screen px,
|
||||
/// rotating with the facing (model +y = screen-up at zero rotation).
|
||||
pub(super) const PLAYER_ARROW_QUAD_PX: f32 = 33.6;
|
||||
pub(super) const PLAYER_ARROW_OFFSET_PX: bevy::math::Vec2 = bevy::math::Vec2::new(0.51, -1.73);
|
||||
/// The player arrow's art — the minimap ring's and, since decision 1980, the world map's: the stock
|
||||
/// `WorldMapFrame.lua` `SetModel`s an anonymous pane to the same M2 and the app draws that pane as
|
||||
/// this sprite (`ui_script::extract`).
|
||||
pub(crate) const PLAYER_ARROW_TEXTURE: &str = "Interface\\Minimap\\MinimapArrow";
|
||||
pub(crate) const PLAYER_ARROW_QUAD_PX: f32 = 33.6;
|
||||
pub(crate) const PLAYER_ARROW_OFFSET_PX: bevy::math::Vec2 = bevy::math::Vec2::new(0.51, -1.73);
|
||||
/// The rim arrow's on-screen quad: the `Rotating-MinimapArrow.m2` geometry is a stack of six
|
||||
/// full-texture 0.0500-unit quads (measured from the real M2's 24 vertices, z 0/0.0145/0.0291),
|
||||
/// and the frame renders at 768 px per model unit (modelScale 0.6 × 1280 px/unit):
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
//! stem; [`blips`] — the phase-3 blip layer (AreaPOI landmark arrows, quest-giver dots, the
|
||||
//! hover tooltip).
|
||||
|
||||
mod blips;
|
||||
pub(crate) mod blips;
|
||||
/// The party blip's position law, shared with the world map (report B320): one function decides
|
||||
/// where a member is, so the two surfaces can never disagree about it.
|
||||
pub(crate) use blips::party_member_pos;
|
||||
@@ -315,7 +315,7 @@ fn setup_minimap(
|
||||
Ok(translate) => {
|
||||
info!("minimap: md5translate.trs — {} tiles", translate.len());
|
||||
let mask = assets.mask_texture("Textures\\MinimapMask", &mut images);
|
||||
let arrow = assets.sprite_texture("Interface\\Minimap\\MinimapArrow", &mut images);
|
||||
let arrow = assets.sprite_texture(blips::PLAYER_ARROW_TEXTURE, &mut images);
|
||||
let poi = assets.sprite_texture("Interface\\Minimap\\POIIcons", &mut images);
|
||||
let mut rim_arrows = blips::RimArrowArt::default();
|
||||
for kind in blips::RimArrow::ALL {
|
||||
@@ -471,7 +471,14 @@ fn emit_minimap(
|
||||
// always spent in the frame it was made. Held across frames it would seat against geometry
|
||||
// the player never clicked on; and a click made on a frame the map does not draw is simply
|
||||
// not a ping (decision 1596).
|
||||
let click = script.and_then(|mut s| s.take_minimap_ping_request());
|
||||
// The stock `MiniMapPing` frame's alpha is read beside it: the ping's lifetime is that
|
||||
// frame's (`ping.rs`), so the sprite wants the frame as it stands after this tick.
|
||||
let (click, ping_shown) = script.map_or((None, None), |mut s| {
|
||||
(
|
||||
s.take_minimap_ping_request(),
|
||||
s.frame_effective_alpha(ping::PING_FRAME),
|
||||
)
|
||||
});
|
||||
let (Some(slot), Some(assets), Some(map), Some(catalog)) =
|
||||
(widget.0.as_ref(), assets, map, catalog)
|
||||
else {
|
||||
@@ -939,7 +946,7 @@ fn emit_minimap(
|
||||
// This is also where a `Minimap:PingLocation` click is drained: the geometry it must resolve
|
||||
// against is the geometry standing right here, this frame (decision 1596).
|
||||
if let Some(ctx) = &blip_ctx {
|
||||
ping::emit_ping(ctx, &mut ping, click, map.0, &assets.ping, &mut quads);
|
||||
ping::emit_ping(ctx, &mut ping, click, ping_shown, &assets.ping, &mut quads);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,14 +29,23 @@
|
||||
//! offsets the byte-verified relay `0x4ee330` hands Lua (`(−dy·k, dx·k)`, `k = 1/(2·radius)` —
|
||||
//! wow-re `party-group-wire.md` §TU-D). `Minimap:GetPingPosition()` reads the live value back.
|
||||
//!
|
||||
//! ## Lifetime
|
||||
//! ## Lifetime — the stock `Minimap.lua`'s, not ours (1974)
|
||||
//!
|
||||
//! 5 s hold, then the reference's 0.5 s "fade" — see [`PING_ALPHA_IS_A_POP`]. A map change drops
|
||||
//! it (the point is not here any more). **Nothing else clears it**, and in particular *not*
|
||||
//! proximity: the first version applied the client's 10-yd `d² < 100` auto-clear to the party
|
||||
//! ping, and that clear belongs to the **`SMSG_GOSSIP_POI` marker** — a different feature in a
|
||||
//! different slot (wow-re `party-group-wire.md` §TU-D corrects it explicitly; `MSG_MINIMAP_PING`
|
||||
//! has no C-side storage at all). Walking to your own ping used to delete it mid-hold.
|
||||
//! The reference splits the ping in two: the engine stores the world point in a pair of statics
|
||||
//! **nothing ever clears** (wow-re `minimap-ping-law.md` §3 — six instructions touch those cells
|
||||
//! and the only zeroing is a CRT initializer), and FrameXML owns everything visible — the
|
||||
//! `MiniMapPing` `<Model>` it shows on `MINIMAP_PING`, re-seats every frame from
|
||||
//! `GetPingPosition()`, holds 5 s (`MINIMAPPING_TIMER`), "fades" 0.5 s through a `SetAlpha(255·t)`
|
||||
//! that clamps to full until the last ~2 ms, and hides. Since 1751's swap that file runs here
|
||||
//! verbatim, and this engine's `<Model>` draws no pixels — so the sprite below is **that frame's
|
||||
//! renderer**: it draws while `MiniMapPing` is effectively visible, at its effective alpha, and
|
||||
//! keeps no clock of its own. The world point is likewise never cleared: `GetPingPosition()`
|
||||
//! answers two numbers always, which the stock `Minimap_OnUpdate` multiplies without a nil test.
|
||||
//!
|
||||
//! What is *not* a lifetime: proximity. The first version applied the client's 10-yd
|
||||
//! `d² < 100` auto-clear to the party ping, and that clear belongs to the **`SMSG_GOSSIP_POI`
|
||||
//! marker** — a different feature in a different slot (wow-re `party-group-wire.md` §TU-D
|
||||
//! corrects it explicitly). Walking to your own ping used to delete it mid-hold.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -48,20 +57,12 @@ use crate::net::{ClientCommand, Guid, NetCommands, SelfPlayer};
|
||||
use crate::player::Player;
|
||||
use crate::ui_pass::{UiQuad, UiQuads};
|
||||
|
||||
/// How long the ping holds at full strength — the reference's `MINIMAPPING_TIMER`.
|
||||
const PING_HOLD: f32 = 5.0;
|
||||
/// The tail after the hold — the reference's `MINIMAPPING_FADE_TIMER`.
|
||||
const PING_FADE: f32 = 0.5;
|
||||
|
||||
/// **The reference's ping does not fade; it pops** — now byte-confirmed, not inferred from the Lua
|
||||
/// (wow-re `minimap-ping-law.md`). `Minimap_OnUpdate` writes `SetAlpha(255 * (t / 0.5))`, and
|
||||
/// `Frame:SetAlpha` (`0x774e90`) **clamps to [0,1]** before scaling to a 0–255 byte — so the
|
||||
/// nominal half-second ramp is at full alpha for 498 of its 500 ms and the whole visible fall is
|
||||
/// ~12 % of one frame at 60 fps. The real client holds bright for ~5 s and vanishes.
|
||||
///
|
||||
/// Reproduced as the clamp rather than as a constant 1.0, so the mechanism stays visible and the
|
||||
/// `false` branch is a real alternative (a smooth fade, which wow-re notes will visibly diverge).
|
||||
const PING_ALPHA_IS_A_POP: bool = true;
|
||||
/// The stock frame whose pixels this module draws: `Minimap.xml`'s `<Model name="MiniMapPing">`.
|
||||
/// Its show/hide and alpha ARE the ping's lifetime (module doc). The reference's "fade" pops
|
||||
/// rather than fades — `Minimap_OnUpdate` writes `SetAlpha(255 · t/0.5)` and `Frame:SetAlpha`
|
||||
/// (`0x774e90`) clamps to [0,1] first, so the frame reads full alpha for 498 of the 500 ms — and
|
||||
/// that arrives here through the frame's effective alpha, with no constant of ours to keep it.
|
||||
pub(super) const PING_FRAME: &str = "MiniMapPing";
|
||||
|
||||
/// **The marker, byte-measured** (wow-re `system/ui/scratch/minimap-ping-law.md` §10, VERIFIED).
|
||||
///
|
||||
@@ -165,27 +166,27 @@ pub(super) struct PingArt {
|
||||
pub(super) ping4: Option<Handle<Image>>,
|
||||
}
|
||||
|
||||
/// The live ping. One at a time — the reference keeps no list either.
|
||||
/// The stored ping — the reference's two statics. One, never cleared: the reference keeps no
|
||||
/// list, and no map tag either (a worldport mid-ping re-projects the old point against the new
|
||||
/// map's player position, which the stock Lua's disc test then hides — reproduced as is).
|
||||
struct LivePing {
|
||||
/// **The pin**: the WoW `(x, y)` this ping marks. The only stored position; the screen seat is
|
||||
/// re-derived from it every frame.
|
||||
world: (f32, f32),
|
||||
/// The map it was placed on. A map change drops the ping rather than re-projecting a point
|
||||
/// that does not exist here.
|
||||
map: u32,
|
||||
/// Seconds since it was seated.
|
||||
age: f32,
|
||||
/// The pinger's guid, `0` = ourselves — resolved to the `MINIMAP_PING` event's unit token, and
|
||||
/// the test for "this is ours, put it on the wire".
|
||||
sender: u64,
|
||||
}
|
||||
|
||||
/// The engine-owned ping state (decision 1596). Seated by a click (drained in the renderer, with
|
||||
/// that frame's geometry) or by a group member's `MSG_MINIMAP_PING`; aged, announced and expired
|
||||
/// by [`drive_minimap_ping`].
|
||||
/// that frame's geometry) or by a group member's `MSG_MINIMAP_PING`; announced by
|
||||
/// [`drive_minimap_ping`]; shown for exactly as long as the stock `MiniMapPing` frame is.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct MinimapPing {
|
||||
live: Option<LivePing>,
|
||||
/// [`PING_FRAME`]'s effective alpha while it is effectively visible, as the renderer last read
|
||||
/// it — `None` hidden. The model clock below runs only while this is `Some`.
|
||||
shown: Option<f32>,
|
||||
/// The model's own clock, in **milliseconds, accumulated only while a ping is shown** — which
|
||||
/// is the reference's behaviour rather than a simplification of it. `SetSequence(0)` runs once
|
||||
/// in the ref's `OnLoad` and stores an *anchor*; the sampler free-runs from it, and a Model
|
||||
@@ -202,34 +203,10 @@ impl MinimapPing {
|
||||
/// Seat a ping at a world point. Re-pinging replaces: the reference tolerates the same, and a
|
||||
/// group echo of our own click lands on the spot we already drew (`Minimap_SetPing` twice on
|
||||
/// one spot just restarts the timer).
|
||||
pub(crate) fn seat(&mut self, world: (f32, f32), map: u32, sender: u64) {
|
||||
self.live = Some(LivePing {
|
||||
world,
|
||||
map,
|
||||
age: 0.0,
|
||||
sender,
|
||||
});
|
||||
pub(crate) fn seat(&mut self, world: (f32, f32), sender: u64) {
|
||||
self.live = Some(LivePing { world, sender });
|
||||
self.fresh = true;
|
||||
}
|
||||
|
||||
/// The ping's alpha at its current age, or `None` once it is over.
|
||||
fn alpha(&self) -> Option<f32> {
|
||||
let age = self.live.as_ref()?.age;
|
||||
if age <= PING_HOLD {
|
||||
return Some(1.0);
|
||||
}
|
||||
let tail = (PING_HOLD + PING_FADE - age) / PING_FADE;
|
||||
if tail <= 0.0 {
|
||||
None
|
||||
} else if PING_ALPHA_IS_A_POP {
|
||||
// The reference's `255 * tail`, clamped by SetAlpha's own 0..1 — full until the last
|
||||
// ~2 ms. Written as the clamp rather than as a constant 1.0 so the mechanism is
|
||||
// visible and the `false` branch is a real alternative, not a rewrite.
|
||||
Some((255.0 * tail).min(1.0))
|
||||
} else {
|
||||
Some(tail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a `Minimap:PingLocation(x, y)` click into the world point it names, drain-side.
|
||||
@@ -254,8 +231,10 @@ fn click_to_world(ctx: &BlipCtx, ui: (f32, f32), seam: f32) -> Option<(f32, f32)
|
||||
Some((ctx.wx + up_yd, ctx.wy - right_yd))
|
||||
}
|
||||
|
||||
/// Seat this frame's `Minimap:PingLocation` click and draw the live ping — both inside the
|
||||
/// Seat this frame's `Minimap:PingLocation` click and draw the ping — both inside the
|
||||
/// renderer, against the geometry the player actually clicked on and the map actually drew at.
|
||||
/// `shown` is the stock [`PING_FRAME`]'s effective alpha this frame (`None` = hidden), read
|
||||
/// beside the click: the frame is the ping's lifetime (module doc), so nothing draws without it.
|
||||
///
|
||||
/// The seat happens here rather than in a system of its own precisely so there is no window in
|
||||
/// which a click is held against a *stale* view scale: the first version parked the click for a
|
||||
@@ -266,15 +245,16 @@ pub(super) fn emit_ping(
|
||||
ctx: &BlipCtx,
|
||||
ping: &mut MinimapPing,
|
||||
click: Option<(f32, f32)>,
|
||||
map: u32,
|
||||
shown: Option<f32>,
|
||||
art: &PingArt,
|
||||
quads: &mut UiQuads,
|
||||
) {
|
||||
if let Some(world) = click.and_then(|c| click_to_world(ctx, c, ctx.seam)) {
|
||||
ping.seat(world, map, 0);
|
||||
ping.seat(world, 0);
|
||||
}
|
||||
|
||||
let Some(alpha) = ping.alpha() else { return };
|
||||
ping.shown = shown;
|
||||
let Some(alpha) = shown else { return };
|
||||
let Some(live) = ping.live.as_ref() else {
|
||||
return;
|
||||
};
|
||||
@@ -282,7 +262,8 @@ pub(super) fn emit_ping(
|
||||
|
||||
// The reference's Lua hides the marker outside the disc and keeps the ping alive
|
||||
// (`Minimap_SetPing`'s else-branch is `MiniMapPing:Hide()`, not a clear) — so walking back
|
||||
// into range brings it back for the rest of its 5 s.
|
||||
// into range brings it back for the rest of its 5 s. The stock file makes that test itself
|
||||
// and hides the frame; this one is the same test in yards, for the frame it cannot see.
|
||||
let d = (px - ctx.wx).hypot(py - ctx.wy);
|
||||
if d >= ctx.radius_yd {
|
||||
return;
|
||||
@@ -329,7 +310,7 @@ pub(super) fn emit_ping(
|
||||
);
|
||||
}
|
||||
|
||||
/// Age the ping, announce a fresh one, and expire it — everything that is *not* geometry.
|
||||
/// Announce a fresh ping and republish the position — everything that is *not* geometry.
|
||||
///
|
||||
/// Runs before the script tick so the `MINIMAP_PING` event and the position behind
|
||||
/// `Minimap:GetPingPosition()` land in the same tick, and so an addon's handler sees a ping that
|
||||
@@ -340,46 +321,21 @@ pub(super) fn drive_minimap_ping(
|
||||
mut ping: ResMut<MinimapPing>,
|
||||
time: Res<Time>,
|
||||
player: Res<Player>,
|
||||
map: Option<Res<benilla_world::world_map::CurrentMap>>,
|
||||
widget: Res<super::MinimapWidget>,
|
||||
inside: Res<super::MinimapInside>,
|
||||
group: Res<crate::ui_party::GroupState>,
|
||||
self_q: Query<&Guid, With<SelfPlayer>>,
|
||||
commands: Res<NetCommands>,
|
||||
) {
|
||||
// Ageing and expiry run before the VM guard: a ping seated with no UI up (the wire can land
|
||||
// one across a world-enter) must still run out, rather than waiting to start its five seconds
|
||||
// whenever a VM next appears.
|
||||
if let Some(live) = ping.live.as_mut() {
|
||||
live.age += time.delta_secs();
|
||||
// The model clock runs only while a ping is up — see `shown_ms`. (The reference stops it
|
||||
// on `Hide()`, which also covers the off-disc case; we keep it running there, a difference
|
||||
// of a few tenths of a phase on a marker nobody can see.)
|
||||
// The model clock runs only while the frame is shown — see `shown_ms`. (The reference's
|
||||
// `Hide()` covers the off-disc case too; ours keeps running there, a difference of a few
|
||||
// tenths of a phase on a marker nobody can see.)
|
||||
if ping.shown.is_some() {
|
||||
ping.shown_ms += time.delta_secs() * 1000.0;
|
||||
}
|
||||
// A map change drops it — a DELIBERATE divergence, now that the law is known. wow-re
|
||||
// `minimap-ping-law.md` §3 is categorical: **nothing** engine-side ever clears the reference's
|
||||
// ping (six instructions touch those cells; the only zeroing is a CRT static initializer before
|
||||
// `WinMain`), so a real 1.12 client that teleports mid-ping keeps drawing a marker at a world
|
||||
// point that is now somewhere else entirely. That is a consequence of storing the ping in a
|
||||
// never-cleared global, not a behaviour anyone chose, and the 5 s timer hides it in practice.
|
||||
// We drop it instead: same observable in every case a player can produce, minus the stale
|
||||
// marker after a worldport.
|
||||
if let (Some(live), Some(map)) = (ping.live.as_ref(), map.as_ref()) {
|
||||
if live.map != map.0 {
|
||||
ping.live = None;
|
||||
}
|
||||
}
|
||||
if ping.alpha().is_none() {
|
||||
ping.live = None;
|
||||
}
|
||||
if ping.live.is_none() {
|
||||
ping.fresh = false;
|
||||
}
|
||||
|
||||
let Some(mut script) = script else { return };
|
||||
let Some(live) = ping.live.as_ref() else {
|
||||
script.set_minimap_ping(None);
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -402,7 +358,7 @@ pub(super) fn drive_minimap_ping(
|
||||
);
|
||||
let k = 1.0 / (2.0 * radius);
|
||||
let norm = ((wow[1] - live.world.1) * k, (live.world.0 - wow[0]) * k);
|
||||
script.set_minimap_ping(Some(norm));
|
||||
script.set_minimap_ping(norm);
|
||||
|
||||
if !std::mem::take(&mut ping.fresh) {
|
||||
return;
|
||||
@@ -523,7 +479,7 @@ mod tests {
|
||||
fn the_marker_tracks_the_world_as_the_player_walks() {
|
||||
let mut c = ctx();
|
||||
let mut ping = MinimapPing::default();
|
||||
ping.seat((30.0, 0.0), 0, 0); // 30 yd north of the player
|
||||
ping.seat((30.0, 0.0), 0); // 30 yd north of the player
|
||||
let live = ping.live.as_ref().unwrap();
|
||||
let before = c.offset([live.world.0, live.world.1, 0.0]);
|
||||
assert!(before.y < 0.0, "north draws UP the screen: {before:?}");
|
||||
@@ -542,29 +498,43 @@ mod tests {
|
||||
/// storage to clear at all. Standing on your own ping must not delete it.
|
||||
#[test]
|
||||
fn reaching_the_ping_does_not_clear_it() {
|
||||
let mut c = ctx();
|
||||
let mut ping = MinimapPing::default();
|
||||
ping.seat((1.0, 1.0), 0, 0);
|
||||
assert!(ping.alpha().is_some());
|
||||
// Age it well inside the hold, standing right on top of the point.
|
||||
ping.live.as_mut().unwrap().age = 2.0;
|
||||
assert_eq!(ping.alpha(), Some(1.0), "a reached ping still holds");
|
||||
let mut quads = UiQuads::default();
|
||||
ping.seat((1.0, 1.0), 0);
|
||||
// Walk onto the point: it still draws.
|
||||
c.wx = 1.0;
|
||||
c.wy = 1.0;
|
||||
emit_ping(&c, &mut ping, None, Some(1.0), &art(), &mut quads);
|
||||
assert!(!quads.overlays.is_empty(), "a reached ping still draws");
|
||||
}
|
||||
|
||||
/// The hold, then the reference's clamped tail, then gone.
|
||||
/// **The lifetime is the stock frame's** (1974): the sprite draws while `MiniMapPing` is
|
||||
/// shown, at the frame's alpha, and nothing here ages it. The model clock advances only
|
||||
/// while the frame is shown.
|
||||
#[test]
|
||||
fn the_ping_holds_five_seconds_and_pops() {
|
||||
fn the_marker_draws_only_while_the_stock_frame_is_shown() {
|
||||
let c = ctx();
|
||||
let mut ping = MinimapPing::default();
|
||||
ping.seat((0.0, 0.0), 0, 0);
|
||||
for (age, want) in [(0.0, Some(1.0)), (4.9, Some(1.0)), (5.4, Some(1.0))] {
|
||||
ping.live.as_mut().unwrap().age = age;
|
||||
assert_eq!(ping.alpha(), want, "at {age}s");
|
||||
}
|
||||
// The last ~2 ms is the only part of the "fade" that is below full.
|
||||
ping.live.as_mut().unwrap().age = PING_HOLD + PING_FADE - 0.0005;
|
||||
let a = ping.alpha().expect("still alive");
|
||||
assert!(a > 0.0 && a < 1.0, "the pop's one dim frame: {a}");
|
||||
ping.live.as_mut().unwrap().age = PING_HOLD + PING_FADE;
|
||||
assert_eq!(ping.alpha(), None, "over");
|
||||
let mut quads = UiQuads::default();
|
||||
ping.seat((0.0, 0.0), 0);
|
||||
emit_ping(&c, &mut ping, None, None, &art(), &mut quads);
|
||||
assert!(quads.overlays.is_empty(), "hidden frame: nothing drawn");
|
||||
assert!(ping.shown.is_none());
|
||||
emit_ping(&c, &mut ping, None, Some(0.5), &art(), &mut quads);
|
||||
assert!(!quads.overlays.is_empty(), "shown frame: drawn");
|
||||
assert!(
|
||||
quads
|
||||
.overlays
|
||||
.iter()
|
||||
.all(|q| (q.color[3] - 0.5).abs() < 1e-6 || q.color[3] == 0.0),
|
||||
"at the frame's alpha (the ring is transparent at phase 0)"
|
||||
);
|
||||
assert_eq!(ping.shown, Some(0.5));
|
||||
assert!(
|
||||
ping.live.is_some(),
|
||||
"the world point outlives the frame's show"
|
||||
);
|
||||
}
|
||||
|
||||
fn art() -> PingArt {
|
||||
@@ -588,11 +558,18 @@ mod tests {
|
||||
let art = art();
|
||||
|
||||
// No ping: nothing drawn.
|
||||
emit_ping(&c, &mut ping, None, 0, &art, &mut quads);
|
||||
emit_ping(&c, &mut ping, None, Some(1.0), &art, &mut quads);
|
||||
assert!(quads.overlays.is_empty());
|
||||
|
||||
// A click 30 UI units up (north) at seam 1 seats a ping 30/px_per_yd yards north.
|
||||
emit_ping(&c, &mut ping, Some((0.0, 30.0)), 0, &art, &mut quads);
|
||||
emit_ping(
|
||||
&c,
|
||||
&mut ping,
|
||||
Some((0.0, 30.0)),
|
||||
Some(1.0),
|
||||
&art,
|
||||
&mut quads,
|
||||
);
|
||||
assert_eq!(
|
||||
quads.overlays.len(),
|
||||
2,
|
||||
@@ -618,9 +595,9 @@ mod tests {
|
||||
quads.overlays.clear();
|
||||
let mut far = ctx();
|
||||
far.wx = -200.0;
|
||||
emit_ping(&far, &mut ping, None, 0, &art, &mut quads);
|
||||
emit_ping(&far, &mut ping, None, Some(1.0), &art, &mut quads);
|
||||
assert!(quads.overlays.is_empty(), "off the disc: hidden");
|
||||
emit_ping(&c, &mut ping, None, 0, &art, &mut quads);
|
||||
emit_ping(&c, &mut ping, None, Some(1.0), &art, &mut quads);
|
||||
assert_eq!(quads.overlays.len(), 2, "back in range: visible again");
|
||||
}
|
||||
|
||||
@@ -635,10 +612,10 @@ mod tests {
|
||||
let art = art();
|
||||
let ring_at = |ms: f32| {
|
||||
let mut ping = MinimapPing::default();
|
||||
ping.seat((0.0, 0.0), 0, 0);
|
||||
ping.seat((0.0, 0.0), 0);
|
||||
ping.shown_ms = ms;
|
||||
let mut quads = UiQuads::default();
|
||||
emit_ping(&c, &mut ping, None, 0, &art, &mut quads);
|
||||
emit_ping(&c, &mut ping, None, Some(1.0), &art, &mut quads);
|
||||
// The ring is the third layer whenever it is visible at all.
|
||||
quads
|
||||
.overlays
|
||||
@@ -701,9 +678,9 @@ mod tests {
|
||||
#[test]
|
||||
fn a_second_ping_resumes_the_animation_rather_than_restarting_it() {
|
||||
let mut ping = MinimapPing::default();
|
||||
ping.seat((0.0, 0.0), 0, 0);
|
||||
ping.seat((0.0, 0.0), 0);
|
||||
ping.shown_ms = 1234.0;
|
||||
ping.seat((10.0, 10.0), 0, 0);
|
||||
ping.seat((10.0, 10.0), 0);
|
||||
assert!(
|
||||
(ping.shown_ms - 1234.0).abs() < f32::EPSILON,
|
||||
"the clock is the model's, not the ping's"
|
||||
|
||||
@@ -1282,6 +1282,40 @@ pub(crate) enum ClientCommand {
|
||||
},
|
||||
/// `CancelMeetingStoneRequest()` — `CMSG 0x293`, empty.
|
||||
MeetingStoneLeave,
|
||||
/// The enter-world meeting-stone status query — `CMSG 0x296`, empty (decision 1974).
|
||||
MeetingStoneStatusQuery,
|
||||
// ── The tutorial system (decision 1976) ──
|
||||
/// `FlagTutorial` / an auto-acknowledge site — `CMSG_TUTORIAL_FLAG`, the 0-based id.
|
||||
TutorialFlag {
|
||||
id: u32,
|
||||
},
|
||||
/// `ClearTutorials()` — `CMSG_TUTORIAL_CLEAR`, empty.
|
||||
TutorialClear,
|
||||
/// `ResetTutorials()` — `CMSG_TUTORIAL_RESET`, empty.
|
||||
TutorialReset,
|
||||
// ── The battleground list window (decision 1974) ──
|
||||
/// `ShowBattlefieldList(index)` — `CMSG_BATTLEFIELD_LIST`: the queued slot's map.
|
||||
BattlefieldList {
|
||||
map_id: u32,
|
||||
},
|
||||
/// `JoinBattlefield` when the list came from a battlemaster — `CMSG_BATTLEMASTER_JOIN`.
|
||||
BattlemasterJoin {
|
||||
battlemaster: u64,
|
||||
map_id: u32,
|
||||
instance_id: u32,
|
||||
as_group: bool,
|
||||
},
|
||||
/// `RequestBattlefieldPositions()` — `MSG_BATTLEGROUND_PLAYER_POSITIONS` outbound (empty;
|
||||
/// decision 1980), throttled app-side to the reference's 5000 ms.
|
||||
RequestBattlefieldPositions,
|
||||
/// `JoinBattlefield` when the list arrived without one — `CMSG_BATTLEFIELD_JOIN`.
|
||||
BattlefieldJoin {
|
||||
map_id: u32,
|
||||
instance_id: u32,
|
||||
as_group: bool,
|
||||
},
|
||||
/// The world-enter status request — `CMSG_BATTLEFIELD_STATUS`, empty.
|
||||
BattlefieldStatusRequest,
|
||||
/// `ConfirmPetUnlearn()` — `CMSG_PET_UNLEARN` with the latched trainer guid.
|
||||
PetUnlearn {
|
||||
trainer: u64,
|
||||
@@ -2291,6 +2325,9 @@ pub(crate) struct EnteredWorldMessage {
|
||||
/// admitted the session — pushed into the script here because this is the only moment it ever
|
||||
/// arrives (decision 1820).
|
||||
pub(crate) billing_time_rested: u32,
|
||||
/// The tutorial bank, if `SMSG_TUTORIAL_FLAGS` landed during the login handshake (1976);
|
||||
/// otherwise it arrives in the world stream.
|
||||
pub(crate) tutorial_flags: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// The server asked us to play a cinematic (`SMSG_TRIGGER_CINEMATIC`) — a `CinematicSequences.dbc`
|
||||
|
||||
@@ -259,6 +259,9 @@ pub(super) fn apply_net_updates(
|
||||
ResMut<crate::ui_dialog_verbs::BattlefieldQueue>,
|
||||
ResMut<crate::ui_dialog_verbs::MeetingStone>,
|
||||
ResMut<crate::ui_battlefield_score::BattlefieldScoreboard>,
|
||||
ResMut<crate::ui_battlefield::Battlefield>,
|
||||
ResMut<crate::tutorial::Tutorials>,
|
||||
ResMut<crate::ui_battlefield_positions::BattlefieldPositions>,
|
||||
),
|
||||
),
|
||||
// The guard's directions marker (`SMSG_GOSSIP_POI`) and the map id it has to be
|
||||
@@ -483,6 +486,9 @@ pub(super) fn apply_net_updates(
|
||||
mut battlefield_queue,
|
||||
mut meeting_stone,
|
||||
mut battlefield_scoreboard,
|
||||
mut battlefield,
|
||||
mut tutorials,
|
||||
mut battlefield_positions,
|
||||
),
|
||||
),
|
||||
mut poi_marker,
|
||||
@@ -571,10 +577,12 @@ pub(super) fn apply_net_updates(
|
||||
self_guid: guid,
|
||||
name,
|
||||
billing_time_rested,
|
||||
tutorial_flags,
|
||||
} => session::connected(
|
||||
guid,
|
||||
name,
|
||||
billing_time_rested,
|
||||
tutorial_flags,
|
||||
&mut self_guid,
|
||||
&mut status,
|
||||
&mut names,
|
||||
@@ -962,9 +970,17 @@ pub(super) fn apply_net_updates(
|
||||
}
|
||||
SessionEvent::BattlefieldStatus(status) => battlefield_queue.apply(status),
|
||||
SessionEvent::PvpLogData(data) => battlefield_scoreboard.apply(data),
|
||||
SessionEvent::BattlefieldList(list) => battlefield.apply_list(list),
|
||||
SessionEvent::GroupJoinedBattleground { result } => battlefield.apply_verdict(result),
|
||||
SessionEvent::BattlegroundPlayer { guid, joined } => {
|
||||
battlefield.apply_player(guid, joined);
|
||||
}
|
||||
SessionEvent::MeetingStoneSetQueue { area, status } => {
|
||||
meeting_stone.apply(area, status);
|
||||
}
|
||||
SessionEvent::MeetingStoneNotice(notice) => meeting_stone.apply_notice(notice),
|
||||
SessionEvent::TutorialFlags(bytes) => tutorials.apply_flags(&bytes),
|
||||
SessionEvent::BattlefieldPositions(packet) => battlefield_positions.apply(packet),
|
||||
SessionEvent::PlayerBound { binder: npc, area } => {
|
||||
debug!("net: bound to area {area} by {npc:#x}");
|
||||
crate::ui_binder::apply::bound(
|
||||
@@ -1221,7 +1237,7 @@ pub(super) fn apply_net_updates(
|
||||
// between people who are grouped, and a ping from another map would be dropped by the
|
||||
// renderer's own map test anyway.
|
||||
SessionEvent::MinimapPing { guid, x, y } => {
|
||||
ping.seat((x, y), current_map.as_ref().map_or(0, |m| m.0), guid);
|
||||
ping.seat((x, y), guid);
|
||||
}
|
||||
SessionEvent::ReadyCheckAnswer { .. } => {}
|
||||
// ── The duel family (decision 0633): the session mirror + the two DisplayError
|
||||
@@ -1398,7 +1414,9 @@ pub(super) fn apply_net_updates(
|
||||
SessionEvent::LootReleaseResponse { guid } => {
|
||||
loot_release_response(guid, &mut loot, &mut loot_latch)
|
||||
}
|
||||
SessionEvent::ItemPushResult(p) => item_push_result(p, &self_guid, &mut loot),
|
||||
SessionEvent::ItemPushResult(p) => {
|
||||
item_push_result(p, &self_guid, &mut loot, &mut tutorials)
|
||||
}
|
||||
// ── The group-loot roll family (decision 0591) — the GroupLootFrame feed ───────────
|
||||
SessionEvent::LootStartRoll(p) => loot_start_roll(p, &mut loot_rolls),
|
||||
SessionEvent::LootRoll(p) => loot_roll(p, &mut loot_rolls),
|
||||
|
||||
@@ -220,7 +220,12 @@ fn is_our_push(p: &ItemPushResult, self_guid: &SelfGuid) -> bool {
|
||||
/// self check being the only thing that can stop a push here. The reference's
|
||||
/// `CGGameUI::OnItemPush 0x491a60` emits both from this one packet: it returns early only on a guid
|
||||
/// mismatch, and tests `showInChat` further down, after the `ITEM_PUSH` fire.
|
||||
pub(super) fn item_push_result(p: ItemPushResult, self_guid: &SelfGuid, loot: &mut LootState) {
|
||||
pub(super) fn item_push_result(
|
||||
p: ItemPushResult,
|
||||
self_guid: &SelfGuid,
|
||||
loot: &mut LootState,
|
||||
tutorials: &mut crate::tutorial::Tutorials,
|
||||
) {
|
||||
// Solo loot never gets a MONEY_NOTIFY from this server (vmangos comments it out;
|
||||
// the purse rides the ordinary COINAGE field flush) — so this push line is the
|
||||
// one reliable "it landed" signal, surfaced as the "You receive loot/item" line.
|
||||
@@ -231,6 +236,8 @@ pub(super) fn item_push_result(p: ItemPushResult, self_guid: &SelfGuid, loot: &m
|
||||
if !is_our_push(&p, self_guid) {
|
||||
return;
|
||||
}
|
||||
// The item-received handler's tutorial sites (1976): resolved on the tutorial feed.
|
||||
tutorials.item_received(p.item_entry, p.bag_slot, p.item_slot);
|
||||
loot.push_receive(&p);
|
||||
}
|
||||
|
||||
@@ -470,7 +477,12 @@ mod tests {
|
||||
// animates — it only loses its chat line, downstream in `drain_receives`.
|
||||
assert!(is_our_push(&push(ME, false), &me));
|
||||
let mut loot = LootState::default();
|
||||
item_push_result(push(ME, false), &me, &mut loot);
|
||||
item_push_result(
|
||||
push(ME, false),
|
||||
&me,
|
||||
&mut loot,
|
||||
&mut crate::tutorial::Tutorials::default(),
|
||||
);
|
||||
assert_eq!(loot.pending_receive_count(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,10 +101,12 @@ pub(super) fn cinematic_triggered(
|
||||
|
||||
/// We are in the world (the IO thread's first in-world event): record our guid, flip the status,
|
||||
/// and seed the name cache with our own name.
|
||||
#[allow(clippy::too_many_arguments)] // the login's whole hand-off
|
||||
pub(super) fn connected(
|
||||
guid: u64,
|
||||
name: String,
|
||||
billing_time_rested: u32,
|
||||
tutorial_flags: Option<Vec<u8>>,
|
||||
self_guid: &mut SelfGuid,
|
||||
status: &mut NetStatus,
|
||||
names: &mut NameCache,
|
||||
@@ -118,6 +120,7 @@ pub(super) fn connected(
|
||||
names.insert_player(guid, name, None);
|
||||
entered_world.write(EnteredWorldMessage {
|
||||
billing_time_rested,
|
||||
tutorial_flags,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -589,12 +589,14 @@ fn run(
|
||||
session.set_active_mover(guid)?;
|
||||
|
||||
let billing_time_rested = session.billing_time_rested();
|
||||
let tutorial_flags = session.take_tutorial_flags();
|
||||
let (mut reader, writer) = session.into_split()?;
|
||||
if events_tx
|
||||
.send(SessionEvent::Connected {
|
||||
self_guid: guid,
|
||||
name,
|
||||
billing_time_rested,
|
||||
tutorial_flags,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
@@ -1076,6 +1078,26 @@ fn writer_loop(
|
||||
}
|
||||
ClientCommand::LeaveBattlefield { map_id } => w.leave_battlefield(map_id),
|
||||
ClientCommand::MeetingStoneLeave => w.meeting_stone_leave(),
|
||||
ClientCommand::MeetingStoneStatusQuery => w.meeting_stone_status_query(),
|
||||
ClientCommand::TutorialFlag { id } => w.tutorial_flag(id),
|
||||
ClientCommand::TutorialClear => w.tutorial_clear(),
|
||||
ClientCommand::TutorialReset => w.tutorial_reset(),
|
||||
ClientCommand::BattlefieldList { map_id } => w.battlefield_list(map_id),
|
||||
ClientCommand::RequestBattlefieldPositions => {
|
||||
w.request_battlefield_positions()
|
||||
}
|
||||
ClientCommand::BattlemasterJoin {
|
||||
battlemaster,
|
||||
map_id,
|
||||
instance_id,
|
||||
as_group,
|
||||
} => w.battlemaster_join(battlemaster, map_id, instance_id, as_group),
|
||||
ClientCommand::BattlefieldJoin {
|
||||
map_id,
|
||||
instance_id,
|
||||
as_group,
|
||||
} => w.battlefield_join(map_id, instance_id, as_group),
|
||||
ClientCommand::BattlefieldStatusRequest => w.battlefield_status(),
|
||||
ClientCommand::PetUnlearn { trainer } => w.pet_unlearn(trainer),
|
||||
ClientCommand::BankerActivate { guid } => w.banker_activate(guid),
|
||||
ClientCommand::BuyBankSlot { guid } => w.buy_bank_slot(guid),
|
||||
|
||||
@@ -231,11 +231,19 @@ pub(super) fn pill_quads(
|
||||
!matches!(&*cache, Some(c) if c.snap_at == hud.snap_at && c.win_w == win_w && c.top == top);
|
||||
if stale {
|
||||
let cpu = hud.snap.cpu.mean();
|
||||
let main = hud.snap.main.mean();
|
||||
let fps = hud.snap.fps();
|
||||
// One string, the dim run via markup: "59 fps 8.5 ms" — fps dim, cost in full text.
|
||||
let text = match cpu {
|
||||
Some(cpu) => format!("{Q_DIM_MARKUP}{fps:.0} fps|r {cpu:.1} ms"),
|
||||
None => format!("{Q_DIM_MARKUP}-- ms"),
|
||||
// One string, the dim runs via markup: "59 fps 7.0 ms 17.3 cpu" — fps dim, the MAIN
|
||||
// thread's ms in full text (the part of the frame the player feels), and the process-wide
|
||||
// sum dim at the end (every thread, the number a CPU % agrees with — decision 1954: a
|
||||
// raid read 17 on it at a solid 60 with the main thread at 7, and the sum was taken for
|
||||
// a frame time by everyone who looked at it).
|
||||
let text = match (main, cpu) {
|
||||
(Some(main), Some(cpu)) => {
|
||||
format!("{Q_DIM_MARKUP}{fps:.0} fps|r {main:.1} ms {Q_DIM_MARKUP}{cpu:.1} cpu|r")
|
||||
}
|
||||
(None, Some(cpu)) => format!("{Q_DIM_MARKUP}{fps:.0} fps|r {cpu:.1} cpu"),
|
||||
_ => format!("{Q_DIM_MARKUP}-- ms"),
|
||||
};
|
||||
let center = Vec2::new(win_w * 0.5, 0.0); // measured first, then shifted under PILL_TOP
|
||||
let mut e = atlas.lock();
|
||||
|
||||
@@ -16,7 +16,7 @@ use std::collections::VecDeque;
|
||||
use bevy::prelude::*;
|
||||
use bevy::time::Real;
|
||||
|
||||
use super::clock::process_cpu_secs;
|
||||
use super::clock::{main_thread_cpu_secs, process_cpu_secs};
|
||||
|
||||
/// Recent frames kept for the pill's windowed means (~5 s at 60 fps, ~2.5 s at 120).
|
||||
pub(super) const SAMPLE_WINDOW: usize = 300;
|
||||
@@ -70,6 +70,10 @@ pub(super) struct FrameStats {
|
||||
/// currency (0736) and the meter the rail cannot fool (0717).
|
||||
pub(super) cpu: Series,
|
||||
prev_cpu_secs: Option<f64>,
|
||||
/// The MAIN thread's CPU per frame — the part of the frame the player can feel: the wall
|
||||
/// the schedule runs on. Sampled pinned (`sample_frame_time`'s `NonSendMarker`).
|
||||
pub(super) main: Series,
|
||||
prev_main_secs: Option<f64>,
|
||||
}
|
||||
|
||||
impl Default for FrameStats {
|
||||
@@ -77,7 +81,9 @@ impl Default for FrameStats {
|
||||
Self {
|
||||
wall: Series::new(SAMPLE_WINDOW),
|
||||
cpu: Series::new(SAMPLE_WINDOW),
|
||||
main: Series::new(SAMPLE_WINDOW),
|
||||
prev_cpu_secs: None,
|
||||
prev_main_secs: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,6 +108,9 @@ impl FrameStats {
|
||||
for &(wall, cpu) in frames {
|
||||
self.wall.push(wall);
|
||||
self.cpu.push(cpu);
|
||||
// The fixture's main-thread share: a fixed fraction of the sum, so a test can tell
|
||||
// the two apart without a second column.
|
||||
self.main.push(cpu * 0.5);
|
||||
t += dt;
|
||||
}
|
||||
t
|
||||
@@ -109,7 +118,12 @@ impl FrameStats {
|
||||
}
|
||||
|
||||
/// Sample both meters for this frame.
|
||||
pub(super) fn sample_frame_time(time: Res<Time<Real>>, mut stats: ResMut<FrameStats>) {
|
||||
pub(super) fn sample_frame_time(
|
||||
// Pinned to the main thread: `main_thread_cpu_secs` reads the CALLING thread's clock.
|
||||
_pin: bevy::ecs::system::NonSendMarker,
|
||||
time: Res<Time<Real>>,
|
||||
mut stats: ResMut<FrameStats>,
|
||||
) {
|
||||
let wall_ms = time.delta_secs() * 1000.0;
|
||||
stats.wall.push(wall_ms);
|
||||
|
||||
@@ -118,6 +132,11 @@ pub(super) fn sample_frame_time(time: Res<Time<Real>>, mut stats: ResMut<FrameSt
|
||||
stats.cpu.push(((n - prev) * 1000.0) as f32);
|
||||
}
|
||||
stats.prev_cpu_secs = cpu;
|
||||
let main = main_thread_cpu_secs();
|
||||
if let (Some(prev), Some(n)) = (stats.prev_main_secs, main) {
|
||||
stats.main.push(((n - prev) * 1000.0) as f32);
|
||||
}
|
||||
stats.prev_main_secs = main;
|
||||
|
||||
// Log hard hitches so a load freeze is attributable from the log alone (one big stall vs many
|
||||
// medium ones, and roughly when). The first frame's delta is the startup gap, not a hitch.
|
||||
|
||||
@@ -71,6 +71,9 @@ pub(super) fn control(
|
||||
// A knockback the server aimed at our mover (decision 1702) — `wire_in` latches it, the
|
||||
// take-off site below flies it, and the movement stream acks it with the post-launch pose.
|
||||
MessageReader<crate::net::KnockBackMessage>,
|
||||
// The tutorial system's world-input sites (1976): Movement acknowledged + the 10 s
|
||||
// Targeting popup on a movement input, Cameras acknowledged on a mouse-look.
|
||||
crate::tutorial::InputHooks,
|
||||
),
|
||||
// Nested into one param to stay within Bevy's 16-element system-param tuple limit (see `mouse`).
|
||||
speed_capsule: (
|
||||
@@ -483,6 +486,13 @@ pub(super) fn control(
|
||||
turn_right,
|
||||
..
|
||||
} = axes;
|
||||
// The world-input tutorial sites (1976): a movement input, a mouse-look.
|
||||
if fwd_axis != 0 || side_axis != 0 {
|
||||
net.13.moved();
|
||||
}
|
||||
if mouselook {
|
||||
net.13.mouselooked();
|
||||
}
|
||||
// **The mover's own** six speeds, read once here for the whole frame — the turn below and
|
||||
// the run/backpedal selection further down. All six live on the driven unit's `CMovement`
|
||||
// and nothing in the reference's applied-input path ever reads *our* speeds when the mover
|
||||
|
||||
@@ -46,7 +46,12 @@ use super::{AudioListener, SoundConfig, SoundOutput};
|
||||
/// the frame its line appears. Rows, not resolved sounds: the *reading* of `+0x08`/`+0x0c` belongs
|
||||
/// here beside the players, not scattered across the windows that raise messages.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct MessageSounds(Vec<&'static MessageRecord>);
|
||||
pub(crate) struct MessageSounds {
|
||||
records: Vec<&'static MessageRecord>,
|
||||
/// Cues by `PlaySoundByName` name with no catalog row behind them — the tutorial popup
|
||||
/// (`0x4b5390`'s `"TutorialPopup"`, 1976): played on the same 2D SFX path as a row's cue.
|
||||
cues: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl MessageSounds {
|
||||
/// Queue one displayed message's sound.
|
||||
@@ -65,14 +70,19 @@ impl MessageSounds {
|
||||
/// off the display. It is written down because the *ordering* is the surprising part, and a
|
||||
/// future locale or a patched table could give it a tenant.
|
||||
pub(crate) fn push(&mut self, record: &'static MessageRecord) {
|
||||
self.0.push(record);
|
||||
self.records.push(record);
|
||||
}
|
||||
|
||||
/// Queue a named cue with no message behind it.
|
||||
pub(crate) fn push_cue(&mut self, name: &'static str) {
|
||||
self.cues.push(name);
|
||||
}
|
||||
|
||||
/// What is queued — for the tests that drive the producer
|
||||
/// (`crate::ui_action::show_messages`) and read back what the drain would be handed.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn queued(&self) -> &[&'static MessageRecord] {
|
||||
&self.0
|
||||
&self.records
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,10 +98,11 @@ fn play_message_sounds(
|
||||
config: Res<SoundConfig>,
|
||||
listener: Res<AudioListener>,
|
||||
) {
|
||||
if queue.0.is_empty() {
|
||||
if queue.records.is_empty() && queue.cues.is_empty() {
|
||||
return;
|
||||
}
|
||||
let records = std::mem::take(&mut queue.0);
|
||||
let records = std::mem::take(&mut queue.records);
|
||||
let cues = std::mem::take(&mut queue.cues);
|
||||
// Drained even when there is nothing to play it with (headless, no client data), so the queue
|
||||
// can never grow unbounded — `super::ui`'s posture exactly.
|
||||
let (Some(mut kits), Some(assets)) = (kits, assets) else {
|
||||
@@ -100,6 +111,20 @@ fn play_message_sounds(
|
||||
// The reference reads the player's sex per play (`0x49676f` → `0x5ed5b0`), not at table-build
|
||||
// time; a descriptor that has not arrived yet means no voice, not a guessed one.
|
||||
let sex = self_q.iter().next().and_then(|s| s.0.unit_gender());
|
||||
for name in cues {
|
||||
if let Err(e) = kit::play_kit(
|
||||
&mut kits,
|
||||
&assets,
|
||||
&mut out,
|
||||
&config,
|
||||
listener.pos,
|
||||
KitRef::Name(name),
|
||||
None,
|
||||
SoundCategory::Sfx,
|
||||
) {
|
||||
warn!("sound(message): cue {name:?}: {e:#}");
|
||||
}
|
||||
}
|
||||
for record in records {
|
||||
if record.type_tag == NO_SPEECH_TAG {
|
||||
let Some(name) = record.sound else {
|
||||
|
||||
@@ -0,0 +1,705 @@
|
||||
//! The tutorial system (decision 1976; wow-re `system/ui/scratch/tutorial-flags.md`): the two
|
||||
//! bit banks, the fire-once trigger, the acknowledge-and-send setter, the timers, the popup
|
||||
//! sound, and the trigger sites the app can produce — everything behind the stock
|
||||
//! `TutorialFrame.xml`.
|
||||
//!
|
||||
//! ## Two banks, not one (§1, §2)
|
||||
//!
|
||||
//! Both are filled byte for byte from the same `SMSG_TUTORIAL_FLAGS`, and until it lands no
|
||||
//! tutorial can fire. **Bank A** is the fire-once bank: [`Tutorials::trigger`] tests it, sets it,
|
||||
//! and raises `TUTORIAL_TRIGGER(id + 1)` — never sending. **Bank B** is the acknowledged bank:
|
||||
//! [`Tutorials::acknowledge`] (Lua's `FlagTutorial` and the C++ auto-acknowledge sites) tests it,
|
||||
//! sets the bit in BOTH banks, cancels the id's pending timer, and sends `CMSG_TUTORIAL_FLAG`.
|
||||
//! `ClearTutorials`/`ResetTutorials` write both banks and send. So A ⊇ B, and doing the thing
|
||||
//! (moving, chatting, adding a friend…) suppresses the tutorial about it, account-wide.
|
||||
//!
|
||||
//! ## The trigger (§3)
|
||||
//!
|
||||
//! `TriggerTutorial(id, delayMs)`: no bank → silent; bank-A bit set → silent; else the bit is
|
||||
//! set and, with a zero delay, the `TutorialPopup` cue plays and the event fires now; with any
|
||||
//! other delay (unsigned — the 10 s Targeting popup is the one site) a timer holds it. The
|
||||
//! reference bounds-checks nothing; ours refuses an id past the bank as a no-op.
|
||||
//!
|
||||
//! ## The sites (§4, §5)
|
||||
//!
|
||||
//! Fifty-one trigger sites and seven acknowledge sites exist in the reference. The ones this app
|
||||
//! can produce are wired — the self-descriptor edges here, the packets and drains at their own
|
||||
//! seams through [`TutorialEvent`] — and the record names the rest with their conditions.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_ui::script::{ScriptValue, UiScript};
|
||||
|
||||
use crate::char_select::ClientState;
|
||||
use crate::net::{ClientCommand, EnteredWorldMessage, NetCommands, ObjectStore, SelfPlayer};
|
||||
use crate::player::Player;
|
||||
use crate::ui_script::UiInput;
|
||||
|
||||
/// The popup cue `0x4b5390` plays on both legs (`0x846b68`), gated on `MasterSoundEffects` at
|
||||
/// the mixer like every SFX kit.
|
||||
const TUTORIAL_POPUP_SOUND: &str = "TutorialPopup";
|
||||
|
||||
/// The internal (0-based) ids of the sites this app produces, named by their published title.
|
||||
pub(crate) mod id {
|
||||
pub(crate) const QUESTGIVERS: u32 = 0x00;
|
||||
pub(crate) const MOVEMENT: u32 = 0x01;
|
||||
pub(crate) const CAMERAS: u32 = 0x02;
|
||||
pub(crate) const TARGETING: u32 = 0x03;
|
||||
pub(crate) const LOOTING: u32 = 0x06;
|
||||
pub(crate) const BACKPACK: u32 = 0x07;
|
||||
pub(crate) const TRAINERS: u32 = 0x0d;
|
||||
pub(crate) const GROUPING: u32 = 0x11;
|
||||
pub(crate) const VENDORS: u32 = 0x13;
|
||||
pub(crate) const QUEST_LOG: u32 = 0x14;
|
||||
pub(crate) const FRIENDS: u32 = 0x15;
|
||||
pub(crate) const CHATTING: u32 = 0x16;
|
||||
pub(crate) const EQUIPPABLE_ITEMS: u32 = 0x17;
|
||||
pub(crate) const DEATH: u32 = 0x18;
|
||||
pub(crate) const RESTED: u32 = 0x19;
|
||||
pub(crate) const FATIGUE: u32 = 0x1a;
|
||||
pub(crate) const SWIMMING: u32 = 0x1b;
|
||||
pub(crate) const BREATH: u32 = 0x1c;
|
||||
pub(crate) const RESTING: u32 = 0x1d;
|
||||
pub(crate) const HEARTHSTONES: u32 = 0x1e;
|
||||
pub(crate) const PVP_COMBAT: u32 = 0x1f;
|
||||
pub(crate) const TRAVEL: u32 = 0x22;
|
||||
pub(crate) const WELCOME: u32 = 0x29;
|
||||
pub(crate) const RANGED_WEAPONS: u32 = 0x2b;
|
||||
pub(crate) const RAID_GROUPS: u32 = 0x2d;
|
||||
pub(crate) const MEETING_STONES: u32 = 0x2e;
|
||||
pub(crate) const BATTLEGROUND_QUEUE: u32 = 0x2f;
|
||||
pub(crate) const PORT_TO_BATTLEGROUND: u32 = 0x30;
|
||||
pub(crate) const KEYRINGS: u32 = 0x31;
|
||||
}
|
||||
|
||||
/// The level-gated arm of the `SMSG_LEVELUP_INFO` handler (§4): in the handler's own order,
|
||||
/// each fires when the new level is at least its threshold.
|
||||
const LEVEL_TRIGGERS: [(u32, u32); 8] = [
|
||||
(3, 0x16), // Chatting
|
||||
(4, 0x0d), // Trainers
|
||||
(4, 0x02), // Cameras
|
||||
(5, 0x26), // Groups
|
||||
(7, 0x15), // Friends
|
||||
(7, 0x25), // Professions
|
||||
(8, 0x20), // Jumping
|
||||
(10, 0x0c), // Learning Talents
|
||||
];
|
||||
|
||||
/// The Targeting popup's delay at its one site (`0x514a8d mov edx,0x2710`).
|
||||
pub(crate) const TARGETING_DELAY_MS: u32 = 10_000;
|
||||
|
||||
/// The Movement popup's silence window (`0x482ff7`: elapsed − stamp − 90 000 ≥ 0) — INFERRED as
|
||||
/// ninety seconds after world enter with no movement input (§4's row reads the stamp getter,
|
||||
/// not its meaning).
|
||||
const MOVEMENT_SILENCE: Duration = Duration::from_millis(90_000);
|
||||
|
||||
/// A site's ask of the tutorial system — written from wherever the reference's site lives.
|
||||
#[derive(Message, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum TutorialEvent {
|
||||
/// `TriggerTutorial(id, delayMs)`: the fire-once leg.
|
||||
Trigger { id: u32, delay_ms: u32 },
|
||||
/// `SetTutorialFlag(id)` (`0x4b54c0`): the acknowledge-and-send leg.
|
||||
Acknowledge { id: u32 },
|
||||
}
|
||||
|
||||
impl TutorialEvent {
|
||||
pub(crate) const fn trigger(id: u32) -> Self {
|
||||
Self::Trigger { id, delay_ms: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// A send the drains owe the wire.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum TutorialSend {
|
||||
Flag(u32),
|
||||
Clear,
|
||||
Reset,
|
||||
}
|
||||
|
||||
/// A bank: `bit = bytes[id >> 3] & (1 << (id & 7))` — the client's `word = id >> 5` on
|
||||
/// little-endian dwords, byte-wise.
|
||||
fn bank_bit(bank: &[u8], id: u32) -> Option<bool> {
|
||||
bank.get((id >> 3) as usize)
|
||||
.map(|b| b & (1 << (id & 7)) != 0)
|
||||
}
|
||||
|
||||
fn set_bank_bit(bank: &mut [u8], id: u32) {
|
||||
if let Some(b) = bank.get_mut((id >> 3) as usize) {
|
||||
*b |= 1 << (id & 7);
|
||||
}
|
||||
}
|
||||
|
||||
/// The two banks, the timers, and what the frame owes the VM and the wire.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct Tutorials {
|
||||
/// Bank A (`0xb711b8`…): fire-once. `None` until the packet lands.
|
||||
fire_once: Option<Vec<u8>>,
|
||||
/// Bank B (`0xb711e4`…): acknowledged.
|
||||
acknowledged: Option<Vec<u8>>,
|
||||
/// The delayed triggers (`TUTORIALTIMER` nodes): `(id, due)`.
|
||||
timers: Vec<(u32, Instant)>,
|
||||
/// Published ids (`id + 1`) whose event and cue are owed this frame.
|
||||
fired: Vec<u32>,
|
||||
sends: Vec<TutorialSend>,
|
||||
/// Bank B changed since the VM last saw it.
|
||||
dirty: bool,
|
||||
/// Item pushes for us since the last feed (`0x491a60`'s sites): `(entry, bag, slot)`.
|
||||
pushes: Vec<(u32, u8, u32)>,
|
||||
}
|
||||
|
||||
impl Tutorials {
|
||||
/// `SMSG_TUTORIAL_FLAGS`: both banks copied from the payload.
|
||||
pub(crate) fn apply_flags(&mut self, bytes: &[u8]) {
|
||||
self.fire_once = Some(bytes.to_vec());
|
||||
self.acknowledged = Some(bytes.to_vec());
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// The bring-up `0x4b5330`: both banks zeroed if present (the bit count stays).
|
||||
fn world_enter(&mut self) {
|
||||
for bank in [&mut self.fire_once, &mut self.acknowledged]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
bank.iter_mut().for_each(|b| *b = 0);
|
||||
}
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// The teardown `0x4b5380`: every pending timer flushed.
|
||||
fn world_leave(&mut self) {
|
||||
self.timers.clear();
|
||||
}
|
||||
|
||||
/// `TriggerTutorial(id, delayMs)` (§3).
|
||||
pub(crate) fn trigger(&mut self, id: u32, delay_ms: u32, now: Instant) {
|
||||
let Some(bank) = self.fire_once.as_mut() else {
|
||||
return; // no bank yet: silent
|
||||
};
|
||||
match bank_bit(bank, id) {
|
||||
Some(false) => set_bank_bit(bank, id),
|
||||
_ => return, // already set — or past the bank, which the reference would overrun
|
||||
}
|
||||
if delay_ms == 0 {
|
||||
self.fired.push(id + 1);
|
||||
} else {
|
||||
self.timers
|
||||
.push((id, now + Duration::from_millis(u64::from(delay_ms))));
|
||||
}
|
||||
}
|
||||
|
||||
/// `SetTutorialFlag(id)` (`0x4b54c0`, §5): bank B's bit gates; both banks set; the id's timer
|
||||
/// cancelled; the 0-based id sent. The reference dereferences an absent bank B; ours treats
|
||||
/// that as a no-op.
|
||||
pub(crate) fn acknowledge(&mut self, id: u32) {
|
||||
let Some(bank_b) = self.acknowledged.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if bank_bit(bank_b, id) != Some(false) {
|
||||
return;
|
||||
}
|
||||
self.timers.retain(|(t, _)| *t != id);
|
||||
set_bank_bit(bank_b, id);
|
||||
if let Some(bank_a) = self.fire_once.as_mut() {
|
||||
set_bank_bit(bank_a, id);
|
||||
}
|
||||
self.sends.push(TutorialSend::Flag(id));
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// `ClearTutorials()`: the timers flushed, every bit of both banks set, the empty clear sent.
|
||||
fn clear(&mut self) {
|
||||
self.timers.clear();
|
||||
for bank in [&mut self.fire_once, &mut self.acknowledged]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
bank.iter_mut().for_each(|b| *b = 0xFF);
|
||||
}
|
||||
self.sends.push(TutorialSend::Clear);
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// `ResetTutorials()`: every bit of both banks cleared — the timers NOT flushed — the empty
|
||||
/// reset sent.
|
||||
fn reset(&mut self) {
|
||||
for bank in [&mut self.fire_once, &mut self.acknowledged]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
bank.iter_mut().for_each(|b| *b = 0);
|
||||
}
|
||||
self.sends.push(TutorialSend::Reset);
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// `ProcessTutorialTimers`: every due node fires the same cue and event.
|
||||
fn tick(&mut self, now: Instant) {
|
||||
let mut due = Vec::new();
|
||||
self.timers.retain(|&(id, at)| {
|
||||
if at <= now {
|
||||
due.push(id + 1);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
self.fired.extend(due);
|
||||
}
|
||||
|
||||
/// `SMSG_ITEM_PUSH_RESULT` for one of ours — the item-received handler's five sites, resolved
|
||||
/// on the next feed with the item's template.
|
||||
pub(crate) fn item_received(&mut self, entry: u32, bag: u8, slot: u32) {
|
||||
self.pushes.push((entry, bag, slot));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn bits(&self) -> (Option<&[u8]>, Option<&[u8]>) {
|
||||
(self.fire_once.as_deref(), self.acknowledged.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
/// The self descriptor's last-seen fields, for the edge-triggered sites (§4): the level-up
|
||||
/// arm, the ghost flag, the resting flag, the PvP flag, the rest state.
|
||||
#[derive(Default)]
|
||||
struct SelfWatch {
|
||||
level: Option<u32>,
|
||||
player_flags: Option<u32>,
|
||||
unit_flags: Option<u32>,
|
||||
rest_state: Option<u8>,
|
||||
swimming: Option<bool>,
|
||||
}
|
||||
|
||||
const PLAYER_FLAGS_GHOST: u32 = 0x10;
|
||||
const PLAYER_FLAGS_RESTING: u32 = 0x20;
|
||||
const UNIT_FLAG_PVP_ATTACKABLE: u32 = 0x1000;
|
||||
|
||||
/// The hearthstone's item entry (`0x1b24`).
|
||||
const HEARTHSTONE: u32 = 6948;
|
||||
const INVTYPE_RANGED: u32 = 15;
|
||||
const CLASS_HUNTER: u8 = 3;
|
||||
/// The keyring bag and its slot band (`edi == 0xff`, slot ∈ [0x51, 0x70]).
|
||||
const KEYRING_BAG: u8 = 0xff;
|
||||
const KEYRING_SLOTS: std::ops::RangeInclusive<u32> = 0x51..=0x70;
|
||||
|
||||
/// Before the script tick: the sites' asks, the descriptor edges, the timers, the cue and the
|
||||
/// event, and the acknowledged bank's push to the VM.
|
||||
fn feed_tutorials(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut tutorials: ResMut<Tutorials>,
|
||||
mut asks: MessageReader<TutorialEvent>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
items: Res<crate::items::Items>,
|
||||
mut sounds: ResMut<crate::sound::MessageSounds>,
|
||||
) {
|
||||
let now = Instant::now();
|
||||
for ask in asks.read() {
|
||||
match *ask {
|
||||
TutorialEvent::Trigger { id, delay_ms } => tutorials.trigger(id, delay_ms, now),
|
||||
TutorialEvent::Acknowledge { id } => tutorials.acknowledge(id),
|
||||
}
|
||||
}
|
||||
|
||||
// The item-received handler's sites, over the item's template: Backpack unconditionally;
|
||||
// Equippable Items for an inventory type with an equip slot (INFERRED as any non-zero type —
|
||||
// the reference's 23-bit mask is not carved bit by bit); Hearthstones by entry; Ranged
|
||||
// Weapons for a ranged type on a non-hunter; Keyrings by the slot band.
|
||||
let pushes = std::mem::take(&mut tutorials.pushes);
|
||||
let class = self_q.single().ok().and_then(|s| s.0.unit_class());
|
||||
for (entry, bag, slot) in pushes {
|
||||
tutorials.trigger(id::BACKPACK, 0, now);
|
||||
if let Some(info) = items.template_cached(entry) {
|
||||
if info.inventory_type != 0 {
|
||||
tutorials.trigger(id::EQUIPPABLE_ITEMS, 0, now);
|
||||
}
|
||||
if info.inventory_type == INVTYPE_RANGED && class != Some(CLASS_HUNTER) {
|
||||
tutorials.trigger(id::RANGED_WEAPONS, 0, now);
|
||||
}
|
||||
}
|
||||
if entry == HEARTHSTONE {
|
||||
tutorials.trigger(id::HEARTHSTONES, 0, now);
|
||||
}
|
||||
if bag == KEYRING_BAG && KEYRING_SLOTS.contains(&slot) {
|
||||
tutorials.trigger(id::KEYRINGS, 0, now);
|
||||
}
|
||||
}
|
||||
|
||||
tutorials.tick(now);
|
||||
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
for published in std::mem::take(&mut tutorials.fired) {
|
||||
sounds.push_cue(TUTORIAL_POPUP_SOUND);
|
||||
script.fire_event(
|
||||
"TUTORIAL_TRIGGER",
|
||||
vec![ScriptValue::Int(i64::from(published))],
|
||||
);
|
||||
}
|
||||
if std::mem::take(&mut tutorials.dirty) {
|
||||
script.set_tutorial_bank(tutorials.acknowledged.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// The self descriptor's edge sites (§4): the `SMSG_LEVELUP_INFO` arm on the level rising, the
|
||||
/// ghost and resting flags, the PvP flag, the rested table's one live row, and the mover
|
||||
/// entering water — a change each, never the login descriptor. The memory is per world session:
|
||||
/// the bring-up resets it, so a re-login's first descriptor arms silently like the first one.
|
||||
fn watch_self(
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
player: Res<Player>,
|
||||
mut cascade: ResMut<WorldEnterCascade>,
|
||||
mut watch: Local<SelfWatch>,
|
||||
mut asks: MessageWriter<TutorialEvent>,
|
||||
) {
|
||||
if std::mem::take(&mut cascade.reset_watch) {
|
||||
*watch = SelfWatch::default();
|
||||
}
|
||||
// The descriptor edges — a change, never the login descriptor (the watchers run on
|
||||
// field changes; the first sight arms silently).
|
||||
if let Ok(store) = self_q.single() {
|
||||
let level = store.0.unit_level();
|
||||
let player_flags = store.0.player_flags();
|
||||
let unit_flags = store.0.unit_flags();
|
||||
let rest_state = store.0.player_rest_state();
|
||||
if let (Some(prev), Some(new)) = (watch.level, level) {
|
||||
if new > prev {
|
||||
for (threshold, id) in LEVEL_TRIGGERS {
|
||||
if new >= threshold {
|
||||
asks.write(TutorialEvent::trigger(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(prev) = watch.player_flags {
|
||||
let rose = |bit: u32| prev & bit == 0 && player_flags & bit != 0;
|
||||
if rose(PLAYER_FLAGS_GHOST) {
|
||||
asks.write(TutorialEvent::trigger(id::DEATH));
|
||||
}
|
||||
if rose(PLAYER_FLAGS_RESTING) {
|
||||
asks.write(TutorialEvent::trigger(id::RESTING));
|
||||
}
|
||||
}
|
||||
if let Some(prev) = watch.unit_flags {
|
||||
if prev & UNIT_FLAG_PVP_ATTACKABLE == 0 && unit_flags & UNIT_FLAG_PVP_ATTACKABLE != 0 {
|
||||
asks.write(TutorialEvent::trigger(id::PVP_COMBAT));
|
||||
}
|
||||
}
|
||||
if let (Some(prev), Some(new)) = (watch.rest_state, rest_state) {
|
||||
// The rested table: only rest state 1 names a tutorial (the other rows carry the
|
||||
// sentinel), on the field's change.
|
||||
if new != prev && new == 1 {
|
||||
asks.write(TutorialEvent::trigger(id::RESTED));
|
||||
}
|
||||
}
|
||||
watch.level = level;
|
||||
watch.player_flags = Some(player_flags);
|
||||
watch.unit_flags = Some(unit_flags);
|
||||
watch.rest_state = rest_state;
|
||||
}
|
||||
if let Some(prev) = watch.swimming {
|
||||
if !prev && player.swimming {
|
||||
asks.write(TutorialEvent::trigger(id::SWIMMING));
|
||||
}
|
||||
}
|
||||
watch.swimming = Some(player.swimming);
|
||||
}
|
||||
|
||||
/// After the script tick: the four verbs' writes, then every send.
|
||||
fn drain_tutorials(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut tutorials: ResMut<Tutorials>,
|
||||
commands: Res<NetCommands>,
|
||||
) {
|
||||
if let Some(mut script) = script {
|
||||
for id in script.take_tutorial_flag_requests() {
|
||||
tutorials.acknowledge(id);
|
||||
}
|
||||
for _ in 0..script.take_tutorial_clears() {
|
||||
tutorials.clear();
|
||||
}
|
||||
for _ in 0..script.take_tutorial_resets() {
|
||||
tutorials.reset();
|
||||
}
|
||||
}
|
||||
for send in std::mem::take(&mut tutorials.sends) {
|
||||
let command = match send {
|
||||
TutorialSend::Flag(id) => ClientCommand::TutorialFlag { id },
|
||||
TutorialSend::Clear => ClientCommand::TutorialClear,
|
||||
TutorialSend::Reset => ClientCommand::TutorialReset,
|
||||
};
|
||||
let _ = commands.0.send(command);
|
||||
}
|
||||
}
|
||||
|
||||
/// The world-enter bring-up: the banks zeroed, then the bank captured during the login handshake
|
||||
/// applied if there was one (the world stream's copy lands through `SessionEvent` otherwise).
|
||||
fn on_world_enter(
|
||||
mut entered: MessageReader<EnteredWorldMessage>,
|
||||
mut tutorials: ResMut<Tutorials>,
|
||||
mut cascade: ResMut<WorldEnterCascade>,
|
||||
) {
|
||||
for e in entered.read() {
|
||||
tutorials.world_enter();
|
||||
if let Some(bytes) = &e.tutorial_flags {
|
||||
tutorials.apply_flags(bytes);
|
||||
}
|
||||
cascade.armed = true;
|
||||
cascade.entered_at = Some(Instant::now());
|
||||
cascade.moved = false;
|
||||
cascade.reset_watch = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// The enter-world cascade `0x4908c0`'s two unconditional triggers — Welcome (`0x490a48`) then
|
||||
/// Questgivers (`0x490a51`) — run once per world session when the local player object exists,
|
||||
/// which is after the bank has landed on a stock server; and the Movement popup's silence window.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct WorldEnterCascade {
|
||||
armed: bool,
|
||||
entered_at: Option<Instant>,
|
||||
/// A movement input happened this session (the Movement auto-acknowledge site).
|
||||
pub(crate) moved: bool,
|
||||
/// The descriptor watcher's memory is owed a reset (a new world session).
|
||||
reset_watch: bool,
|
||||
}
|
||||
|
||||
fn run_world_enter_cascade(
|
||||
mut cascade: ResMut<WorldEnterCascade>,
|
||||
mut tutorials: ResMut<Tutorials>,
|
||||
self_q: Query<(), With<SelfPlayer>>,
|
||||
) {
|
||||
let now = Instant::now();
|
||||
if cascade.armed && !self_q.is_empty() {
|
||||
cascade.armed = false;
|
||||
tutorials.trigger(id::WELCOME, 0, now);
|
||||
tutorials.trigger(id::QUESTGIVERS, 0, now);
|
||||
}
|
||||
if let Some(at) = cascade.entered_at {
|
||||
if !cascade.moved && now.duration_since(at) >= MOVEMENT_SILENCE {
|
||||
cascade.entered_at = None;
|
||||
tutorials.trigger(id::MOVEMENT, 0, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The world-input sites (`0x514840`, §4/§5), bundled for the controller: a movement input
|
||||
/// acknowledges Movement and arms the 10 s Targeting popup; a mouse-look acknowledges Cameras.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
pub(crate) struct InputHooks<'w> {
|
||||
/// Optional, so a controller test's partial app runs without the tutorial plugin.
|
||||
asks: Option<MessageWriter<'w, TutorialEvent>>,
|
||||
cascade: Option<ResMut<'w, WorldEnterCascade>>,
|
||||
}
|
||||
|
||||
impl InputHooks<'_> {
|
||||
/// A movement input this frame (`test ecx,0x1030` — INFERRED as the forward/side axes).
|
||||
pub(crate) fn moved(&mut self) {
|
||||
if let Some(c) = self.cascade.as_mut() {
|
||||
c.moved = true;
|
||||
}
|
||||
if let Some(asks) = self.asks.as_mut() {
|
||||
asks.write(TutorialEvent::Acknowledge { id: id::MOVEMENT });
|
||||
asks.write(TutorialEvent::Trigger {
|
||||
id: id::TARGETING,
|
||||
delay_ms: TARGETING_DELAY_MS,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// A mouse-look this frame (`[ebp+8] & 3`, the two buttons).
|
||||
pub(crate) fn mouselooked(&mut self) {
|
||||
if let Some(asks) = self.asks.as_mut() {
|
||||
asks.write(TutorialEvent::Acknowledge { id: id::CAMERAS });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The window edges the reference triggers or acknowledges from inside its handlers: the
|
||||
/// trainer list (Trainers acknowledged, `0x4d74ae`), the vendor list (Vendors, `0x4fad32`), the
|
||||
/// taxi map (Travel, `0x4dbaa2`), and the group turning raid (Raid Groups, `0x4ba60d` — INFERRED
|
||||
/// as the raid-type edge; the row names only its non-zero argument).
|
||||
#[derive(Default)]
|
||||
struct WindowWatch {
|
||||
trainer: Option<u64>,
|
||||
vendor: Option<u64>,
|
||||
taxi: bool,
|
||||
raid: Option<bool>,
|
||||
}
|
||||
|
||||
fn watch_windows(
|
||||
trainer: Res<crate::ui_trainer::TrainerOpen>,
|
||||
merchant: Res<crate::ui_merchant::MerchantOpen>,
|
||||
taxi: Res<crate::ui_taxi::TaxiState>,
|
||||
group: Res<crate::ui_party::GroupState>,
|
||||
mut watch: Local<WindowWatch>,
|
||||
mut asks: MessageWriter<TutorialEvent>,
|
||||
) {
|
||||
if trainer.trainer.is_some() && trainer.trainer != watch.trainer {
|
||||
asks.write(TutorialEvent::Acknowledge { id: id::TRAINERS });
|
||||
}
|
||||
watch.trainer = trainer.trainer;
|
||||
if merchant.vendor.is_some() && merchant.vendor != watch.vendor {
|
||||
asks.write(TutorialEvent::trigger(id::VENDORS));
|
||||
}
|
||||
watch.vendor = merchant.vendor;
|
||||
let taxi_open = taxi.open.is_some();
|
||||
if taxi_open && !watch.taxi {
|
||||
asks.write(TutorialEvent::trigger(id::TRAVEL));
|
||||
}
|
||||
watch.taxi = taxi_open;
|
||||
let raid = group.in_group && group.group_type == crate::ui_party::GROUPTYPE_RAID;
|
||||
if raid && watch.raid == Some(false) {
|
||||
asks.write(TutorialEvent::trigger(id::RAID_GROUPS));
|
||||
}
|
||||
watch.raid = Some(raid);
|
||||
}
|
||||
|
||||
fn on_world_leave(mut tutorials: ResMut<Tutorials>, mut cascade: ResMut<WorldEnterCascade>) {
|
||||
tutorials.world_leave();
|
||||
cascade.armed = false;
|
||||
cascade.entered_at = None;
|
||||
}
|
||||
|
||||
pub(crate) struct TutorialPlugin;
|
||||
|
||||
impl Plugin for TutorialPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<Tutorials>()
|
||||
.init_resource::<WorldEnterCascade>()
|
||||
.add_message::<TutorialEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
on_world_enter.before(run_world_enter_cascade),
|
||||
run_world_enter_cascade.before(feed_tutorials),
|
||||
watch_windows.before(feed_tutorials),
|
||||
watch_self.after(on_world_enter).before(feed_tutorials),
|
||||
feed_tutorials.before(UiInput),
|
||||
drain_tutorials.after(UiInput),
|
||||
),
|
||||
)
|
||||
.add_systems(OnExit(ClientState::InWorld), on_world_leave);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn banked() -> Tutorials {
|
||||
let mut t = Tutorials::default();
|
||||
t.apply_flags(&[0u8; 32]);
|
||||
t
|
||||
}
|
||||
|
||||
/// No bank: silent. A clear bit: set in A only, the event owed, nothing sent. Twice: once.
|
||||
#[test]
|
||||
fn the_trigger_fires_once_per_bank_a_and_never_sends() {
|
||||
let now = Instant::now();
|
||||
let mut t = Tutorials::default();
|
||||
t.trigger(id::WELCOME, 0, now);
|
||||
assert!(t.fired.is_empty(), "no bank yet: silent");
|
||||
let mut t = banked();
|
||||
t.trigger(id::WELCOME, 0, now);
|
||||
t.trigger(id::WELCOME, 0, now);
|
||||
assert_eq!(t.fired, vec![id::WELCOME + 1], "published as id + 1, once");
|
||||
let (a, b) = t.bits();
|
||||
assert_eq!(bank_bit(a.unwrap(), id::WELCOME), Some(true));
|
||||
assert_eq!(
|
||||
bank_bit(b.unwrap(), id::WELCOME),
|
||||
Some(false),
|
||||
"bank B untouched"
|
||||
);
|
||||
assert!(t.sends.is_empty(), "the trigger sends nothing");
|
||||
t.trigger(300, 0, now);
|
||||
assert_eq!(t.fired.len(), 1, "past the bank: a no-op");
|
||||
}
|
||||
|
||||
/// The delayed leg holds the id until due; an acknowledge cancels it.
|
||||
#[test]
|
||||
fn a_delayed_trigger_waits_and_an_acknowledge_cancels_it() {
|
||||
let now = Instant::now();
|
||||
let mut t = banked();
|
||||
t.trigger(id::TARGETING, TARGETING_DELAY_MS, now);
|
||||
assert!(t.fired.is_empty());
|
||||
t.tick(now + Duration::from_millis(9_999));
|
||||
assert!(t.fired.is_empty(), "not yet");
|
||||
t.tick(now + Duration::from_millis(10_000));
|
||||
assert_eq!(t.fired, vec![id::TARGETING + 1]);
|
||||
|
||||
let mut t = banked();
|
||||
t.trigger(id::TARGETING, TARGETING_DELAY_MS, now);
|
||||
t.acknowledge(id::TARGETING);
|
||||
t.tick(now + Duration::from_millis(20_000));
|
||||
assert!(t.fired.is_empty(), "the acknowledge cancelled the timer");
|
||||
assert_eq!(t.sends, vec![TutorialSend::Flag(id::TARGETING)]);
|
||||
}
|
||||
|
||||
/// The acknowledge: bank B gates, both banks set, one send; a second is silent; absent bank
|
||||
/// B is a no-op.
|
||||
#[test]
|
||||
fn the_acknowledge_sets_both_banks_and_sends_once() {
|
||||
let mut t = Tutorials::default();
|
||||
t.acknowledge(id::CHATTING);
|
||||
assert!(t.sends.is_empty(), "no bank: nothing");
|
||||
let mut t = banked();
|
||||
t.acknowledge(id::CHATTING);
|
||||
t.acknowledge(id::CHATTING);
|
||||
assert_eq!(t.sends, vec![TutorialSend::Flag(id::CHATTING)]);
|
||||
let (a, b) = t.bits();
|
||||
assert_eq!(bank_bit(a.unwrap(), id::CHATTING), Some(true));
|
||||
assert_eq!(bank_bit(b.unwrap(), id::CHATTING), Some(true));
|
||||
// Acknowledged first: the later trigger is silent (A ⊇ B).
|
||||
t.trigger(id::CHATTING, 0, Instant::now());
|
||||
assert!(t.fired.is_empty());
|
||||
}
|
||||
|
||||
/// Clear sets every bit and flushes the timers; Reset clears every bit and keeps them.
|
||||
#[test]
|
||||
fn clear_and_reset_write_both_banks() {
|
||||
let now = Instant::now();
|
||||
let mut t = banked();
|
||||
t.trigger(id::TARGETING, TARGETING_DELAY_MS, now);
|
||||
t.clear();
|
||||
assert!(t.timers.is_empty(), "clear flushes the timers");
|
||||
let (a, b) = t.bits();
|
||||
assert!(a.unwrap().iter().all(|&x| x == 0xFF) && b.unwrap().iter().all(|&x| x == 0xFF));
|
||||
t.reset();
|
||||
t.trigger(id::TARGETING, TARGETING_DELAY_MS, now);
|
||||
assert_eq!(
|
||||
t.timers.len(),
|
||||
1,
|
||||
"a cleared bank lets the trigger through again"
|
||||
);
|
||||
t.reset();
|
||||
assert_eq!(t.timers.len(), 1, "reset does NOT flush the timers");
|
||||
let (a, b) = t.bits();
|
||||
assert!(a.unwrap().iter().all(|&x| x == 0) && b.unwrap().iter().all(|&x| x == 0));
|
||||
assert_eq!(
|
||||
t.sends,
|
||||
vec![
|
||||
TutorialSend::Clear,
|
||||
TutorialSend::Reset,
|
||||
TutorialSend::Reset
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// World enter zeroes present banks and keeps their size; the packet fills both.
|
||||
#[test]
|
||||
fn world_enter_zeroes_and_the_packet_fills() {
|
||||
let mut t = Tutorials::default();
|
||||
t.world_enter();
|
||||
assert!(t.fire_once.is_none(), "no bank: nothing to zero");
|
||||
t.apply_flags(&[0xFFu8; 4]);
|
||||
t.world_enter();
|
||||
let (a, b) = t.bits();
|
||||
assert_eq!(a.unwrap(), &[0u8; 4]);
|
||||
assert_eq!(b.unwrap(), &[0u8; 4]);
|
||||
assert_eq!(bank_bit(a.unwrap(), 31), Some(false));
|
||||
assert_eq!(bank_bit(a.unwrap(), 32), None, "past a 4-byte bank");
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,12 @@ pub(crate) struct Shown {
|
||||
}
|
||||
|
||||
impl Shown {
|
||||
/// The resolved text — what the surface will show.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
/// A **catalog** message, named by its GlobalStrings key — the reference's `DisplayError(id)`,
|
||||
/// which is nearly every line benilla shows. One lookup answers both "where does it go" and
|
||||
/// "what does it sound like".
|
||||
@@ -232,6 +238,17 @@ pub(crate) fn keyed_line(script: &UiScript, key: &'static str) -> Option<Shown>
|
||||
(!text.is_empty()).then(|| Shown::keyed(key, text))
|
||||
}
|
||||
|
||||
/// [`keyed_line`] for a `%s`-shaped string: each argument fills the next `%s`, in order — the
|
||||
/// reference's `format`-with-pushed-arguments face of the same catalog rows (the battleground
|
||||
/// join verdict's map name, the joined-or-left player's name, 1974).
|
||||
pub(crate) fn keyed_line_s(script: &UiScript, key: &'static str, args: &[&str]) -> Option<Shown> {
|
||||
let mut text = script.lua().globals().get::<String>(key).ok()?;
|
||||
for arg in args {
|
||||
text = text.replacen("%s", arg, 1);
|
||||
}
|
||||
(!text.is_empty()).then(|| Shown::keyed(key, text))
|
||||
}
|
||||
|
||||
/// **The one sink** for a resolved message line: put it on the surface its [`MsgKind`] names, and
|
||||
/// queue the sound its catalog row names.
|
||||
///
|
||||
|
||||
@@ -66,9 +66,9 @@ pub(crate) struct CooldownEvents;
|
||||
pub(crate) use cast_send::{CastCommit, CastLadder};
|
||||
pub(crate) use cast_target::AutoSelfCast;
|
||||
pub(crate) use errors::{
|
||||
attack_actor_blocked, attack_actor_refusal, keyed_line, reagent_totem_refusal, show_messages,
|
||||
ui_error_text, CastErrors, CastFail, MessageSink, MountErrors, Shown, UiError, UiErrorKeys,
|
||||
UiErrorTexts,
|
||||
attack_actor_blocked, attack_actor_refusal, keyed_line, keyed_line_s, reagent_totem_refusal,
|
||||
show_messages, ui_error_text, CastErrors, CastFail, MessageSink, MountErrors, Shown, UiError,
|
||||
UiErrorKeys, UiErrorTexts,
|
||||
};
|
||||
// `pub(crate)`: the requirement validator's mounted block is ONE gate in the reference
|
||||
// (`0x6094f0` @ `0x609c6c`) sitting under the ONE cast entry `TryCast 0x6e4b60` — but benilla still
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
//! The battleground **list window**'s app half (decision 1974; wow-re
|
||||
//! `system/ui/scratch/battlefield-verb-family.md` §2.1, §4.1, §4.4, §4.5, §5.2, §7.1, §8): what
|
||||
//! the stock `BattlefieldFrame.lua` and the minimap's queue icon need from the wire, the clock and
|
||||
//! the Map.dbc rows. The verbs themselves are `benilla_ui::script::battlefield_queue`'s; the
|
||||
//! queue slots live in [`BattlefieldQueue`]; the scoreboard is [`crate::ui_battlefield_score`]'s.
|
||||
//!
|
||||
//! - **The list** (`SMSG_BATTLEFIELD_LIST`, §4.1) is the battlemaster's guid, the map, the bracket
|
||||
//! index and the instance ids. The handler derives the bracket's level pair off the map row
|
||||
//! (`bracket · span + minLevel`, max clamped at 60), fires `BATTLEFIELDS_SHOW`, and only THEN
|
||||
//! anchors the player's position — the leash below measures from it.
|
||||
//! - **The leash** (§7.1): a world tick fires `BATTLEFIELDS_CLOSED` once the player is strictly
|
||||
//! more than 5.5556 yd from where the list was opened, and zeroes the anchor first. The stock
|
||||
//! window hides itself on the event; there is no other closer.
|
||||
//! - **The queue view**: every frame the three slots go to the VM with their clock-shaped fields
|
||||
//! reduced — the port deadline, the raw estimated wait, the time waited — and the map names
|
||||
//! resolved (map 0 included: the reference looks a cleared slot's id up like any other).
|
||||
//! - **The join** (§5.2): `JoinBattlefield`'s instance and group flag come back from the VM; the
|
||||
//! group leg is refused client-side with message 442 when the map's `MaxPlayers` is below
|
||||
//! either the party count or the raid count, and the opcode is chosen by the cached
|
||||
//! battlemaster guid — `CMSG_BATTLEMASTER_JOIN` with it, `CMSG_BATTLEFIELD_JOIN` without.
|
||||
//! - **The three speaking handlers** (§4.4/§4.5) print and store nothing: the group verdict
|
||||
//! (deserters / joined `%s` / failed) and the joined-or-left player lines, the latter once the
|
||||
//! name cache answers — the reference's fallback callback, in the shape this app already uses.
|
||||
//! - **World enter** (§8) clears the list scalars and the selection (not the queue slots) and
|
||||
//! sends the bodyless `CMSG_BATTLEFIELD_STATUS`, which the server answers slot by slot.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_assets::MapCatalogRes;
|
||||
use benilla_formats::MapCatalog;
|
||||
use benilla_protocol::messages::{BattlefieldList, BattlefieldStatus};
|
||||
use benilla_ui::script::{BattlefieldListView, BattlefieldMapInfo, BattlefieldQueueSlot, UiScript};
|
||||
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{ClientCommand, EnteredWorldMessage, NetCommands};
|
||||
use crate::player::Player;
|
||||
use crate::ui_dialog_verbs::BattlefieldQueue;
|
||||
use crate::ui_party::GroupState;
|
||||
use crate::ui_script::UiInput;
|
||||
|
||||
/// The leash radius, squared: the CRT initialiser's `fld [0x806574]; fmul` of the `.rdata` f32
|
||||
/// `5.55555534362793` (§7.1).
|
||||
const LEASH_RADIUS_YD: f32 = 5.555_555_3;
|
||||
|
||||
/// The leash's degenerate-anchor refusal (`fcomp [0x8029d4]`, `2.384185791015625e-07`): an
|
||||
/// anchor this close to the origin is "no list open", never a place to measure from.
|
||||
const DEGENERATE_ANCHOR: f32 = 2.384_185_8e-7;
|
||||
|
||||
/// `SMSG_GROUP_JOINED_BATTLEGROUND`'s deserters sentinel (`cmp eax,-2`, §4.4).
|
||||
const GROUP_JOIN_DESERTERS: u32 = 0xFFFF_FFFE;
|
||||
|
||||
/// The list window's state: the last list, the leash anchor, and what the speaking handlers
|
||||
/// still owe the screen.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct Battlefield {
|
||||
/// The last `SMSG_BATTLEFIELD_LIST` — the battlemaster, the map, the bracket, the ids.
|
||||
list: Option<BattlefieldList>,
|
||||
/// `BATTLEFIELDS_SHOW` owed for a list that just landed.
|
||||
show: bool,
|
||||
/// The list view needs re-pushing (a new list, or the world-enter reset).
|
||||
dirty: bool,
|
||||
/// Where the list was opened (`[0xb6e870..78]`), the leash's origin; `None` once it fired.
|
||||
anchor: Option<Vec3>,
|
||||
/// `SMSG_GROUP_JOINED_BATTLEGROUND` results awaiting their line.
|
||||
verdicts: Vec<u32>,
|
||||
/// Joined (`true`) / left guids awaiting a name.
|
||||
players: Vec<(u64, bool)>,
|
||||
}
|
||||
|
||||
impl Battlefield {
|
||||
/// `SessionEvent::BattlefieldList` — replaces the list; the event and the anchor follow on
|
||||
/// the next feed, in that order.
|
||||
pub(crate) fn apply_list(&mut self, list: BattlefieldList) {
|
||||
self.list = Some(list);
|
||||
self.show = true;
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// `SessionEvent::GroupJoinedBattleground` — one line, no state.
|
||||
pub(crate) fn apply_verdict(&mut self, result: u32) {
|
||||
self.verdicts.push(result);
|
||||
}
|
||||
|
||||
/// `SessionEvent::BattlegroundPlayer` — one line once the name resolves, no state.
|
||||
pub(crate) fn apply_player(&mut self, guid: u64, joined: bool) {
|
||||
self.players.push((guid, joined));
|
||||
}
|
||||
|
||||
/// The world-enter reset (§8): the list scalars and the anchor go; the queue slots do not.
|
||||
fn clear_session(&mut self) {
|
||||
self.list = None;
|
||||
self.show = false;
|
||||
self.dirty = true;
|
||||
self.anchor = None;
|
||||
self.verdicts.clear();
|
||||
self.players.clear();
|
||||
}
|
||||
|
||||
/// The listed map — `[0xb6eba4]`, which is 0 (a real Map.dbc row) with nothing listed.
|
||||
fn map_id(&self) -> u32 {
|
||||
self.list.as_ref().map_or(0, |l| l.map_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// The VM's view of the list: the ids, the bracket pair the handler derived, the map row's
|
||||
/// `GetBattlefieldInfo` half, and the group-queue flag — every one off `[0xb6eba4]`'s row,
|
||||
/// whatever that id is.
|
||||
fn list_view(
|
||||
state: &Battlefield,
|
||||
catalog: Option<&MapCatalog>,
|
||||
faction: Option<&str>,
|
||||
) -> BattlefieldListView {
|
||||
let map_id = state.map_id();
|
||||
let row = catalog.and_then(|c| c.battleground(map_id));
|
||||
let (bracket_min, bracket_max) = state
|
||||
.list
|
||||
.as_ref()
|
||||
.zip(row)
|
||||
.map_or((0, 0), |(l, r)| r.bracket_levels(l.bracket));
|
||||
let info = catalog
|
||||
.and_then(|c| c.name(map_id))
|
||||
.zip(row)
|
||||
.map(|(name, r)| BattlefieldMapInfo {
|
||||
name: name.to_string(),
|
||||
// The faction-group index (§3.4): 0 for the mask-bit-4 side, 1 for bit 2 — Horde
|
||||
// and Alliance in the emulator's naming; the shipped rows carry one text in both.
|
||||
description: match faction {
|
||||
Some("Horde") => Some(r.descriptions[0].clone()),
|
||||
Some("Alliance") => Some(r.descriptions[1].clone()),
|
||||
_ => None,
|
||||
},
|
||||
min_level: r.min_level,
|
||||
max_level: r.max_level,
|
||||
field_16: r.field_16,
|
||||
field_17: r.field_17,
|
||||
field_18: r.field_18,
|
||||
});
|
||||
BattlefieldListView {
|
||||
instances: state
|
||||
.list
|
||||
.as_ref()
|
||||
.map_or_else(Vec::new, |l| l.instances.clone()),
|
||||
bracket_min,
|
||||
bracket_max,
|
||||
info,
|
||||
group_queue: row.is_some_and(|r| r.group_queue != 0),
|
||||
}
|
||||
}
|
||||
|
||||
/// One slot's VM view at `now`: the three clock-shaped getters reduced (§3.3), the map name
|
||||
/// resolved, the bracket pair derived (§4.2).
|
||||
fn slot_view(
|
||||
slot: Option<&(BattlefieldStatus, Instant)>,
|
||||
catalog: Option<&MapCatalog>,
|
||||
now: Instant,
|
||||
) -> BattlefieldQueueSlot {
|
||||
let ms = |d: std::time::Duration| d.as_millis().min(u128::from(u32::MAX)) as u32;
|
||||
let map_id = slot.map_or(0, |(s, _)| s.map_id);
|
||||
let mut view = BattlefieldQueueSlot {
|
||||
map_id,
|
||||
map_name: catalog.and_then(|c| c.name(map_id)).map(str::to_string),
|
||||
..Default::default()
|
||||
};
|
||||
let Some((status, at)) = slot else {
|
||||
return view;
|
||||
};
|
||||
view.status = status.status;
|
||||
view.instance_id = status.instance_id;
|
||||
if let Some(row) = catalog.and_then(|c| c.battleground(map_id)) {
|
||||
(view.min_level, view.max_level) = row.bracket_levels(status.bracket);
|
||||
}
|
||||
// Status 2: `[slot+0x14] = Δ ? now + Δ : 0`; read as `deadline − now`, 0 once past.
|
||||
if let Some(delta) = status.time_ms.filter(|&d| d != 0) {
|
||||
let deadline = *at + std::time::Duration::from_millis(u64::from(delta));
|
||||
view.port_expiration_ms = ms(deadline.saturating_duration_since(now));
|
||||
}
|
||||
// Status 1: the raw estimate, and `[slot+0x1c] = now − Δ` read back as `now − stamp`.
|
||||
if let Some((estimate, waited)) = status.queued {
|
||||
view.estimated_wait_ms = estimate;
|
||||
let stamp = *at - std::time::Duration::from_millis(u64::from(waited));
|
||||
view.time_waited_ms = ms(now.saturating_duration_since(stamp));
|
||||
}
|
||||
view
|
||||
}
|
||||
|
||||
/// The as-group refusal (§5.2): the map's `MaxPlayers` must be at least the party count (the
|
||||
/// populated party slots, 0..4 — the members besides us) and the raid roster count. Which
|
||||
/// members the reference's raid roster counts is INFERRED here as the wire list's members in a
|
||||
/// raid and none in a party; the party count is VERIFIED.
|
||||
fn group_fits(catalog: Option<&MapCatalog>, map_id: u32, group: Option<&GroupState>) -> bool {
|
||||
let max = catalog
|
||||
.and_then(|c| c.battleground(map_id))
|
||||
.map_or(0, |r| r.max_players) as usize;
|
||||
let Some(group) = group else {
|
||||
return true;
|
||||
};
|
||||
let party = group.party_slots().count();
|
||||
let raid = if group.group_type == 1 {
|
||||
group.members.len()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
max >= party && max >= raid
|
||||
}
|
||||
|
||||
/// Every frame, before the dialog feed fires `UPDATE_BATTLEFIELD_STATUS` and the score feed
|
||||
/// fires `UPDATE_BATTLEFIELD_SCORE`: the list (when it changed), the queue slots (always), the
|
||||
/// `BATTLEFIELDS_SHOW` event with its anchor, the leash, and the speaking handlers' lines.
|
||||
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
|
||||
fn feed_battlefield(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut state: ResMut<Battlefield>,
|
||||
queue: Res<BattlefieldQueue>,
|
||||
maps: Option<Res<MapCatalogRes>>,
|
||||
player: Res<Player>,
|
||||
mut names: ResMut<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
mut sink: crate::ui_action::MessageSink,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let catalog = maps.as_deref().map(|m| &m.0);
|
||||
let now = Instant::now();
|
||||
|
||||
if std::mem::take(&mut state.dirty) {
|
||||
// The description's side is the local player's faction group (`0x5efe00(player)`, §3.4)
|
||||
// — the same answer `UnitFactionGroup("player")` gives, read where it already lives.
|
||||
let faction = script
|
||||
.eval::<Option<String>>(r#"return (UnitFactionGroup("player"))"#)
|
||||
.ok()
|
||||
.flatten();
|
||||
let view = list_view(&state, catalog, faction.as_deref());
|
||||
script.set_battlefield_list(view);
|
||||
}
|
||||
|
||||
let slots = queue
|
||||
.slots()
|
||||
.iter()
|
||||
.map(|s| slot_view(s.as_ref(), catalog, now))
|
||||
.collect();
|
||||
script.set_battlefield_queue(slots, queue.instance_expiration_ms(now));
|
||||
|
||||
if std::mem::take(&mut state.show) {
|
||||
script.fire_event("BATTLEFIELDS_SHOW", vec![]);
|
||||
// §4.1 step 7: the anchor is written AFTER the event, from the live player position.
|
||||
state.anchor = Some(player.pos);
|
||||
}
|
||||
|
||||
// §7.1: strictly farther than the leash from where the list opened → the event, anchor first.
|
||||
if let Some(anchor) = state.anchor {
|
||||
if anchor.length_squared() > DEGENERATE_ANCHOR
|
||||
&& player.pos.distance_squared(anchor) > LEASH_RADIUS_YD * LEASH_RADIUS_YD
|
||||
{
|
||||
state.anchor = None;
|
||||
script.fire_event("BATTLEFIELDS_CLOSED", vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
for result in std::mem::take(&mut state.verdicts) {
|
||||
let line = if result == GROUP_JOIN_DESERTERS {
|
||||
crate::ui_action::keyed_line(&script, "ERR_GROUP_JOIN_BATTLEGROUND_DESERTERS")
|
||||
} else if let Some(name) = catalog.and_then(|c| c.name(result)) {
|
||||
crate::ui_action::keyed_line_s(&script, "ERR_GROUP_JOIN_BATTLEGROUND_S", &[name])
|
||||
} else {
|
||||
crate::ui_action::keyed_line(&script, "ERR_GROUP_JOIN_BATTLEGROUND_FAIL")
|
||||
};
|
||||
lines.extend(line);
|
||||
}
|
||||
let pending = std::mem::take(&mut state.players);
|
||||
for (guid, joined) in pending {
|
||||
let Some(name) = names.resolve(guid, &commands).map(str::to_string) else {
|
||||
state.players.push((guid, joined));
|
||||
continue;
|
||||
};
|
||||
let line = if joined {
|
||||
crate::ui_action::keyed_line_s(&script, "ERR_BG_PLAYER_JOINED_SS", &[&name, &name])
|
||||
} else {
|
||||
crate::ui_action::keyed_line_s(&script, "ERR_BG_PLAYER_LEFT_S", &[&name])
|
||||
};
|
||||
lines.extend(line);
|
||||
}
|
||||
if !lines.is_empty() {
|
||||
crate::ui_action::show_messages(&mut script, &mut sink, "ui_battlefield", lines);
|
||||
}
|
||||
}
|
||||
|
||||
/// After the script tick: `JoinBattlefield`'s sends (with the group refusal and the opcode
|
||||
/// choice) and `ShowBattlefieldList`'s.
|
||||
fn drain_battlefield(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
state: Res<Battlefield>,
|
||||
maps: Option<Res<MapCatalogRes>>,
|
||||
group: Option<Res<GroupState>>,
|
||||
commands: Res<NetCommands>,
|
||||
mut sink: crate::ui_action::MessageSink,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let catalog = maps.as_deref().map(|m| &m.0);
|
||||
let map_id = state.map_id();
|
||||
for (instance_id, as_group) in script.take_battlefield_join_requests() {
|
||||
if as_group && !group_fits(catalog, map_id, group.as_deref()) {
|
||||
if let Some(line) =
|
||||
crate::ui_action::keyed_line(&script, "ERR_GROUP_JOIN_BATTLEGROUND_TOO_MANY")
|
||||
{
|
||||
crate::ui_action::show_messages(&mut script, &mut sink, "ui_battlefield", [line]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let battlemaster = state.list.as_ref().map_or(0, |l| l.battlemaster);
|
||||
let command = if battlemaster != 0 {
|
||||
ClientCommand::BattlemasterJoin {
|
||||
battlemaster,
|
||||
map_id,
|
||||
instance_id,
|
||||
as_group,
|
||||
}
|
||||
} else {
|
||||
ClientCommand::BattlefieldJoin {
|
||||
map_id,
|
||||
instance_id,
|
||||
as_group,
|
||||
}
|
||||
};
|
||||
let _ = commands.0.send(command);
|
||||
}
|
||||
for map_id in script.take_battlefield_list_requests() {
|
||||
let _ = commands.0.send(ClientCommand::BattlefieldList { map_id });
|
||||
}
|
||||
}
|
||||
|
||||
/// The world-enter reset (§8): the list scalars, the selection and the anchor cleared, the
|
||||
/// queue slots kept, and the bodyless status request sent.
|
||||
fn reset_on_world_enter(
|
||||
mut entered: MessageReader<EnteredWorldMessage>,
|
||||
mut state: ResMut<Battlefield>,
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
commands: Res<NetCommands>,
|
||||
) {
|
||||
if entered.read().next().is_none() {
|
||||
return;
|
||||
}
|
||||
state.clear_session();
|
||||
if let Some(mut script) = script {
|
||||
script.reset_battlefield_selection();
|
||||
}
|
||||
let _ = commands.0.send(ClientCommand::BattlefieldStatusRequest);
|
||||
}
|
||||
|
||||
pub(crate) struct BattlefieldPlugin;
|
||||
|
||||
impl Plugin for BattlefieldPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<Battlefield>().add_systems(
|
||||
Update,
|
||||
(
|
||||
reset_on_world_enter.before(feed_battlefield),
|
||||
feed_battlefield
|
||||
.before(crate::ui_battlefield_score::feed_battlefield_score)
|
||||
.before(crate::ui_dialog_verbs::feed_dialog_verbs)
|
||||
.before(UiInput),
|
||||
drain_battlefield.after(UiInput),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn status(slot: u32, map_id: u32, status: u32) -> BattlefieldStatus {
|
||||
BattlefieldStatus {
|
||||
slot,
|
||||
map_id,
|
||||
bracket: 1,
|
||||
instance_id: 7,
|
||||
status,
|
||||
time_ms: None,
|
||||
in_progress: None,
|
||||
queued: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A queued slot's view: the raw estimate, the time waited growing with the clock, the
|
||||
/// port deadline zero; a confirm slot's deadline counting down and stopping at zero.
|
||||
#[test]
|
||||
fn the_slot_view_reduces_the_clocks() {
|
||||
let at = Instant::now();
|
||||
let mut queued = status(0, 489, 1);
|
||||
queued.queued = Some((30_000, 5_000));
|
||||
let later = at + std::time::Duration::from_millis(2_000);
|
||||
let v = slot_view(Some(&(queued, at)), None, later);
|
||||
assert_eq!((v.map_id, v.status, v.instance_id), (489, 1, 7));
|
||||
assert_eq!(v.estimated_wait_ms, 30_000, "raw, no clock");
|
||||
assert_eq!(v.time_waited_ms, 7_000, "the wire's 5 s plus the 2 s since");
|
||||
assert_eq!(v.port_expiration_ms, 0);
|
||||
assert_eq!((v.min_level, v.max_level), (0, 0), "no catalog: no bracket");
|
||||
assert!(v.map_name.is_none());
|
||||
|
||||
let mut confirm = status(1, 529, 2);
|
||||
confirm.time_ms = Some(60_000);
|
||||
let v = slot_view(Some(&(confirm.clone(), at)), None, later);
|
||||
assert_eq!(v.port_expiration_ms, 58_000);
|
||||
let past = at + std::time::Duration::from_millis(61_000);
|
||||
let v = slot_view(Some(&(confirm, at)), None, past);
|
||||
assert_eq!(
|
||||
v.port_expiration_ms, 0,
|
||||
"a past deadline reads 0, never negative"
|
||||
);
|
||||
|
||||
let v = slot_view(None, None, later);
|
||||
assert_eq!(
|
||||
(v.map_id, v.status),
|
||||
(0, 0),
|
||||
"an empty slot: map 0, status none"
|
||||
);
|
||||
}
|
||||
|
||||
/// The group refusal: the party count and the raid count against `MaxPlayers`; no group
|
||||
/// always fits.
|
||||
#[test]
|
||||
fn the_group_gate_reads_both_counts() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let catalog = benilla_formats::load_map_catalog(&mut chain).expect("Map.dbc");
|
||||
assert!(group_fits(Some(&catalog), 489, None));
|
||||
let member = |guid: u64| benilla_protocol::messages::GroupMemberEntry {
|
||||
name: format!("m{guid}"),
|
||||
guid,
|
||||
status: 1,
|
||||
flags: 0,
|
||||
};
|
||||
let mut group = GroupState {
|
||||
in_group: true,
|
||||
..Default::default()
|
||||
};
|
||||
group.members = (1..=4).map(member).collect();
|
||||
assert!(
|
||||
group_fits(Some(&catalog), 489, Some(&group)),
|
||||
"a full party fits WSG's 10"
|
||||
);
|
||||
group.group_type = 1;
|
||||
group.members = (1..=12).map(member).collect();
|
||||
assert!(
|
||||
!group_fits(Some(&catalog), 489, Some(&group)),
|
||||
"a 12-member raid outruns WSG's 10"
|
||||
);
|
||||
assert!(
|
||||
group_fits(Some(&catalog), 30, Some(&group)),
|
||||
"and fits AV's 40"
|
||||
);
|
||||
}
|
||||
|
||||
/// The list view with nothing listed reads map 0's row — the reference resolves
|
||||
/// `[0xb6eba4] = 0` like any other id — and a listed map's bracket pair off its bracket.
|
||||
#[test]
|
||||
fn the_list_view_resolves_the_listed_map_or_map_zero() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let catalog = benilla_formats::load_map_catalog(&mut chain).expect("Map.dbc");
|
||||
let mut state = Battlefield::default();
|
||||
let v = list_view(&state, Some(&catalog), Some("Horde"));
|
||||
assert!(v.instances.is_empty());
|
||||
assert_eq!(
|
||||
v.info.as_ref().map(|i| i.name.as_str()),
|
||||
Some("Eastern Kingdoms")
|
||||
);
|
||||
assert_eq!((v.bracket_min, v.bracket_max), (0, 0));
|
||||
state.apply_list(BattlefieldList {
|
||||
battlemaster: 0x10,
|
||||
map_id: 529,
|
||||
bracket: 2,
|
||||
instances: vec![4, 9],
|
||||
});
|
||||
let v = list_view(&state, Some(&catalog), Some("Alliance"));
|
||||
assert_eq!(v.instances, vec![4, 9]);
|
||||
assert_eq!((v.bracket_min, v.bracket_max), (40, 49));
|
||||
let info = v.info.expect("Arathi Basin's row");
|
||||
assert_eq!(info.name, "Arathi Basin");
|
||||
assert!(info
|
||||
.description
|
||||
.as_deref()
|
||||
.is_some_and(|d| d.starts_with("The Arathi Basin")));
|
||||
assert_eq!(
|
||||
(info.min_level, info.max_level, info.field_16),
|
||||
(20, 60, -1)
|
||||
);
|
||||
assert!(v.group_queue);
|
||||
let v = list_view(&state, Some(&catalog), None);
|
||||
assert!(
|
||||
v.info.is_some_and(|i| i.description.is_none()),
|
||||
"the -1 faction leg: no description"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
//! The battleground **teammate and flag positions** on the world map (decision 1980; wow-re
|
||||
//! `worldmap-arrow-and-positions.md` §3): `MSG_BATTLEGROUND_PLAYER_POSITIONS` both ways, resolved
|
||||
//! into the engine's `GetBattlefieldPosition` / `GetBattlefieldFlagPosition` /
|
||||
//! `GetBattlefieldMapIconScale` backing the stock `WorldMapFrame.lua` and
|
||||
//! `Blizzard_BattlefieldMinimap.lua` poll every update.
|
||||
//!
|
||||
//! **The reference's law, and where each half lives.** The packet carries the teammates outside
|
||||
//! the requester's own group as raw world floats, plus the friendly flag carrier. The handler
|
||||
//! (`0x4aad40`) keeps them as sent; the *getter* (`0x4abf90`) does the work per call: it skips
|
||||
//! the player itself, the four party slots and the raid roster (all of which the map already
|
||||
//! draws through `GetPlayerMapPosition`), prefers the live object's position when the guid is
|
||||
//! streamed to us, and normalizes through the world-map projection under the **active queue
|
||||
//! slot's map** — which is why the getter answers `(0, 0)` off that map. Every one of those is an
|
||||
//! app-side fact (the group, the entity index, the projection, the catalog), so the app resolves
|
||||
//! the whole list here each frame and the engine holds only the finished view.
|
||||
//!
|
||||
//! **The name** is the name cache's answer (a `CMSG_NAME_QUERY` goes out for a guid it has not
|
||||
//! seen), `nil` until it lands — and when it lands the reference fires `WORLD_MAP_NAME_UPDATE`,
|
||||
//! which `WorldMapFrame_OnEvent` turns into a repaint; that is produced here, once per landing.
|
||||
//!
|
||||
//! **The request** (`RequestBattlefieldPositions()`) is the reference's own 5000 ms throttle
|
||||
//! (`0x4ac0f0`, the `[0xb6ec00]` stamp) and goes out only with an active slot — outside a
|
||||
//! battleground the list is cleared instead, so a stale roster never draws on the next map.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_assets::coords::bevy_to_wow;
|
||||
use benilla_assets::MapCatalogRes;
|
||||
use benilla_protocol::messages::{BattlefieldPosition, BattlefieldPositions as PositionsPacket};
|
||||
use benilla_ui::script::{BattlefieldFlagView, BattlefieldPositionView, UiScript};
|
||||
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{ClientCommand, EnteredWorldMessage, GuidIndex, NetCommands, NetEntity, SelfGuid};
|
||||
use crate::ui_dialog_verbs::BattlefieldQueue;
|
||||
use crate::ui_party::GroupState;
|
||||
use crate::ui_script::UiInput;
|
||||
use crate::ui_world_map::{project_on_displayed, WorldMapUiData};
|
||||
|
||||
/// The reference's request throttle: `RequestBattlefieldPositions` sends at most once per 5000 ms.
|
||||
pub(crate) const REQUEST_THROTTLE: Duration = Duration::from_millis(5000);
|
||||
|
||||
/// The last positions packet and the request stamp.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct BattlefieldPositions {
|
||||
/// The last `MSG_BATTLEGROUND_PLAYER_POSITIONS`, as sent; `None` outside a battleground.
|
||||
packet: Option<PositionsPacket>,
|
||||
/// When the last request went out — the throttle's stamp.
|
||||
last_request: Option<Instant>,
|
||||
/// Guids whose name the cache has not answered yet; a landing fires `WORLD_MAP_NAME_UPDATE`.
|
||||
pending_names: Vec<u64>,
|
||||
}
|
||||
|
||||
impl BattlefieldPositions {
|
||||
/// `SessionEvent::BattlefieldPositions` — replaces the list.
|
||||
pub(crate) fn apply(&mut self, packet: PositionsPacket) {
|
||||
self.packet = Some(packet);
|
||||
}
|
||||
|
||||
fn clear(&mut self) {
|
||||
self.packet = None;
|
||||
self.pending_names.clear();
|
||||
}
|
||||
|
||||
/// The throttle: `true` (and the stamp moves) when a request may go out now.
|
||||
fn request_due(&mut self, now: Instant) -> bool {
|
||||
let due = self
|
||||
.last_request
|
||||
.is_none_or(|t| now.saturating_duration_since(t) >= REQUEST_THROTTLE);
|
||||
if due {
|
||||
self.last_request = Some(now);
|
||||
}
|
||||
due
|
||||
}
|
||||
}
|
||||
|
||||
/// The getter's position source (`0x4abf90`): the live object's when the guid is streamed to us,
|
||||
/// else the packet's floats. Both in wow space `(x, y)`.
|
||||
fn live_or_packet(
|
||||
p: &BattlefieldPosition,
|
||||
guids: &GuidIndex,
|
||||
unit_pos: &Query<&GlobalTransform, With<NetEntity>>,
|
||||
) -> (f32, f32) {
|
||||
match guids.0.get(&p.guid).and_then(|e| unit_pos.get(*e).ok()) {
|
||||
Some(tf) => {
|
||||
let w = bevy_to_wow(tf.translation());
|
||||
(w[0], w[1])
|
||||
}
|
||||
None => (p.x, p.y),
|
||||
}
|
||||
}
|
||||
|
||||
/// The flag texture token the local faction selects: the friendly carrier is carrying the OTHER
|
||||
/// side's flag, so an Alliance viewer sees `HordeFlag` and a Horde viewer `AllianceFlag`.
|
||||
fn flag_token(faction: Option<&str>) -> Option<&'static str> {
|
||||
match faction {
|
||||
Some("Alliance") => Some("HordeFlag"),
|
||||
Some("Horde") => Some("AllianceFlag"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn feed_battlefield_positions(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut state: ResMut<BattlefieldPositions>,
|
||||
queue: Res<BattlefieldQueue>,
|
||||
data: Option<Res<WorldMapUiData>>,
|
||||
maps: Option<Res<MapCatalogRes>>,
|
||||
me: Res<SelfGuid>,
|
||||
group: Res<GroupState>,
|
||||
guids: Res<GuidIndex>,
|
||||
unit_pos: Query<&GlobalTransform, With<NetEntity>>,
|
||||
mut names: ResMut<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
// What the empty push last carried (`(has_list, icon_scale bits)`): with no list the engine
|
||||
// is told once per change, not per frame; with one, every frame — the positions move.
|
||||
mut last_empty: Local<crate::ui_script::VmMemo<Option<(bool, u32)>>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let active = queue.active_map();
|
||||
// `GetBattlefieldMapIconScale`: the active slot's `Map.dbc` row, map 0's with no slot.
|
||||
let icon_scale = maps
|
||||
.as_deref()
|
||||
.and_then(|m| m.0.battleground(active.unwrap_or(0)))
|
||||
.map_or(1.0, |b| b.minimap_icon_scale);
|
||||
let BattlefieldPositions {
|
||||
packet,
|
||||
pending_names,
|
||||
..
|
||||
} = &mut *state;
|
||||
let (Some(map), Some(packet)) = (active, packet.as_ref()) else {
|
||||
let key = Some((false, icon_scale.to_bits()));
|
||||
let last = last_empty.get(&script);
|
||||
if *last != key {
|
||||
*last = key;
|
||||
script.set_battlefield_positions(Vec::new(), None, icon_scale);
|
||||
}
|
||||
return;
|
||||
};
|
||||
*last_empty.get(&script) = Some((true, icon_scale.to_bits()));
|
||||
|
||||
let selection = script.world_map_selection();
|
||||
let project = |x: f32, y: f32| {
|
||||
data.as_deref()
|
||||
.and_then(|d| project_on_displayed(d, selection, map, x, y))
|
||||
.unwrap_or((0.0, 0.0))
|
||||
};
|
||||
let mut name_landed = false;
|
||||
let mut players = Vec::with_capacity(packet.players.len());
|
||||
for p in &packet.players {
|
||||
if me.0 == Some(p.guid) || group.members.iter().any(|m| m.guid == p.guid) {
|
||||
continue;
|
||||
}
|
||||
let (x, y) = live_or_packet(p, &guids, &unit_pos);
|
||||
let name = names.resolve(p.guid, &commands).map(str::to_string);
|
||||
match name {
|
||||
None => {
|
||||
if !pending_names.contains(&p.guid) {
|
||||
pending_names.push(p.guid);
|
||||
}
|
||||
}
|
||||
Some(_) => {
|
||||
if let Some(i) = pending_names.iter().position(|g| *g == p.guid) {
|
||||
pending_names.swap_remove(i);
|
||||
name_landed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
players.push(BattlefieldPositionView {
|
||||
uv: project(x, y),
|
||||
name,
|
||||
});
|
||||
}
|
||||
let flag = packet.carrier.as_ref().map(|c| {
|
||||
let (x, y) = live_or_packet(c, &guids, &unit_pos);
|
||||
let faction = script
|
||||
.eval::<Option<String>>(r#"return (UnitFactionGroup("player"))"#)
|
||||
.ok()
|
||||
.flatten();
|
||||
BattlefieldFlagView {
|
||||
uv: project(x, y),
|
||||
token: flag_token(faction.as_deref()).map(str::to_string),
|
||||
}
|
||||
});
|
||||
script.set_battlefield_positions(players, flag, icon_scale);
|
||||
if name_landed {
|
||||
script.fire_event("WORLD_MAP_NAME_UPDATE", Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_battlefield_positions(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut state: ResMut<BattlefieldPositions>,
|
||||
queue: Res<BattlefieldQueue>,
|
||||
commands: Res<NetCommands>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
if script.take_battlefield_position_requests() == 0 {
|
||||
return;
|
||||
}
|
||||
if queue.active_map().is_none() {
|
||||
state.clear();
|
||||
return;
|
||||
}
|
||||
if state.request_due(Instant::now()) {
|
||||
let _ = commands.0.send(ClientCommand::RequestBattlefieldPositions);
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_on_world_enter(
|
||||
mut entered: MessageReader<EnteredWorldMessage>,
|
||||
mut state: ResMut<BattlefieldPositions>,
|
||||
) {
|
||||
if entered.read().next().is_none() {
|
||||
return;
|
||||
}
|
||||
state.clear();
|
||||
state.last_request = None;
|
||||
}
|
||||
|
||||
pub(crate) struct BattlefieldPositionsPlugin;
|
||||
|
||||
impl Plugin for BattlefieldPositionsPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<BattlefieldPositions>().add_systems(
|
||||
Update,
|
||||
(
|
||||
reset_on_world_enter.before(feed_battlefield_positions),
|
||||
feed_battlefield_positions.before(UiInput),
|
||||
drain_battlefield_positions.after(UiInput),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_request_throttle_is_the_references_five_seconds() {
|
||||
let mut s = BattlefieldPositions::default();
|
||||
let t0 = Instant::now();
|
||||
assert!(s.request_due(t0), "the first request goes out");
|
||||
assert!(!s.request_due(t0 + Duration::from_millis(4999)));
|
||||
assert!(s.request_due(t0 + REQUEST_THROTTLE));
|
||||
assert!(!s.request_due(t0 + REQUEST_THROTTLE + Duration::from_millis(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_flag_token_is_the_other_sides() {
|
||||
assert_eq!(flag_token(Some("Alliance")), Some("HordeFlag"));
|
||||
assert_eq!(flag_token(Some("Horde")), Some("AllianceFlag"));
|
||||
assert_eq!(flag_token(None), None);
|
||||
}
|
||||
}
|
||||
@@ -105,7 +105,7 @@ fn resolve_board(
|
||||
Some(rows)
|
||||
}
|
||||
|
||||
fn feed_battlefield_score(
|
||||
pub(crate) fn feed_battlefield_score(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
board: Res<BattlefieldScoreboard>,
|
||||
mut queue: ResMut<BattlefieldQueue>,
|
||||
|
||||
@@ -103,9 +103,6 @@ pub(super) struct ChatProbes<'w, 's> {
|
||||
/// "leader" on this wire is a guid. Here rather than as a 17th drain parameter for the
|
||||
/// reason this struct exists at all — the drain is at Bevy's 16-param ceiling.
|
||||
self_guid: Res<'w, crate::net::SelfGuid>,
|
||||
/// The map `/partytest ping` stamps its synthetic ping with — a ping carries no map on the
|
||||
/// wire either, so "the one we are standing on" is the client's answer in both paths.
|
||||
map: Option<Res<'w, benilla_world::world_map::CurrentMap>>,
|
||||
}
|
||||
|
||||
/// Everything the drain **queues into another subsystem's one setter** rather than applying itself,
|
||||
@@ -218,7 +215,6 @@ pub(super) fn drain_chat_input(
|
||||
guids,
|
||||
kinds,
|
||||
self_guid,
|
||||
map,
|
||||
} = &probes;
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -573,9 +569,7 @@ pub(super) fn drain_chat_input(
|
||||
let w = benilla_assets::coords::bevy_to_wow(tf.translation());
|
||||
// +x is north, −y is east (0203's north-up mapping).
|
||||
let at = (w[0] + 24.75, w[1] - 24.75);
|
||||
chat_out
|
||||
.ping
|
||||
.seat(at, map.as_ref().map_or(0, |m| m.0), 0xF001);
|
||||
chat_out.ping.seat(at, 0xF001);
|
||||
format!(
|
||||
"partytest: Alice pinged ({:.0}, {:.0}) — 35 yd NE, party1, 5 s",
|
||||
at.0, at.1
|
||||
@@ -1479,6 +1473,7 @@ pub(super) fn drain_addon_chat_sends(
|
||||
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
|
||||
commands: Res<NetCommands>,
|
||||
mut chat_log: ResMut<super::feed::ChatLog>,
|
||||
mut tutorials: Option<MessageWriter<crate::tutorial::TutorialEvent>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -1495,6 +1490,19 @@ pub(super) fn drain_addon_chat_sends(
|
||||
));
|
||||
continue;
|
||||
};
|
||||
// `SendChatMessage`'s acknowledge (`0x49f5fc`, 1976): the twelve social wire types —
|
||||
// party, raid, guild, officer, whisper, channel, AFK/DND, raid leader/warning, the two
|
||||
// battleground types — say, yell and emote are not among them.
|
||||
if !matches!(
|
||||
kind,
|
||||
super::edit::SendType::Say | super::edit::SendType::Yell | super::edit::SendType::Emote
|
||||
) {
|
||||
if let Some(t) = tutorials.as_mut() {
|
||||
t.write(crate::tutorial::TutorialEvent::Acknowledge {
|
||||
id: crate::tutorial::id::CHATTING,
|
||||
});
|
||||
}
|
||||
}
|
||||
let cmd = ClientCommand::Chat {
|
||||
kind: kind.wire(),
|
||||
target: send.target,
|
||||
|
||||
@@ -569,7 +569,17 @@ pub(super) fn plugin(app: &mut App) {
|
||||
Update,
|
||||
(load_chat_looks, watch_chat_looks)
|
||||
.chain()
|
||||
.run_if(in_state(crate::char_select::ClientState::InWorld)),
|
||||
.run_if(in_state(crate::char_select::ClientState::InWorld))
|
||||
// …and never before the in-game UI exists (1348's law, the unit feed's own
|
||||
// words): the restore fires `UPDATE_CHAT_WINDOWS` once and latches a per-VM
|
||||
// memo, and the VM that is live between the world-entry edge and the deferred
|
||||
// entry load is the SAME object the load then fills — so a restore made in
|
||||
// that window reaches no chat frame and its memo blocks the one that would.
|
||||
// Every plate texture stayed at the XML's white, alpha 1 (director report,
|
||||
// 2026-09-04).
|
||||
.run_if(bevy::ecs::schedule::common_conditions::not(
|
||||
crate::ui_script::ingame_ui_pending,
|
||||
)),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
|
||||
@@ -20,17 +20,31 @@
|
||||
//! * **Battlefield queue** — `SMSG_BATTLEFIELD_STATUS` fills one of three slots and fires
|
||||
//! `UPDATE_BATTLEFIELD_STATUS`; `AcceptBattlefieldPort(index, accept)` sends the slot's map id
|
||||
//! with the answer as one byte.
|
||||
//! * **Meeting stone** — `SMSG 0x295 {areaId, status}` stores the queued area and fires
|
||||
//! `MEETINGSTONE_CHANGED`; `CancelMeetingStoneRequest()` sends `0x293` unless in a party led by
|
||||
//! someone else (`ERR_MEETING_STONE_NOT_LEADER`). The five status messages the reference prints
|
||||
//! are not built: the carve lists the five strings but not which status byte picks which.
|
||||
//! * **Meeting stone** (wow-re `meeting-stone-status.md`, 1974) — two globals: the queued area
|
||||
//! (`[0xb72038]`) and the cached status text (`[0xb7203c]`). `SMSG 0x295 {areaId, status}`
|
||||
//! latches the old area, stores the new one unconditionally, prints one of five chat lines by
|
||||
//! the status byte (with the two asymmetries §8 records: status 0 names the OLD area and is
|
||||
//! silent when it has no row; status 1 is skipped entirely when the area did not change, names
|
||||
//! the NEW one with an `UNKNOWN` fallback, and plays the `HARDCODED Meeting Stone Join` visual
|
||||
//! on the player), then — on EVERY path, an out-of-range status included — rebuilds the text
|
||||
//! (`MEETINGSTONE_TOOLTIP` over the area's name or `UNKNOWN`, into a 256-byte buffer) and fires
|
||||
//! `MEETINGSTONE_CHANGED`. World enter resets the text to the bare `UNKNOWN` and sends the empty
|
||||
//! `CMSG 0x296` once per world session; world leave drops the text to none.
|
||||
//! `CancelMeetingStoneRequest()` sends `0x293` unless in a party led by someone else
|
||||
//! (`ERR_MEETING_STONE_NOT_LEADER`). The four display-only replies (`0x297/0x298/0x299/0x2BB`)
|
||||
//! are chat lines with no state; the status-1 arm also triggers the Meeting Stones tutorial
|
||||
//! (`crate::tutorial`, 1976).
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use benilla_protocol::messages::BattlefieldStatus;
|
||||
use benilla_protocol::messages::{BattlefieldStatus, MeetingStoneNotice};
|
||||
use benilla_ui::script::{ScriptValue, UiScript};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::area::AreaTableRes;
|
||||
use crate::creature_anim::spell_visual::{meeting_stone_join_fx, SpellKitFx, SpellVisuals};
|
||||
use crate::names::NameCache;
|
||||
|
||||
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfGuid, SelfPlayer};
|
||||
use crate::ui_party::GroupState;
|
||||
use crate::ui_script::UiInput;
|
||||
@@ -130,46 +144,97 @@ impl AreaSpiritHealer {
|
||||
}
|
||||
}
|
||||
|
||||
/// The three battleground queue slots (`0xb6e9d0`, stride `0x20`).
|
||||
/// The three battleground queue slots (`0xb6e9d0`, stride `0x20`), each with the moment its
|
||||
/// status landed — the clock every stamp in the slot is relative to.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct BattlefieldQueue {
|
||||
slots: [Option<BattlefieldStatus>; 3],
|
||||
slots: [Option<(BattlefieldStatus, Instant)>; 3],
|
||||
changed: bool,
|
||||
/// The slot the player is IN (`[0x8457cc]`, the status-3 arm), and the instance's clocks
|
||||
/// (`[0xb6ebb8]`/`[0xb6ebbc]`, §4.2): the run-time stamp `now − Δ₂` and the expiration
|
||||
/// `now + Δ₁`. Any other status for that slot resets both (1972).
|
||||
/// The slot the player is IN (`[0x8457cc]`, the status-3 arm) and its map.
|
||||
active: Option<(usize, u32)>,
|
||||
/// The instance's two clocks (`[0xb6ebbc]`/`[0xb6ebb8]`, wow-re `battlefield-verb-family.md`
|
||||
/// §4.2): the run-time stamp `now − Δ₂` and the expiration `now + Δ₁`, set by a status-3
|
||||
/// message and zeroed by ANY non-clearing message of another status — whatever slot it is
|
||||
/// about (§10's anomaly 4, reproduced: 1972 zeroed them only for the active slot; 1974
|
||||
/// corrects it to the handler's unconditional clear).
|
||||
run_started: Option<Instant>,
|
||||
instance_expiration: Option<Instant>,
|
||||
/// The status-3 arm rebuilds the scoreboard and fires `UPDATE_BATTLEFIELD_SCORE` before
|
||||
/// `UPDATE_BATTLEFIELD_STATUS` (§4.2's ordering) — the score feed reads this first.
|
||||
score_dirty: bool,
|
||||
/// The handler's two tutorial arms (`0x2f` on queued, `0x30` on confirm; 1976), owed to the
|
||||
/// tutorial system on the next feed.
|
||||
tutorials: Vec<u32>,
|
||||
}
|
||||
|
||||
impl BattlefieldQueue {
|
||||
/// `SMSG_BATTLEFIELD_STATUS`: an out-of-range slot aborts the handler; a zero map clears.
|
||||
/// `SMSG_BATTLEFIELD_STATUS` (§4.2): an out-of-range slot abandons the message; a zero map
|
||||
/// takes the clear arm (the slot emptied, the instance clocks zeroed only when this was the
|
||||
/// active slot — and the active index itself left alone, as the handler leaves `[0x8457cc]`);
|
||||
/// status 3 stamps the instance clocks and names the slot active; every other status zeroes
|
||||
/// the instance clocks unconditionally and un-names the slot if it was the active one.
|
||||
pub(crate) fn apply(&mut self, status: BattlefieldStatus) {
|
||||
self.apply_at(status, Instant::now());
|
||||
}
|
||||
|
||||
fn apply_at(&mut self, status: BattlefieldStatus, now: Instant) {
|
||||
let index = status.slot as usize;
|
||||
let Some(slot) = self.slots.get_mut(index) else {
|
||||
return;
|
||||
};
|
||||
if status.map_id == 0 {
|
||||
if self.active.is_some_and(|(i, _)| i == index) {
|
||||
self.run_started = None;
|
||||
self.instance_expiration = None;
|
||||
}
|
||||
*slot = None;
|
||||
self.changed = true;
|
||||
return;
|
||||
}
|
||||
match status.status {
|
||||
1 => self.tutorials.push(crate::tutorial::id::BATTLEGROUND_QUEUE),
|
||||
2 => self
|
||||
.tutorials
|
||||
.push(crate::tutorial::id::PORT_TO_BATTLEGROUND),
|
||||
_ => {}
|
||||
}
|
||||
match status.in_progress {
|
||||
Some((_, elapsed_ms)) if status.map_id != 0 => {
|
||||
Some((expires_ms, elapsed_ms)) => {
|
||||
self.active = Some((index, status.map_id));
|
||||
self.run_started =
|
||||
Some(Instant::now() - std::time::Duration::from_millis(u64::from(elapsed_ms)));
|
||||
self.instance_expiration = (expires_ms != 0)
|
||||
.then(|| now + std::time::Duration::from_millis(u64::from(expires_ms)));
|
||||
self.run_started = (elapsed_ms != 0)
|
||||
.then(|| now - std::time::Duration::from_millis(u64::from(elapsed_ms)));
|
||||
self.score_dirty = true;
|
||||
}
|
||||
_ => {
|
||||
None => {
|
||||
self.run_started = None;
|
||||
self.instance_expiration = None;
|
||||
if self.active.is_some_and(|(i, _)| i == index) {
|
||||
self.active = None;
|
||||
self.run_started = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
*slot = (status.map_id != 0).then_some(status);
|
||||
*slot = Some((status, now));
|
||||
self.changed = true;
|
||||
}
|
||||
|
||||
/// The three slots with the instant each status landed — the queue verbs' view builder
|
||||
/// (`crate::ui_battlefield`) reduces their stamps against `now`.
|
||||
pub(crate) fn slots(&self) -> &[Option<(BattlefieldStatus, Instant)>; 3] {
|
||||
&self.slots
|
||||
}
|
||||
|
||||
/// `GetBattlefieldInstanceExpiration()`: `deadline − now` in ms, 0 when unset or past
|
||||
/// (`[0xb6ebb8]`, the `jns` guard).
|
||||
pub(crate) fn instance_expiration_ms(&self, now: Instant) -> u32 {
|
||||
self.instance_expiration.map_or(0, |d| {
|
||||
d.saturating_duration_since(now)
|
||||
.as_millis()
|
||||
.min(u128::from(u32::MAX)) as u32
|
||||
})
|
||||
}
|
||||
|
||||
/// The map of the battleground the player is in — `LeaveBattlefield`'s payload; `None` = 0.
|
||||
pub(crate) fn active_map(&self) -> Option<u32> {
|
||||
self.active.map(|(_, map)| map)
|
||||
@@ -194,25 +259,259 @@ impl BattlefieldQueue {
|
||||
self.slots
|
||||
.get(usize::from(index).checked_sub(1)?)
|
||||
.and_then(|s| s.as_ref())
|
||||
.map(|s| s.map_id)
|
||||
.map(|(s, _)| s.map_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// The meeting-stone queue (`[0xb72038]`).
|
||||
/// The cached status text's three states (`[0xb7203c]`): none from process start and after world
|
||||
/// leave; the bare localized `UNKNOWN` from world enter until the server's `0x295` lands; a
|
||||
/// built line after that. The two localized halves are resolved against the VM at push time.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Default)]
|
||||
enum StoneText {
|
||||
#[default]
|
||||
None,
|
||||
Unknown,
|
||||
Built(String),
|
||||
}
|
||||
|
||||
/// `SStrPrintf`'s buffer at the rebuild: 256 bytes, so 255 of text.
|
||||
const STONE_TEXT_BYTES: usize = 255;
|
||||
|
||||
/// The meeting-stone queue: the two globals and what the wire still owes the screen.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct MeetingStone {
|
||||
/// `[0xb72038]` — the queued area id, `0` = none.
|
||||
pub(crate) area: u32,
|
||||
pub(crate) status: Option<u8>,
|
||||
changed: bool,
|
||||
text: StoneText,
|
||||
/// `0x295` arrivals since the last feed: `(the area BEFORE the store, status)`; the new area
|
||||
/// is already in `area` (the handler stores it before it switches).
|
||||
updates: Vec<(u32, u8)>,
|
||||
notices: Vec<MeetingStoneNotice>,
|
||||
/// `0x299` guids whose name has not resolved yet.
|
||||
pending_members: Vec<u64>,
|
||||
/// The VM's copy of the two globals is stale.
|
||||
dirty: bool,
|
||||
}
|
||||
|
||||
impl MeetingStone {
|
||||
/// `SMSG 0x295`: the area is stored unconditionally; the status picks the (unbuilt) message.
|
||||
/// `SMSG 0x295`: the area is stored unconditionally; the line, the rebuild and the event
|
||||
/// follow on the next feed, with the VM.
|
||||
pub(crate) fn apply(&mut self, area: u32, status: u8) {
|
||||
let old = self.area;
|
||||
self.area = area;
|
||||
self.status = Some(status);
|
||||
self.changed = true;
|
||||
self.updates.push((old, status));
|
||||
}
|
||||
|
||||
/// One of the four display-only replies.
|
||||
pub(crate) fn apply_notice(&mut self, notice: MeetingStoneNotice) {
|
||||
self.notices.push(notice);
|
||||
}
|
||||
|
||||
/// The enter-world bring-up (`0x4c9f40`): the text becomes the bare `UNKNOWN`; the area is
|
||||
/// untouched (the server's reply resets it).
|
||||
fn enter_world(&mut self) {
|
||||
self.text = StoneText::Unknown;
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// The leave-world sweep (`0x4c9f80`): the text goes, the area stays.
|
||||
fn leave_world(&mut self) {
|
||||
self.text = StoneText::None;
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// A `GlobalStrings` value as the client's `GetText` reads it: the string, or `""` when the Lua
|
||||
/// global is missing (`0x882748`, the shared empty-string constant — never NULL).
|
||||
fn global_text(script: &UiScript, key: &str) -> String {
|
||||
script
|
||||
.lua()
|
||||
.globals()
|
||||
.get::<String>(key)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The area's localized name, or `None` where the reference's three-part AreaTable resolve fails.
|
||||
fn area_name(areas: Option<&AreaTableRes>, id: u32) -> Option<&str> {
|
||||
areas.and_then(|a| a.0.name(id))
|
||||
}
|
||||
|
||||
/// The status-text rebuild `0x4ca070`: `MEETINGSTONE_TOOLTIP` (or `""` when missing) over the
|
||||
/// queued area's name (or `UNKNOWN`), printed into a 256-byte buffer.
|
||||
fn build_stone_text(script: &UiScript, areas: Option<&AreaTableRes>, area: u32) -> String {
|
||||
let name = area_name(areas, area)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| global_text(script, "UNKNOWN"));
|
||||
let mut text = global_text(script, "MEETINGSTONE_TOOLTIP").replacen("%s", &name, 1);
|
||||
if text.len() > STONE_TEXT_BYTES {
|
||||
let mut cut = STONE_TEXT_BYTES;
|
||||
while !text.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
text.truncate(cut);
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// The `0x295` handler's five-way table (§8), as the line it prints for `(old, new, status)` —
|
||||
/// `None` where the reference prints nothing: status 0 with no row for the OLD area, status 1
|
||||
/// with an unchanged area, and any status past 4.
|
||||
fn stone_line(
|
||||
script: &UiScript,
|
||||
areas: Option<&AreaTableRes>,
|
||||
old: u32,
|
||||
new: u32,
|
||||
status: u8,
|
||||
) -> Option<crate::ui_action::Shown> {
|
||||
match status {
|
||||
0 => {
|
||||
let name = area_name(areas, old)?;
|
||||
crate::ui_action::keyed_line_s(script, "ERR_MEETING_STONE_LEFT_QUEUE_S", &[name])
|
||||
}
|
||||
1 => {
|
||||
if new == old {
|
||||
return None;
|
||||
}
|
||||
let name = area_name(areas, new)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| global_text(script, "UNKNOWN"));
|
||||
crate::ui_action::keyed_line_s(script, "ERR_MEETING_STONE_IN_QUEUE_S", &[&name])
|
||||
}
|
||||
2 => crate::ui_action::keyed_line(script, "ERR_MEETING_STONE_OTHER_MEMBER_LEFT"),
|
||||
3 => crate::ui_action::keyed_line(script, "ERR_MEETING_STONE_PARTY_KICKED_FROM_QUEUE"),
|
||||
4 => crate::ui_action::keyed_line(script, "ERR_MEETING_STONE_MEMBER_STILL_IN_QUEUE"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The inputs the meeting-stone feed reads beside the VM and its own state — bundled because the
|
||||
/// feed sits at Bevy's parameter ceiling otherwise.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
pub(crate) struct MeetingStoneInputs<'w, 's> {
|
||||
areas: Option<Res<'w, AreaTableRes>>,
|
||||
names: ResMut<'w, NameCache>,
|
||||
commands: Res<'w, NetCommands>,
|
||||
visuals: Option<Res<'w, SpellVisuals>>,
|
||||
fx: MessageWriter<'w, SpellKitFx>,
|
||||
self_q: Query<'w, 's, Entity, With<SelfPlayer>>,
|
||||
tutorials: Option<MessageWriter<'w, crate::tutorial::TutorialEvent>>,
|
||||
}
|
||||
|
||||
/// The meeting stone's feed: the `0x295` lines, the rebuild and `MEETINGSTONE_CHANGED` per
|
||||
/// arrival; the four display replies; the two globals pushed when they moved.
|
||||
fn feed_meeting_stone(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut stone: ResMut<MeetingStone>,
|
||||
mut inputs: MeetingStoneInputs,
|
||||
mut sink: crate::ui_action::MessageSink,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let areas = inputs.areas.as_deref();
|
||||
let mut lines = Vec::new();
|
||||
|
||||
for (old, status) in std::mem::take(&mut stone.updates) {
|
||||
let new = stone.area;
|
||||
lines.extend(stone_line(&script, areas, old, new, status));
|
||||
if status == 1 && new != old {
|
||||
// The status-1 arm's extra block: `Effect_C` kind `0xc` on the local player, then
|
||||
// the Meeting Stones tutorial (`0x4ca363`, 1976).
|
||||
if let (Some(visuals), Ok(entity)) = (inputs.visuals.as_deref(), inputs.self_q.single())
|
||||
{
|
||||
if let Some(fx) = meeting_stone_join_fx(visuals, entity) {
|
||||
inputs.fx.write(fx);
|
||||
}
|
||||
}
|
||||
if let Some(t) = inputs.tutorials.as_mut() {
|
||||
t.write(crate::tutorial::TutorialEvent::trigger(
|
||||
crate::tutorial::id::MEETING_STONES,
|
||||
));
|
||||
}
|
||||
}
|
||||
// Every path — the silent legs and an out-of-range status included — rebuilds and fires.
|
||||
// The two globals reach the VM BEFORE the event: the stock handler's first act is
|
||||
// `IsInMeetingStoneQueue()`, which has to see the area this packet stored.
|
||||
let text = build_stone_text(&script, areas, new);
|
||||
stone.text = StoneText::Built(text.clone());
|
||||
stone.dirty = false;
|
||||
script.set_meeting_stone(new, Some(text));
|
||||
script.fire_event("MEETINGSTONE_CHANGED", vec![]);
|
||||
}
|
||||
|
||||
for notice in std::mem::take(&mut stone.notices) {
|
||||
match notice {
|
||||
MeetingStoneNotice::Success => {
|
||||
lines.extend(crate::ui_action::keyed_line(
|
||||
&script,
|
||||
"ERR_MEETING_STONE_SUCCESS",
|
||||
));
|
||||
}
|
||||
MeetingStoneNotice::InProgress => {
|
||||
lines.extend(crate::ui_action::keyed_line(
|
||||
&script,
|
||||
"ERR_MEETING_STONE_IN_PROGRESS",
|
||||
));
|
||||
}
|
||||
MeetingStoneNotice::MemberAdded { guid } => stone.pending_members.push(guid),
|
||||
MeetingStoneNotice::JoinFailed { code } => {
|
||||
let key = match code {
|
||||
1 => "ERR_MEETING_STONE_MUST_BE_LEADER",
|
||||
2 => "ERR_MEETING_STONE_GROUP_FULL",
|
||||
3 => "ERR_MEETING_STONE_NO_RAID_GROUP",
|
||||
_ => continue,
|
||||
};
|
||||
lines.extend(crate::ui_action::keyed_line(&script, key));
|
||||
}
|
||||
}
|
||||
}
|
||||
// `0x299`'s name-cache callback: the line when the name lands, nothing until then.
|
||||
let pending = std::mem::take(&mut stone.pending_members);
|
||||
for guid in pending {
|
||||
match inputs
|
||||
.names
|
||||
.resolve(guid, &inputs.commands)
|
||||
.map(str::to_string)
|
||||
{
|
||||
Some(name) => lines.extend(crate::ui_action::keyed_line_s(
|
||||
&script,
|
||||
"ERR_MEETING_STONE_MEMBER_ADDED_S",
|
||||
&[&name],
|
||||
)),
|
||||
None => stone.pending_members.push(guid),
|
||||
}
|
||||
}
|
||||
if !lines.is_empty() {
|
||||
crate::ui_action::show_messages(&mut script, &mut sink, "ui_dialog_verbs", lines);
|
||||
}
|
||||
|
||||
if std::mem::take(&mut stone.dirty) {
|
||||
let text = match &stone.text {
|
||||
StoneText::None => None,
|
||||
StoneText::Unknown => Some(global_text(&script, "UNKNOWN")),
|
||||
StoneText::Built(t) => Some(t.clone()),
|
||||
};
|
||||
script.set_meeting_stone(stone.area, text);
|
||||
}
|
||||
}
|
||||
|
||||
/// The enter-world bring-up's meeting-stone leg: the text reset, then the empty `CMSG 0x296`
|
||||
/// — once per world session, which is what the reference's run-once byte amounts to.
|
||||
fn meeting_stone_enter_world(
|
||||
mut entered: MessageReader<crate::net::EnteredWorldMessage>,
|
||||
mut stone: ResMut<MeetingStone>,
|
||||
commands: Res<NetCommands>,
|
||||
) {
|
||||
if entered.read().next().is_none() {
|
||||
return;
|
||||
}
|
||||
stone.enter_world();
|
||||
let _ = commands.0.send(ClientCommand::MeetingStoneStatusQuery);
|
||||
}
|
||||
|
||||
/// The leave-world sweep's leg: the text dropped, the area kept.
|
||||
fn meeting_stone_leave_world(mut stone: ResMut<MeetingStone>) {
|
||||
stone.leave_world();
|
||||
}
|
||||
|
||||
pub(crate) fn feed_dialog_verbs(
|
||||
@@ -221,8 +520,8 @@ pub(crate) fn feed_dialog_verbs(
|
||||
mut boot: ResMut<InstanceBoot>,
|
||||
mut spirit: ResMut<AreaSpiritHealer>,
|
||||
mut queue: ResMut<BattlefieldQueue>,
|
||||
mut stone: ResMut<MeetingStone>,
|
||||
mut sink: crate::ui_action::MessageSink,
|
||||
mut tutorials: Option<MessageWriter<crate::tutorial::TutorialEvent>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -256,12 +555,14 @@ pub(crate) fn feed_dialog_verbs(
|
||||
script.fire_event("AREA_SPIRIT_HEALER_IN_RANGE", vec![]);
|
||||
}
|
||||
|
||||
for id in std::mem::take(&mut queue.tutorials) {
|
||||
if let Some(t) = tutorials.as_mut() {
|
||||
t.write(crate::tutorial::TutorialEvent::trigger(id));
|
||||
}
|
||||
}
|
||||
if std::mem::take(&mut queue.changed) {
|
||||
script.fire_event("UPDATE_BATTLEFIELD_STATUS", vec![]);
|
||||
}
|
||||
if std::mem::take(&mut stone.changed) {
|
||||
script.fire_event("MEETINGSTONE_CHANGED", vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The pet trainer's confirm and the spirit healer's accept — the two drains over a latch.
|
||||
@@ -372,9 +673,15 @@ impl Plugin for UiDialogVerbsPlugin {
|
||||
(
|
||||
close_npc_session_out_of_range::<PetUnlearnState>.before(feed_dialog_verbs),
|
||||
feed_dialog_verbs.before(UiInput),
|
||||
meeting_stone_enter_world.before(feed_meeting_stone),
|
||||
feed_meeting_stone.before(UiInput),
|
||||
drain_latch_verbs.after(UiInput),
|
||||
drain_queue_verbs.after(UiInput),
|
||||
),
|
||||
)
|
||||
.add_systems(
|
||||
OnExit(crate::char_select::ClientState::InWorld),
|
||||
meeting_stone_leave_world,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -454,7 +761,7 @@ mod tests {
|
||||
slot,
|
||||
map_id,
|
||||
bracket: 0,
|
||||
unknown: 0,
|
||||
instance_id: 0,
|
||||
status: 2,
|
||||
time_ms: Some(0),
|
||||
in_progress: None,
|
||||
@@ -473,6 +780,149 @@ mod tests {
|
||||
assert_eq!(q.map_id(2), None, "a zero map clears the slot");
|
||||
}
|
||||
|
||||
/// The instance clocks (§4.2): stamped by status 3, zeroed by any other status of ANY slot,
|
||||
/// and by a clear of the active slot only — which leaves the active index alone.
|
||||
#[test]
|
||||
fn the_instance_clocks_follow_the_status_handler() {
|
||||
let mut q = BattlefieldQueue::default();
|
||||
let now = Instant::now();
|
||||
let mut active = BattlefieldStatus {
|
||||
slot: 0,
|
||||
map_id: 489,
|
||||
bracket: 0,
|
||||
instance_id: 3,
|
||||
status: 3,
|
||||
time_ms: None,
|
||||
in_progress: Some((90_000, 30_000)),
|
||||
queued: None,
|
||||
};
|
||||
q.apply_at(active.clone(), now);
|
||||
assert_eq!(q.active_map(), Some(489));
|
||||
assert_eq!(q.instance_expiration_ms(now), 90_000);
|
||||
assert_eq!(q.run_time_ms(now), 30_000);
|
||||
assert!(q.take_score_dirty());
|
||||
// A QUEUED update for slot 2 zeroes both clocks — the handler's unconditional arm.
|
||||
let mut queued = active.clone();
|
||||
queued.slot = 1;
|
||||
queued.map_id = 529;
|
||||
queued.status = 1;
|
||||
queued.in_progress = None;
|
||||
queued.queued = Some((60_000, 5_000));
|
||||
q.apply_at(queued, now);
|
||||
assert_eq!(
|
||||
q.active_map(),
|
||||
Some(489),
|
||||
"another slot's status leaves the active index"
|
||||
);
|
||||
assert_eq!(q.instance_expiration_ms(now), 0);
|
||||
assert_eq!(q.run_time_ms(now), 0);
|
||||
assert_eq!(q.map_id(2), Some(529));
|
||||
// Re-arm, then clear the ACTIVE slot: the clocks go, the index stays (`[0x8457cc]`).
|
||||
q.apply_at(active.clone(), now);
|
||||
active.map_id = 0;
|
||||
q.apply_at(active, now);
|
||||
assert_eq!(q.instance_expiration_ms(now), 0);
|
||||
assert_eq!(q.map_id(1), None);
|
||||
assert_eq!(
|
||||
q.active_map(),
|
||||
Some(489),
|
||||
"the clear arm never resets the active index"
|
||||
);
|
||||
}
|
||||
|
||||
/// `0x295`'s store is unconditional and the old id is latched first; the feed reads both.
|
||||
#[test]
|
||||
fn the_stone_latches_the_old_area_before_storing_the_new() {
|
||||
let mut stone = MeetingStone::default();
|
||||
stone.apply(1519, 1);
|
||||
stone.apply(1519, 1);
|
||||
stone.apply(0, 0);
|
||||
stone.apply(12, 9);
|
||||
assert_eq!(stone.area, 12);
|
||||
assert_eq!(stone.updates, vec![(0, 1), (1519, 1), (1519, 0), (0, 9)]);
|
||||
stone.enter_world();
|
||||
assert_eq!(stone.text, StoneText::Unknown);
|
||||
assert_eq!(
|
||||
stone.area, 12,
|
||||
"the enter-world reset leaves the area alone"
|
||||
);
|
||||
stone.leave_world();
|
||||
assert_eq!(stone.text, StoneText::None);
|
||||
}
|
||||
|
||||
/// The five-way table with its two asymmetries, and the rebuild's fallbacks and buffer.
|
||||
#[test]
|
||||
fn the_status_table_and_the_rebuild_follow_the_handler() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let areas =
|
||||
AreaTableRes(benilla_formats::load_area_table_catalog(&mut chain).expect("AreaTable"));
|
||||
let s = UiScript::new().unwrap();
|
||||
s.run(
|
||||
r#"MEETINGSTONE_TOOLTIP = "Looking for more for %s" UNKNOWN = "Unknown"
|
||||
ERR_MEETING_STONE_LEFT_QUEUE_S = "You are no longer queued for %s."
|
||||
ERR_MEETING_STONE_IN_QUEUE_S = "You are now in the queue to join a party for %s."
|
||||
ERR_MEETING_STONE_OTHER_MEMBER_LEFT = "left"
|
||||
ERR_MEETING_STONE_PARTY_KICKED_FROM_QUEUE = "kicked"
|
||||
ERR_MEETING_STONE_MEMBER_STILL_IN_QUEUE = "still""#,
|
||||
)
|
||||
.unwrap();
|
||||
let a = Some(&areas);
|
||||
let text = |l: Option<crate::ui_action::Shown>| l.map(|l| l.text().to_string());
|
||||
// Status 0 names the OLD area — and is silent when it has no row.
|
||||
assert_eq!(
|
||||
text(stone_line(&s, a, 1519, 0, 0)).as_deref(),
|
||||
Some("You are no longer queued for Stormwind City.")
|
||||
);
|
||||
assert_eq!(
|
||||
text(stone_line(&s, a, 0, 0, 0)),
|
||||
None,
|
||||
"no row for area 0: silent"
|
||||
);
|
||||
assert_eq!(text(stone_line(&s, a, 999_999, 0, 0)), None);
|
||||
// Status 1 names the NEW area, falls back to UNKNOWN, and is skipped when unchanged.
|
||||
assert_eq!(
|
||||
text(stone_line(&s, a, 0, 1519, 1)).as_deref(),
|
||||
Some("You are now in the queue to join a party for Stormwind City.")
|
||||
);
|
||||
assert_eq!(
|
||||
text(stone_line(&s, a, 0, 999_999, 1)).as_deref(),
|
||||
Some("You are now in the queue to join a party for Unknown.")
|
||||
);
|
||||
assert_eq!(
|
||||
text(stone_line(&s, a, 1519, 1519, 1)),
|
||||
None,
|
||||
"unchanged: skipped"
|
||||
);
|
||||
assert_eq!(text(stone_line(&s, a, 0, 0, 2)).as_deref(), Some("left"));
|
||||
assert_eq!(text(stone_line(&s, a, 0, 0, 3)).as_deref(), Some("kicked"));
|
||||
assert_eq!(text(stone_line(&s, a, 0, 0, 4)).as_deref(), Some("still"));
|
||||
assert_eq!(
|
||||
text(stone_line(&s, a, 0, 0, 5)),
|
||||
None,
|
||||
"past the table: nothing"
|
||||
);
|
||||
// The rebuild.
|
||||
assert_eq!(
|
||||
build_stone_text(&s, a, 1519),
|
||||
"Looking for more for Stormwind City"
|
||||
);
|
||||
assert_eq!(build_stone_text(&s, a, 0), "Looking for more for Unknown");
|
||||
s.run("MEETINGSTONE_TOOLTIP = string.rep('x', 300) .. '%s'")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
build_stone_text(&s, a, 1519).len(),
|
||||
STONE_TEXT_BYTES,
|
||||
"the 256-byte buffer"
|
||||
);
|
||||
s.run("MEETINGSTONE_TOOLTIP = nil").unwrap();
|
||||
assert_eq!(
|
||||
build_stone_text(&s, a, 1519),
|
||||
"",
|
||||
"a missing template is GetText's empty string, not the unreachable %s fallback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pet_question_latches_and_closes_like_the_talent_wipe() {
|
||||
let mut pet = PetUnlearnState::default();
|
||||
|
||||
@@ -114,6 +114,7 @@ fn feed_mirror_timers(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut feed: ResMut<MirrorTimerFeed>,
|
||||
spells: Option<Res<Spells>>,
|
||||
mut tutorials: Option<MessageWriter<crate::tutorial::TutorialEvent>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
// No VM (a capture/headless run): drop the edges rather than let them pile up unbounded.
|
||||
@@ -135,6 +136,26 @@ fn feed_mirror_timers(
|
||||
let Some(kind) = MirrorTimerKind::from_wire(raw) else {
|
||||
continue;
|
||||
};
|
||||
// The handler's two tutorial arms (`0x5e7ab0` type 0, `0x5e7acd` type 1; 1976).
|
||||
if matches!(edge, MirrorTimerEdge::Start(_)) {
|
||||
match kind {
|
||||
MirrorTimerKind::Fatigue => {
|
||||
if let Some(t) = tutorials.as_mut() {
|
||||
t.write(crate::tutorial::TutorialEvent::trigger(
|
||||
crate::tutorial::id::FATIGUE,
|
||||
));
|
||||
}
|
||||
}
|
||||
MirrorTimerKind::Breath => {
|
||||
if let Some(t) = tutorials.as_mut() {
|
||||
t.write(crate::tutorial::TutorialEvent::trigger(
|
||||
crate::tutorial::id::BREATH,
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let name = ScriptValue::Str(script_name(kind).into());
|
||||
let (event, args): (&str, Vec<ScriptValue>) = match edge {
|
||||
MirrorTimerEdge::Start(start) => (
|
||||
|
||||
@@ -833,6 +833,7 @@ pub(super) fn drain_party(
|
||||
mut names: ResMut<NameCache>,
|
||||
self_q: Query<&Guid, With<SelfPlayer>>,
|
||||
commands: Res<NetCommands>,
|
||||
mut tutorials: Option<MessageWriter<crate::tutorial::TutorialEvent>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -859,6 +860,11 @@ pub(super) fn drain_party(
|
||||
let _ = commands.0.send(ClientCommand::GroupLeave);
|
||||
}
|
||||
PartyRequest::InviteName(name) => {
|
||||
if let Some(t) = tutorials.as_mut() {
|
||||
t.write(crate::tutorial::TutorialEvent::Acknowledge {
|
||||
id: crate::tutorial::id::GROUPING,
|
||||
});
|
||||
}
|
||||
let _ = commands.0.send(ClientCommand::GroupInvite { name });
|
||||
}
|
||||
PartyRequest::InviteUnit(token) => {
|
||||
@@ -873,6 +879,11 @@ pub(super) fn drain_party(
|
||||
member_for_token(&group, &token).map(|m| m.name.clone())
|
||||
};
|
||||
if let Some(name) = name {
|
||||
if let Some(t) = tutorials.as_mut() {
|
||||
t.write(crate::tutorial::TutorialEvent::Acknowledge {
|
||||
id: crate::tutorial::id::GROUPING,
|
||||
});
|
||||
}
|
||||
let _ = commands.0.send(ClientCommand::GroupInvite { name });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,6 +542,15 @@ pub(crate) fn project_overlay(
|
||||
/// The accept REGION: exactly the viewport, **inclusive**. `[0xb4b2bc]` is the WorldFrame, and its
|
||||
/// region rect is mirrored ÷G44/÷G48 into the compare fields (`0x483970`), so the aspect factors
|
||||
/// cancel exactly and the test is against the raw screen box.
|
||||
/// `WOW_PROBE_UI_ONE_TEX=1` — a PRICING lever, never a look: the run split ignores texture
|
||||
/// identity, so runs break on state flags alone and every run draws with its first quad's
|
||||
/// texture. What it measures is the draw-count ceiling a UI texture atlas would reach (the
|
||||
/// module doc's "standard fix"), before that atlas is built.
|
||||
fn one_texture_probe() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var_os("WOW_PROBE_UI_ONE_TEX").is_some())
|
||||
}
|
||||
|
||||
fn accepts(p: Vec2, viewport: Vec2) -> bool {
|
||||
(0.0..=viewport.x).contains(&p.x) && (0.0..=viewport.y).contains(&p.y)
|
||||
}
|
||||
@@ -1197,7 +1206,7 @@ fn rebuild_ui_mesh(
|
||||
};
|
||||
let texture = q.texture.clone().unwrap_or_else(|| white.0.clone());
|
||||
let same_run = runs.last().is_some_and(|r| {
|
||||
r.texture == texture
|
||||
(r.texture == texture || one_texture_probe())
|
||||
&& r.additive == q.additive
|
||||
&& r.circular == q.circular
|
||||
&& r.desaturated == q.desaturated
|
||||
|
||||
@@ -668,6 +668,7 @@ fn drain_quest(
|
||||
commands: Res<NetCommands>,
|
||||
index: Res<GuidIndex>,
|
||||
stores: Query<&ObjectStore>,
|
||||
mut tutorials: Option<MessageWriter<crate::tutorial::TutorialEvent>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -735,6 +736,12 @@ fn drain_quest(
|
||||
let _ = commands
|
||||
.0
|
||||
.send(ClientCommand::QuestgiverAccept { npc, quest });
|
||||
// `AcceptQuest`'s tail (`0x5013df`): the Quest Log tutorial (1976).
|
||||
if let Some(t) = tutorials.as_mut() {
|
||||
t.write(crate::tutorial::TutorialEvent::trigger(
|
||||
crate::tutorial::id::QUEST_LOG,
|
||||
));
|
||||
}
|
||||
// `Script::AcceptQuest` (`0x501380`) closes the window on the CLICK, not on any
|
||||
// answer: send `0x189` (`0x5eac10`), then `0x501130(0,0)` — the same clear the
|
||||
// refusal handlers call, firing `QUEST_FINISHED`. Ours used to leave the panel
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
//! The stock `BattlefieldFrame.xml` and `Minimap.xml`'s queue icon (decision 1974), driven
|
||||
//! engine-only over the queue family's pushes: the icon and its tooltip across
|
||||
//! `UPDATE_BATTLEFIELD_STATUS`, the confirm dialog, the list window across `BATTLEFIELDS_SHOW`
|
||||
//! / `_CLOSED`, and the join button's request.
|
||||
|
||||
use benilla_ui::script::{BattlefieldListView, BattlefieldMapInfo, BattlefieldQueueSlot, UiScript};
|
||||
|
||||
use super::test_ui::load_ui as load_xml;
|
||||
|
||||
/// The stock pair with everything they call at load and on the events below — the durability
|
||||
/// harness's shape (UIParent.xml for the panel kit and `UIPanelWindows`; StaticPopup for the
|
||||
/// confirm dialog; UIDropDownMenu for the icon's menu; UIPanelTemplates for the buttons).
|
||||
fn session() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
// The list verbs read the local player object at call time (1974) — seat one, as every
|
||||
// in-world call has.
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
s.run("function PlaySound() end").unwrap();
|
||||
// `FloatingChatFrame.lua`'s constant (l.9), which the queue icon's fade-in reads; the chat
|
||||
// files are not part of this harness.
|
||||
s.run("CHAT_FRAME_FADE_TIME = 0.15").unwrap();
|
||||
for f in [
|
||||
"Interface\\FrameXML\\Fonts.xml",
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"Interface\\FrameXML\\Localization.xml",
|
||||
"Interface\\FrameXML\\LocaleProperties.lua",
|
||||
"Interface\\FrameXML\\BasicControls.xml",
|
||||
"UIParent.xml",
|
||||
r"Interface\FrameXML\MoneyFrame.lua",
|
||||
r"Interface\FrameXML\MoneyFrame.xml",
|
||||
"UiPanels.xml",
|
||||
"Interface\\FrameXML\\GameTooltip.xml",
|
||||
"Interface\\FrameXML\\UIDropDownMenu.xml",
|
||||
r"Interface\FrameXML\UIPanelTemplates.lua",
|
||||
r"Interface\FrameXML\UIPanelTemplates.xml",
|
||||
"Interface\\FrameXML\\StaticPopup.xml",
|
||||
"Interface\\FrameXML\\BattlefieldFrame.xml",
|
||||
"Interface\\FrameXML\\Minimap.xml",
|
||||
] {
|
||||
load_xml(&s, f);
|
||||
}
|
||||
s.resolve();
|
||||
s
|
||||
}
|
||||
|
||||
fn slot(map_id: u32, name: &str, status: u32, instance: u32) -> BattlefieldQueueSlot {
|
||||
BattlefieldQueueSlot {
|
||||
map_id,
|
||||
map_name: Some(name.into()),
|
||||
status,
|
||||
instance_id: instance,
|
||||
min_level: 20,
|
||||
max_level: 29,
|
||||
port_expiration_ms: 75_000,
|
||||
estimated_wait_ms: 90_000,
|
||||
time_waited_ms: 12_000,
|
||||
}
|
||||
}
|
||||
|
||||
fn empty() -> BattlefieldQueueSlot {
|
||||
BattlefieldQueueSlot {
|
||||
map_name: Some("Eastern Kingdoms".into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn visible(s: &UiScript, frame: &str) -> bool {
|
||||
s.eval::<bool>(&format!("return {frame}:IsVisible()"))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The minimap's queue icon: hidden with no queue, shown by a queued slot with the reference's
|
||||
/// three-line tooltip, the confirm dialog on a ready slot, hidden again when the queue clears.
|
||||
#[test]
|
||||
fn the_queue_icon_follows_the_slots_across_update_battlefield_status() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = session();
|
||||
assert!(!visible(&s, "MiniMapBattlefieldFrame"), "no queue at load");
|
||||
|
||||
s.set_battlefield_queue(vec![slot(529, "Arathi Basin", 1, 0), empty(), empty()], 0);
|
||||
s.fire_event("UPDATE_BATTLEFIELD_STATUS", vec![]);
|
||||
assert!(
|
||||
visible(&s, "MiniMapBattlefieldFrame"),
|
||||
"a queued slot shows the icon"
|
||||
);
|
||||
let tooltip = s
|
||||
.eval::<String>("return MiniMapBattlefieldFrame.tooltip")
|
||||
.unwrap();
|
||||
assert!(
|
||||
tooltip.starts_with("You are in the queue for Arathi Basin\n"),
|
||||
"{tooltip}"
|
||||
);
|
||||
// `SecondsToTime` leaves a trailing space after every unit — the reference's own text.
|
||||
assert!(
|
||||
tooltip.contains("Average wait time: 1 Min (Last 10 players)"),
|
||||
"{tooltip}"
|
||||
);
|
||||
assert!(tooltip.contains("Time in queue: 12 Secs \n"), "{tooltip}");
|
||||
assert!(
|
||||
tooltip.ends_with("|cffffffff<Right Click> for PvP Options|r"),
|
||||
"{tooltip}"
|
||||
);
|
||||
assert!(!visible(&s, "StaticPopup1"), "queued is not yet a question");
|
||||
|
||||
// Ready: the confirm dialog with the map name and the port countdown.
|
||||
s.set_battlefield_queue(vec![slot(529, "Arathi Basin", 2, 3), empty(), empty()], 0);
|
||||
s.fire_event("UPDATE_BATTLEFIELD_STATUS", vec![]);
|
||||
assert!(
|
||||
visible(&s, "StaticPopup1"),
|
||||
"confirm raises CONFIRM_BATTLEFIELD_ENTRY"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return StaticPopup1Text:GetText()")
|
||||
.unwrap(),
|
||||
"You are now eligible to enter Arathi Basin 3, choose an action:"
|
||||
);
|
||||
let tooltip = s
|
||||
.eval::<String>("return MiniMapBattlefieldFrame.tooltip")
|
||||
.unwrap();
|
||||
assert!(
|
||||
tooltip.starts_with(
|
||||
"You are eligible to enter Arathi Basin 3\nYou will be removed from the queue in 1 Min 15 Secs "
|
||||
),
|
||||
"{tooltip}"
|
||||
);
|
||||
// The dialog's Enter Battle is AcceptBattlefieldPort(slot, 1).
|
||||
s.run("StaticPopup1Button1:Click()").unwrap();
|
||||
assert_eq!(s.take_battlefield_port_requests(), vec![(1, true)]);
|
||||
|
||||
// The queue clears: the icon goes.
|
||||
s.set_battlefield_queue(vec![empty(), empty(), empty()], 0);
|
||||
s.fire_event("UPDATE_BATTLEFIELD_STATUS", vec![]);
|
||||
assert!(
|
||||
!visible(&s, "MiniMapBattlefieldFrame"),
|
||||
"no queue hides the icon"
|
||||
);
|
||||
}
|
||||
|
||||
/// The list window: `BATTLEFIELDS_SHOW` opens it on the pushed list — the fake "First
|
||||
/// Available" row, one row per instance, the description — the join button asks for the
|
||||
/// selection, and `BATTLEFIELDS_CLOSED` hides it.
|
||||
#[test]
|
||||
fn the_list_window_opens_on_battlefields_show_and_joins_the_selection() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = session();
|
||||
s.set_battlefield_list(BattlefieldListView {
|
||||
instances: vec![4, 9],
|
||||
bracket_min: 20,
|
||||
bracket_max: 29,
|
||||
info: Some(BattlefieldMapInfo {
|
||||
name: "Arathi Basin".into(),
|
||||
description: Some("The Arathi Basin is one of the main staging points.".into()),
|
||||
min_level: 20,
|
||||
max_level: 60,
|
||||
field_16: -1,
|
||||
field_17: 0.0,
|
||||
field_18: 0.0,
|
||||
}),
|
||||
group_queue: true,
|
||||
});
|
||||
s.set_battlefield_queue(vec![empty(), empty(), empty()], 0);
|
||||
assert!(!visible(&s, "BattlefieldFrame"));
|
||||
s.fire_event("BATTLEFIELDS_SHOW", vec![]);
|
||||
assert!(visible(&s, "BattlefieldFrame"), "the event shows the panel");
|
||||
assert_eq!(
|
||||
s.eval::<String>("return BattlefieldFrameFrameLabel:GetText()")
|
||||
.unwrap(),
|
||||
"Arathi Basin"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return BattlefieldZone1:GetText()")
|
||||
.unwrap(),
|
||||
"First Available"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return BattlefieldZone2:GetText()")
|
||||
.unwrap(),
|
||||
"Arathi Basin 4"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return BattlefieldZone3:GetText()")
|
||||
.unwrap(),
|
||||
"Arathi Basin 9"
|
||||
);
|
||||
assert!(
|
||||
!visible(&s, "BattlefieldZone4"),
|
||||
"two instances, three rows"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return BattlefieldFrameZoneDescription:GetText()")
|
||||
.unwrap(),
|
||||
"The Arathi Basin is one of the main staging points."
|
||||
);
|
||||
assert!(
|
||||
visible(&s, "BattlefieldFrameGroupJoinButton"),
|
||||
"the map allows group queues: the button shows"
|
||||
);
|
||||
assert!(
|
||||
!s.eval::<bool>("return BattlefieldFrameGroupJoinButton:IsEnabled() ~= 0")
|
||||
.unwrap(),
|
||||
"…disabled while not leading a group"
|
||||
);
|
||||
|
||||
// Join the default selection (row 1 = first available), then a specific instance.
|
||||
s.run("BattlefieldFrameJoinButton:Click()").unwrap();
|
||||
assert_eq!(s.take_battlefield_join_requests(), vec![(0, false)]);
|
||||
assert!(!visible(&s, "BattlefieldFrame"), "joining hides the panel");
|
||||
s.fire_event("BATTLEFIELDS_SHOW", vec![]);
|
||||
s.run("BattlefieldZone3:Click()").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return GetSelectedBattlefield()").unwrap(), 2);
|
||||
s.run("BattlefieldFrameJoinButton:Click()").unwrap();
|
||||
assert_eq!(s.take_battlefield_join_requests(), vec![(9, false)]);
|
||||
|
||||
s.fire_event("BATTLEFIELDS_SHOW", vec![]);
|
||||
assert!(visible(&s, "BattlefieldFrame"));
|
||||
s.fire_event("BATTLEFIELDS_CLOSED", vec![]);
|
||||
assert!(
|
||||
!visible(&s, "BattlefieldFrame"),
|
||||
"the leash's event hides it"
|
||||
);
|
||||
}
|
||||
|
||||
/// The queue icon's status pass marks the listed rows: a queued instance reads "(In Queue)"
|
||||
/// beside its row, and the first-available row carries it when the queue names no instance.
|
||||
#[test]
|
||||
fn the_list_rows_carry_the_queue_status() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = session();
|
||||
s.set_battlefield_list(BattlefieldListView {
|
||||
instances: vec![4],
|
||||
info: Some(BattlefieldMapInfo {
|
||||
name: "Arathi Basin".into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
s.set_battlefield_queue(vec![slot(529, "Arathi Basin", 1, 4), empty(), empty()], 0);
|
||||
s.fire_event("BATTLEFIELDS_SHOW", vec![]);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return BattlefieldZone2Status:GetText()")
|
||||
.unwrap(),
|
||||
"(In Queue)"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return BattlefieldZone1Status:GetText()")
|
||||
.unwrap(),
|
||||
""
|
||||
);
|
||||
}
|
||||
@@ -79,7 +79,7 @@ fn shipped_xml() -> Vec<(String, String)> {
|
||||
out.sort();
|
||||
// A sanity floor for the walk, not a census: `assets/ui` retires file by file (1751), so
|
||||
// the floor sits well under the count rather than one step above it (1956).
|
||||
assert!(out.len() >= 15, "only {} xml files swept", out.len());
|
||||
assert!(out.len() >= 10, "only {} xml files swept", out.len());
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
@@ -465,7 +465,7 @@ fn corpse_map_position_binding() {
|
||||
.eval::<(f64, f64)>("return GetCorpseMapPosition()")
|
||||
.unwrap();
|
||||
assert_eq!((x, y), (0.0, 0.0), "no corpse ⇒ the (0,0) hide sentinel");
|
||||
s.set_world_map_feed(None, None, 0.0, Some((0.25, 0.75)), Vec::new());
|
||||
s.set_world_map_feed(None, None, 0.0, Some((0.25, 0.75)), Vec::new(), Vec::new());
|
||||
let (x, y) = s
|
||||
.eval::<(f64, f64)>("return GetCorpseMapPosition()")
|
||||
.unwrap();
|
||||
|
||||
@@ -27,7 +27,9 @@ fn harness() -> UiScript {
|
||||
"Interface\\FrameXML\\StaticPopup.xml",
|
||||
"UIParent.xml",
|
||||
"Interface\\FrameXML\\GameTooltip.xml",
|
||||
"MinimapCluster.xml",
|
||||
"Interface\\FrameXML\\UIDropDownMenu.xml",
|
||||
"Interface\\FrameXML\\BattlefieldFrame.xml",
|
||||
"Interface\\FrameXML\\Minimap.xml",
|
||||
// The reference's own file. This module carried a private disk-only `load_xml` until the
|
||||
// swap, which structurally could not name a chain entry — [`super::test_ui::load_ui`] is
|
||||
// the one reader that speaks both stores, and it is why that helper exists.
|
||||
@@ -404,7 +406,9 @@ fn the_quest_tracker_stacks_below_the_durability_guy() {
|
||||
"Interface\\FrameXML\\StaticPopup.xml",
|
||||
"UIParent.xml",
|
||||
"Interface\\FrameXML\\GameTooltip.xml",
|
||||
"MinimapCluster.xml",
|
||||
"Interface\\FrameXML\\UIDropDownMenu.xml",
|
||||
"Interface\\FrameXML\\BattlefieldFrame.xml",
|
||||
"Interface\\FrameXML\\Minimap.xml",
|
||||
"ScrollTemplates.xml",
|
||||
"Interface\\FrameXML\\DurabilityFrame.xml",
|
||||
"Interface\\FrameXML\\BasicControls.xml",
|
||||
|
||||
@@ -1328,8 +1328,51 @@ fn convert_entry(
|
||||
//
|
||||
// A pane with no name, or one no window claims, draws nothing (`SetModel` panes included:
|
||||
// this engine holds their scene and renders no M2 into it). Nothing is stubbed white.
|
||||
QuadContent::ModelPane { name } => {
|
||||
QuadContent::ModelPane {
|
||||
name,
|
||||
model,
|
||||
facing,
|
||||
model_scale: _,
|
||||
} => {
|
||||
use crate::portrait::PortraitSource;
|
||||
// The world-map arrow (decision 1980): the stock `WorldMapFrame.lua` /
|
||||
// `Blizzard_BattlefieldMinimap.lua` create an anonymous `Model` child, `SetModel` it to
|
||||
// the minimap arrow, and steer it with `SetFacing`/`SetPosition` every update. benilla
|
||||
// draws that one file as the minimap's own arrow sprite — the same art the minimap
|
||||
// ring draws (`crate::minimap::blips`), turned the same way (`-facing`: the M2's yaw
|
||||
// is CCW-positive, the screen's is CW), filling the pane the engine sized to the
|
||||
// reference's 33.6 px footprint times the arrow's `SetModelScale`. No 3D pane needs
|
||||
// to exist for a two-triangle arrow.
|
||||
if model.as_deref() == Some(benilla_ui::script::ARROW_MODEL) {
|
||||
use crate::minimap::blips::{
|
||||
PLAYER_ARROW_OFFSET_PX, PLAYER_ARROW_QUAD_PX, PLAYER_ARROW_TEXTURE,
|
||||
};
|
||||
let Some(handle) = assets
|
||||
.as_mut()
|
||||
.and_then(|a| a.sprite_texture(PLAYER_ARROW_TEXTURE, images))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// The M2's single quad sits off its origin by an authored offset that turns with
|
||||
// the facing — the minimap's arrow applies the same one at its 33.6 px basis, and
|
||||
// the pane the engine sized (`worldmap_arrow.rs`: the footprint times the
|
||||
// `SetModelScale`) scales it here. `model_scale` is already in the rect.
|
||||
let rotation = -facing;
|
||||
let (sin, cos) = rotation.sin_cos();
|
||||
let off = PLAYER_ARROW_OFFSET_PX * (rect.width() / PLAYER_ARROW_QUAD_PX);
|
||||
let off = Vec2::new(off.x * cos - off.y * sin, off.x * sin + off.y * cos);
|
||||
let rect = Rect::from_center_size(rect.center() + off, rect.size());
|
||||
out.push(UiQuad {
|
||||
rect,
|
||||
z_key: eq.z,
|
||||
texture: Some(handle),
|
||||
color: [1.0, 1.0, 1.0, eq.alpha],
|
||||
rotation,
|
||||
clip,
|
||||
..default()
|
||||
});
|
||||
return;
|
||||
}
|
||||
let Some(slot) = name.as_deref().and_then(crate::portrait::model_pane_booth) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -192,12 +192,9 @@ const KNOWN: &[Known] = &[
|
||||
// entries here — twelve `MerchantItem<N>` plus `MerchantBuyBackItem` — saying ours took the
|
||||
// mouse on the row itself where the reference splits each row into an inert container plus a
|
||||
// `$parentItemButton`. That is exactly what this gate exists to notice going away.
|
||||
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 `WorldMapFrame` mouse entry RETIRED (1980): it said our map body took the mouse where
|
||||
// the reference relies on `WorldMapButton` alone — true of our transcription, and the map is
|
||||
// the reference's own file now.
|
||||
// ── The faux scroll panes: a different WIDGET KIND, not a missing flag ─────────────────────
|
||||
//
|
||||
// The reference declares each of these `<ScrollFrame …inherits="FauxScrollFrameTemplate">` and
|
||||
@@ -576,10 +573,11 @@ fn the_shipped_frames_carry_the_references_flags() {
|
||||
// 1956 our SkillFrame.xml, 1958 our UnitPopup.xml and 1959 our FriendsFrame.xml (their
|
||||
// frames are the reference's own now), which took the paired count from the low 400s to
|
||||
// the high 150s; 1966 (TradeFrame), 1968 (GameTooltip), 1969 (DressUpFrame) and 1970
|
||||
// (MailFrame) to the high 80s. The floor guards the pairing, not the census — it comes
|
||||
// down with every window that migrates, and reaches zero with the last file of ours
|
||||
// that declares a reference-named frame.
|
||||
compared > 60,
|
||||
// (MailFrame) to the high 80s, and 1980 (WorldMapFrame, whose 50-odd blip frames were
|
||||
// most of what was left) to the high teens. The floor guards the pairing, not the
|
||||
// census — it comes down with every window that migrates, and reaches zero with the
|
||||
// last file of ours that declares a reference-named frame.
|
||||
compared > 10,
|
||||
"only {compared} frames compared — the pairing broke, and the sweep guards nothing"
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -568,7 +568,17 @@ fn the_world_map_cannot_open_behind_the_menu_and_gives_its_slot_back() {
|
||||
"ScrollTemplates.xml",
|
||||
r"Interface\FrameXML\UIPanelTemplates.lua",
|
||||
r"Interface\FrameXML\UIPanelTemplates.xml",
|
||||
"WorldMapFrame.xml",
|
||||
// The stock map's OnShow/OnHide call `UpdateMicroButtons` unguarded (1980), which the
|
||||
// micro menu defines over the action bar it sits in — the spellbook kit's cluster.
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
"Interface\\FrameXML\\MainMenuBar.xml",
|
||||
"Interface\\FrameXML\\ActionBarFrame.xml",
|
||||
"Interface\\FrameXML\\BonusActionBarFrame.xml",
|
||||
"MicroMenu.xml",
|
||||
r"Interface\FrameXML\WorldMapFrame.xml",
|
||||
]);
|
||||
|
||||
// Opens normally, and takes the full-screen slot.
|
||||
@@ -619,7 +629,17 @@ fn nothing_opens_behind_the_world_map_and_escape_closes_it_first() {
|
||||
"ScrollTemplates.xml",
|
||||
r"Interface\FrameXML\UIPanelTemplates.lua",
|
||||
r"Interface\FrameXML\UIPanelTemplates.xml",
|
||||
"WorldMapFrame.xml",
|
||||
// The action-bar cluster the micro menu sits in — the stock map's OnShow/OnHide call
|
||||
// `UpdateMicroButtons` unguarded (1980).
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
"Interface\\FrameXML\\MainMenuBar.xml",
|
||||
"Interface\\FrameXML\\ActionBarFrame.xml",
|
||||
"Interface\\FrameXML\\BonusActionBarFrame.xml",
|
||||
"MicroMenu.xml",
|
||||
r"Interface\FrameXML\WorldMapFrame.xml",
|
||||
"Interface\\FrameXML\\MerchantFrame.xml",
|
||||
// The loot window is the reference's own since 1751 — see `test_ui::LOOT_UI` for what
|
||||
// each of these buys; `PartyFrame`'s MAX_PARTY_MEMBERS is needed at LOAD time.
|
||||
|
||||
@@ -151,10 +151,30 @@ fn ui_wanted(world: &World) -> bool {
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct PendingEntryUiLoad;
|
||||
|
||||
/// `OnEnter(InWorld)`: arm the deferred entry load. The load itself runs a few frames later —
|
||||
/// see [`PendingEntryUiLoad`].
|
||||
pub(crate) fn arm_entry_ui_load(mut commands: Commands) {
|
||||
commands.insert_resource(PendingEntryUiLoad);
|
||||
/// **The boot VM, parked for the deferral window** (decision 1978). Between the world-entry edge
|
||||
/// and the deferred entry load there is no VM in the world at all: the boot VM waits here, out of
|
||||
/// every feed's reach, and the load takes it back. Every feed takes the VM as an `Option` and
|
||||
/// already returns on `None` — the glue phase's shape — so a feed keyed on a per-VM memo cannot
|
||||
/// push into a VM that has no interface yet and then hold the real push back (the chat plate
|
||||
/// that stayed white, and 1348's whole class). `ingame_ui_pending` stays for the feeds that
|
||||
/// name it; with the VM parked it is belt and braces.
|
||||
pub(crate) struct ParkedBootVm(UiScript);
|
||||
|
||||
/// `OnEnter(InWorld)`: arm the deferred entry load and park the boot VM. The load itself runs a
|
||||
/// few frames later — see [`PendingEntryUiLoad`] and [`ParkedBootVm`].
|
||||
pub(crate) fn arm_entry_ui_load(world: &mut World) {
|
||||
world.insert_resource(PendingEntryUiLoad);
|
||||
if let Some(vm) = world.remove_non_send_resource::<UiScript>() {
|
||||
world.insert_non_send_resource(ParkedBootVm(vm));
|
||||
}
|
||||
}
|
||||
|
||||
/// The parked boot VM back into the world, if one is parked — the session end and the
|
||||
/// left-before-the-load arm both want the VM where the tail expects it.
|
||||
fn unpark_boot_vm(world: &mut World) {
|
||||
if let Some(ParkedBootVm(vm)) = world.remove_non_send_resource::<ParkedBootVm>() {
|
||||
world.insert_non_send_resource(vm);
|
||||
}
|
||||
}
|
||||
|
||||
/// **Is the in-game UI still owed for this world entry?** True from `OnEnter(InWorld)` until
|
||||
@@ -200,6 +220,7 @@ pub(crate) fn run_pending_entry_load(world: &mut World) {
|
||||
if !in_world {
|
||||
// Left the world before the load ran — nothing to build a UI for.
|
||||
world.remove_resource::<PendingEntryUiLoad>();
|
||||
unpark_boot_vm(world);
|
||||
return;
|
||||
}
|
||||
let covering = world
|
||||
@@ -245,6 +266,9 @@ pub(crate) fn run_pending_entry_load(world: &mut World) {
|
||||
/// capture boots straight into `InWorld`, so before that this would have run ahead of
|
||||
/// [`benilla_assets::AssetSet::Open`] and loaded against no patch chain.
|
||||
pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
|
||||
// The VM the entry edge parked (1978); the live slot is the fallback for a caller that did
|
||||
// not go through the arm.
|
||||
unpark_boot_vm(world);
|
||||
if !ui_wanted(world) {
|
||||
return;
|
||||
}
|
||||
@@ -449,6 +473,9 @@ pub(crate) fn shutdown_ui_state(script: &mut UiScript, identity: Option<&(String
|
||||
/// Exclusive rather than a `NonSendMut` system because it both drops and installs a `NonSend`, and
|
||||
/// because the shutdown writes must be ordered against each other — see [`shutdown_ui_state`].
|
||||
pub(crate) fn end_ui_session(world: &mut World) {
|
||||
// A VM still parked (the load never ran) goes back into the world first, so the tail below
|
||||
// runs against the same slot it always did (1978).
|
||||
unpark_boot_vm(world);
|
||||
// An armed-but-unrun entry load ([`PendingEntryUiLoad`]) means this session never built an
|
||||
// in-game UI: there are no globals to save and no addon state to write, and running the
|
||||
// shutdown tail against the boot VM would overwrite the real files with that emptiness.
|
||||
@@ -701,3 +728,32 @@ pub(crate) fn is_emote_token_line(line: &str) -> bool {
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The entry edge parks the boot VM (1978): between the arm and the load there is no VM in
|
||||
/// the world for any feed to push into, and the session end puts a never-loaded one back.
|
||||
#[test]
|
||||
fn the_entry_edge_parks_the_boot_vm_and_the_session_end_returns_it() {
|
||||
let mut world = World::new();
|
||||
let boot = UiScript::new().unwrap();
|
||||
let session = boot.session();
|
||||
world.insert_non_send_resource(boot);
|
||||
arm_entry_ui_load(&mut world);
|
||||
assert!(
|
||||
world.get_non_send_resource::<UiScript>().is_none(),
|
||||
"no VM in the deferral window"
|
||||
);
|
||||
assert!(world.get_resource::<PendingEntryUiLoad>().is_some());
|
||||
assert!(world.get_non_send_resource::<ParkedBootVm>().is_some());
|
||||
// Left the world before the load ran: the VM comes back, untouched.
|
||||
unpark_boot_vm(&mut world);
|
||||
let vm = world
|
||||
.get_non_send_resource::<UiScript>()
|
||||
.expect("the parked VM is back");
|
||||
assert_eq!(vm.session(), session, "the same VM, no session moved");
|
||||
assert!(world.get_non_send_resource::<ParkedBootVm>().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The shipped `MinimapCluster.xml` driven engine-only: the +/- zoom buttons must re-sync their
|
||||
//! The stock `Minimap.xml` (1974) driven engine-only: the +/- zoom buttons must re-sync their
|
||||
//! enabled state when the active zoom index switches (stepping inside/outside a WMO flips to the
|
||||
//! other, independent level). Regression guard for the director-caught stale-button bug (2026-07-09):
|
||||
//! `ZoomIn` greyed from an outdoor max-zoom stayed greyed indoors at the middle default level.
|
||||
@@ -27,7 +27,11 @@ fn minimap_zoom_buttons_resync_when_switching_inside_and_outside() {
|
||||
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
|
||||
load_xml(&s, "MinimapCluster.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BattlefieldFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Minimap.xml");
|
||||
|
||||
// OnLoad seeds the +/- state from the live zoom. The CVar default is 3 — a middle level, so
|
||||
// BOTH buttons start enabled (the old hardcoded `MinimapZoomOut:Disable()` assumed zoom 0).
|
||||
@@ -92,7 +96,11 @@ fn tracking_frame_follows_get_tracking_texture_across_player_auras_changed() {
|
||||
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
|
||||
load_xml(&s, "MinimapCluster.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BattlefieldFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Minimap.xml");
|
||||
|
||||
let vis = |s: &UiScript| {
|
||||
s.eval::<bool>("return MiniMapTrackingFrame:IsVisible()")
|
||||
@@ -142,7 +150,11 @@ fn game_time_session(hour: u32, minute: u32) -> UiScript {
|
||||
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
|
||||
load_xml(&s, "MinimapCluster.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BattlefieldFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Minimap.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GameTime.xml");
|
||||
s
|
||||
}
|
||||
@@ -255,7 +267,11 @@ fn a_click_on_the_minimap_parks_a_centre_relative_ping_request() {
|
||||
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
|
||||
load_xml(&s, "MinimapCluster.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BattlefieldFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Minimap.xml");
|
||||
s.resolve();
|
||||
|
||||
let cx = s
|
||||
@@ -269,14 +285,16 @@ fn a_click_on_the_minimap_parks_a_centre_relative_ping_request() {
|
||||
// Nothing is parked until somebody clicks.
|
||||
assert_eq!(s.take_minimap_ping_request(), None);
|
||||
|
||||
// A click 20 UI units right and 12 up of the centre — inside the 70-unit disc.
|
||||
// A click 20 UI units right and 12 up of the centre — inside the 70-unit disc. The stock
|
||||
// `Minimap_OnClick` adds its `CURSOR_OFFSET_X/Y` (−7, −9) to the cursor before subtracting
|
||||
// the centre, so what it parks is the click less that offset.
|
||||
s.mouse_button(cx + 20.0, cy + 12.0, "LeftButton", true);
|
||||
s.mouse_button(cx + 20.0, cy + 12.0, "LeftButton", false);
|
||||
let (dx, dy) = s
|
||||
.take_minimap_ping_request()
|
||||
.expect("OnMouseUp → Minimap_OnClick → PingLocation");
|
||||
assert!((dx - 20.0).abs() < 0.01, "x right of centre: {dx}");
|
||||
assert!((dy - 12.0).abs() < 0.01, "y UP from centre: {dy}");
|
||||
assert!((dx - 13.0).abs() < 0.01, "x right of centre, less 7: {dx}");
|
||||
assert!((dy - 3.0).abs() < 0.01, "y UP from centre, less 9: {dy}");
|
||||
// Draining is a drain: the app must not see the same click twice.
|
||||
assert_eq!(s.take_minimap_ping_request(), None);
|
||||
|
||||
@@ -286,11 +304,12 @@ fn a_click_on_the_minimap_parks_a_centre_relative_ping_request() {
|
||||
assert_eq!(s.take_minimap_ping_request(), None, "off-widget is no ping");
|
||||
}
|
||||
|
||||
/// `Minimap:GetPingPosition()` answers **nil** with no ping, and the live pair once the app
|
||||
/// publishes one — never `(0, 0)` for "there isn't one", which a caller cannot tell from a ping
|
||||
/// under its own feet.
|
||||
/// `Minimap:GetPingPosition()` answers **two numbers always** (the reference recomputes them
|
||||
/// from statics nothing clears — wow-re `minimap-ping-law.md`), and the stock
|
||||
/// `Minimap_OnUpdate` leans on exactly that: while its 5 s timer runs it multiplies the answer
|
||||
/// with no nil test. The pair is the app's last publish, `(0, 0)` before any.
|
||||
#[test]
|
||||
fn get_ping_position_is_nil_until_there_is_a_ping() {
|
||||
fn get_ping_position_answers_two_numbers_always() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
@@ -301,14 +320,23 @@ fn get_ping_position_is_nil_until_there_is_a_ping() {
|
||||
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
|
||||
load_xml(&s, "MinimapCluster.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BattlefieldFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Minimap.xml");
|
||||
|
||||
assert_eq!(
|
||||
s.eval::<Option<f32>>("return (Minimap:GetPingPosition())")
|
||||
s.eval::<i64>("return select('#', Minimap:GetPingPosition())")
|
||||
.unwrap(),
|
||||
None
|
||||
2
|
||||
);
|
||||
s.set_minimap_ping(Some((0.25, -0.125)));
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return (Minimap:GetPingPosition())").unwrap(),
|
||||
0.0,
|
||||
"before any ping: a number, not nil"
|
||||
);
|
||||
s.set_minimap_ping((0.25, -0.125));
|
||||
let x = s
|
||||
.eval::<f32>("local x = Minimap:GetPingPosition(); return x")
|
||||
.unwrap();
|
||||
@@ -319,10 +347,106 @@ fn get_ping_position_is_nil_until_there_is_a_ping() {
|
||||
(x - 0.25).abs() < 1e-6 && (y + 0.125).abs() < 1e-6,
|
||||
"{x} {y}"
|
||||
);
|
||||
s.set_minimap_ping(None);
|
||||
assert_eq!(
|
||||
s.eval::<Option<f32>>("return (Minimap:GetPingPosition())")
|
||||
.unwrap(),
|
||||
None
|
||||
|
||||
// The stock lifetime, end to end: MINIMAP_PING shows the model frame and starts the 5 s
|
||||
// timer; OnUpdate re-seats it from GetPingPosition every frame; past 5 s it "fades" and
|
||||
// hides. The app's sprite follows this frame (`minimap/ping.rs`).
|
||||
assert!(
|
||||
!s.eval::<bool>("return MiniMapPing:IsVisible()").unwrap(),
|
||||
"hidden until a ping"
|
||||
);
|
||||
s.fire_event(
|
||||
"MINIMAP_PING",
|
||||
vec![
|
||||
benilla_ui::script::ScriptValue::Str("player".into()),
|
||||
benilla_ui::script::ScriptValue::Number(0.25),
|
||||
benilla_ui::script::ScriptValue::Number(-0.125),
|
||||
],
|
||||
);
|
||||
assert!(s.eval::<bool>("return MiniMapPing:IsVisible()").unwrap());
|
||||
s.tick(1.0);
|
||||
assert!(
|
||||
s.eval::<bool>("return MiniMapPing:IsVisible()").unwrap(),
|
||||
"held at 1 s"
|
||||
);
|
||||
let cx = s
|
||||
.eval::<f32>(
|
||||
"local x = MiniMapPing:GetCenter(); local mx = Minimap:GetCenter(); return x - mx",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
(cx - 0.25 * 140.0).abs() < 0.5,
|
||||
"re-seated at the normalized offset times the width: {cx}"
|
||||
);
|
||||
s.tick(4.2);
|
||||
s.tick(0.6);
|
||||
assert!(
|
||||
!s.eval::<bool>("return MiniMapPing:IsVisible()").unwrap(),
|
||||
"5 s hold + 0.5 s fade, then hidden"
|
||||
);
|
||||
}
|
||||
|
||||
/// The meeting-stone icon (stock `Minimap.xml`, 1974): hidden until `IsInMeetingStoneQueue()`
|
||||
/// answers across `MEETINGSTONE_CHANGED`, its hover the cached status text, hidden again when
|
||||
/// the queue empties — and a click raises the leave-queue question.
|
||||
#[test]
|
||||
fn the_meeting_stone_icon_follows_the_queue_across_meetingstone_changed() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.run("function GetMinimapZoneText() return '' end")
|
||||
.unwrap();
|
||||
s.run("function PlaySound() end").unwrap();
|
||||
for f in [
|
||||
"Interface\\FrameXML\\Fonts.xml",
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"Interface\\FrameXML\\Localization.xml",
|
||||
"Interface\\FrameXML\\LocaleProperties.lua",
|
||||
"Interface\\FrameXML\\BasicControls.xml",
|
||||
"UIParent.xml",
|
||||
r"Interface\FrameXML\MoneyFrame.lua",
|
||||
r"Interface\FrameXML\MoneyFrame.xml",
|
||||
"UiPanels.xml",
|
||||
"Interface\\FrameXML\\GameTooltip.xml",
|
||||
"Interface\\FrameXML\\UIDropDownMenu.xml",
|
||||
r"Interface\FrameXML\UIPanelTemplates.lua",
|
||||
r"Interface\FrameXML\UIPanelTemplates.xml",
|
||||
"Interface\\FrameXML\\StaticPopup.xml",
|
||||
"Interface\\FrameXML\\BattlefieldFrame.xml",
|
||||
"Interface\\FrameXML\\Minimap.xml",
|
||||
] {
|
||||
load_xml(&s, f);
|
||||
}
|
||||
s.resolve();
|
||||
let vis = |s: &UiScript| {
|
||||
s.eval::<bool>("return MiniMapMeetingStoneFrame:IsVisible()")
|
||||
.unwrap()
|
||||
};
|
||||
assert!(!vis(&s), "hidden at load");
|
||||
|
||||
s.set_meeting_stone(1519, Some("Looking for more for Stormwind City".into()));
|
||||
s.fire_event("MEETINGSTONE_CHANGED", vec![]);
|
||||
assert!(vis(&s), "a queued area shows the icon");
|
||||
s.run("this = MiniMapMeetingStoneFrame; MiniMapMeetingStoneFrame:GetScript('OnEnter')()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
|
||||
.unwrap(),
|
||||
"Looking for more for Stormwind City"
|
||||
);
|
||||
s.run("MiniMapMeetingStoneFrame:Click()").unwrap();
|
||||
assert!(
|
||||
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
|
||||
"a click asks CONFIRM_LEAVE_QUEUE"
|
||||
);
|
||||
s.run("StaticPopup1Button1:Click()").unwrap();
|
||||
assert_eq!(
|
||||
s.take_meeting_stone_cancels(),
|
||||
1,
|
||||
"Accept is CancelMeetingStoneRequest()"
|
||||
);
|
||||
|
||||
s.set_meeting_stone(0, Some("Looking for more for Unknown".into()));
|
||||
s.fire_event("MEETINGSTONE_CHANGED", vec![]);
|
||||
assert!(!vis(&s), "area 0 hides it again");
|
||||
}
|
||||
|
||||
@@ -1023,6 +1023,12 @@ mod quest_tests;
|
||||
#[cfg(test)]
|
||||
mod quest_timer_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod battlefield_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tutorial_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod durability_tests;
|
||||
// `pub(crate)` for its `harness`/`push`/`row` helpers: `perf::hud`'s own test drives the readout
|
||||
|
||||
@@ -468,7 +468,7 @@ fn hovering_the_diet_icon_lists_what_the_pet_eats() {
|
||||
let pane = quads
|
||||
.iter()
|
||||
.find(|q| {
|
||||
matches!(&q.content, QuadContent::ModelPane { name: Some(n) } if n == "PetModelFrame")
|
||||
matches!(&q.content, QuadContent::ModelPane { name: Some(n), .. } if n == "PetModelFrame")
|
||||
})
|
||||
.expect("the pet model pane is in the render list");
|
||||
let pane_rect = pane.rect.expect("…with a resolved rect");
|
||||
|
||||
@@ -1567,78 +1567,6 @@ mod tests {
|
||||
(1889; the producer gate carries the event). Gated since the addon became a reached \
|
||||
LoadOnDemand row (1967).",
|
||||
),
|
||||
(
|
||||
"Blizzard_BattlefieldMinimap.xml",
|
||||
"CreateMiniWorldMapArrowFrame",
|
||||
"the world map's arrow-frame family (Create/Position/Show/UpdateWorldMapArrowFrames), engine verbs the reference's WorldMapFrame.lua also calls — unbuilt; ours draws the map's arrows app-side, and they land with the world map's own migration. The addon is a reached LoadOnDemand row since 1972 (UIParent.xml's \
|
||||
BattlefieldMinimap_LoadUI, which the stock WorldStateFrame.lua calls on a \
|
||||
battleground's world states); its pane is a <Minimap>-kind widget this engine \
|
||||
draws nothing into yet, so the addon loads, paints nothing, and these are the verbs \
|
||||
its update would reach.",
|
||||
),
|
||||
(
|
||||
"Blizzard_BattlefieldMinimap.xml",
|
||||
"GetBattlefieldFlagPosition",
|
||||
"the battlefield minimap's flag-position getter (0x4ac230, structural row only; the position family is uncarved). The addon is a reached LoadOnDemand row since 1972 (UIParent.xml's \
|
||||
BattlefieldMinimap_LoadUI, which the stock WorldStateFrame.lua calls on a \
|
||||
battleground's world states); its pane is a <Minimap>-kind widget this engine \
|
||||
draws nothing into yet, so the addon loads, paints nothing, and these are the verbs \
|
||||
its update would reach.",
|
||||
),
|
||||
(
|
||||
"Blizzard_BattlefieldMinimap.xml",
|
||||
"GetBattlefieldMapIconScale",
|
||||
"the battlefield minimap's icon-scale getter (uncarved; reads the queue slot's Map.dbc row). The addon is a reached LoadOnDemand row since 1972 (UIParent.xml's \
|
||||
BattlefieldMinimap_LoadUI, which the stock WorldStateFrame.lua calls on a \
|
||||
battleground's world states); its pane is a <Minimap>-kind widget this engine \
|
||||
draws nothing into yet, so the addon loads, paints nothing, and these are the verbs \
|
||||
its update would reach.",
|
||||
),
|
||||
(
|
||||
"Blizzard_BattlefieldMinimap.xml",
|
||||
"GetBattlefieldPosition",
|
||||
"the battlefield minimap's player-position getter (wow-re `bindings.md` 0x4abf90, a structural row — the position family is outside 195cd099's carve). The addon is a reached LoadOnDemand row since 1972 (UIParent.xml's \
|
||||
BattlefieldMinimap_LoadUI, which the stock WorldStateFrame.lua calls on a \
|
||||
battleground's world states); its pane is a <Minimap>-kind widget this engine \
|
||||
draws nothing into yet, so the addon loads, paints nothing, and these are the verbs \
|
||||
its update would reach.",
|
||||
),
|
||||
(
|
||||
"Blizzard_BattlefieldMinimap.xml",
|
||||
"PositionMiniWorldMapArrowFrame",
|
||||
"as CreateMiniWorldMapArrowFrame — the arrow-frame family. The addon is a reached LoadOnDemand row since 1972 (UIParent.xml's \
|
||||
BattlefieldMinimap_LoadUI, which the stock WorldStateFrame.lua calls on a \
|
||||
battleground's world states); its pane is a <Minimap>-kind widget this engine \
|
||||
draws nothing into yet, so the addon loads, paints nothing, and these are the verbs \
|
||||
its update would reach.",
|
||||
),
|
||||
(
|
||||
"Blizzard_BattlefieldMinimap.xml",
|
||||
"ShowMiniWorldMapArrowFrame",
|
||||
"as CreateMiniWorldMapArrowFrame — the arrow-frame family. The addon is a reached LoadOnDemand row since 1972 (UIParent.xml's \
|
||||
BattlefieldMinimap_LoadUI, which the stock WorldStateFrame.lua calls on a \
|
||||
battleground's world states); its pane is a <Minimap>-kind widget this engine \
|
||||
draws nothing into yet, so the addon loads, paints nothing, and these are the verbs \
|
||||
its update would reach.",
|
||||
),
|
||||
(
|
||||
"Blizzard_BattlefieldMinimap.xml",
|
||||
"UpdateWorldMapArrowFrames",
|
||||
"as CreateMiniWorldMapArrowFrame — the arrow-frame family. The addon is a reached LoadOnDemand row since 1972 (UIParent.xml's \
|
||||
BattlefieldMinimap_LoadUI, which the stock WorldStateFrame.lua calls on a \
|
||||
battleground's world states); its pane is a <Minimap>-kind widget this engine \
|
||||
draws nothing into yet, so the addon loads, paints nothing, and these are the verbs \
|
||||
its update would reach.",
|
||||
),
|
||||
(
|
||||
"Blizzard_BattlefieldMinimap.xml",
|
||||
"WorldMap_GetPOITextureCoords",
|
||||
"a stock WorldMapFrame.lua function; our WorldMapFrame.xml is still ours and defines its own POI atlas walk, so the name arrives with the world map's migration. The addon is a reached LoadOnDemand row since 1972 (UIParent.xml's \
|
||||
BattlefieldMinimap_LoadUI, which the stock WorldStateFrame.lua calls on a \
|
||||
battleground's world states); its pane is a <Minimap>-kind widget this engine \
|
||||
draws nothing into yet, so the addon loads, paints nothing, and these are the verbs \
|
||||
its update would reach.",
|
||||
),
|
||||
(
|
||||
"StaticPopup.xml",
|
||||
"ReplaceTradeEnchant",
|
||||
@@ -2324,6 +2252,12 @@ mod tests {
|
||||
// built; none is 1819-shaped, because no PAIR is split (a half-fired pair is the tell).
|
||||
const UNPRODUCED: &[(&str, &str)] = &[
|
||||
("BAG_OPEN", "ContainerFrame.lua"),
|
||||
(
|
||||
"CLOSE_WORLD_MAP",
|
||||
"WorldMapFrame.lua — the engine-side close the reference fires when the map is \
|
||||
shut from outside its own frame; benilla closes the map through the frame's own \
|
||||
hide path only (1980)",
|
||||
),
|
||||
("DISPLAY_SIZE_CHANGED", "the four paperdoll files"),
|
||||
(
|
||||
"GMSURVEY_DISPLAY",
|
||||
|
||||
@@ -32,7 +32,7 @@ fn every_shipped_ui_xml_parses() {
|
||||
assert!(
|
||||
// A sanity floor for the walk, not a census: `assets/ui` retires file by file (1751),
|
||||
// so the floor sits well under the count rather than one step above it (1956).
|
||||
checked >= 15,
|
||||
checked >= 10,
|
||||
"only {checked} xml files swept — sweep broke"
|
||||
);
|
||||
}
|
||||
@@ -430,8 +430,9 @@ fn every_shipped_texture_path_resolves_in_the_client_archives() {
|
||||
}
|
||||
// Never let the sweep pass by matching nothing — the shipped UI still names over a hundred
|
||||
// textures (173 after 1971 retired the auction transcription, fewer again after 1973 the
|
||||
// crafting pair; the count falls with every window that migrates, and the floor follows it).
|
||||
assert!(refs.len() >= 80, "only {} texture paths swept", refs.len());
|
||||
// crafting pair, 64 after 1980 the world map; the count falls with every window that
|
||||
// migrates, and the floor follows it).
|
||||
assert!(refs.len() >= 50, "only {} texture paths swept", refs.len());
|
||||
|
||||
// The shape half: a doubled separator is the Lua escaping written into XML, and it resolves to
|
||||
// nothing. Checked without the client so a data-less machine still catches this exact class.
|
||||
@@ -624,10 +625,10 @@ fn every_archive_path_a_shipped_lua_chunk_names_survives_its_own_escaping() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Never let the sweep pass by matching nothing (19 after 1971; the floor follows the census
|
||||
// down as windows migrate).
|
||||
// Never let the sweep pass by matching nothing (19 after 1971, 8 after 1980; the floor
|
||||
// follows the census down as windows migrate).
|
||||
assert!(
|
||||
paths.len() >= 10,
|
||||
paths.len() >= 5,
|
||||
"only {} archive paths swept out of the shipped Lua",
|
||||
paths.len()
|
||||
);
|
||||
@@ -845,7 +846,7 @@ fn no_shipped_script_sets_a_global_string_key_as_display_text() {
|
||||
assert!(offenders.is_empty(), "{}", offenders.join("\n"));
|
||||
// The sweep must never pass by finding nothing to sweep.
|
||||
// The same walk floor as above (1956).
|
||||
assert!(swept >= 15, "only {swept} xml files swept — sweep broke");
|
||||
assert!(swept >= 10, "only {swept} xml files swept — sweep broke");
|
||||
}
|
||||
|
||||
/// **The `$parentTextureFrame` idiom's contract, over the whole shipped UI**: a frame whose art is
|
||||
@@ -1372,7 +1373,10 @@ fn a_cinematic_leaves_nothing_of_the_interface_on_screen() {
|
||||
// The sweep is only worth anything if there was something to hide in the first place.
|
||||
let before = names.iter().filter(|n| visible(&s, n)).count();
|
||||
assert!(
|
||||
before > 20, // 50 until 1938 took the three bar files (and their visible frames) stock
|
||||
// 50 until 1938 took the three bar files stock, 20 until 1974 took the minimap cluster
|
||||
// (its 22 named frames were most of what this sweep counted); the floor follows the
|
||||
// census of OUR files down, and 12 is what is left — the glue and dev frames.
|
||||
before > 10,
|
||||
"only {before} frames visible before the cinematic — the sweep found no interface to \
|
||||
hide, so it would pass no matter what the cascade did"
|
||||
);
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//! The stock `TutorialFrame.xml` (decision 1976) driven engine-only: an alert button per
|
||||
//! `TUTORIAL_TRIGGER`, the window a click opens over the published id's strings, the
|
||||
//! `FlagTutorial` it makes, the unticked checkbox's `ClearTutorials` on hide, and
|
||||
//! `CINEMATIC_STOP` clicking the Welcome alert.
|
||||
|
||||
use benilla_ui::script::{ScriptValue, UiScript};
|
||||
|
||||
use super::test_ui::load_ui as load_xml;
|
||||
|
||||
fn session() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.run("function PlaySound() end").unwrap();
|
||||
for f in [
|
||||
"Interface\\FrameXML\\Fonts.xml",
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"Interface\\FrameXML\\Localization.xml",
|
||||
"Interface\\FrameXML\\LocaleProperties.lua",
|
||||
"Interface\\FrameXML\\BasicControls.xml",
|
||||
"UIParent.xml",
|
||||
r"Interface\FrameXML\MoneyFrame.lua",
|
||||
r"Interface\FrameXML\MoneyFrame.xml",
|
||||
"UiPanels.xml",
|
||||
"Interface\\FrameXML\\GameTooltip.xml",
|
||||
r"Interface\FrameXML\UIPanelTemplates.lua",
|
||||
r"Interface\FrameXML\UIPanelTemplates.xml",
|
||||
"Interface\\FrameXML\\TutorialFrame.xml",
|
||||
] {
|
||||
load_xml(&s, f);
|
||||
}
|
||||
s.resolve();
|
||||
s
|
||||
}
|
||||
|
||||
fn visible(s: &UiScript, frame: &str) -> bool {
|
||||
s.eval::<bool>(&format!("return {frame}:IsVisible()"))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn trigger(s: &mut UiScript, published: i64) {
|
||||
s.fire_event("TUTORIAL_TRIGGER", vec![ScriptValue::Int(published)]);
|
||||
}
|
||||
|
||||
/// A trigger queues an alert button; its click opens the window on the id's title and text and
|
||||
/// acknowledges the id (0-based on the drain); a second trigger queues a second button.
|
||||
#[test]
|
||||
fn a_trigger_queues_an_alert_whose_click_opens_the_window_and_flags_it() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = session();
|
||||
assert!(!visible(&s, "TutorialFrameAlertButton1"));
|
||||
assert!(!visible(&s, "TutorialFrame"));
|
||||
|
||||
trigger(&mut s, 41);
|
||||
assert!(
|
||||
visible(&s, "TutorialFrameAlertButton1"),
|
||||
"the alert button shows"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return TutorialFrameAlertButton1.tooltip")
|
||||
.unwrap(),
|
||||
"Elite Quests"
|
||||
);
|
||||
trigger(&mut s, 42);
|
||||
assert!(
|
||||
visible(&s, "TutorialFrameAlertButton2"),
|
||||
"a second id takes the next button"
|
||||
);
|
||||
|
||||
s.run("TutorialFrameAlertButton1:Click()").unwrap();
|
||||
assert!(visible(&s, "TutorialFrame"), "the click opens the window");
|
||||
assert_eq!(
|
||||
s.eval::<String>("return TutorialFrameTitle:GetText()")
|
||||
.unwrap(),
|
||||
"Elite Quests"
|
||||
);
|
||||
assert!(s
|
||||
.eval::<String>("return TutorialFrameText:GetText()")
|
||||
.unwrap()
|
||||
.starts_with("You have accepted an elite quest"));
|
||||
assert_eq!(
|
||||
s.take_tutorial_flag_requests(),
|
||||
vec![40],
|
||||
"FlagTutorial(41) → the 0-based id"
|
||||
);
|
||||
assert!(
|
||||
!visible(&s, "TutorialFrameAlertButton1"),
|
||||
"the clicked alert is spent; the other stays"
|
||||
);
|
||||
assert!(visible(&s, "TutorialFrameAlertButton2"));
|
||||
|
||||
// Okay hides it; the checkbox is ticked, so nothing is cleared.
|
||||
s.run("TutorialFrameOkayButton:Click()").unwrap();
|
||||
assert!(!visible(&s, "TutorialFrame"));
|
||||
assert_eq!(s.take_tutorial_clears(), 0);
|
||||
}
|
||||
|
||||
/// Unticking "Display Tips" and closing calls `ClearTutorials` and drops every alert.
|
||||
#[test]
|
||||
fn an_unticked_checkbox_clears_the_tutorials_on_hide() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = session();
|
||||
trigger(&mut s, 41);
|
||||
trigger(&mut s, 42);
|
||||
s.run("TutorialFrameAlertButton1:Click()").unwrap();
|
||||
let _ = s.take_tutorial_flag_requests();
|
||||
s.run("TutorialFrameCheckButton:SetChecked(nil) TutorialFrame:Hide()")
|
||||
.unwrap();
|
||||
assert_eq!(s.take_tutorial_clears(), 1, "ClearTutorials() on hide");
|
||||
assert!(
|
||||
!visible(&s, "TutorialFrameAlertButton2"),
|
||||
"every alert dropped"
|
||||
);
|
||||
}
|
||||
|
||||
/// `CINEMATIC_STOP` clicks the Welcome alert (id 42) if it is queued — the first thing a new
|
||||
/// character sees after the intro — and moves the window to the screen's centre.
|
||||
#[test]
|
||||
fn cinematic_stop_opens_the_welcome_window() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = session();
|
||||
trigger(&mut s, 42);
|
||||
trigger(&mut s, 1);
|
||||
s.fire_event("CINEMATIC_STOP", vec![]);
|
||||
assert!(visible(&s, "TutorialFrame"));
|
||||
assert_eq!(
|
||||
s.eval::<String>("return TutorialFrameTitle:GetText()")
|
||||
.unwrap(),
|
||||
"Welcome to World of Warcraft!"
|
||||
);
|
||||
assert_eq!(s.take_tutorial_flag_requests(), vec![41]);
|
||||
assert!(
|
||||
visible(&s, "TutorialFrameAlertButton2"),
|
||||
"the Questgivers alert stays queued"
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,13 @@
|
||||
//! (`SMSG_GOSSIP_POI` → [`crate::poi_marker`]); the `AreaPOI.dbc` rows are 0203's deferred slice
|
||||
//! and arrive through the same list, so these tests pin the *pool*, not the marker.
|
||||
//!
|
||||
//! Driven through the shipped `assets/ui/WorldMapFrame.xml` in a bare engine (no Bevy) — the
|
||||
//! panel-test idiom: push host state, run the repaint, read the frames back.
|
||||
//! Driven through the reference's own `WorldMapFrame.xml` off the player's chain (1980) in a bare
|
||||
//! engine (no Bevy) — the panel-test idiom: push host state, run the repaint, read the frames back.
|
||||
|
||||
use benilla_ui::script::{UiScript, WorldMapLandmarkView};
|
||||
use benilla_ui::script::{
|
||||
BattlefieldFlagView, BattlefieldPositionView, QuadContent, UiScript, WorldMapLandmarkView,
|
||||
ARROW_MODEL,
|
||||
};
|
||||
|
||||
/// The map plus everything its OnLoad touches, in the shipped list's own order.
|
||||
fn harness() -> UiScript {
|
||||
@@ -26,11 +29,12 @@ fn harness() -> UiScript {
|
||||
"Interface\\FrameXML\\GameTooltip.xml",
|
||||
"Interface\\FrameXML\\UIDropDownMenu.xml", // the map's continent/zone pickers initialize into it at OnLoad
|
||||
"ScrollTemplates.xml",
|
||||
// The blip templates, which WorldMapFrame.xml instantiates with inherits=. Not
|
||||
// optional: an unknown template is a loader WARNING, not an error, so leaving this
|
||||
// out loads clean and then hands every blip a sizeless, anchorless frame.
|
||||
"WorldMapFrameTemplates.xml",
|
||||
"WorldMapFrame.xml",
|
||||
// The stock update walks MAX_PARTY_MEMBERS and MAX_RAID_MEMBERS, which the party and
|
||||
// raid files own.
|
||||
r"Interface\FrameXML\PartyMemberFrame.lua",
|
||||
r"Interface\FrameXML\RaidFrame.lua",
|
||||
// The reference's own map, which <Include>s its blip templates itself.
|
||||
r"Interface\FrameXML\WorldMapFrame.xml",
|
||||
] {
|
||||
// `test_ui::load_ui`, not a local read: a manifest entry carrying a path separator is the
|
||||
// REFERENCE's own file and must come off the player's chain, which
|
||||
@@ -44,6 +48,14 @@ fn harness() -> UiScript {
|
||||
s
|
||||
}
|
||||
|
||||
/// The stock per-frame update, driven the way its `OnUpdate` script runs it: `this` is the map
|
||||
/// button and the elapsed time its one argument. Everything on the sheet — the arrow, the party
|
||||
/// and raid blips, the battleground teammates and flags, the corpse — is seated here.
|
||||
fn update(s: &mut UiScript) {
|
||||
s.run("this = WorldMapButton WorldMapButton_OnUpdate(0.1) this = nil")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn landmark(name: &str, icon: u32, uv: (f32, f32)) -> WorldMapLandmarkView {
|
||||
WorldMapLandmarkView {
|
||||
name: name.into(),
|
||||
@@ -140,8 +152,9 @@ fn the_poi_pool_grows_and_parks_its_tail() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// Hovering a POI names it. The description line only appears on landmarks that carry one — the
|
||||
/// guard's directions never do, a battleground node's "In Conflict" would.
|
||||
/// Hovering a POI names it in the map's area label; the description line beneath carries the
|
||||
/// landmark's status only when it has one — the guard's directions never do, a battleground
|
||||
/// node's "In Conflict" would — and is blanked otherwise (stock `WorldMapPOI_OnEnter`).
|
||||
#[test]
|
||||
fn hovering_a_poi_names_it_and_adds_a_status_line_only_when_there_is_one() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
@@ -151,14 +164,15 @@ fn hovering_a_poi_names_it_and_adds_a_status_line_only_when_there_is_one() {
|
||||
s.run("this = WorldMapFramePOI1 this:GetScript(\"OnEnter\")() this = nil")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
|
||||
s.eval::<String>("return WorldMapFrameAreaLabel:GetText()")
|
||||
.unwrap(),
|
||||
"Lion's Pride Inn"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return GameTooltipTextLeft2:GetText() == nil or GameTooltipTextLeft2:GetText() == \"\"")
|
||||
assert_eq!(
|
||||
s.eval::<String>("return WorldMapFrameAreaDescription:GetText()")
|
||||
.unwrap(),
|
||||
"no description → no second line"
|
||||
"",
|
||||
"no description → an empty description line"
|
||||
);
|
||||
|
||||
let mut with_status = landmark("Stables", 6, (0.5, 0.5));
|
||||
@@ -168,7 +182,7 @@ fn hovering_a_poi_names_it_and_adds_a_status_line_only_when_there_is_one() {
|
||||
s.run("this = WorldMapFramePOI1 this:GetScript(\"OnEnter\")() this = nil")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GameTooltipTextLeft2:GetText()")
|
||||
s.eval::<String>("return WorldMapFrameAreaDescription:GetText()")
|
||||
.unwrap(),
|
||||
"In Conflict"
|
||||
);
|
||||
@@ -240,7 +254,7 @@ fn party_blips_sit_at_their_map_positions_and_hide_when_absent() {
|
||||
};
|
||||
|
||||
// Nobody in the party: every slot answers the hide sentinel.
|
||||
s.run("WorldMapParty_Update()").unwrap();
|
||||
update(&mut s);
|
||||
for i in 1..=4 {
|
||||
assert!(!shown(&s, i), "slot {i} hides while the party is empty");
|
||||
}
|
||||
@@ -253,8 +267,9 @@ fn party_blips_sit_at_their_map_positions_and_hide_when_absent() {
|
||||
0.0,
|
||||
None,
|
||||
vec![Some((0.25, 0.75)), None, Some((0.5, 0.125))],
|
||||
Vec::new(),
|
||||
);
|
||||
s.run("WorldMapParty_Update()").unwrap();
|
||||
update(&mut s);
|
||||
let diag = s
|
||||
.eval::<String>(
|
||||
r#"return "p1="..tostring(GetPlayerMapPosition("party1")).." p3="..tostring(GetPlayerMapPosition("party3"))"#,
|
||||
@@ -289,14 +304,162 @@ fn party_blips_sit_at_their_map_positions_and_hide_when_absent() {
|
||||
);
|
||||
|
||||
// The party breaks up: the blips go with it.
|
||||
s.set_world_map_feed(None, Some((0.5, 0.5)), 0.0, None, Vec::new());
|
||||
s.run("WorldMapParty_Update()").unwrap();
|
||||
s.set_world_map_feed(None, Some((0.5, 0.5)), 0.0, None, Vec::new(), Vec::new());
|
||||
update(&mut s);
|
||||
for i in 1..=4 {
|
||||
assert!(!shown(&s, i), "slot {i} hides when the party is gone");
|
||||
}
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The player arrow is the reference's own** (1980): `WorldMapFrame_OnLoad` creates it
|
||||
/// (`CreateWorldMapArrowFrame(WorldMapFrame)`, an anonymous `Model` pane holding the minimap
|
||||
/// arrow), and the update seats it at the player's UV on the detail sheet — the same point the
|
||||
/// `WorldMapPlayer` mouseover button is seated at — turns it to the facing and shows it; off-map
|
||||
/// hides it. The pane extracts as the minimap-arrow model the app draws as a sprite.
|
||||
#[test]
|
||||
fn the_player_arrow_is_the_stock_model_pane_seated_and_turned_by_the_update() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
// The whole manifest, not the kit: the arrow's quad is read off the render list, which
|
||||
// needs the map VISIBLE — and showing it is `ShowUIPanel`'s full-screen route through
|
||||
// `UIParent` (see the furniture test below for the same setup and why the player exists).
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1600.0, 900.0);
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let failures = super::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||||
s.resolve();
|
||||
s.run("ShowUIPanel(WorldMapFrame)").unwrap();
|
||||
s.resolve();
|
||||
s.set_world_map_feed(None, Some((0.25, 0.75)), 1.25, None, Vec::new(), Vec::new());
|
||||
update(&mut s);
|
||||
s.resolve();
|
||||
let arrow = |s: &mut UiScript| {
|
||||
s.extract().into_iter().find(|q| {
|
||||
matches!(&q.content, QuadContent::ModelPane { model: Some(m), .. } if m == ARROW_MODEL)
|
||||
})
|
||||
};
|
||||
let pane = arrow(&mut s).expect("the arrow pane is in the render list");
|
||||
assert!(
|
||||
matches!(&pane.content, QuadContent::ModelPane { facing, .. } if (*facing - 1.25).abs() < 1e-6),
|
||||
"turned to the facing: {:?}",
|
||||
pane.content
|
||||
);
|
||||
let rect = pane.rect.expect("…with a resolved rect");
|
||||
assert!(
|
||||
rect.right > rect.left && rect.top > rect.bottom,
|
||||
"sized: {rect:?}"
|
||||
);
|
||||
// The seat, read back in Lua: the arrow is anonymous, so it is found among the map's
|
||||
// children by kind and compared against the mouseover button the update seats at the same
|
||||
// point.
|
||||
let (dx, dy) = s
|
||||
.eval::<(f64, f64)>(
|
||||
r#"local px, py = WorldMapPlayer:GetCenter()
|
||||
for _, child in ipairs({ WorldMapFrame:GetChildren() }) do
|
||||
if child:GetObjectType() == "Model" and child:IsShown() then
|
||||
local ax, ay = child:GetCenter()
|
||||
return ax - px, ay - py
|
||||
end
|
||||
end
|
||||
return 1e9, 1e9"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
dx.abs() < 0.5 && dy.abs() < 0.5,
|
||||
"seated with WorldMapPlayer ({dx}, {dy})"
|
||||
);
|
||||
|
||||
// Off the displayed map: the (0,0) sentinel hides it.
|
||||
s.set_world_map_feed(None, None, 0.0, None, Vec::new(), Vec::new());
|
||||
update(&mut s);
|
||||
s.resolve();
|
||||
assert!(arrow(&mut s).is_none(), "off-map hides the arrow");
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **Battleground teammates and the flag carrier** (1980) come through the position family the
|
||||
/// stock update polls after the party arm: each teammate takes the next `WorldMapRaid` frame
|
||||
/// past the raid's own, carrying its name for the tooltip; the carrier takes `WorldMapFlag1`
|
||||
/// wearing the token's texture; the `(0, 0)` sentinel hides; and the empty push — leaving the
|
||||
/// battleground — hides them all.
|
||||
#[test]
|
||||
fn battleground_teammates_and_the_flag_draw_from_the_position_family() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = harness();
|
||||
s.set_world_map_feed(None, Some((0.5, 0.5)), 0.0, None, Vec::new(), Vec::new());
|
||||
s.set_battlefield_positions(
|
||||
vec![
|
||||
BattlefieldPositionView {
|
||||
uv: (0.25, 0.75),
|
||||
name: Some("Probe".into()),
|
||||
},
|
||||
BattlefieldPositionView {
|
||||
uv: (0.0, 0.0),
|
||||
name: None,
|
||||
},
|
||||
],
|
||||
Some(BattlefieldFlagView {
|
||||
uv: (0.4, 0.6),
|
||||
token: Some("HordeFlag".into()),
|
||||
}),
|
||||
1.0,
|
||||
);
|
||||
update(&mut s);
|
||||
assert!(
|
||||
s.eval::<bool>(r#"return WorldMapRaid1:IsShown() and WorldMapRaid1.name == "Probe""#)
|
||||
.unwrap(),
|
||||
"the placed teammate shows on the first raid frame with its name"
|
||||
);
|
||||
assert!(
|
||||
!s.eval::<bool>("return WorldMapRaid2:IsShown()").unwrap(),
|
||||
"the (0,0) teammate hides"
|
||||
);
|
||||
assert!(s.eval::<bool>("return WorldMapFlag1:IsShown()").unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<String>("return WorldMapFlag1Texture:GetTexture()")
|
||||
.unwrap(),
|
||||
r"Interface\WorldStateFrame\HordeFlag"
|
||||
);
|
||||
assert!(!s.eval::<bool>("return WorldMapFlag2:IsShown()").unwrap());
|
||||
let (w, h) = s
|
||||
.eval::<(f64, f64)>(
|
||||
"return WorldMapDetailFrame:GetWidth(), WorldMapDetailFrame:GetHeight()",
|
||||
)
|
||||
.unwrap();
|
||||
let (sheet_left, sheet_top) = s
|
||||
.eval::<(f64, f64)>("return WorldMapDetailFrame:GetLeft(), WorldMapDetailFrame:GetTop()")
|
||||
.unwrap();
|
||||
let (bx, by) = s
|
||||
.eval::<(f64, f64)>("return WorldMapRaid1:GetCenter()")
|
||||
.unwrap();
|
||||
assert!(
|
||||
(bx - (sheet_left + 0.25 * w)).abs() < 0.5,
|
||||
"u scales by the sheet's width"
|
||||
);
|
||||
assert!(
|
||||
(by - (sheet_top - 0.75 * h)).abs() < 0.5,
|
||||
"v runs DOWN from the sheet's top"
|
||||
);
|
||||
|
||||
s.set_battlefield_positions(Vec::new(), None, 1.0);
|
||||
update(&mut s);
|
||||
assert!(
|
||||
!s.eval::<bool>("return WorldMapRaid1:IsShown() or WorldMapFlag1:IsShown()")
|
||||
.unwrap(),
|
||||
"leaving the battleground hides them"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **Opening the map must not hide the map's own furniture.** (Director report: the world showed
|
||||
/// through at the left and right of the sheet, where black bars had always been.)
|
||||
///
|
||||
|
||||
@@ -308,6 +308,7 @@ pub(super) fn drain_social(
|
||||
names: Res<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
areas: Option<Res<AreaTableRes>>,
|
||||
mut tutorials: Option<MessageWriter<crate::tutorial::TutorialEvent>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -323,6 +324,12 @@ pub(super) fn drain_social(
|
||||
let _ = commands.0.send(ClientCommand::FriendListRequest);
|
||||
}
|
||||
SocialRequest::AddFriend(name) => {
|
||||
// `0x5ae67c`: Friends acknowledged right before the send (1976).
|
||||
if let Some(t) = tutorials.as_mut() {
|
||||
t.write(crate::tutorial::TutorialEvent::Acknowledge {
|
||||
id: crate::tutorial::id::FRIENDS,
|
||||
});
|
||||
}
|
||||
let _ = commands.0.send(ClientCommand::AddFriend { name });
|
||||
}
|
||||
SocialRequest::SetLookingForGroup { slots, comment } => {
|
||||
|
||||
@@ -48,7 +48,7 @@ use benilla_world::world_map::CurrentMap;
|
||||
/// constants) per continent/zone, in the SAME order as the engine's copy (indices must agree).
|
||||
/// The AreaTable itself is the shared [`crate::area::AreaTableRes`] (decision 0287).
|
||||
#[derive(Resource)]
|
||||
struct WorldMapUiData {
|
||||
pub(crate) struct WorldMapUiData {
|
||||
continents: Vec<ContinentEntry>,
|
||||
}
|
||||
|
||||
@@ -414,6 +414,37 @@ fn landmark_texture_index(poi: &benilla_formats::AreaPoi, level: MapLevel) -> u3
|
||||
}
|
||||
|
||||
/// Per frame: selection read-back → projection → feed push (see the module doc).
|
||||
/// One projection law for every blip on the DISPLAYED map: world-sheet mode (selection
|
||||
/// `(0, _)`) projects through the POSITION's own map's continent constants; continent and zone
|
||||
/// mode through the selected rect, gated to that continent's map. Off-map — the wrong continent,
|
||||
/// an instance map, outside the rect — is `None`, the reference's `(0, 0)` hide sentinel once it
|
||||
/// reaches Lua.
|
||||
pub(crate) fn project_on_displayed(
|
||||
data: &WorldMapUiData,
|
||||
selection: (u32, u32),
|
||||
pos_map: u32,
|
||||
px: f32,
|
||||
py: f32,
|
||||
) -> Option<(f32, f32)> {
|
||||
match selection {
|
||||
(0, _) => data
|
||||
.continents
|
||||
.iter()
|
||||
.find(|cont| cont.map_id == pos_map)
|
||||
.and_then(|cont| cont.proj)
|
||||
.map(|p| map_proj::world_uv(p, px, py)),
|
||||
(c, z) => data
|
||||
.continents
|
||||
.get(c as usize - 1)
|
||||
.filter(|cont| cont.map_id == pos_map)
|
||||
.and_then(|cont| match z {
|
||||
0 => Some(cont.rect),
|
||||
z => cont.zones.get(z as usize - 1).map(|zone| zone.rect),
|
||||
})
|
||||
.map(|rect| map_proj::zone_uv(rect, px, py)),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn feed_world_map(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
@@ -479,26 +510,10 @@ fn feed_world_map(
|
||||
// The player's UV on the DISPLAYED map. Off-map (wrong continent, outside the rect, or an
|
||||
// instance map) resolves to None/(0,0) — the reference's hide-the-blip sentinel.
|
||||
let (c, z) = script.world_map_selection();
|
||||
// One projection law for every blip on the displayed map (the player now, the corpse below):
|
||||
// world-sheet mode projects through the POSITION's own map's continent constants; zone mode
|
||||
// through the selected rect, gated to that continent's map. Off-map → None → the (0,0) hide.
|
||||
let project = |pos_map: u32, px: f32, py: f32| match (c, z) {
|
||||
(0, _) => data
|
||||
.continents
|
||||
.iter()
|
||||
.find(|cont| cont.map_id == pos_map)
|
||||
.and_then(|cont| cont.proj)
|
||||
.map(|p| map_proj::world_uv(p, px, py)),
|
||||
(c, z) => data
|
||||
.continents
|
||||
.get(c as usize - 1)
|
||||
.filter(|cont| cont.map_id == pos_map)
|
||||
.and_then(|cont| match z {
|
||||
0 => Some(cont.rect),
|
||||
z => cont.zones.get(z as usize - 1).map(|zone| zone.rect),
|
||||
})
|
||||
.map(|rect| map_proj::zone_uv(rect, px, py)),
|
||||
};
|
||||
// One projection law for every blip on the displayed map (the player now, the corpse below,
|
||||
// the battleground teammates in `ui_battlefield_positions`): [`project_on_displayed`].
|
||||
let project =
|
||||
|pos_map: u32, px: f32, py: f32| project_on_displayed(&data, (c, z), pos_map, px, py);
|
||||
let uv = project(map.0, wx, wy);
|
||||
// The corpse marker (decision 0308 §5): the query answer's DISPLAY position/map (a dungeon
|
||||
// corpse projects at its entrance — the server rewrote it). `zone_uv`'s outside-the-rect
|
||||
@@ -585,26 +600,44 @@ fn feed_world_map(
|
||||
// owns it supplies the map; a zone we cannot place (an instance, a zone missing from the
|
||||
// catalog) falls back to ours, which is right for the overwhelmingly common case of a party
|
||||
// spread across one continent and merely projects off-rect — the (0,0) hide — when it is not.
|
||||
let party_uv: Vec<Option<(f32, f32)>> = group
|
||||
.party_slots()
|
||||
.map(|m| {
|
||||
let (px, py) = crate::minimap::party_member_pos(m, &group, &guids, &unit_pos)?;
|
||||
let member_map = group
|
||||
.stats
|
||||
.get(&m.guid)
|
||||
.and_then(|st| st.zone)
|
||||
.and_then(|zone| {
|
||||
data.continents
|
||||
.iter()
|
||||
.find(|cont| cont.zones.iter().any(|z| z.area_id == u32::from(zone)))
|
||||
.map(|cont| cont.map_id)
|
||||
})
|
||||
.unwrap_or(map.0);
|
||||
project(member_map, px, py).filter(|uv| *uv != (0.0, 0.0))
|
||||
})
|
||||
.collect();
|
||||
let member_uv = |m: &benilla_protocol::messages::GroupMemberEntry| {
|
||||
let (px, py) = crate::minimap::party_member_pos(m, &group, &guids, &unit_pos)?;
|
||||
let member_map = group
|
||||
.stats
|
||||
.get(&m.guid)
|
||||
.and_then(|st| st.zone)
|
||||
.and_then(|zone| {
|
||||
data.continents
|
||||
.iter()
|
||||
.find(|cont| cont.zones.iter().any(|z| z.area_id == u32::from(zone)))
|
||||
.map(|cont| cont.map_id)
|
||||
})
|
||||
.unwrap_or(map.0);
|
||||
project(member_map, px, py).filter(|uv| *uv != (0.0, 0.0))
|
||||
};
|
||||
let party_uv: Vec<Option<(f32, f32)>> = group.party_slots().map(member_uv).collect();
|
||||
|
||||
script.set_world_map_feed(player_zone, uv, player.facing(), corpse_uv, party_uv);
|
||||
// The raid roster's blips (1980, the stock map's `raid1..raid40` arm over `WorldMapRaid1..40`):
|
||||
// the order the RaidFrame's own roster uses — ourselves first, then the members as listed
|
||||
// (`ui_party::feed::raid_roster`) — so `raidN` here is `raidN` there. The reference skips
|
||||
// `UnitIsUnit(unit, "player")` in Lua, so our own slot carries the player's UV rather than a
|
||||
// hole that would shift every index after it.
|
||||
let raid_uv: Vec<Option<(f32, f32)>> = if group.group_type == crate::ui_party::GROUPTYPE_RAID {
|
||||
std::iter::once(uv)
|
||||
.chain(group.members.iter().map(member_uv))
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
script.set_world_map_feed(
|
||||
player_zone,
|
||||
uv,
|
||||
player.facing(),
|
||||
corpse_uv,
|
||||
party_uv,
|
||||
raid_uv,
|
||||
);
|
||||
}
|
||||
|
||||
/// **Alt+click the world map to go there** — the dev jump.
|
||||
|
||||
@@ -141,7 +141,7 @@ pub use loading_screen::{load_loading_screens, LoadingScreenCatalog};
|
||||
mod liquid;
|
||||
pub use liquid::{LiquidKind, LiquidMesh};
|
||||
mod maps;
|
||||
pub use maps::{load_map_catalog, MapCatalog};
|
||||
pub use maps::{load_map_catalog, MapBattlegroundColumns, MapCatalog};
|
||||
mod anim_data;
|
||||
pub use anim_data::{load_anim_data_catalog, AnimDataCatalog, AnimEntry};
|
||||
mod quest_headers;
|
||||
|
||||
@@ -19,7 +19,7 @@ use crate::Chain;
|
||||
use anyhow::{Context, Result};
|
||||
use benilla_dbc::{FieldType, Schema, SchemaField};
|
||||
|
||||
use crate::dbc::{parse, str_at, u32_at};
|
||||
use crate::dbc::{f32_at, parse, str_at, u32_at};
|
||||
|
||||
const MAP: &str = "DBFilesClient\\Map.dbc";
|
||||
|
||||
@@ -43,6 +43,59 @@ pub struct MapCatalog {
|
||||
/// the same four the client's own `IsInInstance` string table spells `none`/`party`/`raid`/
|
||||
/// `pvp` (`0x83de58`, indexed by this value with a `< 4` guard).
|
||||
instance_types: HashMap<u32, u32>,
|
||||
/// `mapId → ` the columns the battleground list and queue verbs read off the row
|
||||
/// (see [`MapBattlegroundColumns`]) — every row, because the client resolves map 0 too.
|
||||
battleground: HashMap<u32, MapBattlegroundColumns>,
|
||||
}
|
||||
|
||||
/// The Map.dbc columns the client's battleground family reads by row offset (wow-re
|
||||
/// `battlefield-verb-family.md` §3.4, §3.6, §4.1, §5.2; decision 1974). Offsets are into the
|
||||
/// 168-byte record with the id at `+0x00`, so `+0x4·k` is field `k`. VERIFIED by dumping the
|
||||
/// shipped patch-2 `Map.dbc` (2026-09-04): Warsong Gulch `10, 60, 10, −1, (0, 0), span 10, group 1`;
|
||||
/// Arathi Basin `20, 60, 15, …, span 10, group 1`; Alterac Valley `51, 60, 40, −1, (0.74, 0.34),
|
||||
/// span 0, group 0`.
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct MapBattlegroundColumns {
|
||||
/// Field 13 (`+0x34`) — the bracket base: the list and status handlers compute a bracket's
|
||||
/// floor as `bracket · span + min_level` (§4.1). `GetBattlefieldInfo`'s third value.
|
||||
pub min_level: u32,
|
||||
/// Field 14 (`+0x38`) — `GetBattlefieldInfo`'s fourth value.
|
||||
pub max_level: u32,
|
||||
/// Field 15 (`+0x3c`) — the size a group join is checked against (`0x4a9f60`: both the party
|
||||
/// count and the raid count must be `<=` this, else message 442 and nothing sent).
|
||||
pub max_players: u32,
|
||||
/// Field 16 (`+0x40`, signed) — `GetBattlefieldInfo`'s fifth value (`−1` on every shipped row).
|
||||
pub field_16: i32,
|
||||
/// Fields 17–18 (`+0x44`/`+0x48`, f32) — `GetBattlefieldInfo`'s sixth and seventh values.
|
||||
pub field_17: f32,
|
||||
pub field_18: f32,
|
||||
/// Fields 20 and 29 (`+0x50`, `+0x74`) — the two localized descriptions, indexed by the
|
||||
/// client's faction-group index: `0` for a FactionTemplate mask with bit `0x4`, `1` for bit
|
||||
/// `0x2` (§3.4). The shipped rows carry the SAME text in both, so nothing observable rides on
|
||||
/// which side is which.
|
||||
pub descriptions: [String; 2],
|
||||
/// Field 39 (`+0x9c`) — the level-bracket span; `0` means one bracket and zeroed bounds.
|
||||
pub bracket_span: u32,
|
||||
/// Field 40 (`+0xa0`) — non-zero when the battleground can be queued as a group
|
||||
/// (`CanJoinBattlefieldAsGroup`, §3.6).
|
||||
pub group_queue: u32,
|
||||
/// Field 41 (`+0xa4`, f32, the record's last) — `MinimapIconScale`, what
|
||||
/// `GetBattlefieldMapIconScale()` answers for the active queue slot's map (wow-re
|
||||
/// `worldmap-arrow-and-positions.md` §3.6; 1980). Shipped: `1.25` for Arathi Basin, `1.0`
|
||||
/// for every other row read.
|
||||
pub minimap_icon_scale: f32,
|
||||
}
|
||||
|
||||
impl MapBattlegroundColumns {
|
||||
/// The bracket's `(min, max)` for a wire bracket index (§4.1/§4.2): with a positive span,
|
||||
/// `min = bracket · span + min_level` and `max = min(span + min − 1, 60)`; else both zero.
|
||||
pub fn bracket_levels(&self, bracket: u8) -> (u32, u32) {
|
||||
if self.bracket_span == 0 {
|
||||
return (0, 0);
|
||||
}
|
||||
let min = u32::from(bracket) * self.bracket_span + self.min_level;
|
||||
(min, (self.bracket_span + min).saturating_sub(1).min(60))
|
||||
}
|
||||
}
|
||||
|
||||
impl MapCatalog {
|
||||
@@ -81,6 +134,12 @@ impl MapCatalog {
|
||||
self.instance_type(map_id) == Some(1)
|
||||
}
|
||||
|
||||
/// The battleground family's columns for `map_id` — every row has them (the client reads map
|
||||
/// 0's when nothing was ever listed), `None` only for an id with no row.
|
||||
pub fn battleground(&self, map_id: u32) -> Option<&MapBattlegroundColumns> {
|
||||
self.battleground.get(&map_id)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.dirs.len()
|
||||
}
|
||||
@@ -96,9 +155,23 @@ const MAP_NAME_FIELD: usize = 4;
|
||||
/// Field index of `InstanceType` (`+0x8`, see [`MapCatalog::instance_types`]).
|
||||
const INSTANCE_TYPE_FIELD: usize = 2;
|
||||
|
||||
/// The battleground family's columns (see [`MapBattlegroundColumns`]).
|
||||
const MIN_LEVEL_FIELD: usize = 13;
|
||||
const MAX_LEVEL_FIELD: usize = 14;
|
||||
const MAX_PLAYERS_FIELD: usize = 15;
|
||||
const FIELD_16: usize = 16;
|
||||
const FIELD_17: usize = 17;
|
||||
const FIELD_18: usize = 18;
|
||||
const DESCRIPTION_0_FIELD: usize = 20;
|
||||
const DESCRIPTION_1_FIELD: usize = 29;
|
||||
const BRACKET_SPAN_FIELD: usize = 39;
|
||||
const GROUP_QUEUE_FIELD: usize = 40;
|
||||
const MINIMAP_ICON_SCALE_FIELD: usize = 41;
|
||||
|
||||
/// 42 fields total; field 0 = ID, field 1 = Directory string, field 2 = InstanceType,
|
||||
/// field 4 = MapName (enUS), field 38 = LoadingScreenID (FK). Remaining fields are placeholders
|
||||
/// (4-byte dwords; the schema only needs to total 168 B).
|
||||
/// field 4 = MapName (enUS), field 38 = LoadingScreenID (FK), plus the battleground columns
|
||||
/// ([`MapBattlegroundColumns`]). Remaining fields are placeholders (4-byte dwords; the schema only
|
||||
/// needs to total 168 B).
|
||||
fn map_schema() -> Schema {
|
||||
let mut s = Schema::new("Map");
|
||||
s.add_field(SchemaField::new("ID", FieldType::UInt32));
|
||||
@@ -107,6 +180,15 @@ fn map_schema() -> Schema {
|
||||
match i {
|
||||
INSTANCE_TYPE_FIELD => s.add_field(SchemaField::new("InstanceType", FieldType::UInt32)),
|
||||
MAP_NAME_FIELD => s.add_field(SchemaField::new("MapName", FieldType::String)),
|
||||
DESCRIPTION_0_FIELD => {
|
||||
s.add_field(SchemaField::new("MapDescription0", FieldType::String))
|
||||
}
|
||||
DESCRIPTION_1_FIELD => {
|
||||
s.add_field(SchemaField::new("MapDescription1", FieldType::String))
|
||||
}
|
||||
FIELD_17 | FIELD_18 | MINIMAP_ICON_SCALE_FIELD => {
|
||||
s.add_field(SchemaField::new(format!("_f{i}"), FieldType::Float32))
|
||||
}
|
||||
LOADING_SCREEN_FIELD => {
|
||||
s.add_field(SchemaField::new("LoadingScreenID", FieldType::UInt32))
|
||||
}
|
||||
@@ -126,8 +208,27 @@ pub fn load_map_catalog(chain: &mut Chain) -> Result<MapCatalog> {
|
||||
let mut names = HashMap::with_capacity(rs.records().len());
|
||||
let mut loading_screens = HashMap::new();
|
||||
let mut instance_types = HashMap::with_capacity(rs.records().len());
|
||||
let mut battleground = HashMap::with_capacity(rs.records().len());
|
||||
for r in rs.records() {
|
||||
let Some(id) = u32_at(r, 0) else { continue };
|
||||
battleground.insert(
|
||||
id,
|
||||
MapBattlegroundColumns {
|
||||
min_level: u32_at(r, MIN_LEVEL_FIELD).unwrap_or(0),
|
||||
max_level: u32_at(r, MAX_LEVEL_FIELD).unwrap_or(0),
|
||||
max_players: u32_at(r, MAX_PLAYERS_FIELD).unwrap_or(0),
|
||||
field_16: u32_at(r, FIELD_16).unwrap_or(0) as i32,
|
||||
field_17: f32_at(r, FIELD_17).unwrap_or(0.0),
|
||||
field_18: f32_at(r, FIELD_18).unwrap_or(0.0),
|
||||
descriptions: [
|
||||
str_at(&rs, r, DESCRIPTION_0_FIELD).unwrap_or_default(),
|
||||
str_at(&rs, r, DESCRIPTION_1_FIELD).unwrap_or_default(),
|
||||
],
|
||||
bracket_span: u32_at(r, BRACKET_SPAN_FIELD).unwrap_or(0),
|
||||
group_queue: u32_at(r, GROUP_QUEUE_FIELD).unwrap_or(0),
|
||||
minimap_icon_scale: f32_at(r, MINIMAP_ICON_SCALE_FIELD).unwrap_or(1.0),
|
||||
},
|
||||
);
|
||||
if let Some(dir) = str_at(&rs, r, 1) {
|
||||
dirs.insert(id, dir);
|
||||
}
|
||||
@@ -149,5 +250,56 @@ pub fn load_map_catalog(chain: &mut Chain) -> Result<MapCatalog> {
|
||||
names,
|
||||
loading_screens,
|
||||
instance_types,
|
||||
battleground,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The battleground columns off the shipped patch-2 `Map.dbc`, and the bracket arithmetic the
|
||||
/// list and status handlers run on them (wow-re `battlefield-verb-family.md` §4.1).
|
||||
#[test]
|
||||
fn the_battleground_columns_read_the_shipped_rows() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let catalog = load_map_catalog(&mut chain).expect("Map.dbc");
|
||||
let wsg = catalog.battleground(489).expect("Warsong Gulch");
|
||||
assert_eq!(
|
||||
(wsg.min_level, wsg.max_level, wsg.max_players, wsg.field_16),
|
||||
(10, 60, 10, -1)
|
||||
);
|
||||
assert_eq!((wsg.bracket_span, wsg.group_queue), (10, 1));
|
||||
assert!(wsg.descriptions[0].starts_with("A valley bordering Ashenvale"));
|
||||
assert_eq!(
|
||||
wsg.descriptions[0], wsg.descriptions[1],
|
||||
"both faction descriptions carry the same text on the shipped rows"
|
||||
);
|
||||
assert_eq!(wsg.bracket_levels(0), (10, 19));
|
||||
assert_eq!(wsg.bracket_levels(5), (60, 60), "the max clamps at 60");
|
||||
let ab = catalog.battleground(529).expect("Arathi Basin");
|
||||
assert_eq!(
|
||||
(ab.min_level, ab.max_players, ab.bracket_span),
|
||||
(20, 15, 10)
|
||||
);
|
||||
assert!(
|
||||
(ab.minimap_icon_scale - 1.25).abs() < 1e-6,
|
||||
"Arathi Basin\'s MinimapIconScale"
|
||||
);
|
||||
assert!((wsg.minimap_icon_scale - 1.0).abs() < 1e-6);
|
||||
assert_eq!(ab.bracket_levels(1), (30, 39));
|
||||
let av = catalog.battleground(30).expect("Alterac Valley");
|
||||
assert_eq!((av.min_level, av.max_players, av.group_queue), (51, 40, 0));
|
||||
assert_eq!(
|
||||
av.bracket_levels(0),
|
||||
(0, 0),
|
||||
"a zero span zeroes both bounds"
|
||||
);
|
||||
assert!((av.field_17 - 0.74).abs() < 1e-3 && (av.field_18 - 0.34).abs() < 1e-3);
|
||||
assert!(
|
||||
catalog.battleground(0).is_some(),
|
||||
"every row carries the columns — the client resolves map 0's when nothing was listed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +203,9 @@ pub enum SessionEvent {
|
||||
/// Rides the connect event because that is the only moment it ever arrives: the reference
|
||||
/// keeps it in a process-lifetime global written once by the auth parser.
|
||||
billing_time_rested: u32,
|
||||
/// The tutorial bank, when `SMSG_TUTORIAL_FLAGS` landed during the login handshake
|
||||
/// (decision 1976); `None` when it will arrive in the world stream instead.
|
||||
tutorial_flags: Option<Vec<u8>>,
|
||||
},
|
||||
/// The server confirmed our logout (`SMSG_LOGOUT_COMPLETE`) — we are back at character select.
|
||||
/// The IO thread cycles the connection immediately; a fresh [`Self::CharacterList`] follows.
|
||||
@@ -483,9 +486,27 @@ pub enum SessionEvent {
|
||||
/// The battleground scoreboard (`MSG_PVP_LOG_DATA`), rows in wire order — the app resolves
|
||||
/// the names, derives each row's team and pushes the sorted board (decision 1972).
|
||||
PvpLogData(crate::messages::PvpLogData),
|
||||
/// The battleground instance list (`SMSG_BATTLEFIELD_LIST`): the battlemaster, the map, the
|
||||
/// bracket and the instance ids — fires `BATTLEFIELDS_SHOW` (decision 1974).
|
||||
BattlefieldList(crate::messages::BattlefieldList),
|
||||
/// The battleground teammates' positions and the flag carrier
|
||||
/// (`MSG_BATTLEGROUND_PLAYER_POSITIONS`), raw world floats (decision 1980).
|
||||
BattlefieldPositions(crate::messages::BattlefieldPositions),
|
||||
/// A group join's verdict (`SMSG_GROUP_JOINED_BATTLEGROUND`): `0xFFFFFFFE` deserters, a map id
|
||||
/// joined, anything else the generic failure — three message lines, no state (decision 1974).
|
||||
GroupJoinedBattleground { result: u32 },
|
||||
/// A player joined or left the battleground (`SMSG_BATTLEGROUND_PLAYER_JOINED` / `_LEFT`):
|
||||
/// printed once the name cache resolves the guid (decision 1974).
|
||||
BattlegroundPlayer { guid: u64, joined: bool },
|
||||
/// The meeting-stone queue state (`SMSG 0x295`): the area queued for and a status byte
|
||||
/// (decision 1963).
|
||||
MeetingStoneSetQueue { area: u32, status: u8 },
|
||||
/// One of the meeting stone's four display-only replies (`0x297/0x298/0x299/0x2BB`): a chat
|
||||
/// line each, no state, no event (decision 1974).
|
||||
MeetingStoneNotice(crate::messages::MeetingStoneNotice),
|
||||
/// The account's tutorial bank (`SMSG_TUTORIAL_FLAGS`): the raw bytes, both of the client's
|
||||
/// banks copied from them (decision 1976).
|
||||
TutorialFlags(Vec<u8>),
|
||||
/// The bind took (`SMSG_PLAYERBOUND`): `area` is the AreaTable id we are now bound in, the
|
||||
/// same one [`Self::BindPoint`] carries in the packet beside it.
|
||||
PlayerBound { binder: u64, area: u32 },
|
||||
|
||||
@@ -785,9 +785,19 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
|
||||
}
|
||||
ServerPacket::BattlefieldStatus(status) => vec![SessionEvent::BattlefieldStatus(status)],
|
||||
ServerPacket::PvpLogData(data) => vec![SessionEvent::PvpLogData(data)],
|
||||
ServerPacket::BattlefieldList(list) => vec![SessionEvent::BattlefieldList(list)],
|
||||
ServerPacket::BattlefieldPositions(p) => vec![SessionEvent::BattlefieldPositions(p)],
|
||||
ServerPacket::GroupJoinedBattleground { result } => {
|
||||
vec![SessionEvent::GroupJoinedBattleground { result }]
|
||||
}
|
||||
ServerPacket::BattlegroundPlayer { guid, joined } => {
|
||||
vec![SessionEvent::BattlegroundPlayer { guid, joined }]
|
||||
}
|
||||
ServerPacket::MeetingStoneSetQueue { area, status } => {
|
||||
vec![SessionEvent::MeetingStoneSetQueue { area, status }]
|
||||
}
|
||||
ServerPacket::MeetingStoneNotice(notice) => vec![SessionEvent::MeetingStoneNotice(notice)],
|
||||
ServerPacket::TutorialFlags(flags) => vec![SessionEvent::TutorialFlags(flags.bytes)],
|
||||
ServerPacket::PlayerBound { binder, area } => {
|
||||
vec![SessionEvent::PlayerBound { binder, area }]
|
||||
}
|
||||
|
||||
@@ -14,8 +14,9 @@ pub struct BattlefieldStatus {
|
||||
/// The battleground's Map.dbc row id; zero clears the slot.
|
||||
pub map_id: u32,
|
||||
pub bracket: u8,
|
||||
/// Read into `+0x10`; no Lua reader was found for it (parked by the carve).
|
||||
pub unknown: u32,
|
||||
/// `+0x10` — the instance id, `GetBattlefieldStatus`'s third value (1963 parked this dword
|
||||
/// as reader-less; `battlefield-verb-family.md` §2.2 found the reader, 1974).
|
||||
pub instance_id: u32,
|
||||
pub status: u32,
|
||||
/// Status 2: the port deadline's delta, in ms (`+0x14 = now + Δ`, `GetBattlefieldPortExpiration`).
|
||||
pub time_ms: Option<u32>,
|
||||
@@ -30,8 +31,8 @@ pub struct BattlefieldStatus {
|
||||
}
|
||||
|
||||
/// Parse `SMSG_BATTLEFIELD_STATUS`: `u32 slot`, `u32 mapId`, and — only for a non-zero map —
|
||||
/// `u8 bracket`, `u32`, `u32 status`, then the status-conditional tail: two `u32` for status 1,
|
||||
/// one for status 2, two for status 3.
|
||||
/// `u8 bracket`, `u32 instanceId`, `u32 status`, then the status-conditional tail: two `u32` for
|
||||
/// status 1, one for status 2, two for status 3.
|
||||
pub(super) fn read_battlefield_status(r: &mut impl Read) -> io::Result<BattlefieldStatus> {
|
||||
let slot = read_u32_le(r)?;
|
||||
let map_id = read_u32_le(r)?;
|
||||
@@ -40,7 +41,7 @@ pub(super) fn read_battlefield_status(r: &mut impl Read) -> io::Result<Battlefie
|
||||
slot,
|
||||
map_id,
|
||||
bracket: 0,
|
||||
unknown: 0,
|
||||
instance_id: 0,
|
||||
status: 0,
|
||||
time_ms: None,
|
||||
in_progress: None,
|
||||
@@ -48,7 +49,7 @@ pub(super) fn read_battlefield_status(r: &mut impl Read) -> io::Result<Battlefie
|
||||
});
|
||||
}
|
||||
let bracket = read_u8(r)?;
|
||||
let unknown = read_u32_le(r)?;
|
||||
let instance_id = read_u32_le(r)?;
|
||||
let status = read_u32_le(r)?;
|
||||
let time_ms = if status == 2 {
|
||||
Some(read_u32_le(r)?)
|
||||
@@ -69,7 +70,7 @@ pub(super) fn read_battlefield_status(r: &mut impl Read) -> io::Result<Battlefie
|
||||
slot,
|
||||
map_id,
|
||||
bracket,
|
||||
unknown,
|
||||
instance_id,
|
||||
status,
|
||||
time_ms,
|
||||
in_progress,
|
||||
@@ -149,6 +150,118 @@ pub fn leave_battlefield(map_id: u32) -> Vec<u8> {
|
||||
map_id.to_le_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// `SMSG_BATTLEFIELD_LIST` (VERIFIED at the bytes, handler `0x4aa6c0`; wow-re
|
||||
/// `battlefield-verb-family.md` §4.1, 1974): the instance list a battlemaster (or
|
||||
/// `ShowBattlefieldList`) opens.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Default)]
|
||||
pub struct BattlefieldList {
|
||||
/// The battlemaster's guid — `0` when the list was opened without an NPC. Cached by the
|
||||
/// client and read by `JoinBattlefield` to choose between the two join opcodes.
|
||||
pub battlemaster: u64,
|
||||
/// The battleground's Map.dbc row id.
|
||||
pub map_id: u32,
|
||||
/// The level-bracket index; the client derives the bracket's min/max from it and the map row.
|
||||
pub bracket: u8,
|
||||
/// The instance ids, wire order — nothing sorts them; index 0 on the wire is instance 1 in Lua.
|
||||
pub instances: Vec<u32>,
|
||||
}
|
||||
|
||||
/// Parse `SMSG_BATTLEFIELD_LIST` (§4.1): `u64 battlemaster`, `u32 mapId`, `u8 bracket`,
|
||||
/// `u32 count`, `count × u32 instanceId`.
|
||||
pub(super) fn read_battlefield_list(r: &mut impl Read) -> io::Result<BattlefieldList> {
|
||||
let battlemaster = crate::wire::read_u64_le(r)?;
|
||||
let map_id = read_u32_le(r)?;
|
||||
let bracket = read_u8(r)?;
|
||||
let count = read_u32_le(r)?;
|
||||
let mut instances = Vec::with_capacity(count.min(64) as usize);
|
||||
for _ in 0..count {
|
||||
instances.push(read_u32_le(r)?);
|
||||
}
|
||||
Ok(BattlefieldList {
|
||||
battlemaster,
|
||||
map_id,
|
||||
bracket,
|
||||
instances,
|
||||
})
|
||||
}
|
||||
|
||||
/// One teammate's map position off `MSG_BATTLEGROUND_PLAYER_POSITIONS` (1980): raw world
|
||||
/// floats — the client prefers the live object's position when it has one, and normalizes
|
||||
/// either through the world-map projection under the active battleground's map.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct BattlefieldPosition {
|
||||
pub guid: u64,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
}
|
||||
|
||||
/// `MSG_BATTLEGROUND_PLAYER_POSITIONS` inbound (VERIFIED at the bytes, handler `0x4aad40`; wow-re
|
||||
/// `worldmap-arrow-and-positions.md` §3.1): the teammates not in the requester's group, then the
|
||||
/// friendly flag carrier when there is one.
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct BattlefieldPositions {
|
||||
pub players: Vec<BattlefieldPosition>,
|
||||
pub carrier: Option<BattlefieldPosition>,
|
||||
}
|
||||
|
||||
/// The client's store holds 40 entries and its handler writes past them for a larger count
|
||||
/// (§3.2, an overrun the reference has); ours keeps the first 40.
|
||||
pub const BATTLEFIELD_POSITIONS_MAX: usize = 40;
|
||||
|
||||
/// Parse it (§3.1): `u32 count`, `count × (u64, f32, f32)`, `u8 hasCarrier`, and the carrier
|
||||
/// triple only when that byte is non-zero.
|
||||
pub(super) fn read_battlefield_positions(r: &mut impl Read) -> io::Result<BattlefieldPositions> {
|
||||
let count = read_u32_le(r)?;
|
||||
let mut players = Vec::with_capacity((count as usize).min(BATTLEFIELD_POSITIONS_MAX));
|
||||
for i in 0..count {
|
||||
let guid = crate::wire::read_u64_le(r)?;
|
||||
let x = crate::wire::read_f32_le(r)?;
|
||||
let y = crate::wire::read_f32_le(r)?;
|
||||
if (i as usize) < BATTLEFIELD_POSITIONS_MAX {
|
||||
players.push(BattlefieldPosition { guid, x, y });
|
||||
}
|
||||
}
|
||||
let carrier = if read_u8(r)? != 0 {
|
||||
Some(BattlefieldPosition {
|
||||
guid: crate::wire::read_u64_le(r)?,
|
||||
x: crate::wire::read_f32_le(r)?,
|
||||
y: crate::wire::read_f32_le(r)?,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(BattlefieldPositions { players, carrier })
|
||||
}
|
||||
|
||||
/// Body of `CMSG_BATTLEFIELD_LIST` (VERIFIED, `0x4ab8c0`): `u32 mapId` of the queued slot.
|
||||
pub fn battlefield_list(map_id: u32) -> Vec<u8> {
|
||||
map_id.to_le_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// Body of `CMSG_BATTLEMASTER_JOIN` (VERIFIED, `0x4a9f60`'s GUID arm): `u64 battlemaster`,
|
||||
/// `u32 mapId`, `u32 instanceId` (`0` = first available), `u8 asGroup`.
|
||||
pub fn battlemaster_join(
|
||||
battlemaster: u64,
|
||||
map_id: u32,
|
||||
instance_id: u32,
|
||||
as_group: bool,
|
||||
) -> Vec<u8> {
|
||||
let mut body = battlemaster.to_le_bytes().to_vec();
|
||||
body.extend_from_slice(&map_id.to_le_bytes());
|
||||
body.extend_from_slice(&instance_id.to_le_bytes());
|
||||
body.push(u8::from(as_group));
|
||||
body
|
||||
}
|
||||
|
||||
/// Body of `CMSG_BATTLEFIELD_JOIN` (VERIFIED, `0x4a9f60`'s no-GUID arm): `u32 mapId`,
|
||||
/// `u32 instanceId` (`0` = first available), `u8 asGroup`.
|
||||
pub fn battlefield_join(map_id: u32, instance_id: u32, as_group: bool) -> Vec<u8> {
|
||||
let mut body = map_id.to_le_bytes().to_vec();
|
||||
body.extend_from_slice(&instance_id.to_le_bytes());
|
||||
body.push(u8::from(as_group));
|
||||
body
|
||||
}
|
||||
|
||||
/// Body of `CMSG_BATTLEFIELD_PORT` (VERIFIED, `0x4ab3b0`): `u32 mapId` then a genuinely
|
||||
/// one-byte `accept`, normalised to 0/1 before it reaches the wire.
|
||||
pub fn battlefield_port(map_id: u32, accept: bool) -> Vec<u8> {
|
||||
@@ -168,7 +281,7 @@ mod tests {
|
||||
];
|
||||
let s = read_battlefield_status(&mut body.as_slice()).unwrap();
|
||||
assert_eq!(
|
||||
(s.slot, s.map_id, s.bracket, s.unknown, s.status),
|
||||
(s.slot, s.map_id, s.bracket, s.instance_id, s.status),
|
||||
(1, 30, 5, 7, 2)
|
||||
);
|
||||
assert_eq!(s.time_ms, Some(100));
|
||||
@@ -227,6 +340,94 @@ mod pvp_log_tests {
|
||||
assert_eq!(leave_battlefield(489), vec![0xE9, 1, 0, 0]);
|
||||
}
|
||||
|
||||
/// The instance list: the guid, the map, the bracket byte and the ids in wire order.
|
||||
#[test]
|
||||
fn the_list_reads_its_guid_bracket_and_instances() {
|
||||
let mut body = 0x1234_5678_9abc_def0u64.to_le_bytes().to_vec();
|
||||
body.extend_from_slice(&489u32.to_le_bytes());
|
||||
body.push(2);
|
||||
body.extend_from_slice(&3u32.to_le_bytes());
|
||||
for id in [7u32, 3, 11] {
|
||||
body.extend_from_slice(&id.to_le_bytes());
|
||||
}
|
||||
let l = read_battlefield_list(&mut body.as_slice()).unwrap();
|
||||
assert_eq!(l.battlemaster, 0x1234_5678_9abc_def0);
|
||||
assert_eq!((l.map_id, l.bracket), (489, 2));
|
||||
assert_eq!(l.instances, vec![7, 3, 11], "wire order, nothing sorts it");
|
||||
let body = [0u8; 8]
|
||||
.iter()
|
||||
.chain(&30u32.to_le_bytes())
|
||||
.chain(&[0u8])
|
||||
.chain(&0u32.to_le_bytes())
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
let l = read_battlefield_list(&mut body.as_slice()).unwrap();
|
||||
assert_eq!(l.battlemaster, 0, "a list opened without an NPC");
|
||||
assert!(l.instances.is_empty());
|
||||
}
|
||||
|
||||
/// The positions reply: the count-led list, the carrier byte, and the 40-entry cap.
|
||||
#[test]
|
||||
fn the_positions_reply_reads_the_list_and_the_carrier() {
|
||||
let mut body = 2u32.to_le_bytes().to_vec();
|
||||
for (g, x, y) in [(0x10u64, 1.5f32, -2.0f32), (0x11, 3.0, 4.0)] {
|
||||
body.extend_from_slice(&g.to_le_bytes());
|
||||
body.extend_from_slice(&x.to_le_bytes());
|
||||
body.extend_from_slice(&y.to_le_bytes());
|
||||
}
|
||||
body.push(1);
|
||||
body.extend_from_slice(&0x20u64.to_le_bytes());
|
||||
body.extend_from_slice(&9.0f32.to_le_bytes());
|
||||
body.extend_from_slice(&8.0f32.to_le_bytes());
|
||||
let p = read_battlefield_positions(&mut body.as_slice()).unwrap();
|
||||
assert_eq!(p.players.len(), 2);
|
||||
assert_eq!(
|
||||
p.players[1],
|
||||
BattlefieldPosition {
|
||||
guid: 0x11,
|
||||
x: 3.0,
|
||||
y: 4.0
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
p.carrier,
|
||||
Some(BattlefieldPosition {
|
||||
guid: 0x20,
|
||||
x: 9.0,
|
||||
y: 8.0
|
||||
})
|
||||
);
|
||||
let body = [0u8, 0, 0, 0, 0];
|
||||
let p = read_battlefield_positions(&mut body.as_slice()).unwrap();
|
||||
assert!(p.players.is_empty() && p.carrier.is_none());
|
||||
let mut body = 41u32.to_le_bytes().to_vec();
|
||||
for i in 0..41u64 {
|
||||
body.extend_from_slice(&i.to_le_bytes());
|
||||
body.extend_from_slice(&[0u8; 8]);
|
||||
}
|
||||
body.push(0);
|
||||
let p = read_battlefield_positions(&mut body.as_slice()).unwrap();
|
||||
assert_eq!(
|
||||
p.players.len(),
|
||||
40,
|
||||
"the client's store, not the wire's count"
|
||||
);
|
||||
}
|
||||
|
||||
/// The two join bodies differ only by the leading guid; the list request is the map alone.
|
||||
#[test]
|
||||
fn the_join_bodies_and_the_list_request() {
|
||||
assert_eq!(
|
||||
battlefield_join(489, 0, true),
|
||||
vec![0xE9, 1, 0, 0, 0, 0, 0, 0, 1],
|
||||
"first available, as a group"
|
||||
);
|
||||
let mut want = 0x42u64.to_le_bytes().to_vec();
|
||||
want.extend_from_slice(&[0xE9, 1, 0, 0, 5, 0, 0, 0, 0]);
|
||||
assert_eq!(battlemaster_join(0x42, 489, 5, false), want);
|
||||
assert_eq!(battlefield_list(529), vec![0x11, 2, 0, 0]);
|
||||
}
|
||||
|
||||
/// Status 1 carries the two wait dwords 1963's reader dropped.
|
||||
#[test]
|
||||
fn a_queued_status_carries_its_wait_pair() {
|
||||
|
||||
@@ -25,3 +25,29 @@ pub(super) fn read_meeting_stone_set_queue(r: &mut impl Read) -> io::Result<Meet
|
||||
pub fn meeting_stone_leave() -> Vec<u8> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// The four display-only replies one handler serves (`0x4ca3c0`, dispatched on the opcode; wow-re
|
||||
/// `meeting-stone-status.md` §9, 1974). None touches the queue state, none fires an event.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum MeetingStoneNotice {
|
||||
/// `0x297`, empty.
|
||||
Success,
|
||||
/// `0x298`, empty.
|
||||
InProgress,
|
||||
/// `0x299`, `u64 guid` — the line waits for the name cache.
|
||||
MemberAdded { guid: u64 },
|
||||
/// `0x2BB`, `u8 code`.
|
||||
JoinFailed { code: u8 },
|
||||
}
|
||||
|
||||
/// Parse `0x299`'s guid.
|
||||
pub(super) fn read_meeting_stone_member_added(r: &mut impl Read) -> io::Result<MeetingStoneNotice> {
|
||||
Ok(MeetingStoneNotice::MemberAdded {
|
||||
guid: crate::wire::read_u64_le(r)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse `0x2BB`'s code byte.
|
||||
pub(super) fn read_meeting_stone_join_failed(r: &mut impl Read) -> io::Result<MeetingStoneNotice> {
|
||||
Ok(MeetingStoneNotice::JoinFailed { code: read_u8(r)? })
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ mod summon;
|
||||
mod taxi;
|
||||
mod trade;
|
||||
mod trainer;
|
||||
mod tutorial;
|
||||
mod update_object;
|
||||
mod vendor;
|
||||
mod world_state;
|
||||
@@ -85,7 +86,9 @@ pub use bank::{
|
||||
autobank_item, autostore_bank_item, bank_slot_result, banker_activate, buy_bank_slot,
|
||||
};
|
||||
pub use battlefield::{
|
||||
battlefield_port, leave_battlefield, BattlefieldStatus, PvpLogData, PvpLogRow,
|
||||
battlefield_join, battlefield_list, battlefield_port, battlemaster_join, leave_battlefield,
|
||||
BattlefieldList, BattlefieldPosition, BattlefieldPositions, BattlefieldStatus, PvpLogData,
|
||||
PvpLogRow, BATTLEFIELD_POSITIONS_MAX,
|
||||
};
|
||||
pub use binder::{binder_activate, PlayerBound};
|
||||
pub use channel::{channel_notice, ChannelNoticeTail, ChannelNotify};
|
||||
@@ -169,7 +172,7 @@ pub use mail::{
|
||||
mail_mark_as_read, mail_message_type, mail_return_to_sender, mail_take_item, mail_take_money,
|
||||
send_mail, MailAttachment, MailListEntry,
|
||||
};
|
||||
pub use meeting_stone::{meeting_stone_leave, MeetingStoneSetQueue};
|
||||
pub use meeting_stone::{meeting_stone_leave, MeetingStoneNotice, MeetingStoneSetQueue};
|
||||
pub use mirror_timer::{
|
||||
read_pause_mirror_timer, read_start_mirror_timer, read_stop_mirror_timer, MirrorTimerKind,
|
||||
MirrorTimerStart,
|
||||
@@ -245,6 +248,7 @@ pub use trade::{
|
||||
TRADE_SLOT_TRADED_COUNT,
|
||||
};
|
||||
pub use trainer::{train_fail, trainer_buy_spell, trainer_list, trainer_spell_state, TrainerSpell};
|
||||
pub use tutorial::{tutorial_flag, TutorialFlags};
|
||||
pub use update_object::{
|
||||
quest_slot_state, CorpseLook, CreateSpline, MovementBlock, MoverState, Object, ObjectFields,
|
||||
ObjectType, OwnerFallback, PlayerSkillSlot, QuestLogSlot, UnitAuraSlot, AURA_FLAG_CANCELABLE,
|
||||
|
||||
@@ -95,8 +95,18 @@ pub const SMSG_COMPRESSED_UPDATE_OBJECT: u16 = 0x01F6;
|
||||
/// [`super::ServerPacket::CompressedMoves`].
|
||||
pub const SMSG_COMPRESSED_MOVES: u16 = 0x02FB;
|
||||
pub const SMSG_LOGIN_VERIFY_WORLD: u16 = 0x0236;
|
||||
// The server-pushed sound trio (1.12.1 values VERIFIED vmangos `Opcodes_1_12_1.h`:
|
||||
// 631/632/722).
|
||||
/// The account's tutorial bank (wow-re `tutorial-flags.md` §7, decision 1976): every remaining
|
||||
/// byte of the body is the bank, both of the client's banks copied from it — vmangos sends 32
|
||||
/// bytes (256 bits) after the login. Until it lands no tutorial can fire.
|
||||
pub const SMSG_TUTORIAL_FLAGS: u16 = 0x00FD; // 253
|
||||
/// `FlagTutorial(n)` and the six auto-acknowledge sites (§5/§6, 1976): one `u32`, the 0-based id.
|
||||
pub const CMSG_TUTORIAL_FLAG: u16 = 0x00FE; // 254
|
||||
/// `ClearTutorials()` (§6, 1976): EMPTY — every bit set locally and on the server.
|
||||
pub const CMSG_TUTORIAL_CLEAR: u16 = 0x00FF; // 255
|
||||
/// `ResetTutorials()` (§6, 1976): EMPTY — every bit cleared locally and on the server.
|
||||
pub const CMSG_TUTORIAL_RESET: u16 = 0x0100; // 256
|
||||
// The server-pushed sound trio (1.12.1 values VERIFIED vmangos `Opcodes_1_12_1.h`:
|
||||
// 631/632/722).
|
||||
pub const SMSG_PLAY_MUSIC: u16 = 0x0277;
|
||||
pub const SMSG_PLAY_OBJECT_SOUND: u16 = 0x0278;
|
||||
pub const SMSG_PLAY_SOUND: u16 = 0x02D2;
|
||||
@@ -1006,6 +1016,37 @@ pub const MSG_PVP_LOG_DATA: u16 = 0x02E0; // 736
|
||||
/// `LeaveBattlefield()`'s packet (§5.3, 1972): `u32 mapId` of the battleground the client is in,
|
||||
/// sent only once the scoreboard's "ended" byte has arrived.
|
||||
pub const CMSG_LEAVE_BATTLEFIELD: u16 = 0x02E1; // 737
|
||||
/// The battleground teammate positions, both ways (wow-re `worldmap-arrow-and-positions.md` §3.1,
|
||||
/// 1980): `RequestBattlefieldPositions()` sends it EMPTY (5000 ms throttle, only with an active
|
||||
/// slot); the server's answer is `u32 count`, `count × (u64 guid, f32 x, f32 y)`, then
|
||||
/// `u8 hasCarrier` and, when set, one `(u64 guid, f32 x, f32 y)` flag carrier. No event fires on
|
||||
/// arrival — the UI polls.
|
||||
pub const MSG_BATTLEGROUND_PLAYER_POSITIONS: u16 = 0x02E9; // 745
|
||||
/// `ShowBattlefieldList(index)`'s packet (wow-re `battlefield-verb-family.md` §5, 1974): `u32 mapId`
|
||||
/// of a QUEUED slot — the battlemaster-less way to reopen the instance list.
|
||||
pub const CMSG_BATTLEFIELD_LIST: u16 = 0x023C; // 572
|
||||
/// The battleground instance list (§4.1, 1974): `u64 battlemaster` (zero when the list was not
|
||||
/// opened at an NPC), `u32 mapId`, `u8 bracket`, `u32 count`, `count × u32 instanceId`. Fires
|
||||
/// `BATTLEFIELDS_SHOW`; the cached battlemaster guid decides which join opcode a later
|
||||
/// `JoinBattlefield` sends.
|
||||
pub const SMSG_BATTLEFIELD_LIST: u16 = 0x023D; // 573
|
||||
/// `JoinBattlefield`'s packet when the list arrived WITHOUT a battlemaster (§5.2, 1974): `u32 mapId`,
|
||||
/// `u32 instanceId` (0 = first available), `u8 asGroup`.
|
||||
pub const CMSG_BATTLEFIELD_JOIN: u16 = 0x023E; // 574
|
||||
/// `JoinBattlefield`'s packet when the list arrived from a battlemaster (§5.2, 1974): `u64 guid`,
|
||||
/// `u32 mapId`, `u32 instanceId` (0 = first available), `u8 asGroup`.
|
||||
pub const CMSG_BATTLEMASTER_JOIN: u16 = 0x02EE; // 750
|
||||
/// The world-enter status request (§5/§8, 1974): EMPTY; the server answers with one
|
||||
/// `SMSG_BATTLEFIELD_STATUS` per live queue slot.
|
||||
pub const CMSG_BATTLEFIELD_STATUS: u16 = 0x02D3; // 723
|
||||
/// A group join's verdict (§4.4, 1974): `u32 result` — `0xFFFFFFFE` deserters (message 439), a
|
||||
/// Map.dbc id joined (440, with the map's name), anything else the generic failure (441).
|
||||
pub const SMSG_GROUP_JOINED_BATTLEGROUND: u16 = 0x02E8; // 744
|
||||
/// A player joined the battleground (§4.5, 1974): `u64 guid`, printed as message 444 once the name
|
||||
/// cache answers.
|
||||
pub const SMSG_BATTLEGROUND_PLAYER_JOINED: u16 = 0x02EC; // 748
|
||||
/// A player left the battleground (§4.5, 1974): `u64 guid`, message 445.
|
||||
pub const SMSG_BATTLEGROUND_PLAYER_LEFT: u16 = 0x02ED; // 749
|
||||
/// The area spirit healer query the client sends when it adopts a new healer (§6): `u64 guid`;
|
||||
/// answered by [`SMSG_AREA_SPIRIT_HEALER_TIME`] (decision 1963).
|
||||
pub const CMSG_AREA_SPIRIT_HEALER_QUERY: u16 = 0x02E2; // 738
|
||||
@@ -1023,6 +1064,21 @@ pub const CMSG_MEETINGSTONE_LEAVE: u16 = 0x0293; // 659
|
||||
/// The meeting-stone queue state (§8): `u32 areaId`, `u8 status`; stored unconditionally, a
|
||||
/// status message per value, then `MEETINGSTONE_CHANGED` (decision 1963).
|
||||
pub const SMSG_MEETINGSTONE_SETQUEUE: u16 = 0x0295; // 661
|
||||
/// The enter-world "what is my meeting-stone status" query (wow-re `meeting-stone-status.md`
|
||||
/// §6, 1974): EMPTY, sent once per world session right after the status text resets to the
|
||||
/// localized `UNKNOWN`; the server answers with `0x295`. The emulators' names for this block do
|
||||
/// not line up with the client's; the numbers are what is verified.
|
||||
pub const CMSG_MEETINGSTONE_STATUS_QUERY: u16 = 0x0296; // 662
|
||||
/// Display-only (§9): empty; `ERR_MEETING_STONE_SUCCESS` (1974).
|
||||
pub const SMSG_MEETINGSTONE_SUCCESS: u16 = 0x0297; // 663
|
||||
/// Display-only (§9): empty; `ERR_MEETING_STONE_IN_PROGRESS` (1974).
|
||||
pub const SMSG_MEETINGSTONE_IN_PROGRESS: u16 = 0x0298; // 664
|
||||
/// Display-only (§9): `u64 guid`; `ERR_MEETING_STONE_MEMBER_ADDED_S` with the name once the name
|
||||
/// cache answers, nothing on a miss that never resolves (1974).
|
||||
pub const SMSG_MEETINGSTONE_MEMBER_ADDED: u16 = 0x0299; // 665
|
||||
/// Display-only (§9): `u8 code` — `1` must-be-leader, `2` group full, `3` no raid group, anything
|
||||
/// else nothing (1974).
|
||||
pub const SMSG_MEETINGSTONE_JOIN_FAILED: u16 = 0x02BB; // 699
|
||||
/// `ConfirmPetUnlearn()`'s packet (§9): `u64 trainerGuid`, the latch — never an argument
|
||||
/// (decision 1963).
|
||||
pub const CMSG_PET_UNLEARN: u16 = 0x02F0; // 752
|
||||
|
||||
@@ -316,11 +316,28 @@ pub enum ServerPacket {
|
||||
BattlefieldStatus(crate::messages::BattlefieldStatus),
|
||||
/// `MSG_PVP_LOG_DATA` inbound — the battleground scoreboard (decision 1972).
|
||||
PvpLogData(crate::messages::PvpLogData),
|
||||
/// `SMSG_BATTLEFIELD_LIST` — the battleground instance list (decision 1974).
|
||||
BattlefieldList(crate::messages::BattlefieldList),
|
||||
/// `MSG_BATTLEGROUND_PLAYER_POSITIONS` inbound — the teammates and the flag carrier (1980).
|
||||
BattlefieldPositions(crate::messages::BattlefieldPositions),
|
||||
/// `SMSG_GROUP_JOINED_BATTLEGROUND` — a group join's verdict (decision 1974).
|
||||
GroupJoinedBattleground {
|
||||
result: u32,
|
||||
},
|
||||
/// `SMSG_BATTLEGROUND_PLAYER_JOINED` / `_LEFT` — one guid each (decision 1974).
|
||||
BattlegroundPlayer {
|
||||
guid: u64,
|
||||
joined: bool,
|
||||
},
|
||||
/// `SMSG 0x295` — the meeting-stone queue state (decision 1963).
|
||||
MeetingStoneSetQueue {
|
||||
area: u32,
|
||||
status: u8,
|
||||
},
|
||||
/// `SMSG 0x297/0x298/0x299/0x2BB` — the meeting stone's display-only replies (decision 1974).
|
||||
MeetingStoneNotice(crate::messages::MeetingStoneNotice),
|
||||
/// `SMSG_TUTORIAL_FLAGS` — the account's tutorial bank (decision 1976).
|
||||
TutorialFlags(crate::messages::TutorialFlags),
|
||||
/// `SMSG_SUMMON_REQUEST` — someone is *asking* to pull us to them (decision 1747): a
|
||||
/// warlock's ritual, a meeting stone, a GM. The twin of [`Self::BinderConfirm`] in shape —
|
||||
/// nothing moves until `CMSG_SUMMON_RESPONSE` goes back — and its opposite in teardown:
|
||||
@@ -1572,7 +1589,29 @@ impl ServerPacket {
|
||||
ServerPacket::AreaSpiritHealerTime { .. } => "SMSG_AREA_SPIRIT_HEALER_TIME".into(),
|
||||
ServerPacket::BattlefieldStatus(_) => "SMSG_BATTLEFIELD_STATUS".into(),
|
||||
ServerPacket::PvpLogData(_) => "MSG_PVP_LOG_DATA".into(),
|
||||
ServerPacket::BattlefieldList(_) => "SMSG_BATTLEFIELD_LIST".into(),
|
||||
ServerPacket::BattlefieldPositions(_) => "MSG_BATTLEGROUND_PLAYER_POSITIONS".into(),
|
||||
ServerPacket::GroupJoinedBattleground { .. } => "SMSG_GROUP_JOINED_BATTLEGROUND".into(),
|
||||
ServerPacket::BattlegroundPlayer { joined: true, .. } => {
|
||||
"SMSG_BATTLEGROUND_PLAYER_JOINED".into()
|
||||
}
|
||||
ServerPacket::BattlegroundPlayer { joined: false, .. } => {
|
||||
"SMSG_BATTLEGROUND_PLAYER_LEFT".into()
|
||||
}
|
||||
ServerPacket::MeetingStoneSetQueue { .. } => "SMSG_MEETINGSTONE_SETQUEUE".into(),
|
||||
ServerPacket::MeetingStoneNotice(crate::messages::MeetingStoneNotice::Success) => {
|
||||
"SMSG_MEETINGSTONE_SUCCESS".into()
|
||||
}
|
||||
ServerPacket::MeetingStoneNotice(crate::messages::MeetingStoneNotice::InProgress) => {
|
||||
"SMSG_MEETINGSTONE_IN_PROGRESS".into()
|
||||
}
|
||||
ServerPacket::MeetingStoneNotice(
|
||||
crate::messages::MeetingStoneNotice::MemberAdded { .. },
|
||||
) => "SMSG_MEETINGSTONE_MEMBER_ADDED".into(),
|
||||
ServerPacket::MeetingStoneNotice(crate::messages::MeetingStoneNotice::JoinFailed {
|
||||
..
|
||||
}) => "SMSG_MEETINGSTONE_JOIN_FAILED".into(),
|
||||
ServerPacket::TutorialFlags(_) => "SMSG_TUTORIAL_FLAGS".into(),
|
||||
ServerPacket::SetProficiency { .. } => "SMSG_SET_PROFICIENCY".into(),
|
||||
ServerPacket::InitializeFactions { .. } => "SMSG_INITIALIZE_FACTIONS".into(),
|
||||
ServerPacket::SetFactionStanding { .. } => "SMSG_SET_FACTION_STANDING".into(),
|
||||
|
||||
@@ -15,7 +15,7 @@ use super::{
|
||||
combat_log, death, duel, gameobject, gm_ticket, gossip, group, guild, instance, items, loot,
|
||||
mail, meeting_stone, mirror_timer, monster_move, movement, opcode, page_text, pet, petition,
|
||||
progression, pvp, quest, social, spellbook, spells, stable, summon, taxi, trade, trainer,
|
||||
update_object, vendor, world_state, Character, CreatureQueryInfo, JumpInfo, MoveMode,
|
||||
tutorial, update_object, vendor, world_state, Character, CreatureQueryInfo, JumpInfo, MoveMode,
|
||||
ServerPacket, SpeedKind, SplineMode,
|
||||
};
|
||||
|
||||
@@ -407,6 +407,23 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
|
||||
opcode::MSG_PVP_LOG_DATA => {
|
||||
ServerPacket::PvpLogData(battlefield::read_pvp_log_data(&mut r)?)
|
||||
}
|
||||
opcode::SMSG_BATTLEFIELD_LIST => {
|
||||
ServerPacket::BattlefieldList(battlefield::read_battlefield_list(&mut r)?)
|
||||
}
|
||||
opcode::MSG_BATTLEGROUND_PLAYER_POSITIONS => {
|
||||
ServerPacket::BattlefieldPositions(battlefield::read_battlefield_positions(&mut r)?)
|
||||
}
|
||||
opcode::SMSG_GROUP_JOINED_BATTLEGROUND => ServerPacket::GroupJoinedBattleground {
|
||||
result: crate::wire::read_u32_le(&mut r)?,
|
||||
},
|
||||
opcode::SMSG_BATTLEGROUND_PLAYER_JOINED => ServerPacket::BattlegroundPlayer {
|
||||
guid: crate::wire::read_u64_le(&mut r)?,
|
||||
joined: true,
|
||||
},
|
||||
opcode::SMSG_BATTLEGROUND_PLAYER_LEFT => ServerPacket::BattlegroundPlayer {
|
||||
guid: crate::wire::read_u64_le(&mut r)?,
|
||||
joined: false,
|
||||
},
|
||||
opcode::SMSG_MEETINGSTONE_SETQUEUE => {
|
||||
let q = meeting_stone::read_meeting_stone_set_queue(&mut r)?;
|
||||
ServerPacket::MeetingStoneSetQueue {
|
||||
@@ -414,6 +431,21 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
|
||||
status: q.status,
|
||||
}
|
||||
}
|
||||
opcode::SMSG_MEETINGSTONE_SUCCESS => {
|
||||
ServerPacket::MeetingStoneNotice(crate::messages::MeetingStoneNotice::Success)
|
||||
}
|
||||
opcode::SMSG_MEETINGSTONE_IN_PROGRESS => {
|
||||
ServerPacket::MeetingStoneNotice(crate::messages::MeetingStoneNotice::InProgress)
|
||||
}
|
||||
opcode::SMSG_MEETINGSTONE_MEMBER_ADDED => ServerPacket::MeetingStoneNotice(
|
||||
meeting_stone::read_meeting_stone_member_added(&mut r)?,
|
||||
),
|
||||
opcode::SMSG_MEETINGSTONE_JOIN_FAILED => {
|
||||
ServerPacket::MeetingStoneNotice(meeting_stone::read_meeting_stone_join_failed(&mut r)?)
|
||||
}
|
||||
opcode::SMSG_TUTORIAL_FLAGS => {
|
||||
ServerPacket::TutorialFlags(tutorial::read_tutorial_flags(&mut r)?)
|
||||
}
|
||||
opcode::SMSG_PLAYERBOUND => {
|
||||
let bound = binder::read_player_bound(&mut r)?;
|
||||
ServerPacket::PlayerBound {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//! The tutorial system's wire (decision 1976; wow-re `system/ui/scratch/tutorial-flags.md` §7):
|
||||
//! one inbound bank of bits and the three sends the client's tutorial code makes.
|
||||
//!
|
||||
//! The client keeps two bit banks filled from the same `SMSG_TUTORIAL_FLAGS` — the fire-once bank
|
||||
//! its C++ trigger reads and sets, and the acknowledged bank Lua's `FlagTutorial` and six C++
|
||||
//! auto-flag sites write, which is the only path that sends. The handler takes **whatever bytes
|
||||
//! are left in the packet** and sizes both banks as `bytes × 8` bits; vmangos sends 32 bytes.
|
||||
|
||||
use std::io::{self, Read};
|
||||
|
||||
/// `SMSG_TUTORIAL_FLAGS` (VERIFIED at the bytes, handler `0x4b5700`): the raw bank, every
|
||||
/// remaining byte of the packet — bit `id` is `bytes[id >> 3] & (1 << (id & 7))`, the same
|
||||
/// `word = id >> 5, bit = 1 << (id & 31)` addressing on little-endian dwords.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Default)]
|
||||
pub struct TutorialFlags {
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Parse it: the rest of the body, whatever its length.
|
||||
pub(super) fn read_tutorial_flags(r: &mut impl Read) -> io::Result<TutorialFlags> {
|
||||
let mut bytes = Vec::new();
|
||||
r.read_to_end(&mut bytes)?;
|
||||
Ok(TutorialFlags { bytes })
|
||||
}
|
||||
|
||||
/// Body of `CMSG_TUTORIAL_FLAG` (VERIFIED, `0x4b54c0`): one `u32`, the **0-based** id.
|
||||
pub fn tutorial_flag(id: u32) -> Vec<u8> {
|
||||
id.to_le_bytes().to_vec()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_bank_is_every_remaining_byte_and_the_flag_is_one_dword() {
|
||||
let body = [0x01u8, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0xFF];
|
||||
let f = read_tutorial_flags(&mut body.as_slice()).unwrap();
|
||||
assert_eq!(f.bytes, body.to_vec());
|
||||
let f = read_tutorial_flags(&mut [].as_slice()).unwrap();
|
||||
assert!(f.bytes.is_empty(), "an empty body is an empty bank");
|
||||
assert_eq!(tutorial_flag(41), vec![41, 0, 0, 0]);
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,9 @@ pub struct WorldSession {
|
||||
/// was too short for the billing group. Read by [`Self::billing_time_rested`]; see decision
|
||||
/// 1820 for why `0` rather than the client's own uninitialised-global behaviour.
|
||||
billing_time_rested: u32,
|
||||
/// `SMSG_TUTORIAL_FLAGS` if it landed during the login handshake rather than in the world
|
||||
/// stream (decision 1976) — handed to the world entry the way the billing minutes are.
|
||||
tutorial_flags: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl WorldSession {
|
||||
@@ -183,6 +186,7 @@ impl WorldSession {
|
||||
roster_races: Default::default(),
|
||||
chat_language: messages::LANGUAGE_COMMON,
|
||||
billing_time_rested: 0,
|
||||
tutorial_flags: None,
|
||||
};
|
||||
|
||||
// 4. Wait for SMSG_AUTH_RESPONSE. Usually the first encrypted packet, but not always first
|
||||
@@ -257,6 +261,11 @@ impl WorldSession {
|
||||
self.billing_time_rested
|
||||
}
|
||||
|
||||
/// The tutorial bank captured during the login handshake, if any (decision 1976).
|
||||
pub fn take_tutorial_flags(&mut self) -> Option<Vec<u8>> {
|
||||
self.tutorial_flags.take()
|
||||
}
|
||||
|
||||
/// Set a read timeout on the underlying socket (e.g. so a debug read-loop can stop when the
|
||||
/// world goes quiet). `None` clears it (blocking reads).
|
||||
pub fn set_read_timeout(&self, timeout: Option<std::time::Duration>) -> Result<()> {
|
||||
@@ -295,7 +304,13 @@ impl WorldSession {
|
||||
ServerPacket::Other {
|
||||
opcode: opcode::SMSG_WARDEN_DATA,
|
||||
} => return Err(WardenRequired.into()),
|
||||
// The server interleaves account-data / tutorial / cache packets here; skip them.
|
||||
// The tutorial bank, if the server sends it this early (1976): kept for the world
|
||||
// entry — skipped here it would be lost to the roster loop.
|
||||
ServerPacket::TutorialFlags(flags) => {
|
||||
self.tutorial_flags = Some(flags.bytes);
|
||||
continue;
|
||||
}
|
||||
// The server interleaves account-data / cache packets here; skip them.
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,4 +30,54 @@ impl WorldWriter {
|
||||
&messages::leave_battlefield(map_id),
|
||||
)
|
||||
}
|
||||
|
||||
/// Reopen a queued battleground's instance list (`CMSG_BATTLEFIELD_LIST`):
|
||||
/// `ShowBattlefieldList(index)`'s packet, the queued slot's map (decision 1974).
|
||||
pub fn battlefield_list(&mut self, map_id: u32) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_BATTLEFIELD_LIST,
|
||||
&messages::battlefield_list(map_id),
|
||||
)
|
||||
}
|
||||
|
||||
/// Join through the battlemaster the list came from (`CMSG_BATTLEMASTER_JOIN`) —
|
||||
/// `JoinBattlefield`'s packet when the cached guid is non-zero (decision 1974).
|
||||
pub fn battlemaster_join(
|
||||
&mut self,
|
||||
battlemaster: u64,
|
||||
map_id: u32,
|
||||
instance_id: u32,
|
||||
as_group: bool,
|
||||
) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_BATTLEMASTER_JOIN,
|
||||
&messages::battlemaster_join(battlemaster, map_id, instance_id, as_group),
|
||||
)
|
||||
}
|
||||
|
||||
/// Join without a battlemaster (`CMSG_BATTLEFIELD_JOIN`) — `JoinBattlefield`'s packet when
|
||||
/// the list arrived with a zero guid (decision 1974).
|
||||
pub fn battlefield_join(
|
||||
&mut self,
|
||||
map_id: u32,
|
||||
instance_id: u32,
|
||||
as_group: bool,
|
||||
) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_BATTLEFIELD_JOIN,
|
||||
&messages::battlefield_join(map_id, instance_id, as_group),
|
||||
)
|
||||
}
|
||||
|
||||
/// Ask for every queue slot's state (`CMSG_BATTLEFIELD_STATUS`, empty) — the reference's
|
||||
/// world-enter reset sends it once per entry (decision 1974).
|
||||
pub fn battlefield_status(&mut self) -> Result<()> {
|
||||
self.send(opcode::CMSG_BATTLEFIELD_STATUS, &[])
|
||||
}
|
||||
|
||||
/// Ask for the teammates' positions (`MSG_BATTLEGROUND_PLAYER_POSITIONS`, empty) —
|
||||
/// `RequestBattlefieldPositions()`'s packet; the 5000 ms throttle is the caller's (1980).
|
||||
pub fn request_battlefield_positions(&mut self) -> Result<()> {
|
||||
self.send(opcode::MSG_BATTLEGROUND_PLAYER_POSITIONS, &[])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,4 +15,10 @@ impl WorldWriter {
|
||||
&messages::meeting_stone_leave(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Ask for the meeting-stone status (`CMSG 0x296`, empty) — the enter-world query the
|
||||
/// reference sends once per world session (decision 1974).
|
||||
pub fn meeting_stone_status_query(&mut self) -> Result<()> {
|
||||
self.send(opcode::CMSG_MEETINGSTONE_STATUS_QUERY, &[])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ mod summon;
|
||||
mod taxi;
|
||||
mod trade;
|
||||
mod trainer;
|
||||
mod tutorial;
|
||||
mod vendor;
|
||||
|
||||
/// Write half of a split [`WorldSession`](super::WorldSession) — owns a cloned socket + the encrypter. Used to send our own
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
//! The tutorial system's sends (decision 1976).
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::messages::{self, opcode};
|
||||
|
||||
use super::WorldWriter;
|
||||
|
||||
impl WorldWriter {
|
||||
/// Acknowledge one tutorial (`CMSG_TUTORIAL_FLAG`): the 0-based id — `FlagTutorial`'s and the
|
||||
/// six auto-acknowledge sites' packet, sent only when the acknowledged bank's bit was clear.
|
||||
pub fn tutorial_flag(&mut self, id: u32) -> Result<()> {
|
||||
self.send(opcode::CMSG_TUTORIAL_FLAG, &messages::tutorial_flag(id))
|
||||
}
|
||||
|
||||
/// Mark every tutorial acknowledged (`CMSG_TUTORIAL_CLEAR`, empty) — `ClearTutorials()`.
|
||||
pub fn tutorial_clear(&mut self) -> Result<()> {
|
||||
self.send(opcode::CMSG_TUTORIAL_CLEAR, &[])
|
||||
}
|
||||
|
||||
/// Forget every tutorial (`CMSG_TUTORIAL_RESET`, empty) — `ResetTutorials()`.
|
||||
pub fn tutorial_reset(&mut self) -> Result<()> {
|
||||
self.send(opcode::CMSG_TUTORIAL_RESET, &[])
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use mlua::Table;
|
||||
use mlua::{ObjectLike, Table};
|
||||
|
||||
use crate::framexml::{self, Element};
|
||||
|
||||
@@ -118,6 +118,33 @@ impl Loader<'_> {
|
||||
/// processes each child in turn, so an instance's own `<Size>` overwrites its template's. (Taking
|
||||
/// only `.next()` here silently pinned every templated frame to the TEMPLATE's size; caught on
|
||||
/// the quest log's Abandon button — 125×21 in the instance XML, 80×22 on screen.)
|
||||
/// `<TitleRegion setAllPoints="true"/>` (or one with its own `<Size>`/`<Anchors>`): the
|
||||
/// frame's drag handle, built through the same `CreateTitleRegion` the Lua API exposes — so
|
||||
/// the element and a later `frame:CreateTitleRegion()` name ONE object (the verb is
|
||||
/// idempotent; wow-re `widget-api-batch-benilla.md` Q6) — then laid out like any region.
|
||||
/// The stock `TutorialFrame.xml` declares one over its whole plate (1976).
|
||||
pub(super) fn apply_title_region(
|
||||
&mut self,
|
||||
el: &Element,
|
||||
wrapper: &Table,
|
||||
self_name: &str,
|
||||
dbg: &str,
|
||||
) {
|
||||
let Some(tr) = children_named(el, "TitleRegion").next() else {
|
||||
return;
|
||||
};
|
||||
let region: Table = match wrapper.call_method("CreateTitleRegion", ()) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
self.report
|
||||
.errors
|
||||
.push(format!("{dbg}: CreateTitleRegion: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.apply_region_layout(tr, ®ion, self_name, dbg);
|
||||
}
|
||||
|
||||
pub(super) fn apply_size(&mut self, el: &Element, wrapper: &Table, dbg: &str) {
|
||||
for size in children_named(el, "Size") {
|
||||
let (x, y) = abs_dim(size);
|
||||
|
||||
@@ -850,6 +850,8 @@ impl Loader<'_> {
|
||||
self.apply_special_fontstrings(el, wrapper, self_name, dbg_name);
|
||||
// 5a · <Backdrop> plate (rf24 LoadXML `0x77e6c0`): the tiled bg + 8-piece border.
|
||||
self.apply_backdrop(el, wrapper, dbg_name);
|
||||
// 5a' · <TitleRegion>: the drag handle, through the API's own CreateTitleRegion.
|
||||
self.apply_title_region(el, wrapper, self_name, dbg_name);
|
||||
// 5b · per-kind LoadXML extras (RF-28's typed tables) — StatusBar + Button/CheckButton;
|
||||
// the EditBox flags/caps (RF-0082). Every one of these gates on the element's own tag,
|
||||
// which is why `apply_template` builds its synthetic node with the kind CreateFrame was
|
||||
|
||||
@@ -1309,6 +1309,76 @@ mod loader_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `<TitleRegion setAllPoints="true"/>` builds the frame's drag handle over its whole rect —
|
||||
/// the stock `TutorialFrame.xml`'s (1976): a press inside it moves the frame with the cursor,
|
||||
/// exactly as `frame:CreateTitleRegion():SetAllPoints()` from Lua does
|
||||
/// (`script::tests::movable::a_title_region_drag_swallows_the_press_and_ends_on_release`);
|
||||
/// the same frame without the element does not move.
|
||||
#[test]
|
||||
fn title_region_element_builds_the_drag_handle_over_the_frame() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
let doc = parse(
|
||||
r#"<Ui>
|
||||
<Frame name="Tut" enableMouse="true">
|
||||
<Size><AbsDimension x="200" y="80"/></Size>
|
||||
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="100" y="100"/></Offset></Anchor></Anchors>
|
||||
<TitleRegion setAllPoints="true"/>
|
||||
</Frame>
|
||||
<Frame name="Plain" enableMouse="true">
|
||||
<Size><AbsDimension x="200" y="80"/></Size>
|
||||
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="400" y="100"/></Offset></Anchor></Anchors>
|
||||
</Frame>
|
||||
</Ui>"#,
|
||||
);
|
||||
let report = load(&s, &doc, &no_files);
|
||||
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
|
||||
let left = |s: &mut UiScript, f: &str| {
|
||||
s.resolve();
|
||||
s.eval::<f64>(&format!("return {f}:GetLeft()")).unwrap()
|
||||
};
|
||||
assert_eq!(left(&mut s, "Tut"), 100.0);
|
||||
s.mouse_button(150.0, 150.0, "LeftButton", true);
|
||||
s.mouse_move(250.0, 150.0);
|
||||
assert_eq!(
|
||||
left(&mut s, "Tut"),
|
||||
200.0,
|
||||
"the element's title region drags the frame"
|
||||
);
|
||||
s.mouse_button(250.0, 150.0, "LeftButton", false);
|
||||
|
||||
assert_eq!(left(&mut s, "Plain"), 400.0);
|
||||
s.mouse_button(450.0, 150.0, "LeftButton", true);
|
||||
s.mouse_move(550.0, 150.0);
|
||||
assert_eq!(left(&mut s, "Plain"), 400.0, "no element, no handle");
|
||||
s.mouse_button(550.0, 150.0, "LeftButton", false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loader_built_title_region_emits_no_quad() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
let doc = parse(
|
||||
r#"<Ui>
|
||||
<Frame name="Tut" enableMouse="true">
|
||||
<Size><AbsDimension x="200" y="80"/></Size>
|
||||
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="100" y="100"/></Offset></Anchor></Anchors>
|
||||
<TitleRegion setAllPoints="true"/>
|
||||
</Frame>
|
||||
</Ui>"#,
|
||||
);
|
||||
let report = load(&s, &doc, &no_files);
|
||||
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
|
||||
s.resolve();
|
||||
let quads = s.extract();
|
||||
assert!(
|
||||
quads
|
||||
.iter()
|
||||
.all(|q| matches!(q.target, crate::order::ZTarget::Frame(_))),
|
||||
"a title region is a hit rectangle, never a quad: {quads:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `<HitRectInsets>` reaches SetHitRectInsets, and the inset band stops capturing the mouse
|
||||
/// while the frame's own geometry is untouched — the ref micro-button shape (a 29x58 frame whose
|
||||
/// art fills only the lower ~40, `top="18"`).
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
//! The battleground **position** family (decision 1980; wow-re
|
||||
//! `system/ui/scratch/worldmap-arrow-and-positions.md` §3): the six verbs the stock
|
||||
//! `WorldMapFrame.lua` and `Blizzard_BattlefieldMinimap.lua` poll to place teammates and the
|
||||
//! flag carrier on the map.
|
||||
//!
|
||||
//! The wire carries raw world floats for the teammates outside the requester's group; the
|
||||
//! reference filters out itself, its four party slots and its raid roster, prefers the live
|
||||
//! object's position over the packet's, and normalizes through the world-map projection under the
|
||||
//! active queue slot's map — all app-side facts here, so the app pushes the finished list every
|
||||
//! frame ([`BattlefieldPositionView`], already filtered and projected) and the VM owns the getters'
|
||||
//! shapes: three values on every non-raising leg, `(0, 0, nil)` off the list.
|
||||
|
||||
use mlua::{Lua, MultiValue, Value};
|
||||
|
||||
use super::binding_abi::number_arg;
|
||||
use super::Model;
|
||||
|
||||
/// One teammate as the app resolved it: the map-normalized pair (`(0, 0)` = off the displayed
|
||||
/// map) and the name, `None` while the name cache has not answered.
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct BattlefieldPositionView {
|
||||
pub uv: (f32, f32),
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// The flag carrier as the app resolved it: the pair, and the token the local player's faction
|
||||
/// selects (`"HordeFlag"` for an Alliance viewer, `"AllianceFlag"` for a Horde one, `None` for
|
||||
/// neither).
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct BattlefieldFlagView {
|
||||
pub uv: (f32, f32),
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
impl super::UiScript {
|
||||
/// Push the resolved positions, the carrier (or none) and the active map's icon scale.
|
||||
pub fn set_battlefield_positions(
|
||||
&mut self,
|
||||
players: Vec<BattlefieldPositionView>,
|
||||
flag: Option<BattlefieldFlagView>,
|
||||
icon_scale: f32,
|
||||
) {
|
||||
let mut model = self.model_mut();
|
||||
model.battlefield_positions = players;
|
||||
model.battlefield_flag = flag;
|
||||
model.battlefield_icon_scale = icon_scale;
|
||||
}
|
||||
|
||||
/// `RequestBattlefieldPositions()` calls since the last drain — the app throttles to 5000 ms
|
||||
/// and sends only with an active slot.
|
||||
pub fn take_battlefield_position_requests(&mut self) -> u32 {
|
||||
std::mem::take(&mut self.model_mut().battlefield_position_requests)
|
||||
}
|
||||
}
|
||||
|
||||
fn three(lua: &Lua, uv: (f32, f32), third: Option<&str>) -> mlua::Result<MultiValue> {
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
Value::Number(f64::from(uv.0)),
|
||||
Value::Number(f64::from(uv.1)),
|
||||
match third {
|
||||
Some(s) => Value::String(lua.create_string(s)?),
|
||||
None => Value::Nil,
|
||||
},
|
||||
]))
|
||||
}
|
||||
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
|
||||
// `GetNumBattlefieldPositions()` — the survivors of the filter, one number.
|
||||
g.set(
|
||||
"GetNumBattlefieldPositions",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(model.battlefield_positions.len() as i64)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetBattlefieldPosition(index)` — the usage raise; 1-based over the survivors; three values
|
||||
// on every leg, `(0, 0, nil)` off the list and for 0 or a negative.
|
||||
g.set(
|
||||
"GetBattlefieldPosition",
|
||||
lua.create_function(|lua, index: Value| {
|
||||
let index = number_arg(lua, index, "Usage: GetBattlefieldPosition(index)")?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let row = usize::try_from(index)
|
||||
.ok()
|
||||
.and_then(|i| i.checked_sub(1))
|
||||
.and_then(|i| model.battlefield_positions.get(i));
|
||||
match row {
|
||||
Some(r) => three(lua, r.uv, r.name.as_deref()),
|
||||
None => three(lua, (0.0, 0.0), None),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetNumBattlefieldFlagPositions()` — one carrier slot: 1 or 0.
|
||||
g.set(
|
||||
"GetNumBattlefieldFlagPositions",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(i64::from(model.battlefield_flag.is_some()))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetBattlefieldFlagPosition(index)` — the usage raise; selected only for index 1 with a
|
||||
// carrier; three values always.
|
||||
g.set(
|
||||
"GetBattlefieldFlagPosition",
|
||||
lua.create_function(|lua, index: Value| {
|
||||
let index = number_arg(lua, index, "Usage: GetBattlefieldFlagPosition(index)")?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
match model.battlefield_flag.as_ref().filter(|_| index == 1) {
|
||||
Some(f) => three(lua, f.uv, f.token.as_deref()),
|
||||
None => three(lua, (0.0, 0.0), None),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetBattlefieldMapIconScale()` — the active map's `MinimapIconScale` (map 0's with no slot,
|
||||
// 1.0 on a missing row), pushed by the app.
|
||||
g.set(
|
||||
"GetBattlefieldMapIconScale",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(f64::from(model.battlefield_icon_scale))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `RequestBattlefieldPositions()` — counted; the app's 5000 ms throttle and slot gate decide.
|
||||
g.set(
|
||||
"RequestBattlefieldPositions",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.battlefield_position_requests += 1;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::script::UiScript;
|
||||
|
||||
#[test]
|
||||
fn the_getters_answer_three_values_and_zero_off_the_list() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetNumBattlefieldPositions()")
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', GetBattlefieldPosition(1))")
|
||||
.unwrap(),
|
||||
3
|
||||
);
|
||||
s.set_battlefield_positions(
|
||||
vec![
|
||||
BattlefieldPositionView {
|
||||
uv: (0.25, 0.5),
|
||||
name: Some("Probe-Realm".into()),
|
||||
},
|
||||
BattlefieldPositionView {
|
||||
uv: (0.75, 0.125),
|
||||
name: None,
|
||||
},
|
||||
],
|
||||
Some(BattlefieldFlagView {
|
||||
uv: (0.375, 0.625),
|
||||
token: Some("HordeFlag".into()),
|
||||
}),
|
||||
1.25,
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetNumBattlefieldPositions()")
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
let got = s
|
||||
.eval::<String>(
|
||||
"local x, y, n = GetBattlefieldPosition(1) return x .. '|' .. y .. '|' .. n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(got, "0.25|0.5|Probe-Realm");
|
||||
assert!(
|
||||
s.eval::<bool>(
|
||||
"local x, y, n = GetBattlefieldPosition(2) return x == 0.75 and n == nil"
|
||||
)
|
||||
.unwrap(),
|
||||
"a name the cache has not answered is nil"
|
||||
);
|
||||
for i in ["0", "3", "-1", "'2.9'"] {
|
||||
let got = s
|
||||
.eval::<String>(&format!(
|
||||
"local x, y, n = GetBattlefieldPosition({i}) return x .. '|' .. y .. '|' .. tostring(n)"
|
||||
))
|
||||
.unwrap();
|
||||
let want = if i == "'2.9'" {
|
||||
"0.75|0.125|nil"
|
||||
} else {
|
||||
"0|0|nil"
|
||||
};
|
||||
assert_eq!(got, want, "index {i}");
|
||||
}
|
||||
let err = s
|
||||
.run("GetBattlefieldPosition(nil)")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
err.contains("Usage: GetBattlefieldPosition(index)"),
|
||||
"{err}"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetNumBattlefieldFlagPositions()")
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
let got = s
|
||||
.eval::<String>(
|
||||
"local x, y, t = GetBattlefieldFlagPosition(1) return x .. '|' .. y .. '|' .. t",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(got, "0.375|0.625|HordeFlag");
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"local x, y, t = GetBattlefieldFlagPosition(2) return x == 0 and t == nil"
|
||||
)
|
||||
.unwrap());
|
||||
let err = s
|
||||
.run("GetBattlefieldFlagPosition('q')")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
err.contains("Usage: GetBattlefieldFlagPosition(index)"),
|
||||
"{err}"
|
||||
);
|
||||
assert!(
|
||||
(s.eval::<f64>("return GetBattlefieldMapIconScale()")
|
||||
.unwrap()
|
||||
- 1.25)
|
||||
.abs()
|
||||
< 1e-6
|
||||
);
|
||||
s.run("RequestBattlefieldPositions() RequestBattlefieldPositions()")
|
||||
.unwrap();
|
||||
assert_eq!(s.take_battlefield_position_requests(), 2);
|
||||
assert_eq!(s.take_battlefield_position_requests(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
//! The battleground **list and queue** family (decision 1974; wow-re
|
||||
//! `system/ui/scratch/battlefield-verb-family.md` §2.1, §2.2, §3.1–3.6, §5.2): the fourteen
|
||||
//! verbs the stock `BattlefieldFrame.lua` and `Minimap.xml` call, over the instance list and the
|
||||
//! three queue slots the app pushes. The scoreboard half of the same TU is
|
||||
//! [`super::battlefield_score`]; `AcceptBattlefieldPort` is [`super::dialog_verbs`]'s.
|
||||
//!
|
||||
//! ## What is the app's and what is the VM's
|
||||
//!
|
||||
//! The reference keeps three caches in `BattlefieldInfo.cpp`: the instance-id vector the
|
||||
//! `SMSG_BATTLEFIELD_LIST` handler fills (§2.1), the fixed three-slot queue array
|
||||
//! `SMSG_BATTLEFIELD_STATUS` writes (§2.2), and four scalars beside them — the battlemaster
|
||||
//! guid, the listed map, the bracket-adjusted level pair, and the **selected instance id**. Every
|
||||
//! verb below reads those and nothing else. The app owns the wire, the clock and the Map.dbc
|
||||
//! resolves, so it pushes the list ([`BattlefieldListView`]) when a list lands and the slots
|
||||
//! ([`BattlefieldQueueSlot`], with every clock-shaped value already in milliseconds) every frame;
|
||||
//! the VM owns the selection (`[0xb6eba0]`), the index arithmetic and the getters' shapes.
|
||||
//!
|
||||
//! ## The selection is a value, not an index (§3.5)
|
||||
//!
|
||||
//! `SetSelectedBattlefield(n)` stores the **instance id** at list position `n`, and
|
||||
//! `GetSelectedBattlefield()` scans the current list for that id — so a fresh list between the
|
||||
//! two calls can move or drop the selection, which the stock window's `zoneIndex − 1 ==
|
||||
//! GetSelectedBattlefield()` highlight then follows. A client that remembered an index would
|
||||
//! diverge the moment a list reorders.
|
||||
//!
|
||||
//! ## Shapes (§3, §3.1)
|
||||
//!
|
||||
//! Every required index is shape A (`lua_isnumber`, so a numeric string passes; truncated toward
|
||||
//! zero; a non-number raises the binding's own `Usage:`), 1-based with the reference's unsigned
|
||||
//! `dec; cmp; jae` gate that rejects 0 and negatives in one branch. `GetBattlefieldStatus`
|
||||
//! answers **five values on every leg** — `(nil, nil, 0, 0, 0)` off the three slots.
|
||||
//! `GetBattlefieldInfo` answers nine or none. `GetBattlefieldInstanceInfo` raises with
|
||||
//! `GetBattlefieldInfo`'s usage string — the shipped client's own mislabel, reproduced.
|
||||
//! `CloseBattlefield` does nothing (`xor eax,eax; ret`).
|
||||
|
||||
use mlua::{Lua, MultiValue, Value};
|
||||
|
||||
use super::binding_abi::{bool_or_default, number_arg, predicate};
|
||||
use super::Model;
|
||||
|
||||
/// One queue slot as the app pushes it — the reference's `0x20`-byte slot (§2.2) plus the map
|
||||
/// name its `GetBattlefieldStatus` resolves off Map.dbc, and the three clock-shaped fields
|
||||
/// already reduced against the app's clock.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Default)]
|
||||
pub struct BattlefieldQueueSlot {
|
||||
/// `+0x00` — the Map.dbc row id; `0` for a cleared slot. The name is resolved for that id too
|
||||
/// (the reference looks map 0 up like any other row).
|
||||
pub map_id: u32,
|
||||
/// The localized map name, or `None` when the id has no row (a real `nil`).
|
||||
pub map_name: Option<String>,
|
||||
/// `+0x04` — `0` none, `1` queued, `2` confirm, `3` active; anything else answers `"error"`.
|
||||
pub status: u32,
|
||||
/// `+0x10` — the instance id.
|
||||
pub instance_id: u32,
|
||||
/// `+0x08`/`+0x0c` — the bracket-adjusted level pair.
|
||||
pub min_level: u32,
|
||||
pub max_level: u32,
|
||||
/// `GetBattlefieldPortExpiration`: `deadline − now` in ms, 0 when unset or past.
|
||||
pub port_expiration_ms: u32,
|
||||
/// `GetBattlefieldEstimatedWaitTime`: the stored value, raw (no clock).
|
||||
pub estimated_wait_ms: u32,
|
||||
/// `GetBattlefieldTimeWaited`: `now − stamp` in ms, 0 when unset.
|
||||
pub time_waited_ms: u32,
|
||||
}
|
||||
|
||||
/// The Map.dbc half of `GetBattlefieldInfo` (§3.4), resolved by the app for the listed map.
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct BattlefieldMapInfo {
|
||||
/// The localized map name (value 1).
|
||||
pub name: String,
|
||||
/// The faction-side description (value 2) — `None` on the reference's `-1` faction leg, where
|
||||
/// it pushes nothing at all and Lua reads a stack slot below the tuple; here that leg answers
|
||||
/// `nil`, the one shape this binding does not reproduce (the leg is data-unreachable: every
|
||||
/// playable race carries a faction bit).
|
||||
pub description: Option<String>,
|
||||
/// The row's raw `MinLevel`/`MaxLevel` columns (values 3 and 4 — the stock Lua names them
|
||||
/// `minLevel, maxLevel`; the bracket-adjusted pair is values 8 and 9).
|
||||
pub min_level: u32,
|
||||
pub max_level: u32,
|
||||
/// Values 5–7: `[row+0x40]` signed, `[row+0x44]` and `[row+0x48]` f32.
|
||||
pub field_16: i32,
|
||||
pub field_17: f32,
|
||||
pub field_18: f32,
|
||||
}
|
||||
|
||||
/// The instance list and its scalars as the app pushes them ([`super::UiScript::set_battlefield_list`]).
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct BattlefieldListView {
|
||||
/// The instance ids in wire order (`[0xb6e868]`).
|
||||
pub instances: Vec<u32>,
|
||||
/// The bracket-adjusted level pair the list handler derived (`[0xb6eba8]`/`[0xb6ebac]`).
|
||||
pub bracket_min: u32,
|
||||
pub bracket_max: u32,
|
||||
/// The listed map's row, `None` when the id resolves to no row (the second zero-values gate).
|
||||
pub info: Option<BattlefieldMapInfo>,
|
||||
/// The listed map row's group-queue flag (`[row+0xa0]`, §3.6) — `CanJoinBattlefieldAsGroup`.
|
||||
pub group_queue: bool,
|
||||
}
|
||||
|
||||
/// The reference's 1-based index gate: `dec eax; cmp eax,n; jae bail` — an unsigned compare, so
|
||||
/// `0` and every negative fall out in the same branch as `> n`.
|
||||
fn slot_index(index: i32, n: usize) -> Option<usize> {
|
||||
usize::try_from(index)
|
||||
.ok()?
|
||||
.checked_sub(1)
|
||||
.filter(|&i| i < n)
|
||||
}
|
||||
|
||||
/// The local-player gate `GetBattlefieldInfo` and `GetBattlefieldInstanceInfo` answer zero values
|
||||
/// behind (`0x468550` + `0x468460(ecx = 0x10)`: the player object, read at call time) — here the
|
||||
/// unit model's `"player"` token, the same answer `UnitExists("player")` gives.
|
||||
fn player_exists(model: &Model) -> bool {
|
||||
model.unit("player").is_some_and(|u| u.exists)
|
||||
}
|
||||
|
||||
/// `[slot+0x04]` → the status string, through the reference's four-entry jump table with its
|
||||
/// `"error"` default (§3.2).
|
||||
fn status_text(status: u32) -> &'static str {
|
||||
match status {
|
||||
0 => "none",
|
||||
1 => "queued",
|
||||
2 => "confirm",
|
||||
3 => "active",
|
||||
_ => "error",
|
||||
}
|
||||
}
|
||||
|
||||
impl super::UiScript {
|
||||
/// Push the instance list. The selection (`[0xb6eba0]`) is left alone — a new list does not
|
||||
/// clear it, it only decides whether `GetSelectedBattlefield` still finds it (§3.5).
|
||||
pub fn set_battlefield_list(&mut self, list: BattlefieldListView) {
|
||||
self.model_mut().battlefield_list = list;
|
||||
}
|
||||
|
||||
/// The world-enter reset's half of this family (§8): the selection cleared with the list.
|
||||
pub fn reset_battlefield_selection(&mut self) {
|
||||
self.model_mut().battlefield_selected = 0;
|
||||
}
|
||||
|
||||
/// Push the three queue slots (clock-shaped values already reduced) and the instance
|
||||
/// expiration (`[0xb6ebb8]`, `deadline − now`, 0 when unset or past) — every frame, since
|
||||
/// three of the getters move with the clock.
|
||||
pub fn set_battlefield_queue(
|
||||
&mut self,
|
||||
slots: Vec<BattlefieldQueueSlot>,
|
||||
instance_expiration_ms: u32,
|
||||
) {
|
||||
let mut model = self.model_mut();
|
||||
model.battlefield_slots = slots;
|
||||
model.battlefield_instance_expiration_ms = instance_expiration_ms;
|
||||
}
|
||||
|
||||
/// `JoinBattlefield` calls since the last drain: `(instance id, 0 = first available; as a
|
||||
/// group)`. The app adds the map, the cached battlemaster guid (which picks the opcode) and
|
||||
/// the group-size refusal (§5.2).
|
||||
pub fn take_battlefield_join_requests(&mut self) -> Vec<(u32, bool)> {
|
||||
std::mem::take(&mut self.model_mut().battlefield_join_requests)
|
||||
}
|
||||
|
||||
/// `ShowBattlefieldList` calls that passed their gates since the last drain: the queued
|
||||
/// slot's map id, `CMSG_BATTLEFIELD_LIST`'s payload.
|
||||
pub fn take_battlefield_list_requests(&mut self) -> Vec<u32> {
|
||||
std::mem::take(&mut self.model_mut().battlefield_list_requests)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
|
||||
// `GetNumBattlefields()` — 0 args, never raises: the instance count.
|
||||
g.set(
|
||||
"GetNumBattlefields",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(model.battlefield_list.instances.len() as i64)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetBattlefieldInfo()` — 0 args, never raises; ZERO values behind either gate (no player, no
|
||||
// map row), else nine (§3.4).
|
||||
g.set(
|
||||
"GetBattlefieldInfo",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let list = &model.battlefield_list;
|
||||
let (Some(info), true) = (list.info.as_ref(), player_exists(&model)) else {
|
||||
return Ok(MultiValue::new());
|
||||
};
|
||||
let description = match &info.description {
|
||||
Some(d) => Value::String(lua.create_string(d)?),
|
||||
None => Value::Nil,
|
||||
};
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
Value::String(lua.create_string(&info.name)?),
|
||||
description,
|
||||
Value::Number(f64::from(info.min_level)),
|
||||
Value::Number(f64::from(info.max_level)),
|
||||
Value::Number(f64::from(info.field_16)),
|
||||
Value::Number(f64::from(info.field_17)),
|
||||
Value::Number(f64::from(info.field_18)),
|
||||
Value::Number(f64::from(list.bracket_min)),
|
||||
Value::Number(f64::from(list.bracket_max)),
|
||||
]))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetBattlefieldInstanceInfo(index)` — raises with GetBattlefieldInfo's usage string (the
|
||||
// shipped mislabel, §3.1); no player or out of range → zero values; else the instance id,
|
||||
// pushed SIGNED (`fild dword`).
|
||||
g.set(
|
||||
"GetBattlefieldInstanceInfo",
|
||||
lua.create_function(|lua, index: Value| {
|
||||
let index = number_arg(lua, index, "Usage: GetBattlefieldInfo(index)")?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let list = &model.battlefield_list;
|
||||
if !player_exists(&model) {
|
||||
return Ok(MultiValue::new());
|
||||
}
|
||||
Ok(match slot_index(index, list.instances.len()) {
|
||||
Some(i) => {
|
||||
MultiValue::from_vec(vec![Value::Number(f64::from(list.instances[i] as i32))])
|
||||
}
|
||||
None => MultiValue::new(),
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `JoinBattlefield(index [, asGroup])` — arg 1 shape A, arg 2 the never-raising optional
|
||||
// boolean reader; the instance is `index−1 < count ? list[index−1] : 0` (0 = first
|
||||
// available, and what an out-of-range index — 0 included — degrades to), §5.2. The refusal
|
||||
// leg and the opcode choice are the app's and invisible to Lua.
|
||||
g.set(
|
||||
"JoinBattlefield",
|
||||
lua.create_function(|lua, (index, as_group): (Value, Value)| {
|
||||
let index = number_arg(lua, index, "Usage: JoinBattlefield(index)")?;
|
||||
let as_group = bool_or_default(Some(&as_group), false);
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let instance = slot_index(index, model.battlefield_list.instances.len())
|
||||
.map_or(0, |i| model.battlefield_list.instances[i]);
|
||||
model.battlefield_join_requests.push((instance, as_group));
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `CloseBattlefield()` — `xor eax,eax; ret`: nothing, and the stock window calls it on hide.
|
||||
g.set("CloseBattlefield", lua.create_function(|_, ()| Ok(()))?)?;
|
||||
|
||||
// `SetSelectedBattlefield(index)` — stores the instance id at that position, or 0 when out
|
||||
// of range (§3.5).
|
||||
g.set(
|
||||
"SetSelectedBattlefield",
|
||||
lua.create_function(|lua, index: Value| {
|
||||
let index = number_arg(lua, index, "Usage: SetSelectedBattlefield(index)")?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.battlefield_selected = slot_index(index, model.battlefield_list.instances.len())
|
||||
.map_or(0, |i| model.battlefield_list.instances[i]);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetSelectedBattlefield()` — the 1-based position of the stored id in the CURRENT list, or
|
||||
// 0 on a miss (which is also what a stored 0 answers: the "first available" row).
|
||||
g.set(
|
||||
"GetSelectedBattlefield",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let selected = model.battlefield_selected;
|
||||
Ok(model
|
||||
.battlefield_list
|
||||
.instances
|
||||
.iter()
|
||||
.position(|&id| id == selected)
|
||||
.map_or(0, |i| i as i64 + 1))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetBattlefieldStatus(index)` — five values on EVERY leg (§3.2): off 1..3 it is
|
||||
// `(nil, nil, 0, 0, 0)`, never a raise and never zero values.
|
||||
g.set(
|
||||
"GetBattlefieldStatus",
|
||||
lua.create_function(|lua, index: Value| {
|
||||
let index = number_arg(lua, index, "Usage: GetBattlefieldStatus(index)")?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(slot) = slot_index(index, 3).and_then(|i| model.battlefield_slots.get(i))
|
||||
else {
|
||||
return Ok(MultiValue::from_vec(vec![
|
||||
Value::Nil,
|
||||
Value::Nil,
|
||||
Value::Number(0.0),
|
||||
Value::Number(0.0),
|
||||
Value::Number(0.0),
|
||||
]));
|
||||
};
|
||||
let name = match &slot.map_name {
|
||||
Some(n) => Value::String(lua.create_string(n)?),
|
||||
None => Value::Nil,
|
||||
};
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
Value::String(lua.create_string(status_text(slot.status))?),
|
||||
name,
|
||||
Value::Number(f64::from(slot.instance_id)),
|
||||
Value::Number(f64::from(slot.min_level)),
|
||||
Value::Number(f64::from(slot.max_level)),
|
||||
]))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// The three per-slot clock-shaped getters (§3.3): shape A index, 0 off 1..3, one number
|
||||
// always. The app reduces each against its clock; the values arrive in ms.
|
||||
for (name, usage, read) in [
|
||||
(
|
||||
"GetBattlefieldPortExpiration",
|
||||
"Usage: GetBattlefieldPortExpiration(index)",
|
||||
(|s: &BattlefieldQueueSlot| s.port_expiration_ms) as fn(&BattlefieldQueueSlot) -> u32,
|
||||
),
|
||||
(
|
||||
"GetBattlefieldEstimatedWaitTime",
|
||||
"Usage: GetBattlefieldEstimatedWaitTime(index)",
|
||||
|s: &BattlefieldQueueSlot| s.estimated_wait_ms,
|
||||
),
|
||||
(
|
||||
"GetBattlefieldTimeWaited",
|
||||
"Usage: GetBattlefieldTimeWaited(index)",
|
||||
|s: &BattlefieldQueueSlot| s.time_waited_ms,
|
||||
),
|
||||
] {
|
||||
g.set(
|
||||
name,
|
||||
lua.create_function(move |lua, index: Value| {
|
||||
let index = number_arg(lua, index, usage)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(i64::from(
|
||||
slot_index(index, 3)
|
||||
.and_then(|i| model.battlefield_slots.get(i))
|
||||
.map_or(0, read),
|
||||
))
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
// `GetBattlefieldInstanceExpiration()` — 0 args: `[0xb6ebb8] − now`, 0 when unset or past.
|
||||
g.set(
|
||||
"GetBattlefieldInstanceExpiration",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(i64::from(model.battlefield_instance_expiration_ms))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `ShowBattlefieldList(index)` — shape A; silent off 1..3, on an empty slot, and on any
|
||||
// status but "queued"; else the slot's map goes out as `CMSG_BATTLEFIELD_LIST`. No event.
|
||||
g.set(
|
||||
"ShowBattlefieldList",
|
||||
lua.create_function(|lua, index: Value| {
|
||||
let index = number_arg(lua, index, "Usage: ShowBattlefieldList(index)")?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let map_id = slot_index(index, 3)
|
||||
.and_then(|i| model.battlefield_slots.get(i))
|
||||
.filter(|s| s.map_id != 0 && s.status == 1)
|
||||
.map(|s| s.map_id);
|
||||
if let Some(map_id) = map_id {
|
||||
model.battlefield_list_requests.push(map_id);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `CanJoinBattlefieldAsGroup()` — `1` or nil off the listed map's `+0xa0` flag (§3.6). The
|
||||
// join-time size check reads a different column and lives with the sender.
|
||||
g.set(
|
||||
"CanJoinBattlefieldAsGroup",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(predicate(model.battlefield_list.group_queue))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::script::UiScript;
|
||||
|
||||
/// A VM with a local player — the state every in-world call is made in.
|
||||
fn vm() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(crate::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probe".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
s
|
||||
}
|
||||
|
||||
fn list(ids: &[u32]) -> BattlefieldListView {
|
||||
BattlefieldListView {
|
||||
instances: ids.to_vec(),
|
||||
bracket_min: 20,
|
||||
bracket_max: 29,
|
||||
info: Some(BattlefieldMapInfo {
|
||||
name: "Arathi Basin".into(),
|
||||
description: Some("The Arathi Basin is …".into()),
|
||||
min_level: 20,
|
||||
max_level: 60,
|
||||
field_16: -1,
|
||||
field_17: 0.0,
|
||||
field_18: 0.0,
|
||||
}),
|
||||
group_queue: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn slot(map_id: u32, status: u32, instance: u32) -> BattlefieldQueueSlot {
|
||||
BattlefieldQueueSlot {
|
||||
map_id,
|
||||
map_name: Some(format!("Map {map_id}")),
|
||||
status,
|
||||
instance_id: instance,
|
||||
min_level: 20,
|
||||
max_level: 29,
|
||||
port_expiration_ms: 60_000,
|
||||
estimated_wait_ms: 30_000,
|
||||
time_waited_ms: 5_000,
|
||||
}
|
||||
}
|
||||
|
||||
/// The reference's 1-based gate: 0 and negatives fall out with the too-large ones.
|
||||
#[test]
|
||||
fn the_index_gate_is_unsigned_and_one_based() {
|
||||
assert_eq!(slot_index(1, 3), Some(0));
|
||||
assert_eq!(slot_index(3, 3), Some(2));
|
||||
assert_eq!(slot_index(4, 3), None);
|
||||
assert_eq!(slot_index(0, 3), None);
|
||||
assert_eq!(slot_index(-1, 3), None);
|
||||
assert_eq!(slot_index(1, 0), None);
|
||||
}
|
||||
|
||||
/// Nine values past both gates, none behind either; the two raw row levels sit at 3 and 4
|
||||
/// and the bracket pair at 8 and 9.
|
||||
#[test]
|
||||
fn get_battlefield_info_answers_nine_or_none() {
|
||||
let mut s = vm();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', GetBattlefieldInfo())")
|
||||
.unwrap(),
|
||||
0,
|
||||
"no list at all: the map gate"
|
||||
);
|
||||
s.set_battlefield_list(list(&[7, 3]));
|
||||
s.set_unit("player", None);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', GetBattlefieldInfo())")
|
||||
.unwrap(),
|
||||
0,
|
||||
"no player: the other gate, read at call time"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', GetBattlefieldInstanceInfo(1))")
|
||||
.unwrap(),
|
||||
0,
|
||||
"…and the instance verb's"
|
||||
);
|
||||
s = vm();
|
||||
s.set_battlefield_list(list(&[7, 3]));
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', GetBattlefieldInfo())")
|
||||
.unwrap(),
|
||||
9
|
||||
);
|
||||
let got = s
|
||||
.eval::<String>(
|
||||
"local n, d, a, b, c, x, y, lo, hi = GetBattlefieldInfo() \
|
||||
return n .. '|' .. a .. '|' .. b .. '|' .. c .. '|' .. lo .. '|' .. hi",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(got, "Arathi Basin|20|60|-1|20|29");
|
||||
}
|
||||
|
||||
/// The instance verb: the wrong usage string, the silent out-of-range leg, the signed push.
|
||||
#[test]
|
||||
fn get_battlefield_instance_info_raises_with_the_mislabel_and_bails_silently() {
|
||||
let mut s = vm();
|
||||
s.set_battlefield_list(list(&[7, 0xFFFF_FFFF]));
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetBattlefieldInstanceInfo(1)")
|
||||
.unwrap(),
|
||||
7
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetBattlefieldInstanceInfo('2')")
|
||||
.unwrap(),
|
||||
-1,
|
||||
"a numeric string passes; the id is pushed signed"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', GetBattlefieldInstanceInfo(3))")
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', GetBattlefieldInstanceInfo(0))")
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
let err = s
|
||||
.run("GetBattlefieldInstanceInfo(nil)")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
err.contains("Usage: GetBattlefieldInfo(index)"),
|
||||
"the shipped client's own mislabel: {err}"
|
||||
);
|
||||
assert_eq!(s.eval::<i64>("return GetNumBattlefields()").unwrap(), 2);
|
||||
}
|
||||
|
||||
/// The selection round-trips by VALUE: a reordered list moves it, a shortened one drops it.
|
||||
#[test]
|
||||
fn the_selection_is_an_instance_id_not_a_position() {
|
||||
let mut s = vm();
|
||||
s.set_battlefield_list(list(&[7, 3, 11]));
|
||||
assert_eq!(s.eval::<i64>("return GetSelectedBattlefield()").unwrap(), 0);
|
||||
s.run("SetSelectedBattlefield(2)").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return GetSelectedBattlefield()").unwrap(), 2);
|
||||
s.set_battlefield_list(list(&[3, 7, 11]));
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetSelectedBattlefield()").unwrap(),
|
||||
1,
|
||||
"instance 3 moved to the front"
|
||||
);
|
||||
s.set_battlefield_list(list(&[7, 11]));
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetSelectedBattlefield()").unwrap(),
|
||||
0,
|
||||
"instance 3 left the list"
|
||||
);
|
||||
s.run("SetSelectedBattlefield(0)").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return GetSelectedBattlefield()").unwrap(), 0);
|
||||
s.run("SetSelectedBattlefield(9)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetSelectedBattlefield()").unwrap(),
|
||||
0,
|
||||
"out of range stores 0"
|
||||
);
|
||||
let err = s.run("SetSelectedBattlefield({})").unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("Usage: SetSelectedBattlefield(index)"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `JoinBattlefield`: the instance at the position, 0 for "first available" and for any
|
||||
/// out-of-range index, the optional group flag through the never-raising reader.
|
||||
#[test]
|
||||
fn join_battlefield_resolves_the_instance_and_the_group_flag() {
|
||||
let mut s = vm();
|
||||
s.set_battlefield_list(list(&[7, 3]));
|
||||
s.run("JoinBattlefield(0) JoinBattlefield(2, 1) JoinBattlefield(5, true) JoinBattlefield(1, nil)")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.take_battlefield_join_requests(),
|
||||
vec![(0, false), (3, true), (0, true), (7, false)]
|
||||
);
|
||||
assert!(s.take_battlefield_join_requests().is_empty(), "drained");
|
||||
let err = s.run("JoinBattlefield()").unwrap_err().to_string();
|
||||
assert!(err.contains("Usage: JoinBattlefield(index)"), "{err}");
|
||||
s.run("CloseBattlefield()").unwrap();
|
||||
}
|
||||
|
||||
/// Five values on every leg of the status verb; the four status strings and the default.
|
||||
#[test]
|
||||
fn get_battlefield_status_answers_five_values_on_every_leg() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_battlefield_queue(vec![slot(489, 1, 5), slot(0, 0, 0), slot(529, 9, 2)], 0);
|
||||
let n = |s: &mut UiScript, i: &str| {
|
||||
s.eval::<i64>(&format!("return select('#', GetBattlefieldStatus({i}))"))
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(n(&mut s, "1"), 5);
|
||||
assert_eq!(n(&mut s, "0"), 5);
|
||||
assert_eq!(n(&mut s, "4"), 5);
|
||||
let got = s
|
||||
.eval::<String>(
|
||||
"local st, name, id, lo, hi = GetBattlefieldStatus(1) \
|
||||
return st .. '|' .. name .. '|' .. id .. '|' .. lo .. '|' .. hi",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(got, "queued|Map 489|5|20|29");
|
||||
assert_eq!(
|
||||
s.eval::<String>("return (GetBattlefieldStatus(2))")
|
||||
.unwrap(),
|
||||
"none"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return (GetBattlefieldStatus(3))")
|
||||
.unwrap(),
|
||||
"error",
|
||||
"a status past 3 takes the jump table's default"
|
||||
);
|
||||
let off = s
|
||||
.eval::<String>(
|
||||
"local st, name, id, lo, hi = GetBattlefieldStatus(4) \
|
||||
return tostring(st) .. '|' .. tostring(name) .. '|' .. id .. lo .. hi",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(off, "nil|nil|000");
|
||||
let err = s.run("GetBattlefieldStatus('x')").unwrap_err().to_string();
|
||||
assert!(err.contains("Usage: GetBattlefieldStatus(index)"), "{err}");
|
||||
let mut empty = slot(0, 0, 0);
|
||||
empty.map_name = None;
|
||||
s.set_battlefield_queue(vec![empty], 0);
|
||||
assert!(
|
||||
s.eval::<bool>("local _, name = GetBattlefieldStatus(1) return name == nil")
|
||||
.unwrap(),
|
||||
"no row: a real nil"
|
||||
);
|
||||
}
|
||||
|
||||
/// The clock-shaped getters read the pushed ms and answer 0 off the slots; the instance
|
||||
/// expiration is the singleton.
|
||||
#[test]
|
||||
fn the_time_getters_read_the_pushed_milliseconds() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_battlefield_queue(vec![slot(489, 1, 5)], 120_000);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetBattlefieldPortExpiration(1)")
|
||||
.unwrap(),
|
||||
60_000
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetBattlefieldEstimatedWaitTime(1)")
|
||||
.unwrap(),
|
||||
30_000
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetBattlefieldTimeWaited(1)").unwrap(),
|
||||
5_000
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetBattlefieldTimeWaited(2)").unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetBattlefieldPortExpiration(0)")
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetBattlefieldInstanceExpiration()")
|
||||
.unwrap(),
|
||||
120_000
|
||||
);
|
||||
for (call, usage) in [
|
||||
(
|
||||
"GetBattlefieldPortExpiration()",
|
||||
"Usage: GetBattlefieldPortExpiration(index)",
|
||||
),
|
||||
(
|
||||
"GetBattlefieldEstimatedWaitTime(nil)",
|
||||
"Usage: GetBattlefieldEstimatedWaitTime(index)",
|
||||
),
|
||||
(
|
||||
"GetBattlefieldTimeWaited({})",
|
||||
"Usage: GetBattlefieldTimeWaited(index)",
|
||||
),
|
||||
] {
|
||||
let err = s.run(call).unwrap_err().to_string();
|
||||
assert!(err.contains(usage), "{call}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `ShowBattlefieldList` sends only for a queued, non-empty slot; the group predicate is
|
||||
/// `1` or nil.
|
||||
#[test]
|
||||
fn show_battlefield_list_gates_on_a_queued_slot() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_battlefield_queue(vec![slot(489, 1, 5), slot(529, 2, 1), slot(0, 1, 0)], 0);
|
||||
s.run("ShowBattlefieldList(1) ShowBattlefieldList(2) ShowBattlefieldList(3) ShowBattlefieldList(4)")
|
||||
.unwrap();
|
||||
assert_eq!(s.take_battlefield_list_requests(), vec![489]);
|
||||
let err = s.run("ShowBattlefieldList('q')").unwrap_err().to_string();
|
||||
assert!(err.contains("Usage: ShowBattlefieldList(index)"), "{err}");
|
||||
|
||||
assert!(s
|
||||
.eval::<bool>("return CanJoinBattlefieldAsGroup() == nil")
|
||||
.unwrap());
|
||||
s.set_battlefield_list(list(&[]));
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return CanJoinBattlefieldAsGroup()").unwrap(),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@
|
||||
//! | `GetAreaSpiritHealerTime()` | 0 → 1 number, whole seconds | reads the app's deadline |
|
||||
//! | `AcceptBattlefieldPort(index, accept)` | 2 → 0 | raises on a non-number index, silent off 1..3; the optional-bool answer |
|
||||
//! | `CancelMeetingStoneRequest()` | 0 → 0 | queued; the app applies the leadership gate |
|
||||
//! | `IsInMeetingStoneQueue()` | 0 → the number `1` or `nil`, one value always | `[0xb72038] != 0` (wow-re `meeting-stone-status.md` §3.1, 1974) |
|
||||
//! | `GetMeetingStoneStatusText()` | 0 → string or `nil` | the cached text `[0xb7203c]`, `nil` while empty (§3.2) |
|
||||
//! | `CheckPetUntrainerDist()` | 0 → `1` or `nil` | the app's latch-and-range flag |
|
||||
//! | `ConfirmPetUnlearn()` | 0 → 0 | counted; the app holds the latch and the money gate |
|
||||
//!
|
||||
@@ -63,6 +65,15 @@ impl super::UiScript {
|
||||
pub fn take_meeting_stone_cancels(&mut self) -> u32 {
|
||||
std::mem::take(&mut self.model_mut().meeting_stone_cancels)
|
||||
}
|
||||
|
||||
/// The meeting stone's two globals (1974): the queued area id (`[0xb72038]`, `0` = none) and
|
||||
/// the cached status text (`[0xb7203c]`, `None` from process start to world enter and after
|
||||
/// world leave). The app rebuilds the text; the VM only hands it back.
|
||||
pub fn set_meeting_stone(&mut self, area: u32, status_text: Option<String>) {
|
||||
let mut model = self.model_mut();
|
||||
model.meeting_stone_area = area;
|
||||
model.meeting_stone_status_text = status_text;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
@@ -154,6 +165,30 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `IsInMeetingStoneQueue()` (`0x4ca570`): `mov eax,1` on BOTH legs — the number `1` when the
|
||||
// queued area is non-zero, else nil; never `0` (truthy in Lua — it would pin the icon shown),
|
||||
// never a boolean.
|
||||
g.set(
|
||||
"IsInMeetingStoneQueue",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(predicate(model.meeting_stone_area != 0))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetMeetingStoneStatusText()` (`0x4ca5b0`): `lua_pushstring([0xb7203c])`, whose NULL leg
|
||||
// tail-jumps to `lua_pushnil` — a string or nil, one value, never `""` for "nothing".
|
||||
g.set(
|
||||
"GetMeetingStoneStatusText",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(match &model.meeting_stone_status_text {
|
||||
Some(text) => Value::String(lua.create_string(text)?),
|
||||
None => Value::Nil,
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// §9: `1` or `nil` — never `0`. The range test (`d² <= INTERACT_DISTANCE²`, a NaN OUT) is the
|
||||
// app's, on the latched trainer.
|
||||
g.set(
|
||||
@@ -263,6 +298,41 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The meeting stone pair: `1`/nil on the area, string/nil on the text — one value each.
|
||||
#[test]
|
||||
fn the_meeting_stone_pair_answers_one_or_nil_and_string_or_nil() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', IsInMeetingStoneQueue())")
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>("return IsInMeetingStoneQueue() == nil")
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>("return GetMeetingStoneStatusText() == nil")
|
||||
.unwrap());
|
||||
s.set_meeting_stone(1519, Some("Looking for more for Stormwind City".into()));
|
||||
assert_eq!(s.eval::<i64>("return IsInMeetingStoneQueue()").unwrap(), 1);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetMeetingStoneStatusText()")
|
||||
.unwrap(),
|
||||
"Looking for more for Stormwind City"
|
||||
);
|
||||
s.set_meeting_stone(0, Some("Unknown".into()));
|
||||
assert!(
|
||||
s.eval::<bool>("return IsInMeetingStoneQueue() == nil")
|
||||
.unwrap(),
|
||||
"area 0: not queued, whatever the text says"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetMeetingStoneStatusText()")
|
||||
.unwrap(),
|
||||
"Unknown"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pet_pair_counts_and_answers_one_or_nil() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
|
||||
@@ -131,8 +131,11 @@ impl UiScript {
|
||||
// [`QuadContent::ModelPane`]). Both widget kinds share `KindState::Model`
|
||||
// because the client's `CGCharacterModelBase` extends `CSimpleModel`, and
|
||||
// both draw the same way here.
|
||||
Some(crate::widget::KindState::Model(_)) => QuadContent::ModelPane {
|
||||
Some(crate::widget::KindState::Model(m)) => QuadContent::ModelPane {
|
||||
name: frame.and_then(|f| f.name.clone()),
|
||||
model: m.path.clone(),
|
||||
facing: m.facing,
|
||||
model_scale: m.scale,
|
||||
},
|
||||
_ => QuadContent::Frame,
|
||||
};
|
||||
|
||||
@@ -176,22 +176,23 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
m.set(
|
||||
"GetPingPosition",
|
||||
// The live ping's normalized offsets from the widget centre (fractions of the widget
|
||||
// side, x right / y up — the `MINIMAP_PING` event's own arg2/arg3 space), recomputed by
|
||||
// the app from the ping's world point every frame one is live. So a caller polling this
|
||||
// while walking sees the value MOVE, which is the whole point: the ping is pinned to the
|
||||
// world, not to the map.
|
||||
// The ping's normalized offsets from the widget centre (fractions of the widget side,
|
||||
// x right / y up — the `MINIMAP_PING` event's own arg2/arg3 space), recomputed by the app
|
||||
// from the stored world point every frame. So a caller polling this while walking sees
|
||||
// the value MOVE, which is the whole point: the ping is pinned to the world, not to the
|
||||
// map.
|
||||
//
|
||||
// Returns **nil, nil** when no ping is live, rather than `(0, 0)`: zero is a real answer
|
||||
// meaning "the ping is under your feet", and handing it back for "there is no ping" is a
|
||||
// lie a caller cannot tell from the truth (1203).
|
||||
// **Two numbers, always** (`0x4eefd0`, wow-re `minimap-ping-law.md`): the reference
|
||||
// recomputes from statics nothing ever clears, and the stock `Minimap_OnUpdate` feeds the
|
||||
// answer straight into `x * Minimap:GetWidth()` for the whole of its 5 s timer — a nil
|
||||
// here is a Lua error on every frame of that window (1974).
|
||||
lua.create_function(|lua, this: Table| {
|
||||
with_minimap(lua, &this, |_| ())?;
|
||||
let ping = lua
|
||||
let (x, y) = lua
|
||||
.app_data_ref::<Model>()
|
||||
.expect("model app_data")
|
||||
.minimap_ping;
|
||||
Ok(ping.map_or((None, None), |(x, y)| (Some(x), Some(y))))
|
||||
Ok((x, y))
|
||||
})?,
|
||||
)?;
|
||||
lua.set_named_registry_value(REG_MINIMAP_METHODS, m)?;
|
||||
|
||||
@@ -43,11 +43,15 @@ mod auction;
|
||||
mod aura;
|
||||
mod backdrop;
|
||||
mod bank;
|
||||
mod battlefield_positions;
|
||||
mod battlefield_queue;
|
||||
mod battlefield_score;
|
||||
mod bind_confirm;
|
||||
mod binder;
|
||||
mod binding_abi;
|
||||
mod dialog_verbs;
|
||||
mod tutorial;
|
||||
mod worldmap_arrow;
|
||||
// The five camera views + FlipCameraYaw — the reference's `UIUtil\Camera.cpp` Lua surface.
|
||||
mod button;
|
||||
mod camera_view;
|
||||
@@ -84,6 +88,7 @@ mod gossip;
|
||||
mod guild;
|
||||
mod handler_prof;
|
||||
mod screenshot;
|
||||
mod tabard;
|
||||
pub use handler_prof::HandlerRow;
|
||||
mod inspect;
|
||||
mod item_stats;
|
||||
@@ -162,6 +167,8 @@ pub use auction::{
|
||||
pub use aura::{AuraState, TrackingState};
|
||||
pub use backdrop::{inset_atlas_bleed, pieces, Backdrop, BackdropPiece, Insets};
|
||||
pub use bank::BankState;
|
||||
pub use battlefield_positions::{BattlefieldFlagView, BattlefieldPositionView};
|
||||
pub use battlefield_queue::{BattlefieldListView, BattlefieldMapInfo, BattlefieldQueueSlot};
|
||||
pub use battlefield_score::{BattlefieldScoreRow, BattlefieldScores, BattlefieldStatColumn};
|
||||
pub use bind_confirm::PendingEquipAnswer;
|
||||
pub use camera_view::{CameraViewRequest, CAMERA_VIEW_COUNT};
|
||||
@@ -199,6 +206,7 @@ pub use petition::{
|
||||
validate_guild_name, PetitionRecordView, PetitionRequest, PetitionState, PETITION_TYPE_CHARTER,
|
||||
PETITION_TYPE_PETITION,
|
||||
};
|
||||
pub use worldmap_arrow::{ARROW_FOOTPRINT_PX, ARROW_MODEL};
|
||||
|
||||
pub use inspect::{InspectView, UnitReach};
|
||||
pub use item_stats::{item_usable, ItemSetView, ItemTemplateView, PlayerReqState};
|
||||
@@ -611,6 +619,10 @@ impl UiScript {
|
||||
talent::install(&lua)?;
|
||||
dialog_verbs::install(&lua)?;
|
||||
battlefield_score::install(&lua)?;
|
||||
battlefield_queue::install(&lua)?;
|
||||
battlefield_positions::install(&lua)?;
|
||||
tutorial::install(&lua)?;
|
||||
worldmap_arrow::install(&lua)?;
|
||||
shapeshift::install(&lua)?;
|
||||
pet::install(&lua)?;
|
||||
gossip::install(&lua)?;
|
||||
@@ -625,6 +637,7 @@ impl UiScript {
|
||||
trade::install(&lua)?;
|
||||
inspect::install(&lua)?;
|
||||
dressup::install(&lua)?;
|
||||
tabard::install(&lua)?;
|
||||
tradeskill::install(&lua)?;
|
||||
craft::install(&lua)?;
|
||||
reputation::install(&lua)?;
|
||||
@@ -853,7 +866,7 @@ impl UiScript {
|
||||
///
|
||||
/// One field, not one per widget: there is exactly one ping (decision 1596), and the old
|
||||
/// per-widget push walked the whole ~3k-frame arena every frame a ping was live.
|
||||
pub fn set_minimap_ping(&mut self, ping: Option<(f32, f32)>) {
|
||||
pub fn set_minimap_ping(&mut self, ping: (f32, f32)) {
|
||||
self.model_mut().minimap_ping = ping;
|
||||
}
|
||||
|
||||
@@ -1050,6 +1063,20 @@ impl UiScript {
|
||||
.any(|(_, f)| f.name.as_deref() == Some(name) && f.effective_visible)
|
||||
}
|
||||
|
||||
/// The effective alpha of the frame with this global name while it is effectively visible,
|
||||
/// `None` when it is hidden or no live frame carries the name. The read side of a frame whose
|
||||
/// pixels a host draws for the engine: the stock `MiniMapPing` is a `<Model>` this engine
|
||||
/// renders nothing for, so the app's ping sprite follows the frame's own show/hide and alpha
|
||||
/// — the lifetime the reference's `Minimap.lua` owns — instead of keeping a clock of its own
|
||||
/// (1974). A linear scan, like [`Self::frame_visible`].
|
||||
pub fn frame_effective_alpha(&self, name: &str) -> Option<f32> {
|
||||
self.model_ref()
|
||||
.arena
|
||||
.iter_frames()
|
||||
.find(|(_, f)| f.name.as_deref() == Some(name) && f.effective_visible)
|
||||
.map(|(_, f)| f.effective_alpha)
|
||||
}
|
||||
|
||||
/// Resolve every frame's rect: sync each frame's effective scale from the arena into its layout
|
||||
/// input, run the [`crate::layout`] graph (screen root as the external base), and cache the
|
||||
/// resolved rects. `GetWidth`/`GetHeight`/`extract` read this cache.
|
||||
@@ -1419,6 +1446,7 @@ impl UiScript {
|
||||
crate::widget::FrameKind::Model => "Model",
|
||||
crate::widget::FrameKind::PlayerModel => "PlayerModel",
|
||||
crate::widget::FrameKind::DressUpModel => "DressUpModel",
|
||||
crate::widget::FrameKind::TabardModel => "TabardModel",
|
||||
crate::widget::FrameKind::MessageFrame => "MessageFrame",
|
||||
crate::widget::FrameKind::ScrollingMessageFrame => "ScrollingMessageFrame",
|
||||
crate::widget::FrameKind::ColorSelect => "ColorSelect",
|
||||
|
||||
@@ -824,8 +824,37 @@ pub(crate) struct Model {
|
||||
pub(crate) battlefield_score_requests: u32,
|
||||
/// `LeaveBattlefield()` calls that passed the "ended" gate since the last drain.
|
||||
pub(crate) battlefield_leave_requests: u32,
|
||||
/// The battleground instance list and its scalars (1974), as the app last pushed them.
|
||||
pub(crate) battlefield_list: super::battlefield_queue::BattlefieldListView,
|
||||
/// `[0xb6eba0]` — the selected instance id (a VALUE; `SetSelectedBattlefield` writes it).
|
||||
pub(crate) battlefield_selected: u32,
|
||||
/// The three queue slots (1974), pushed each frame with their clock-shaped values reduced.
|
||||
pub(crate) battlefield_slots: Vec<super::battlefield_queue::BattlefieldQueueSlot>,
|
||||
/// `GetBattlefieldInstanceExpiration()`'s answer, pushed each frame.
|
||||
pub(crate) battlefield_instance_expiration_ms: u32,
|
||||
/// `JoinBattlefield` calls since the last drain: `(instance id, as group)`.
|
||||
pub(crate) battlefield_join_requests: Vec<(u32, bool)>,
|
||||
/// `ShowBattlefieldList` calls that passed their gates: the queued slot's map id.
|
||||
pub(crate) battlefield_list_requests: Vec<u32>,
|
||||
/// The battleground teammates' map positions as the app resolved them (1980), the flag
|
||||
/// carrier, the active map's icon scale, and `RequestBattlefieldPositions()` calls.
|
||||
pub(crate) battlefield_positions: Vec<super::battlefield_positions::BattlefieldPositionView>,
|
||||
pub(crate) battlefield_flag: Option<super::battlefield_positions::BattlefieldFlagView>,
|
||||
pub(crate) battlefield_icon_scale: f32,
|
||||
pub(crate) battlefield_position_requests: u32,
|
||||
/// `CancelMeetingStoneRequest()` calls since the last drain — the app gates on leadership.
|
||||
pub(crate) meeting_stone_cancels: u32,
|
||||
/// The meeting stone's queued area (`[0xb72038]`) and cached status text (`[0xb7203c]`), as the
|
||||
/// app last pushed them (1974).
|
||||
pub(crate) meeting_stone_area: u32,
|
||||
pub(crate) meeting_stone_status_text: Option<String>,
|
||||
/// The tutorial system's acknowledged bank, as the app last pushed it (1976); `None` before
|
||||
/// `SMSG_TUTORIAL_FLAGS` — `TutorialsEnabled()`'s only input.
|
||||
pub(crate) tutorial_bank: Option<Vec<u8>>,
|
||||
/// `FlagTutorial(n)` calls since the last drain (0-based, in range).
|
||||
pub(crate) tutorial_flag_requests: Vec<u32>,
|
||||
pub(crate) tutorial_clears: u32,
|
||||
pub(crate) tutorial_resets: u32,
|
||||
|
||||
/// The stance/shapeshift bar's form list (bar order) the app pushes — the
|
||||
/// `GetNumShapeshiftForms`/`GetShapeshiftFormInfo` family reads it ([`super::shapeshift`]).
|
||||
@@ -1508,13 +1537,16 @@ pub(crate) struct Model {
|
||||
/// the app's minimap geometry lives in; the app applies the 0582 seam scale on the way in,
|
||||
/// and getting that wrong is decision 1596's first root cause).
|
||||
pub(crate) minimap_ping_request: Option<(f32, f32)>,
|
||||
/// The live minimap ping's **normalized** offsets from the widget centre (fractions of the
|
||||
/// widget side, x right / y up — the `MINIMAP_PING` event's arg2/arg3 space), republished
|
||||
/// by the app every frame a ping is live and cleared when the ping ends. Behind
|
||||
/// `Minimap:GetPingPosition()`. There is exactly one ping, so it lives here rather than on
|
||||
/// each Minimap widget's [`KindState`](crate::widget::KindState) — one write, one read,
|
||||
/// no arena walk (decision 1596).
|
||||
pub(crate) minimap_ping: Option<(f32, f32)>,
|
||||
/// The minimap ping's **normalized** offsets from the widget centre (fractions of the widget
|
||||
/// side, x right / y up — the `MINIMAP_PING` event's arg2/arg3 space), republished by the app
|
||||
/// every frame from the stored world point against the player's live position. Behind
|
||||
/// `Minimap:GetPingPosition()`, which answers **two numbers always** — the reference
|
||||
/// recomputes them per call from a pair of statics nothing ever clears (wow-re
|
||||
/// `minimap-ping-law.md`), and the stock `Minimap_OnUpdate` multiplies the answer without a
|
||||
/// nil test for the whole of its 5 s timer (1974; 1596's nil answer stood until then). There
|
||||
/// is exactly one ping, so it lives here rather than on each Minimap widget's
|
||||
/// [`KindState`](crate::widget::KindState) — one write, one read, no arena walk.
|
||||
pub(crate) minimap_ping: (f32, f32),
|
||||
/// The realm this session is on, behind `GetRealmName()` (decision 1195). `""` until the app
|
||||
/// pushes one — the glue screen's own answer, and never `nil`, because the corpus idiom is
|
||||
/// `db[GetRealmName()] = …` at file scope and a nil index errors one call deeper.
|
||||
@@ -1796,7 +1828,23 @@ impl Model {
|
||||
battlefield_run_time_ms: 0,
|
||||
battlefield_score_requests: 0,
|
||||
battlefield_leave_requests: 0,
|
||||
battlefield_list: Default::default(),
|
||||
battlefield_selected: 0,
|
||||
battlefield_slots: Vec::new(),
|
||||
battlefield_instance_expiration_ms: 0,
|
||||
battlefield_join_requests: Vec::new(),
|
||||
battlefield_list_requests: Vec::new(),
|
||||
battlefield_positions: Vec::new(),
|
||||
battlefield_flag: None,
|
||||
battlefield_icon_scale: 1.0,
|
||||
battlefield_position_requests: 0,
|
||||
meeting_stone_cancels: 0,
|
||||
meeting_stone_area: 0,
|
||||
meeting_stone_status_text: None,
|
||||
tutorial_bank: None,
|
||||
tutorial_flag_requests: Vec::new(),
|
||||
tutorial_clears: 0,
|
||||
tutorial_resets: 0,
|
||||
shapeshift_forms: Vec::new(),
|
||||
shapeshift_casts: Vec::new(),
|
||||
pet_bar: super::pet::PetBarState::default(),
|
||||
@@ -1954,7 +2002,7 @@ impl Model {
|
||||
pending_events: Vec::new(),
|
||||
cursor_pos: (0.0, 0.0),
|
||||
minimap_ping_request: None,
|
||||
minimap_ping: None,
|
||||
minimap_ping: (0.0, 0.0),
|
||||
chat_sends: Vec::new(),
|
||||
addon_sends: Vec::new(),
|
||||
played_time_asks: 0,
|
||||
|
||||
@@ -16,18 +16,17 @@
|
||||
//! CSimpleFrame 0x778590
|
||||
//! └─ CSimpleModel 0x76f870 table 0x878948 (23) <Model> ← built here
|
||||
//! └─ CGCharacterModelBase 0x506260 table 0x84f1fc (3) <PlayerModel> ← built here
|
||||
//! ├─ DressUpModelFrame 0x5050d0 table 0x84f190 (3) <DressUpModel> ← not built
|
||||
//! └─ TabardModel 0x503bd0 table 0x84ee40 (10) <TabardModel> ← not built
|
||||
//! ├─ DressUpModelFrame 0x5050d0 table 0x84f190 (3) <DressUpModel> ← `dressup` (1969)
|
||||
//! └─ TabardModel 0x503bd0 table 0x84ee40 (10) <TabardModel> ← `tabard` (1977)
|
||||
//! ```
|
||||
//!
|
||||
//! Our `__index` dispatcher already walks a *slice* of registry keys per kind, so the chain is
|
||||
//! `&[REG_PLAYERMODEL_METHODS, REG_MODEL_METHODS]` and neither table duplicates the other. The
|
||||
//! direction is derived → base **only**: a plain `<Model>` does not acquire `SetUnit`.
|
||||
//!
|
||||
//! `DressUpModel` (`Undress`/`Dress`/`TryOn`) and `TabardModel` (10 tabard verbs) are deliberately
|
||||
//! **not built**: zero callers in the corpus, and our dress-up window already models the intents
|
||||
//! host-side (`super::dressup`), so wiring them is a design change to that subsystem rather than a
|
||||
//! missing verb. Named, not stubbed (decision 1134 §4).
|
||||
//! `DressUpModel` (`Undress`/`Dress`/`TryOn`) is `super::dressup` (1969) and `TabardModel` (the ten
|
||||
//! tabard verbs) `super::tabard` (1977) — each built the day its stock window came onto the chain,
|
||||
//! as 1134 §4 said they would be.
|
||||
//!
|
||||
//! ## Why this surface, in this order
|
||||
//!
|
||||
@@ -93,7 +92,11 @@ pub(super) const REG_PLAYERMODEL_METHODS: &str = "__benilla_playermodel_methods"
|
||||
/// Run `f` over a frame's Model state under one short write borrow. Errors if `this` is not a live
|
||||
/// Model (unreachable through the kind dispatcher, but the method table is a plain Lua value — a
|
||||
/// caller can fish it out and misapply it).
|
||||
fn with_model<T>(lua: &Lua, this: &Table, f: impl FnOnce(&mut ModelState) -> T) -> mlua::Result<T> {
|
||||
pub(super) fn with_model<T>(
|
||||
lua: &Lua,
|
||||
this: &Table,
|
||||
f: impl FnOnce(&mut ModelState) -> T,
|
||||
) -> mlua::Result<T> {
|
||||
let h = frame_handle_of(lua, this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let frame = model
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
use std::ffi::c_void;
|
||||
|
||||
use mlua::{LightUserData, Lua, Table, Value};
|
||||
use mlua::{LightUserData, Lua, ObjectLike, Table, Value};
|
||||
|
||||
use super::binding_abi::optional_string;
|
||||
use super::{Model, REG_FRAME_META, REG_FRAME_METHODS, REG_SCRIPTS, REG_WRAPPERS};
|
||||
@@ -235,6 +235,7 @@ fn frame_kind_from_str(s: &str) -> Option<FrameKind> {
|
||||
"MODEL" => FrameKind::Model,
|
||||
"PLAYERMODEL" => FrameKind::PlayerModel,
|
||||
"DRESSUPMODEL" => FrameKind::DressUpModel,
|
||||
"TABARDMODEL" => FrameKind::TabardModel,
|
||||
"MESSAGEFRAME" => FrameKind::MessageFrame,
|
||||
"SCROLLINGMESSAGEFRAME" => FrameKind::ScrollingMessageFrame,
|
||||
"COLORSELECT" => FrameKind::ColorSelect,
|
||||
@@ -318,6 +319,39 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SetupFullscreenScale(frame) — the registered binding `0x48c270` the stock WorldMapFrame.xml,
|
||||
// CinematicFrame.xml and UIOptionsFrame.xml call from OnShow (decision 1980; wow-re
|
||||
// `system/ui/scratch/setup-fullscreen-scale.md`, VERIFIED at the bytes): the frame's scale
|
||||
// becomes `min(0.75 · a, 1.0)` for the CONFIGURED aspect `a` — the `gxResolution` width over
|
||||
// height (the `widescreen` CVar's default of 1 selects it; at 0 the aspect is 4:3 and the
|
||||
// scale 1) — and nothing else is written: no anchor, size or position, and `UIParent`'s
|
||||
// scale never enters. It is the same `CSimpleFrame::SetScale` the Lua method calls. The
|
||||
// window's aspect is the configured one here (the UI-unit rect keeps the pixel ratio); NaN
|
||||
// takes the 1.0 leg as the reference's `fcomp` does. The three raises are the binding's own.
|
||||
lua.globals().set(
|
||||
"SetupFullscreenScale",
|
||||
lua.create_function(|lua, frame: Value| {
|
||||
let Value::Table(frame) = frame else {
|
||||
return Err(mlua::Error::runtime("Usage: SetupFullscreenScale(frame)"));
|
||||
};
|
||||
if decode_id(&frame).is_err() {
|
||||
return Err(mlua::Error::runtime(
|
||||
"SetupFullscreenScale(): Couldn't find 'this' in frame object",
|
||||
));
|
||||
}
|
||||
if frame_handle_of(lua, &frame).is_err() {
|
||||
return Err(mlua::Error::runtime(
|
||||
"SetupFullscreenScale(): Wrong object type, expected frame",
|
||||
));
|
||||
}
|
||||
let scale = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
fullscreen_scale(model.screen.width() / model.screen.height())
|
||||
};
|
||||
frame.call_method::<()>("SetScale", scale)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetCursorPosition() → x, y — the last cursor position the host fed (`mouse_move`/
|
||||
// `mouse_button`), UI units y-up like every other coordinate read. The real client scales by
|
||||
// the UI scale; ours is the constant 1 (`GetEffectiveScale`), so the ref's `/scale` dance is
|
||||
@@ -408,6 +442,13 @@ fn kind_method_registries(lua: &Lua, this: &Table) -> &'static [&'static str] {
|
||||
super::modelframe::REG_PLAYERMODEL_METHODS,
|
||||
super::modelframe::REG_MODEL_METHODS,
|
||||
],
|
||||
// Ten of its own (`0x84ee40`), then PlayerModel's three, then Model's 23 — the same
|
||||
// derived → base probe as its sibling (1977).
|
||||
Some(FrameKind::TabardModel) => &[
|
||||
super::tabard::REG_TABARDMODEL_METHODS,
|
||||
super::modelframe::REG_PLAYERMODEL_METHODS,
|
||||
super::modelframe::REG_MODEL_METHODS,
|
||||
],
|
||||
Some(FrameKind::Minimap) => &[super::minimap::REG_MINIMAP_METHODS],
|
||||
Some(FrameKind::Cooldown) => &[super::cooldown::REG_COOLDOWN_METHODS],
|
||||
Some(FrameKind::GameTooltip) => &[super::tooltip::REG_TOOLTIP_METHODS],
|
||||
@@ -431,7 +472,7 @@ fn kind_method_registries(lua: &Lua, this: &Table) -> &'static [&'static str] {
|
||||
/// `virtual="true"`, or of a shape that does not fit the kind asked for — is still a warning plus a
|
||||
/// working frame, because the registry lookup itself succeeded and that is the only thing the miss
|
||||
/// branch tests.
|
||||
fn create_frame(
|
||||
pub(super) fn create_frame(
|
||||
lua: &Lua,
|
||||
(kind, name, parent, inherits): (String, Option<Value>, Option<Value>, Option<Value>),
|
||||
) -> mlua::Result<Table> {
|
||||
@@ -576,3 +617,61 @@ fn install_frame_methods(lua: &Lua) -> mlua::Result<()> {
|
||||
lua.set_named_registry_value(REG_FRAME_METHODS, m)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `0x48c270`'s law: `g = 0.75 · aspect`, and the scale is `g` below 1.0, else 1.0 — NaN lands on
|
||||
/// the 1.0 leg (the `fcomp` compare fails every ordered test).
|
||||
pub(crate) fn fullscreen_scale(aspect: f32) -> f32 {
|
||||
let g = 0.75 * aspect;
|
||||
if g < 1.0 {
|
||||
g
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fullscreen_scale_tests {
|
||||
use super::fullscreen_scale;
|
||||
use crate::script::UiScript;
|
||||
|
||||
#[test]
|
||||
fn the_scale_is_three_quarters_of_the_aspect_capped_at_one() {
|
||||
assert_eq!(fullscreen_scale(4.0 / 3.0), 1.0);
|
||||
assert_eq!(fullscreen_scale(16.0 / 9.0), 1.0);
|
||||
assert!((fullscreen_scale(5.0 / 4.0) - 0.9375).abs() < 1e-6);
|
||||
assert_eq!(fullscreen_scale(f32::NAN), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_verb_scales_the_frame_and_raises_its_three_strings() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1280.0, 1024.0);
|
||||
s.run(r#"f = CreateFrame("Frame", "FS") SetupFullscreenScale(f)"#)
|
||||
.unwrap();
|
||||
assert!((s.eval::<f64>("return f:GetScale()").unwrap() - 0.9375).abs() < 1e-6);
|
||||
s.set_screen_size(1600.0, 900.0);
|
||||
s.run("SetupFullscreenScale(f)").unwrap();
|
||||
assert_eq!(s.eval::<f64>("return f:GetScale()").unwrap(), 1.0);
|
||||
for (call, needle) in [
|
||||
(
|
||||
"SetupFullscreenScale()",
|
||||
"Usage: SetupFullscreenScale(frame)",
|
||||
),
|
||||
(
|
||||
"SetupFullscreenScale(7)",
|
||||
"Usage: SetupFullscreenScale(frame)",
|
||||
),
|
||||
(
|
||||
"SetupFullscreenScale({})",
|
||||
"Couldn't find 'this' in frame object",
|
||||
),
|
||||
(
|
||||
"SetupFullscreenScale(f:CreateTexture())",
|
||||
"Wrong object type, expected frame",
|
||||
),
|
||||
] {
|
||||
let err = s.run(call).unwrap_err().to_string();
|
||||
assert!(err.contains(needle), "{call}: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,10 +131,11 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
FrameKind::ScrollFrame => &["ScrollFrame", "Frame", "Region"],
|
||||
FrameKind::Model => &["Model", "Frame", "Region"],
|
||||
// 4 deep: `PlayerModel` derives from `Model`, and `DressUpModel` (1969) and
|
||||
// `TabardModel` (unbuilt) derive from it in turn, for the roster's maximum depth of 5
|
||||
// `TabardModel` (1977) derive from it in turn, for the roster's maximum depth of 5
|
||||
// (wow-re `ui/scratch/widget-type-identity.md` §6).
|
||||
FrameKind::PlayerModel => &["PlayerModel", "Model", "Frame", "Region"],
|
||||
FrameKind::DressUpModel => &["DressUpModel", "PlayerModel", "Model", "Frame", "Region"],
|
||||
FrameKind::TabardModel => &["TabardModel", "PlayerModel", "Model", "Frame", "Region"],
|
||||
FrameKind::MessageFrame => &["MessageFrame", "Frame", "Region"],
|
||||
FrameKind::ScrollingMessageFrame => &["ScrollingMessageFrame", "Frame", "Region"],
|
||||
FrameKind::ColorSelect => &["ColorSelect", "Frame", "Region"],
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! The guild tabard designer's engine surface (decision 1977): the `TabardModel` kind's own method
|
||||
//! table (`0x84ee40`, ten verbs — filled from wow-re `system/ui/scratch/tabard-designer.md`) and
|
||||
//! the window's two globals.
|
||||
//!
|
||||
//! `GetTabardCreationCost()` is a **hard-coded constant**: `0x6d6de0` is `mov eax,0x186a0; ret` —
|
||||
//! 100 000 copper, ten gold, no `.data` cell and no server input (wow-re
|
||||
//! `system/ui/scratch/petition-charter-api.md`, the adjacent-family paragraph; VERIFIED there).
|
||||
|
||||
use mlua::Lua;
|
||||
|
||||
/// Registry key of the TabardModel method table — probed before PlayerModel's and Model's
|
||||
/// (`object.rs`'s kind chain).
|
||||
pub(super) const REG_TABARDMODEL_METHODS: &str = "__benilla_tabardmodel_methods";
|
||||
|
||||
/// `0x6d6de0`: the designer's price, in copper.
|
||||
pub const TABARD_CREATION_COST: u32 = 100_000;
|
||||
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let m = lua.create_table()?;
|
||||
lua.set_named_registry_value(REG_TABARDMODEL_METHODS, m)?;
|
||||
|
||||
let g = lua.globals();
|
||||
// `GetTabardCreationCost()` — 0 args, one number, the constant.
|
||||
g.set(
|
||||
"GetTabardCreationCost",
|
||||
lua.create_function(|_, ()| Ok(i64::from(TABARD_CREATION_COST)))?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::script::UiScript;
|
||||
|
||||
#[test]
|
||||
fn the_creation_cost_is_ten_gold_and_a_tabard_model_is_its_own_kind() {
|
||||
let s = UiScript::new().unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetTabardCreationCost()").unwrap(),
|
||||
100_000
|
||||
);
|
||||
s.run(r#"t = CreateFrame("TabardModel", "TM")"#).unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return t:GetObjectType()").unwrap(),
|
||||
"TabardModel"
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>("return t:IsObjectType('PlayerModel') and t:IsObjectType('Model')")
|
||||
.unwrap());
|
||||
assert!(
|
||||
s.eval::<bool>("return t.SetUnit ~= nil and t.SetRotation ~= nil")
|
||||
.unwrap(),
|
||||
"the inherited pane verbs"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -293,3 +293,27 @@ fn set_mask_texture_is_state_and_empty_restores_the_default() {
|
||||
// There is no getter in 1.12, and we do not invent one (decision 1189).
|
||||
assert!(s.eval::<bool>("return m.GetMaskTexture == nil").unwrap());
|
||||
}
|
||||
|
||||
/// `frame_effective_alpha` — the host-side read behind a frame whose pixels the app draws (the
|
||||
/// stock `MiniMapPing` model, 1974): `None` while hidden, or hidden through a parent; the
|
||||
/// effective alpha while effectively visible.
|
||||
#[test]
|
||||
fn frame_effective_alpha_reads_the_shown_frames_alpha() {
|
||||
let s = UiScript::new().unwrap();
|
||||
s.run(
|
||||
r#"p = CreateFrame("Frame", "PingParent")
|
||||
f = CreateFrame("Model", "PingModel", p) f:SetAlpha(0.5) f:Hide()"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.frame_effective_alpha("PingModel"), None, "hidden");
|
||||
assert_eq!(s.frame_effective_alpha("NoSuchFrame"), None);
|
||||
s.run("f:Show()").unwrap();
|
||||
let a = s.frame_effective_alpha("PingModel").expect("shown");
|
||||
assert!((a - 0.5).abs() < 1e-6, "the frame's alpha: {a}");
|
||||
s.run("p:Hide()").unwrap();
|
||||
assert_eq!(
|
||||
s.frame_effective_alpha("PingModel"),
|
||||
None,
|
||||
"hidden through the parent"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -163,6 +163,7 @@ fn worldmap_current_zone_and_player_feed() {
|
||||
1.5,
|
||||
None,
|
||||
vec![Some((0.1, 0.2)), None],
|
||||
Vec::new(),
|
||||
);
|
||||
s.run("SetMapToCurrentZone()").unwrap();
|
||||
assert_eq!(
|
||||
@@ -192,7 +193,7 @@ fn worldmap_current_zone_and_player_feed() {
|
||||
}
|
||||
|
||||
// No feed → the world sheet (never an error).
|
||||
s.set_world_map_feed(None, None, 0.0, None, Vec::new());
|
||||
s.set_world_map_feed(None, None, 0.0, None, Vec::new(), Vec::new());
|
||||
s.run("SetMapToCurrentZone()").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return GetCurrentMapContinent()").unwrap(), 0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! The tutorial system's four Lua verbs (decision 1976; wow-re
|
||||
//! `system/ui/scratch/tutorial-flags.md` §6): the registrar table at `.data 0x846b04` —
|
||||
//! `TutorialsEnabled`, `FlagTutorial`, `ClearTutorials`, `ResetTutorials`.
|
||||
//!
|
||||
//! The banks are the app's (they are wire state, and the fire-once trigger law lives beside the
|
||||
//! sites that trigger): the app pushes the **acknowledged** bank's bytes here when they change,
|
||||
//! which is all `TutorialsEnabled` reads, and drains the three writes.
|
||||
//!
|
||||
//! | verb | shape |
|
||||
//! |---|---|
|
||||
//! | `TutorialsEnabled()` | one value on both legs: the number `1` if ANY byte of the acknowledged bank is not `0xFF`, else `nil` — over the whole bank, not the 50 real ids; `nil` with no bank |
|
||||
//! | `FlagTutorial(n)` | `n` must pass `lua_isnumber` else `Usage: FlagTutorial("tutorial")` (the quoted usage is the reference's own, and misleading — a number is required); truncated, `n − 1` must lie in `0..50` else a SILENT no-op; zero values |
|
||||
//! | `ClearTutorials()` | zero values; every bit set in both banks and `CMSG_TUTORIAL_CLEAR` |
|
||||
//! | `ResetTutorials()` | zero values; every bit cleared in both banks and `CMSG_TUTORIAL_RESET` |
|
||||
|
||||
use mlua::{Lua, Value};
|
||||
|
||||
use super::binding_abi::{number_arg, predicate};
|
||||
use super::Model;
|
||||
|
||||
/// `FlagTutorial`'s clamp: `0 ≤ n − 1 < 0x32` — the fifty ids `GlobalStrings.lua` names.
|
||||
const TUTORIAL_IDS: i32 = 0x32;
|
||||
|
||||
impl super::UiScript {
|
||||
/// The acknowledged bank's bytes (`None` before `SMSG_TUTORIAL_FLAGS` lands) — what
|
||||
/// `TutorialsEnabled()` scans.
|
||||
pub fn set_tutorial_bank(&mut self, bank: Option<Vec<u8>>) {
|
||||
self.model_mut().tutorial_bank = bank;
|
||||
}
|
||||
|
||||
/// `FlagTutorial(n)` calls since the last drain: the **0-based** ids (`n − 1`), in range.
|
||||
pub fn take_tutorial_flag_requests(&mut self) -> Vec<u32> {
|
||||
std::mem::take(&mut self.model_mut().tutorial_flag_requests)
|
||||
}
|
||||
|
||||
/// `ClearTutorials()` calls since the last drain.
|
||||
pub fn take_tutorial_clears(&mut self) -> u32 {
|
||||
std::mem::take(&mut self.model_mut().tutorial_clears)
|
||||
}
|
||||
|
||||
/// `ResetTutorials()` calls since the last drain.
|
||||
pub fn take_tutorial_resets(&mut self) -> u32 {
|
||||
std::mem::take(&mut self.model_mut().tutorial_resets)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
|
||||
// `TutorialsEnabled()` (`0x4b5960`): scans `[0xb711ec] * 4` bytes of bank B for one ≠ 0xFF;
|
||||
// `1.0` or nil, one value; an unallocated bank skips the loop and answers nil.
|
||||
g.set(
|
||||
"TutorialsEnabled",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let enabled = model
|
||||
.tutorial_bank
|
||||
.as_ref()
|
||||
.is_some_and(|b| b.iter().any(|&byte| byte != 0xFF));
|
||||
Ok(predicate(enabled))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `FlagTutorial(n)` (`0x4b59b0`): `lua_isnumber` else the Usage raise; `__ftol`; `dec`; `js`
|
||||
// and `cmp 0x32 / jge` bail silently; else the acknowledge-and-send setter with the 0-based id.
|
||||
g.set(
|
||||
"FlagTutorial",
|
||||
lua.create_function(|lua, n: Value| {
|
||||
let n = number_arg(lua, n, "Usage: FlagTutorial(\"tutorial\")")?;
|
||||
let id = n.wrapping_sub(1);
|
||||
if !(0..TUTORIAL_IDS).contains(&id) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.tutorial_flag_requests.push(id as u32);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
g.set(
|
||||
"ClearTutorials",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.tutorial_clears += 1;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
g.set(
|
||||
"ResetTutorials",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.tutorial_resets += 1;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::script::UiScript;
|
||||
|
||||
/// `1` or nil, one value, over the WHOLE bank — and nil with none.
|
||||
#[test]
|
||||
fn tutorials_enabled_scans_the_whole_acknowledged_bank() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', TutorialsEnabled())")
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return TutorialsEnabled() == nil").unwrap(),
|
||||
"no bank"
|
||||
);
|
||||
s.set_tutorial_bank(Some(vec![0xFF; 32]));
|
||||
assert!(
|
||||
s.eval::<bool>("return TutorialsEnabled() == nil").unwrap(),
|
||||
"every bit acknowledged: disabled"
|
||||
);
|
||||
let mut bank = vec![0xFF; 32];
|
||||
bank[31] = 0x7F; // a bit past the fifty real ids
|
||||
s.set_tutorial_bank(Some(bank));
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return TutorialsEnabled()").unwrap(),
|
||||
1,
|
||||
"the scan covers the whole bank, not the 50 ids"
|
||||
);
|
||||
s.set_tutorial_bank(Some(vec![0; 32]));
|
||||
assert_eq!(s.eval::<i64>("return TutorialsEnabled()").unwrap(), 1);
|
||||
}
|
||||
|
||||
/// `FlagTutorial`: the number gate's raise, `n − 1`, the silent out-of-range legs, truncation.
|
||||
#[test]
|
||||
fn flag_tutorial_takes_a_one_based_number_and_bails_silently_off_the_fifty() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.run("FlagTutorial(1) FlagTutorial(50) FlagTutorial('42') FlagTutorial(7.9) FlagTutorial(0) FlagTutorial(51) FlagTutorial(-3)")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.take_tutorial_flag_requests(),
|
||||
vec![0, 49, 41, 6],
|
||||
"0-based, truncated toward zero, 0/51/−3 dropped"
|
||||
);
|
||||
assert!(s.take_tutorial_flag_requests().is_empty(), "drained");
|
||||
for call in ["FlagTutorial()", "FlagTutorial(nil)", "FlagTutorial({})"] {
|
||||
let err = s.run(call).unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("Usage: FlagTutorial(\"tutorial\")"),
|
||||
"{call}: {err}"
|
||||
);
|
||||
}
|
||||
s.run("ClearTutorials() ClearTutorials() ResetTutorials()")
|
||||
.unwrap();
|
||||
assert_eq!(s.take_tutorial_clears(), 2);
|
||||
assert_eq!(s.take_tutorial_resets(), 1);
|
||||
assert_eq!(s.take_tutorial_clears(), 0);
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,15 @@ pub enum QuadContent {
|
||||
/// The pane's global frame name (`$parent`-expanded), or `None` for an anonymous
|
||||
/// `CreateFrame("Model")` — pfUI's autocast shine is the corpus example of the latter.
|
||||
name: Option<String>,
|
||||
/// The `SetModel` path the pane holds (`None` for a unit pane or an empty one). Decision
|
||||
/// 1980: the world-map arrow is an anonymous `Model` child whose file is the minimap arrow
|
||||
/// (`crate::script::ARROW_MODEL`) — the one path the app draws as a sprite, so the seam
|
||||
/// carries it with the facing and the pane's `SetModelScale`.
|
||||
model: Option<String>,
|
||||
/// `SetFacing`'s radians (0 default).
|
||||
facing: f32,
|
||||
/// `SetModelScale`'s factor (1 default).
|
||||
model_scale: f32,
|
||||
},
|
||||
/// A `Texture` region: a BLP path *or* a solid/vertex color (or both — a tinted texture).
|
||||
Texture {
|
||||
|
||||
@@ -211,6 +211,11 @@ pub struct WorldMapState {
|
||||
/// `GetPlayerMapPosition("player")` for the CURRENT selection — projected app-side each frame
|
||||
/// (`map_proj`), `None`/(0,0) = off this map (the reference hides the blip).
|
||||
pub player_uv: Option<(f32, f32)>,
|
||||
/// The world map's arrow frame and the battlefield minimap's — the two per-session
|
||||
/// singletons `CreateWorldMapArrowFrame`/`CreateMiniWorldMapArrowFrame` build, by wrapper id
|
||||
/// (`worldmap_arrow`, 1980). `None` until the first create.
|
||||
pub arrow_world: Option<u32>,
|
||||
pub arrow_mini: Option<u32>,
|
||||
/// `GetPlayerMapPosition("party1".."party4")` for the CURRENT selection, in `party_slots`
|
||||
/// order — projected app-side through the same law as [`Self::player_uv`] (report B320).
|
||||
/// A slot is `None` when there is no member there, when we hold no position for them, or when
|
||||
@@ -219,6 +224,8 @@ pub struct WorldMapState {
|
||||
///
|
||||
/// Shorter than 4 whenever the party is: a missing index is a `None`.
|
||||
pub party_uv: Vec<Option<(f32, f32)>>,
|
||||
/// `raid1..raid40` in roster order — the stock map's raid arm (1980), fed like `party_uv`.
|
||||
pub raid_uv: Vec<Option<(f32, f32)>>,
|
||||
/// `GetPlayerFacing()` — wow orientation radians (0 = north, counterclockwise-positive). Our
|
||||
/// transcribed frame spins its arrow texture with it (`SetRotation`); the reference rotates
|
||||
/// an engine-rendered arrow model instead (0203 flags the stand-in).
|
||||
@@ -317,6 +324,7 @@ impl super::UiScript {
|
||||
player_facing: f32,
|
||||
corpse_uv: Option<(f32, f32)>,
|
||||
party_uv: Vec<Option<(f32, f32)>>,
|
||||
raid_uv: Vec<Option<(f32, f32)>>,
|
||||
) {
|
||||
let mut model = self.model_mut();
|
||||
model.worldmap.player_zone = player_zone;
|
||||
@@ -324,6 +332,7 @@ impl super::UiScript {
|
||||
model.worldmap.player_facing = player_facing;
|
||||
model.worldmap.corpse_uv = corpse_uv;
|
||||
model.worldmap.party_uv = party_uv;
|
||||
model.worldmap.raid_uv = raid_uv;
|
||||
}
|
||||
|
||||
/// The engine-owned selection `(continent, zone)` — the app reads it each frame to project
|
||||
@@ -491,22 +500,25 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
|
||||
// GetPlayerMapPosition(unit) → x, y in [0,1] map UV; (0,0) = not on this map (hide the
|
||||
// blip). `"player"` and `"party1".."party4"` resolve (report B320); `"raid1".."raid40"` do
|
||||
// not yet — the reference's raid arm draws a 25-frame pool this map has no children for, and
|
||||
// its party arm is the one a five-man actually takes.
|
||||
// blip). `"player"`, `"party1".."party4"` (report B320) and `"raid1".."raid40"` (1980, the
|
||||
// stock map's raid arm over its `WorldMapRaid1..40` pool) resolve; anything else is (0,0).
|
||||
g.set(
|
||||
"GetPlayerMapPosition",
|
||||
lua.create_function(|lua, unit: String| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let (x, y) = match unit.as_str() {
|
||||
"player" => model.worldmap.player_uv.unwrap_or((0.0, 0.0)),
|
||||
u => u
|
||||
.strip_prefix("party")
|
||||
let slot = |prefix: &str, list: &[Option<(f32, f32)>]| {
|
||||
unit.strip_prefix(prefix)
|
||||
.and_then(|n| n.parse::<usize>().ok())
|
||||
.filter(|n| *n >= 1)
|
||||
.and_then(|n| model.worldmap.party_uv.get(n - 1).copied().flatten())
|
||||
.unwrap_or((0.0, 0.0)),
|
||||
.and_then(|n| list.get(n - 1).copied().flatten())
|
||||
};
|
||||
let (x, y) = match unit.as_str() {
|
||||
"player" => model.worldmap.player_uv,
|
||||
u if u.starts_with("party") => slot("party", &model.worldmap.party_uv),
|
||||
u if u.starts_with("raid") => slot("raid", &model.worldmap.raid_uv),
|
||||
_ => None,
|
||||
}
|
||||
.unwrap_or((0.0, 0.0));
|
||||
Ok((f64::from(x), f64::from(y)))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
//! The world map's **arrow frames** (decision 1980; wow-re
|
||||
//! `system/ui/scratch/worldmap-arrow-and-positions.md` §2): the seven bindings the stock
|
||||
//! `WorldMapFrame.lua` and `Blizzard_BattlefieldMinimap.lua` call to put the player's arrow on
|
||||
//! the map.
|
||||
//!
|
||||
//! The reference keeps **two singletons per UI session** — the world map's and the battlefield
|
||||
//! minimap's — each a stock `Model` widget (the same class `CreateFrame("Model")` makes) born as a
|
||||
//! child of the frame the first `Create…` was given, loaded with `Interface\Minimap\MinimapArrow.mdx`,
|
||||
//! shown, and never freed: every later `Create…` is a no-op even with a different parent. Its
|
||||
//! rect is the model's bounding box (no authored size), its model is re-centred on that rect by
|
||||
//! every `Update…`, and its facing is the camera-tracked object's — the player's. `Position…` is
|
||||
//! literally `arrow:SetPoint(...)` with the generic binder's three error strings and two silent
|
||||
//! legs; `Show…` is `arrow:Show()`/`Hide()` under the never-raising boolean coercion.
|
||||
//!
|
||||
//! Here the singleton is an anonymous `Model` frame this module creates through the same factory
|
||||
//! `CreateFrame` uses, sized to the arrow's byte-measured footprint, and the app draws it: a
|
||||
//! `Model` frame whose file is the minimap arrow extracts as [`QuadContent::ModelPane`] with the
|
||||
//! path and the facing, and the app's model-pane arm paints the arrow sprite there
|
||||
//! (`ui_script/extract`).
|
||||
|
||||
use mlua::{Lua, MultiValue, ObjectLike, Table, Value};
|
||||
|
||||
use super::binding_abi::bool_or_default;
|
||||
use super::object::{create_frame, decode_id, frame_handle_of, frame_wrapper, point_from_str};
|
||||
use super::Model;
|
||||
|
||||
/// The arrow's model — `0x8453c0`, and NOT `Rotating-MinimapArrow`.
|
||||
pub const ARROW_MODEL: &str = "Interface\\Minimap\\MinimapArrow.mdx";
|
||||
|
||||
/// The arrow's footprint at the reference basis: `MinimapArrow.mdx`'s single quad at 1280 px per
|
||||
/// model unit — 33.6 px on the 1024×768 sheet, the same number the minimap's player arrow is
|
||||
/// drawn at (`minimap::blips::PLAYER_ARROW_QUAD_PX`). The world-map arrow's model scale is
|
||||
/// `G48 · 5/3` (`= 1.0` at 4:3, `G48 = 1/√(aspect² + 1)`) and the mini's `G48 · 10/9`, so both
|
||||
/// hold a constant apparent size as the window's shape changes.
|
||||
pub const ARROW_FOOTPRINT_PX: f32 = 33.6;
|
||||
|
||||
/// Which of the two singletons a binding addresses.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Arrow {
|
||||
World,
|
||||
Mini,
|
||||
}
|
||||
|
||||
impl Arrow {
|
||||
fn slot(self, model: &Model) -> Option<u32> {
|
||||
match self {
|
||||
Arrow::World => model.worldmap.arrow_world,
|
||||
Arrow::Mini => model.worldmap.arrow_mini,
|
||||
}
|
||||
}
|
||||
fn set_slot(self, model: &mut Model, id: u32) {
|
||||
match self {
|
||||
Arrow::World => model.worldmap.arrow_world = Some(id),
|
||||
Arrow::Mini => model.worldmap.arrow_mini = Some(id),
|
||||
}
|
||||
}
|
||||
/// The model-scale numerator: `5/3` for the world map's arrow, `10/9` for the mini's.
|
||||
fn scale_over_g48(self) -> f32 {
|
||||
match self {
|
||||
Arrow::World => 5.0 / 3.0,
|
||||
Arrow::Mini => 10.0 / 9.0,
|
||||
}
|
||||
}
|
||||
fn usage_create(self) -> &'static str {
|
||||
match self {
|
||||
Arrow::World => "Usage: CreateWorldMapArrowFrame(parent)",
|
||||
Arrow::Mini => "Usage: CreateMiniWorldMapArrowFrame(parent)",
|
||||
}
|
||||
}
|
||||
fn no_this(self) -> &'static str {
|
||||
match self {
|
||||
Arrow::World => "CreateWorldMapArrowFrame(): Couldn't find 'this' in parent object",
|
||||
Arrow::Mini => "CreateMiniWorldMapArrowFrame(): Couldn't find 'this' in parent object",
|
||||
}
|
||||
}
|
||||
fn not_a_frame(self) -> &'static str {
|
||||
match self {
|
||||
Arrow::World => "CreateWorldMapArrowFrame(): Wrong object type, expected frame",
|
||||
Arrow::Mini => "CreateMiniWorldMapArrowFrame(): Wrong object type, expected frame",
|
||||
}
|
||||
}
|
||||
fn usage_position(self) -> &'static str {
|
||||
match self {
|
||||
Arrow::World => {
|
||||
"Usage: PositionWorldMapArrowFrame(\"point\" \"frame\" [, relativePoint] [, offsetX, offsetY])"
|
||||
}
|
||||
Arrow::Mini => {
|
||||
"Usage: PositionMiniWorldMapArrowFrame(\"point\" \"frame\" [, relativePoint] [, offsetX, offsetY])"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `[0x832a48]` — the Y half-extent of the reference's layout space, `1/√(A² + 1)` for the
|
||||
/// screen's aspect `A`; `0.6` at 4:3.
|
||||
fn g48(model: &Model) -> f32 {
|
||||
let (w, h) = (model.screen.width(), model.screen.height());
|
||||
let a = if h > 0.0 { w / h } else { 4.0 / 3.0 };
|
||||
1.0 / (a * a + 1.0).sqrt()
|
||||
}
|
||||
|
||||
/// The arrow's model scale for this screen, and the footprint that scale gives it.
|
||||
fn arrow_scale(model: &Model, which: Arrow) -> f32 {
|
||||
g48(model) * which.scale_over_g48()
|
||||
}
|
||||
|
||||
/// `lua_isstring`: a string, or a number (which `lua_tostring` would print).
|
||||
fn is_string(v: &Value) -> bool {
|
||||
matches!(v, Value::String(_) | Value::Integer(_) | Value::Number(_))
|
||||
}
|
||||
|
||||
fn as_string(v: &Value) -> mlua::Result<String> {
|
||||
match v {
|
||||
Value::String(s) => Ok(s.to_str()?.to_string()),
|
||||
Value::Integer(i) => Ok(i.to_string()),
|
||||
Value::Number(n) => Ok(super::binding_abi::lua_number_text(*n)),
|
||||
_ => Ok(String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `lua_isnumber`: a number, or a string that parses as one.
|
||||
fn as_number(lua: &Lua, v: &Value) -> Option<f64> {
|
||||
lua.coerce_number(v.clone()).ok().flatten()
|
||||
}
|
||||
|
||||
fn create(lua: &Lua, which: Arrow, parent: Value) -> mlua::Result<()> {
|
||||
let Value::Table(t) = parent else {
|
||||
return Err(mlua::Error::runtime(which.usage_create()));
|
||||
};
|
||||
if decode_id(&t).is_err() {
|
||||
return Err(mlua::Error::runtime(which.no_this()));
|
||||
}
|
||||
if frame_handle_of(lua, &t).is_err() {
|
||||
return Err(mlua::Error::runtime(which.not_a_frame()));
|
||||
}
|
||||
{
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
if which.slot(&model).is_some() {
|
||||
return Ok(()); // the singleton exists: the parent is not even read (§2.1)
|
||||
}
|
||||
}
|
||||
let wrapper = create_frame(
|
||||
lua,
|
||||
("Model".to_string(), None, Some(Value::Table(t)), None),
|
||||
)?;
|
||||
let id = decode_id(&wrapper)?;
|
||||
let (scale, side) = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let scale = arrow_scale(&model, which);
|
||||
(scale, ARROW_FOOTPRINT_PX * scale)
|
||||
};
|
||||
// The widget's rect is the resident model's bounding box (§2.2); the footprint at this scale.
|
||||
wrapper.call_method::<()>("SetWidth", side)?;
|
||||
wrapper.call_method::<()>("SetHeight", side)?;
|
||||
super::modelframe::with_model(lua, &wrapper, |m| {
|
||||
m.path = Some(ARROW_MODEL.to_string());
|
||||
m.scale = scale;
|
||||
m.position = (side * 0.5, side * 0.5, 0.0);
|
||||
})?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
which.set_slot(&mut model, id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The singleton's wrapper, or `None` while it does not exist.
|
||||
fn arrow_wrapper(lua: &Lua, which: Arrow) -> mlua::Result<Option<Table>> {
|
||||
let id = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
which.slot(&model)
|
||||
};
|
||||
id.map(|id| frame_wrapper(lua, id)).transpose()
|
||||
}
|
||||
|
||||
/// `UpdateWorldMapArrowFrames()` (§2.3): for each existing arrow, re-centre the model on its
|
||||
/// rect and copy the camera-tracked object's facing — the player's, which the app pushes with
|
||||
/// the world-map feed — into its rotation about +Z. No player → neither facing moves.
|
||||
fn update(lua: &Lua) -> mlua::Result<()> {
|
||||
for which in [Arrow::World, Arrow::Mini] {
|
||||
let Some(wrapper) = arrow_wrapper(lua, which)? else {
|
||||
continue;
|
||||
};
|
||||
let (facing, tracked) = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
(
|
||||
model.worldmap.player_facing,
|
||||
model.worldmap.player_uv.is_some(),
|
||||
)
|
||||
};
|
||||
let w: f32 = wrapper.call_method("GetWidth", ())?;
|
||||
let h: f32 = wrapper.call_method("GetHeight", ())?;
|
||||
super::modelframe::with_model(lua, &wrapper, |m| {
|
||||
m.position = (w * 0.5, h * 0.5, 0.0);
|
||||
if tracked {
|
||||
m.facing = facing;
|
||||
}
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `Position…("point", "frame" [, relativePoint] [, offsetX, offsetY])` (§2.4): silent with no
|
||||
/// arrow; args 1 and 2 must be strings (a number is one) else the usage raise; the point through
|
||||
/// the same table `SetPoint` reads, else `Unknown frame point`; the frame by name — `$parent`
|
||||
/// expanded against the arrow's parent, then `_G` — else `Couldn't find frame named '%s'`, and
|
||||
/// the arrow itself `Error: %s is anchored to itself`; a non-string third argument means
|
||||
/// `relativePoint = point` and zero offsets with no error; offsets only when both are numbers.
|
||||
fn position(lua: &Lua, which: Arrow, args: [Value; 5]) -> mlua::Result<()> {
|
||||
let Some(arrow) = arrow_wrapper(lua, which)? else {
|
||||
return Ok(());
|
||||
};
|
||||
let [point, frame, rel, x, y] = args;
|
||||
if !is_string(&point) || !is_string(&frame) {
|
||||
return Err(mlua::Error::runtime(which.usage_position()));
|
||||
}
|
||||
let point_name = as_string(&point)?;
|
||||
if point_from_str(&point_name).is_none() {
|
||||
return Err(mlua::Error::runtime("Unknown frame point"));
|
||||
}
|
||||
let frame_name = as_string(&frame)?;
|
||||
let resolved = if let Some(rest) = frame_name.strip_prefix("$parent") {
|
||||
// The first NAMED ancestor of the arrow — its parent frame, or that frame's own parent.
|
||||
let parent_name: Option<String> = arrow
|
||||
.call_method::<Option<Table>>("GetParent", ())?
|
||||
.and_then(|p| {
|
||||
p.call_method::<Option<String>>("GetName", ())
|
||||
.ok()
|
||||
.flatten()
|
||||
});
|
||||
parent_name.map(|p| format!("{p}{rest}"))
|
||||
} else {
|
||||
Some(frame_name.clone())
|
||||
};
|
||||
let target: Option<Table> = resolved
|
||||
.as_deref()
|
||||
.and_then(|n| lua.globals().get::<Option<Table>>(n).ok().flatten())
|
||||
.filter(|t| frame_handle_of(lua, t).is_ok());
|
||||
let Some(target) = target else {
|
||||
return Err(mlua::Error::runtime(format!(
|
||||
"Couldn't find frame named '{}'",
|
||||
resolved.unwrap_or(frame_name)
|
||||
)));
|
||||
};
|
||||
if decode_id(&target)? == decode_id(&arrow)? {
|
||||
return Err(mlua::Error::runtime(format!(
|
||||
"Error: {} is anchored to itself",
|
||||
resolved.unwrap_or(frame_name)
|
||||
)));
|
||||
}
|
||||
let (rel_point, ox, oy) = if is_string(&rel) {
|
||||
let r = as_string(&rel)?;
|
||||
if point_from_str(&r).is_none() {
|
||||
return Err(mlua::Error::runtime("Unknown frame point"));
|
||||
}
|
||||
match (as_number(lua, &x), as_number(lua, &y)) {
|
||||
(Some(x), Some(y)) => (r, x, y),
|
||||
_ => (r, 0.0, 0.0),
|
||||
}
|
||||
} else {
|
||||
(point_name.clone(), 0.0, 0.0)
|
||||
};
|
||||
arrow.call_method::<()>(
|
||||
"SetPoint",
|
||||
(point_name, Value::Table(target), rel_point, ox, oy),
|
||||
)
|
||||
}
|
||||
|
||||
/// `Show…([shown])` (§2.5): silent with no arrow; the never-raising boolean coercion with a
|
||||
/// default of true (absent → show, `nil` → hide).
|
||||
fn show(lua: &Lua, which: Arrow, args: MultiValue) -> mlua::Result<()> {
|
||||
let Some(arrow) = arrow_wrapper(lua, which)? else {
|
||||
return Ok(());
|
||||
};
|
||||
// An ABSENT argument is not nil at the C API (`LUA_TNONE`, which the binding's nil test does
|
||||
// not match), so the bare call shows; an explicit nil hides; anything else is the boolean
|
||||
// coercion with its true default.
|
||||
let on = match args.front() {
|
||||
None => true,
|
||||
Some(Value::Nil) => false,
|
||||
Some(v) => bool_or_default(Some(v), true),
|
||||
};
|
||||
arrow.call_method::<()>(if on { "Show" } else { "Hide" }, ())
|
||||
}
|
||||
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
for (which, create_name, position_name, show_name) in [
|
||||
(
|
||||
Arrow::World,
|
||||
"CreateWorldMapArrowFrame",
|
||||
"PositionWorldMapArrowFrame",
|
||||
"ShowWorldMapArrowFrame",
|
||||
),
|
||||
(
|
||||
Arrow::Mini,
|
||||
"CreateMiniWorldMapArrowFrame",
|
||||
"PositionMiniWorldMapArrowFrame",
|
||||
"ShowMiniWorldMapArrowFrame",
|
||||
),
|
||||
] {
|
||||
g.set(
|
||||
create_name,
|
||||
lua.create_function(move |lua, parent: Value| create(lua, which, parent))?,
|
||||
)?;
|
||||
g.set(
|
||||
position_name,
|
||||
lua.create_function(
|
||||
move |lua, (p, f, r, x, y): (Value, Value, Value, Value, Value)| {
|
||||
position(lua, which, [p, f, r, x, y])
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
g.set(
|
||||
show_name,
|
||||
lua.create_function(move |lua, args: MultiValue| show(lua, which, args))?,
|
||||
)?;
|
||||
}
|
||||
g.set(
|
||||
"UpdateWorldMapArrowFrames",
|
||||
lua.create_function(|lua, ()| update(lua))?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::script::UiScript;
|
||||
|
||||
fn vm() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.run(
|
||||
r#"WM = CreateFrame("Frame", "WM") WM:SetSize(1024, 768) WM:SetPoint("CENTER")
|
||||
Detail = CreateFrame("Frame", "WorldMapDetailFrame", WM)
|
||||
Detail:SetSize(1002, 668) Detail:SetPoint("CENTER")"#,
|
||||
)
|
||||
.unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// The three raise strings, then the singleton: an anonymous Model child of the parent,
|
||||
/// loaded with the arrow, born shown, sized to its footprint; a second create a no-op.
|
||||
#[test]
|
||||
fn create_validates_its_parent_and_makes_one_arrow_per_session() {
|
||||
let s = vm();
|
||||
for (call, want) in [
|
||||
(
|
||||
"CreateWorldMapArrowFrame(5)",
|
||||
"Usage: CreateWorldMapArrowFrame(parent)",
|
||||
),
|
||||
(
|
||||
"CreateWorldMapArrowFrame({})",
|
||||
"CreateWorldMapArrowFrame(): Couldn't find 'this' in parent object",
|
||||
),
|
||||
(
|
||||
"CreateWorldMapArrowFrame(WM:CreateTexture())",
|
||||
"CreateWorldMapArrowFrame(): Wrong object type, expected frame",
|
||||
),
|
||||
] {
|
||||
let err = s.run(call).unwrap_err().to_string();
|
||||
assert!(err.contains(want), "{call}: {err}");
|
||||
}
|
||||
s.run("CreateWorldMapArrowFrame(WM)").unwrap();
|
||||
let before = s
|
||||
.eval::<i64>("return table.getn({WM:GetChildren()})")
|
||||
.unwrap();
|
||||
s.run("CreateWorldMapArrowFrame(Detail)").unwrap();
|
||||
let after = s
|
||||
.eval::<i64>("return table.getn({WM:GetChildren()})")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
before, after,
|
||||
"a second create is a no-op, whatever the parent"
|
||||
);
|
||||
let (kind, shown, w) = s
|
||||
.eval::<(String, bool, f32)>(
|
||||
"local a = ({WM:GetChildren()})[2] \
|
||||
return a:GetObjectType(), a:IsShown(), a:GetWidth()",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(kind, "Model");
|
||||
assert!(shown, "born shown");
|
||||
assert!(
|
||||
(w - ARROW_FOOTPRINT_PX).abs() < 1e-3,
|
||||
"the footprint at 4:3: {w}"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return select('#', CreateWorldMapArrowFrame(WM)) == 0")
|
||||
.unwrap(),
|
||||
"zero values"
|
||||
);
|
||||
}
|
||||
|
||||
/// `Position…` anchors the arrow like SetPoint, with the reference's three strings and its
|
||||
/// two silent legs; `Show…` follows the boolean coercion; `Update…` copies the facing.
|
||||
#[test]
|
||||
fn position_show_and_update_drive_the_arrow() {
|
||||
let mut s = vm();
|
||||
// No arrow yet: every one of the three is silent, whatever the arguments.
|
||||
s.run("PositionWorldMapArrowFrame(1, 2) ShowWorldMapArrowFrame('x') UpdateWorldMapArrowFrames()")
|
||||
.unwrap();
|
||||
s.run("CreateWorldMapArrowFrame(WM)").unwrap();
|
||||
s.run("arrow = ({WM:GetChildren()})[2]").unwrap();
|
||||
for (call, want) in [
|
||||
(
|
||||
"PositionWorldMapArrowFrame(nil, 'WorldMapDetailFrame')",
|
||||
"Usage: PositionWorldMapArrowFrame",
|
||||
),
|
||||
(
|
||||
"PositionWorldMapArrowFrame('NOWHERE', 'WorldMapDetailFrame')",
|
||||
"Unknown frame point",
|
||||
),
|
||||
(
|
||||
"PositionWorldMapArrowFrame('CENTER', 'NoSuchFrame')",
|
||||
"Couldn't find frame named 'NoSuchFrame'",
|
||||
),
|
||||
(
|
||||
"PositionWorldMapArrowFrame('CENTER', 'WorldMapDetailFrame', 'BOGUS', 1, 2)",
|
||||
"Unknown frame point",
|
||||
),
|
||||
] {
|
||||
let err = s.run(call).unwrap_err().to_string();
|
||||
assert!(err.contains(want), "{call}: {err}");
|
||||
}
|
||||
s.run("PositionWorldMapArrowFrame('CENTER', 'WorldMapDetailFrame', 'TOPLEFT', 100, -50)")
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let (cx, cy, dl, dt) = s
|
||||
.eval::<(f32, f32, f32, f32)>(
|
||||
"local cx, cy = arrow:GetCenter() \
|
||||
return cx, cy, WorldMapDetailFrame:GetLeft(), WorldMapDetailFrame:GetTop()",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
(cx - (dl + 100.0)).abs() < 0.01 && (cy - (dt - 50.0)).abs() < 0.01,
|
||||
"{cx} {cy}"
|
||||
);
|
||||
// A non-string third argument: relativePoint = point, offsets ignored, no error.
|
||||
s.run("PositionWorldMapArrowFrame('TOPLEFT', 'WorldMapDetailFrame', nil, 100, -50)")
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let (l, t, dl, dt) = s
|
||||
.eval::<(f32, f32, f32, f32)>(
|
||||
"return arrow:GetLeft(), arrow:GetTop(), WorldMapDetailFrame:GetLeft(), WorldMapDetailFrame:GetTop()",
|
||||
)
|
||||
.unwrap();
|
||||
assert!((l - dl).abs() < 0.01 && (t - dt).abs() < 0.01, "{l} {t}");
|
||||
// The name resolver expands $parent against the arrow's parent.
|
||||
s.run("PositionWorldMapArrowFrame('CENTER', '$parent')")
|
||||
.unwrap();
|
||||
|
||||
s.run("ShowWorldMapArrowFrame(nil)").unwrap();
|
||||
assert!(
|
||||
!s.eval::<bool>("return arrow:IsShown()").unwrap(),
|
||||
"nil hides"
|
||||
);
|
||||
s.run("ShowWorldMapArrowFrame()").unwrap();
|
||||
assert!(
|
||||
s.eval::<bool>("return arrow:IsShown()").unwrap(),
|
||||
"absent shows"
|
||||
);
|
||||
s.run("ShowWorldMapArrowFrame('off')").unwrap();
|
||||
assert!(!s.eval::<bool>("return arrow:IsShown()").unwrap());
|
||||
s.run("ShowWorldMapArrowFrame(1)").unwrap();
|
||||
assert!(s.eval::<bool>("return arrow:IsShown()").unwrap());
|
||||
|
||||
s.set_world_map_feed(None, Some((0.5, 0.5)), 1.25, None, Vec::new(), Vec::new());
|
||||
s.run("UpdateWorldMapArrowFrames()").unwrap();
|
||||
let facing = s.eval::<f32>("return arrow:GetFacing()").unwrap();
|
||||
assert!(
|
||||
(facing - 1.25).abs() < 1e-6,
|
||||
"the tracked object's facing: {facing}"
|
||||
);
|
||||
s.set_world_map_feed(None, None, 2.0, None, Vec::new(), Vec::new());
|
||||
s.run("UpdateWorldMapArrowFrames()").unwrap();
|
||||
assert!(
|
||||
(s.eval::<f32>("return arrow:GetFacing()").unwrap() - 1.25).abs() < 1e-6,
|
||||
"no tracked object: the facing does not move"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,12 @@ pub enum FrameKind {
|
||||
/// substitution set is a look composed against the player's live equipment, which the VM
|
||||
/// never holds (decisions 1060, 1969; wow-re `ui/scratch/dressup-model-equipment.md` §0).
|
||||
DressUpModel,
|
||||
/// `TabardModel` (`0x503bd0`) — the guild tabard designer's pane: `CGCharacterModelBase`'s
|
||||
/// other subclass (the dress-up model's sibling), with ten verbs of its own in table
|
||||
/// `0x84ee40` — the emblem/colour cycling, the two emblem-texture setters, the save gate and
|
||||
/// the save. Its state is [`KindState::Model`] like its siblings; the design it carries lives
|
||||
/// app-side (`script::tabard`, decision 1977).
|
||||
TabardModel,
|
||||
/// `CSimpleMessageFrame` — the non-scrolling message frame (`UIErrorsFrame`'s class, and the
|
||||
/// one `CreateFrame("MessageFrame")` makes). Its behaviour (the display lines, the per-line
|
||||
/// fade, `insertMode`) is modeled in [`KindState::Message`]. Sibling of
|
||||
|
||||
@@ -705,9 +705,10 @@ impl WidgetArena {
|
||||
// Both model panes share one state: `CGCharacterModelBase` EXTENDS `CSimpleModel`,
|
||||
// so a `<PlayerModel>` carries every `CSimpleModel` member and adds only the
|
||||
// turn-animation pair we do not model (`ModelState`'s doc).
|
||||
FrameKind::Model | FrameKind::PlayerModel | FrameKind::DressUpModel => {
|
||||
KindState::Model(kinds::ModelState::default())
|
||||
}
|
||||
FrameKind::Model
|
||||
| FrameKind::PlayerModel
|
||||
| FrameKind::DressUpModel
|
||||
| FrameKind::TabardModel => KindState::Model(kinds::ModelState::default()),
|
||||
FrameKind::Minimap => KindState::Minimap(kinds::MinimapState::default()),
|
||||
FrameKind::Cooldown => KindState::Cooldown(kinds::CooldownState::default()),
|
||||
FrameKind::GameTooltip => KindState::Tooltip(kinds::TooltipState::default()),
|
||||
|
||||
Reference in New Issue
Block a user