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:
Sam
2026-09-04 20:59:53 +07:00
co-authored by Claude Fable 5.1
parent c965b5425d
commit e7ac8b94f3
89 changed files with 6164 additions and 2107 deletions
@@ -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>
+36 -11
View File
@@ -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 {
+1 -1
View File
@@ -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,
+9
View File
@@ -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.
+6 -2
View File
@@ -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):
+11 -4
View File
@@ -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);
}
}
+95 -118
View File
@@ -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"
+37
View File
@@ -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`
+20 -2
View File
@@ -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),
+14 -2
View File
@@ -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,
});
}
+22
View File
@@ -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),
+12 -4
View File
@@ -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();
+21 -2
View File
@@ -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
+30 -5
View File
@@ -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 {
+705
View File
@@ -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.
///
+3 -3
View File
@@ -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
+501
View File
@@ -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>,
+15 -7
View File
@@ -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,
+11 -1
View File
@@ -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,
+479 -29
View File
@@ -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();
+21
View File
@@ -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) => (
+11
View File
@@ -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 });
}
}
+10 -1
View File
@@ -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
+7
View File
@@ -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.
+60 -4
View File
@@ -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());
}
}
+145 -21
View File
@@ -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");
}
+6
View File
@@ -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.)
///
+7
View File
@@ -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 } => {
+73 -40
View File
@@ -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.
+1 -1
View File
@@ -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;
+155 -3
View File
@@ -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"
);
}
}
+21
View File
@@ -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)? })
}
+6 -2
View File
@@ -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,
+58 -2
View File
@@ -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(),
+33 -1
View File
@@ -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]);
}
}
+16 -1
View File
@@ -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, &[])
}
}
+28 -1
View File
@@ -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, &region, 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);
+2
View File
@@ -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
+70
View File
@@ -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();
+4 -1
View File
@@ -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,
};
+11 -10
View File
@@ -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)?;
+29 -1
View File
@@ -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",
+56 -8
View File
@@ -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,
+10 -7
View File
@@ -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
+101 -2
View File
@@ -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"],
+56
View File
@@ -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);
}
+160
View File
@@ -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);
}
}
+9
View File
@@ -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 {
+21 -9
View File
@@ -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
+4 -3
View File
@@ -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()),