landmarks and the Plaguelands towers on the map, no ultrawide crop
- the world map's landmarks: city icons and the Eastern Plaguelands towers, from AreaPOI.dbc through the reference's own three gates (level flags, exploration, world state) — the tower rows are one per (tower, owner, state), so the server flipping a world state swaps which icon exists; and the always-up PvP readout ("Towers Controlled: n") over a new WorldStateUI.dbc loader, the init clearing the table on a zone change as the client does
- the character-select scene holds its authored 4:3 view box: the diagonal-FOV relation was fed the display's aspect, so every extra pixel of width came out of height (1.55× zoom at 21:9, head and feet gone) — now width grows and height never shrinks; the reference does the same and is wrong on the same panel
- a trail particle is stored in world space and drawn raw: Sprint's trail was welded to the chest bone (turn and it swings, run and it wobbles) because the trail was faked by subtracting translation inside a bone-local store; the correction machinery deleted itself, gravity is plain world down, and the emitters' live attach frame is retired
- the spellbook can shrink: `SMSG_REMOVED_SPELL` had no handler, so a talent wipe took the points back and left every rank drawn; and the chat dock's hover driver rides a frame the dock cannot hide, so selecting the Combat Log no longer freezes the tab alphas
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -269,8 +269,9 @@
|
||||
|
||||
-- FCF_SelectDockFrame: show the selected frame, hide the other; selected tab full alpha,
|
||||
-- unselected half (both still scaled by the reveal fade below). Stops the selected tab's
|
||||
-- whisper flash (ref FCF_SelectDockFrame → UIFrameFlashRemoveFrame). Tab getglobals are
|
||||
-- guarded: window 2's tab is deliberately unbuilt (see the note at the tab section).
|
||||
-- whisper flash (ref FCF_SelectDockFrame → UIFrameFlashRemoveFrame). The tab getglobal
|
||||
-- stays guarded: both dock tabs exist (1575), but the guard also covers ChatFrame3..7,
|
||||
-- which have no tabs at all and which the error above is what actually keeps out.
|
||||
function BenillaFCF_SelectDock(id)
|
||||
BenillaFCF.selected = id
|
||||
for i = 1, 2 do
|
||||
@@ -687,21 +688,34 @@
|
||||
|
||||
<!-- ── The dock: ChatFrame1 (General) + ChatFrame2 (Combat Log) sharing its rect ─────── -->
|
||||
<!--
|
||||
ChatFrame1: 430×120 at BOTTOMLEFT (32, 85) — the managed-position base
|
||||
(UIPARENT_MANAGED_FRAME_POSITIONS["ChatFrame1"]: baseY 85 + the bottom-stack offsets,
|
||||
applied by UIParent_ManageFramePositions per decision 0272); displayDuration 120 /
|
||||
maxLines 128 (ref ChatFrameTemplate). The background stretches -2/+2 x and +3/-6 y past
|
||||
the frame (ref $parentBackground anchors), the 8 border slices ring it (the ref draws
|
||||
them through its resize buttons' textures; benilla authors them as plain textures — the
|
||||
hover LOOK without the resize mechanics). Everything starts alpha 0; BenillaFCF fades.
|
||||
**One declaration, two windows.** The dock's two windows are the SAME widget seated
|
||||
differently, and saying that twice is how three straight rounds of bugs got in: 1575 found
|
||||
the Combat Log with no tab, 1579 found it with no rect and no border art, and 1588 found it
|
||||
with no `OnUpdate` and no scroll column — each time because window 2 was a HAND-MIRRORED
|
||||
partial copy of window 1 and the mirroring missed a part. The reference does not mirror: it
|
||||
declares `FloatingChatFrameTemplate` `virtual="true"` and every `ChatFrameN` inherits it
|
||||
(FloatingChatFrame.xml l.211, l.685-832). So do we, and the class of bug goes with it —
|
||||
a part that exists for one docked window now cannot fail to exist for the other.
|
||||
|
||||
Per-instance is only what genuinely differs: the seat (ChatFrame1 is the managed-position
|
||||
base — `UIPARENT_MANAGED_FRAME_POSITIONS["ChatFrame1"]`, baseY 85 + the bottom-stack
|
||||
offsets, decision 0272 — and ChatFrame2 takes its rect from ChatFrame1's, which is what
|
||||
being docked MEANS and why it must never carry a managed row of its own, 1579), the id,
|
||||
and which of the two ships shown.
|
||||
|
||||
The chrome the template carries: displayDuration 120 / maxLines 128 (ref ChatFrameTemplate);
|
||||
a background stretched -2/+2 x and +3/-6 y past the frame (ref $parentBackground anchors)
|
||||
and the 8 border slices ringing it (the ref draws those through its resize buttons'
|
||||
textures — benilla authors them as plain textures, the hover LOOK without the resize
|
||||
mechanics); the scroll column; and the per-frame scripts. Everything starts alpha 0;
|
||||
BenillaFCF fades it.
|
||||
|
||||
**`EXECUTE_CHAT_LINE` is gated to window 1 on purpose** — see the OnLoad below. It is the
|
||||
one script here that is genuinely ChatFrame1's rather than the dock's, so the template
|
||||
carries the gate rather than the registration.
|
||||
-->
|
||||
<ScrollingMessageFrame name="ChatFrame1" frameStrata="BACKGROUND" displayDuration="120.0" maxLines="128" id="1">
|
||||
<Size><AbsDimension x="430" y="120"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT">
|
||||
<Offset><AbsDimension x="32" y="85"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<ScrollingMessageFrame name="BenillaDockedChatFrameTemplate" virtual="true"
|
||||
frameStrata="BACKGROUND" displayDuration="120.0" maxLines="128">
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
<Texture name="$parentBackground" file="Interface\ChatFrame\ChatFrameBackground" alpha="0">
|
||||
@@ -851,13 +865,12 @@
|
||||
SubmitChatInput stands in for ChatEdit_SendText: the grammar behind it is Rust
|
||||
(0881), which is the only part of that chain we replace. -->
|
||||
<OnLoad>
|
||||
this:RegisterEvent("EXECUTE_CHAT_LINE")
|
||||
if this:GetID() == 1 then this:RegisterEvent("EXECUTE_CHAT_LINE") end
|
||||
</OnLoad>
|
||||
<OnEvent>
|
||||
if event == "EXECUTE_CHAT_LINE" then SubmitChatInput(arg1) end
|
||||
</OnEvent>
|
||||
<OnUpdate>
|
||||
FCF_OnUpdate(arg1)
|
||||
ChatFrame_OnUpdate(arg1)
|
||||
</OnUpdate>
|
||||
<OnMouseWheel>
|
||||
@@ -874,92 +887,51 @@
|
||||
<FontString inherits="ChatFontNormal" justifyH="LEFT"/>
|
||||
</ScrollingMessageFrame>
|
||||
|
||||
<!-- ChatFrame2 "Combat Log": docked — shares ChatFrame1's rect (ref FCF_DockUpdate anchors),
|
||||
hidden until its tab selects it. Its message-group wiring (the chat-cache combat set)
|
||||
lands with the router (0288 P4); until then it renders what it's given: nothing. -->
|
||||
<ScrollingMessageFrame name="ChatFrame2" frameStrata="BACKGROUND" displayDuration="120.0" maxLines="128" hidden="true" id="2">
|
||||
<ScrollingMessageFrame name="ChatFrame1" inherits="BenillaDockedChatFrameTemplate" id="1">
|
||||
<Size><AbsDimension x="430" y="120"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT">
|
||||
<Offset><AbsDimension x="32" y="85"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
</ScrollingMessageFrame>
|
||||
|
||||
<!-- ChatFrame2 "Combat Log": docked — it takes ChatFrame1's rect from these two anchors and
|
||||
nothing else, ships hidden, and its tab (ChatFrame2Tab, below) selects it. Its message
|
||||
groups are the chat-cache combat set, registered app-side (`ui_chat::frames`, 1571). -->
|
||||
<ScrollingMessageFrame name="ChatFrame2" inherits="BenillaDockedChatFrameTemplate" hidden="true" id="2">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="ChatFrame1" relativePoint="TOPLEFT"/>
|
||||
<Anchor point="BOTTOMRIGHT" relativeTo="ChatFrame1" relativePoint="BOTTOMRIGHT"/>
|
||||
</Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
<Texture name="$parentBackground" file="Interface\ChatFrame\ChatFrameBackground" alpha="0">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT"><Offset><AbsDimension x="-2" y="3"/></Offset></Anchor>
|
||||
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="2" y="-6"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
</Texture>
|
||||
<!-- The ChatFrameBorder ring (ref ChatFrameBorder slices; texcoords quoted from
|
||||
the ref resize buttons). Corners 16×16, edges stretched between them. -->
|
||||
<Texture name="$parentBorderTopLeft" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
|
||||
<Size><AbsDimension x="16" y="16"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentBackground"><Offset><AbsDimension x="-2" y="2"/></Offset></Anchor></Anchors>
|
||||
<TexCoords left="0" right="0.25" top="0" bottom="0.125"/>
|
||||
</Texture>
|
||||
<Texture name="$parentBorderTopRight" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
|
||||
<Size><AbsDimension x="16" y="16"/></Size>
|
||||
<Anchors><Anchor point="TOPRIGHT" relativeTo="$parentBackground"><Offset><AbsDimension x="2" y="2"/></Offset></Anchor></Anchors>
|
||||
<TexCoords left="0.75" right="1.0" top="0" bottom="0.125"/>
|
||||
</Texture>
|
||||
<Texture name="$parentBorderBottomLeft" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
|
||||
<Size><AbsDimension x="16" y="16"/></Size>
|
||||
<Anchors><Anchor point="BOTTOMLEFT" relativeTo="$parentBackground"><Offset><AbsDimension x="-2" y="-3"/></Offset></Anchor></Anchors>
|
||||
<TexCoords left="0" right="0.25" top="0.7265625" bottom="0.8515625"/>
|
||||
</Texture>
|
||||
<Texture name="$parentBorderBottomRight" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
|
||||
<Size><AbsDimension x="16" y="16"/></Size>
|
||||
<Anchors><Anchor point="BOTTOMRIGHT" relativeTo="$parentBackground"><Offset><AbsDimension x="2" y="-3"/></Offset></Anchor></Anchors>
|
||||
<TexCoords left="0.75" right="1.0" top="0.7265625" bottom="0.8515625"/>
|
||||
</Texture>
|
||||
<Texture name="$parentBorderTop" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
|
||||
<Size><AbsDimension x="16" y="16"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="LEFT" relativeTo="$parentBorderTopLeft" relativePoint="RIGHT"/>
|
||||
<Anchor point="RIGHT" relativeTo="$parentBorderTopRight" relativePoint="LEFT"/>
|
||||
</Anchors>
|
||||
<TexCoords left="0.25" right="0.75" top="0" bottom="0.125"/>
|
||||
</Texture>
|
||||
<Texture name="$parentBorderBottom" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
|
||||
<Size><AbsDimension x="16" y="16"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="LEFT" relativeTo="$parentBorderBottomLeft" relativePoint="RIGHT"/>
|
||||
<Anchor point="RIGHT" relativeTo="$parentBorderBottomRight" relativePoint="LEFT"/>
|
||||
</Anchors>
|
||||
<TexCoords left="0.25" right="0.75" top="0.7265625" bottom="0.8515625"/>
|
||||
</Texture>
|
||||
<Texture name="$parentBorderLeft" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
|
||||
<Size><AbsDimension x="16" y="16"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOP" relativeTo="$parentBorderTopLeft" relativePoint="BOTTOM"/>
|
||||
<Anchor point="BOTTOM" relativeTo="$parentBorderBottomLeft" relativePoint="TOP"/>
|
||||
</Anchors>
|
||||
<TexCoords left="0" right="0.25" top="0.125" bottom="0.7265625"/>
|
||||
</Texture>
|
||||
<Texture name="$parentBorderRight" file="Interface\ChatFrame\ChatFrameBorder" alpha="0">
|
||||
<Size><AbsDimension x="16" y="16"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOP" relativeTo="$parentBorderTopRight" relativePoint="BOTTOM"/>
|
||||
<Anchor point="BOTTOM" relativeTo="$parentBorderBottomRight" relativePoint="TOP"/>
|
||||
</Anchors>
|
||||
<TexCoords left="0.75" right="1.0" top="0.125" bottom="0.7265625"/>
|
||||
</Texture>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Scripts>
|
||||
<OnMouseWheel>
|
||||
if arg1 > 0 then this:ScrollUp() else this:ScrollDown() end
|
||||
</OnMouseWheel>
|
||||
<OnClick>
|
||||
BenillaChatFrame_DismissCursor(arg1)
|
||||
</OnClick>
|
||||
<OnHyperlinkClick>
|
||||
SetItemRef(arg1, arg2, arg3)
|
||||
</OnHyperlinkClick>
|
||||
</Scripts>
|
||||
<FontString inherits="ChatFontNormal" justifyH="LEFT"/>
|
||||
</ScrollingMessageFrame>
|
||||
|
||||
<!-- The dock's DRIVER. `FCF_OnUpdate` is dock-GLOBAL — one `reveal`, one selected tab, both
|
||||
windows' tabs and textures — and it used to ride ChatFrame1's own OnUpdate. Selecting the
|
||||
Combat Log hides ChatFrame1, a hidden frame gets no OnUpdate, and so the whole dock froze
|
||||
the instant it was used: the tab alphas stuck showing General as selected (what the
|
||||
director saw), the hover fade stuck at whatever it was, the tab-width settle stopped, and
|
||||
the whisper flash stopped blinking. A driver must not be one of the things it drives.
|
||||
|
||||
So it rides a frame the dock cannot hide. Mounting it on BOTH windows instead would work
|
||||
only while exactly one of them is ever visible — an invariant the dock happens to hold
|
||||
today and that a second visible window would turn into a double-speed fade. The reference
|
||||
offers no shape to copy here: 1.12.1 ships `FCF_OnUpdate` DEFINED and never referenced
|
||||
(0 hits across all of FrameXML), because vanilla's tabs do not hover-fade at all — the
|
||||
reveal is benilla's (0288), so its mounting point is ours to get right.
|
||||
|
||||
`ChatFrame_OnUpdate` stays on the windows, where it belongs: it reads `this` and blinks
|
||||
that frame's own bottom button. -->
|
||||
<Frame name="BenillaChatDockDriver" parent="UIParent">
|
||||
<Size><AbsDimension x="1" y="1"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"/></Anchors>
|
||||
<Scripts>
|
||||
<OnUpdate>
|
||||
FCF_OnUpdate(arg1)
|
||||
</OnUpdate>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
|
||||
<!-- ── ChatFrame3..ChatFrame7: the five undocked windows ────────────────────────────────
|
||||
The reference declares seven ScrollingMessageFrames (FloatingChatFrame.xml l.822-832,
|
||||
`inherits="FloatingChatFrameTemplate"`), of which 3..7 ship HIDDEN and UNDOCKED — the
|
||||
@@ -991,10 +963,14 @@
|
||||
nothing reads them while these frames stay hidden and undocked. Outfitter.lua:3099 is
|
||||
the one addon that would touch a tab — `local vTab = getglobal("ChatFrame"..i.."Tab")`
|
||||
then `vTab:GetText()`, unguarded — but only INSIDE `if vChatFrame:IsVisible() or
|
||||
vChatFrame.isDocked`, so a hidden, `isDocked`-less window never reaches it. That is also
|
||||
precisely why `isDocked` is not set on ChatFrame2 either: our Combat Log tab is
|
||||
deliberately unbuilt (see the tab section below), and marking window 2 docked — which
|
||||
the reference does — would walk Outfitter straight into a nil tab.
|
||||
vChatFrame.isDocked`, so a hidden, `isDocked`-less window never reaches it.
|
||||
|
||||
`isDocked` is not set on ChatFrame2 either, and the reason has CHANGED: it used to be
|
||||
that window 2 had no tab for Outfitter to read, so claiming dockedness would have walked
|
||||
it into a nil. 1575 built the tab, so that hazard is gone — what is left is that
|
||||
`isDocked` belongs to a dock MODEL (dock/undock, FCF_DockUpdate's re-anchoring) that
|
||||
0288 §2 holds out. Setting the flag would advertise a model we do not implement; our two
|
||||
windows are docked by their anchors, which is the part that matters.
|
||||
|
||||
No background/border art: the ref's own defaults put every one of these at window alpha
|
||||
0 (COLOR's fourth byte), and BenillaFCF only fades the dock's two. A shown ChatFrame5
|
||||
|
||||
@@ -20,11 +20,9 @@
|
||||
art spun by SetRotation(GetPlayerFacing()) — the reference renders an engine arrow model;
|
||||
0203 flags the stand-in).
|
||||
|
||||
OUT (later slices, per 0203): the AreaPOI.dbc landmarks (the POI pool IS here — the guard's
|
||||
directions marker fills it today, and the DBC rows join through the same GetNumMapLandmarks);
|
||||
raid/battlefield/ping children (the PARTY blips landed with report B320 — the raid pool
|
||||
waits on `GetPlayerMapPosition` answering a `raid` token); the magnifying-glass zoom-out
|
||||
shortcut (redundant with the button); SetupFullscreenScale (the map renders at its native
|
||||
OUT (later slices, per 0203): the raid/battlefield/ping children (the PARTY blips landed with
|
||||
report B320 — the raid pool waits on `GetPlayerMapPosition` answering a `raid` token); 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); WorldMapTooltip (only unit blips used it).
|
||||
|
||||
Deviations from the reference, deliberate: BlackoutWorld fills via anchors instead of the
|
||||
@@ -144,9 +142,9 @@
|
||||
-- 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. Today the host reports exactly one kind, the guard-directions marker
|
||||
-- (SMSG_GOSSIP_POI); the AreaPOI.dbc landmarks are 0203's deferred slice and arrive
|
||||
-- through this same list.
|
||||
-- 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
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
<Ui>
|
||||
<!--
|
||||
The always-up world-state readout — report B190's second half: the alliance/horde progress
|
||||
line an outdoor-PvP zone or a battleground shows at the top of the screen. In Eastern
|
||||
Plaguelands that is the two tower counters ("Towers Controlled: 3") plus the capture-point
|
||||
progress; in Warsong Gulch the flag captures; in Arathi Basin bases and resources.
|
||||
|
||||
The SPEC is the reference's two C bindings, byte-carved (wow-re
|
||||
system/ui/scratch/worldstate-ui-law.md, 0x4c5a40 / 0x4c5a70) and re-expressed here:
|
||||
|
||||
GetNumWorldStateUI() -> how many rows the current scope admits
|
||||
GetWorldStateUIInfo(i) -> TEN values, in this order:
|
||||
uiState, text, icon, dynamicIcon,
|
||||
tooltip, dynamicTooltip, extendedUI,
|
||||
extUIState1, extUIState2, extUIState3
|
||||
|
||||
Note what that return is NOT: there is no uiType and no hidden in 1.12 — those are a later
|
||||
expansion of the API, and reading this list as if they were there shifts every value by two.
|
||||
`text` arrives already macro-expanded (the host runs the reference's %<id>w expander);
|
||||
every other string is its DBC column verbatim, and never nil — an empty column is "".
|
||||
|
||||
Which rows exist at all is the host's (crates/benilla-app/src/world_state_ui.rs), including
|
||||
the gate that makes this frame look broken if you don't know about it: the world-PvP rows
|
||||
(Eastern Plaguelands, Silithus) are Type 1, which the reference admits only while the player
|
||||
has joined the zone-dependent DEFENSE chat channel. Leave LocalDefense and the readout goes
|
||||
away while the map icons stay.
|
||||
|
||||
`uiState` picks which of the two icons a row draws: the reference's dynamic pair is a state
|
||||
that toggles (Warsong Gulch's "the enemy flag has been picked up"), so a row with a
|
||||
dynamicIcon shows it while its state is live and the static icon otherwise. A row with no
|
||||
StateVariable of its own answers uiState = 1, which is why the plain counters draw their
|
||||
static icon and never blink.
|
||||
|
||||
OUT, named: the CAPTUREPOINT extended UI — the sliding bar over
|
||||
Interface\WorldStateFrame\WorldState-CaptureBar that the one Eastern Plaguelands progress
|
||||
row drives. Its two states come through the binding already (extUIState1/2 carry the
|
||||
alliance/horde fractions), so it is a widget to add, not a seam to change; the row itself
|
||||
renders as its text line until then, so the progress number is on screen either way. Also
|
||||
out: the battleground scoreboard (the Type 2 rows), which the reference builds from the
|
||||
same DBC through a DIFFERENT handler and a different pair of bindings whose bail leg is not
|
||||
even the same shape — GetNumBattlefieldStats/GetBattlefieldStatInfo, a separate slice.
|
||||
-->
|
||||
<Script>
|
||||
<![CDATA[
|
||||
NUM_ALWAYS_UP_ROWS = 0;
|
||||
|
||||
-- Vertical pitch between rows, and the icon's box. Ours, not measured from the reference:
|
||||
-- this frame is being written rather than transcribed, and the numbers below are what make
|
||||
-- a two- or three-row readout sit legibly under the top edge.
|
||||
WORLD_STATE_ROW_HEIGHT = 22;
|
||||
WORLD_STATE_ICON_SIZE = 20;
|
||||
|
||||
-- Rebuild the readout from the host's list. Cheap enough to run whole on every fire: the
|
||||
-- list is at most a handful of rows, and the events are a zone's worth apart.
|
||||
function WorldStateAlwaysUpFrame_Update()
|
||||
local numUI = GetNumWorldStateUI();
|
||||
|
||||
-- Grow the row pool on demand and never shrink it, the way every pooled list here
|
||||
-- does: the tail from a busier zone is parked hidden rather than destroyed.
|
||||
if ( numUI > NUM_ALWAYS_UP_ROWS ) then
|
||||
for i = NUM_ALWAYS_UP_ROWS + 1, numUI do
|
||||
local row = CreateFrame("Frame", "WorldStateAlwaysUpFrame" .. i,
|
||||
WorldStateAlwaysUpFrame, "WorldStateAlwaysUpTemplate");
|
||||
row:SetPoint("TOP", WorldStateAlwaysUpFrame, "TOP", 0,
|
||||
-(i - 1) * WORLD_STATE_ROW_HEIGHT);
|
||||
end
|
||||
NUM_ALWAYS_UP_ROWS = numUI;
|
||||
end
|
||||
|
||||
for i = 1, NUM_ALWAYS_UP_ROWS do
|
||||
local row = getglobal("WorldStateAlwaysUpFrame" .. i);
|
||||
if ( i > numUI ) then
|
||||
row:Hide();
|
||||
else
|
||||
local uiState, text, icon, dynamicIcon, tooltip, dynamicTooltip = GetWorldStateUIInfo(i);
|
||||
local iconTexture = getglobal(row:GetName() .. "Icon");
|
||||
local textString = getglobal(row:GetName() .. "Text");
|
||||
|
||||
-- The dynamic icon replaces the static one while the row's own state is live.
|
||||
-- A row with neither draws no icon at all and the text takes the whole width;
|
||||
-- the reference's own data has several such rows (the progress line).
|
||||
local shown = icon;
|
||||
row.tooltip = tooltip;
|
||||
if ( dynamicIcon ~= "" and uiState ~= 0 ) then
|
||||
shown = dynamicIcon;
|
||||
if ( dynamicTooltip ~= "" ) then
|
||||
row.tooltip = dynamicTooltip;
|
||||
end
|
||||
end
|
||||
-- ClearAllPoints first: SetPoint ADDS an anchor, and the template already
|
||||
-- seated the text against the icon.
|
||||
textString:ClearAllPoints();
|
||||
if ( shown == "" ) then
|
||||
iconTexture:Hide();
|
||||
textString:SetPoint("LEFT", row, "LEFT", 0, 0);
|
||||
else
|
||||
iconTexture:SetTexture(shown);
|
||||
iconTexture:Show();
|
||||
textString:SetPoint("LEFT", iconTexture, "RIGHT", 4, 0);
|
||||
end
|
||||
textString:SetText(text);
|
||||
row:Show();
|
||||
end
|
||||
end
|
||||
|
||||
-- The container is only as tall as the rows it shows, and disappears entirely when
|
||||
-- there are none — this frame has no chrome of its own to leave behind.
|
||||
if ( numUI > 0 ) then
|
||||
WorldStateAlwaysUpFrame:SetHeight(numUI * WORLD_STATE_ROW_HEIGHT);
|
||||
WorldStateAlwaysUpFrame:Show();
|
||||
else
|
||||
WorldStateAlwaysUpFrame:Hide();
|
||||
end
|
||||
end
|
||||
|
||||
function WorldStateAlwaysUpFrame_OnEvent()
|
||||
WorldStateAlwaysUpFrame_Update();
|
||||
end
|
||||
]]>
|
||||
</Script>
|
||||
|
||||
<!-- One row: an icon and its line of text, with the row's tooltip on hover. Grown on demand
|
||||
into WorldStateAlwaysUpFrame by the update above. -->
|
||||
<Frame name="WorldStateAlwaysUpTemplate" virtual="true" enableMouse="true">
|
||||
<Size><AbsDimension x="220" y="20"/></Size>
|
||||
<Layers>
|
||||
<Layer level="ARTWORK">
|
||||
<Texture name="$parentIcon">
|
||||
<Size><AbsDimension x="20" y="20"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="LEFT"/>
|
||||
</Anchors>
|
||||
</Texture>
|
||||
<FontString name="$parentText" inherits="GameFontNormalSmall" justifyH="LEFT">
|
||||
<Anchors>
|
||||
<Anchor point="LEFT" relativeTo="$parentIcon" relativePoint="RIGHT">
|
||||
<Offset><AbsDimension x="4" y="0"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
</FontString>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Scripts>
|
||||
<OnEnter>
|
||||
if ( this.tooltip and this.tooltip ~= "" ) then
|
||||
GameTooltip:SetOwner(this, "ANCHOR_BOTTOMLEFT");
|
||||
GameTooltip:SetText(this.tooltip);
|
||||
GameTooltip:Show();
|
||||
end
|
||||
</OnEnter>
|
||||
<OnLeave>
|
||||
GameTooltip:Hide();
|
||||
</OnLeave>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
|
||||
<!-- The container. Top-centre of the screen, which is where an outdoor-PvP zone's counters and
|
||||
a battleground's score belong: the minimap owns the top-right and the readout must not
|
||||
fight it. Hidden until a scope admits a row. -->
|
||||
<Frame name="WorldStateAlwaysUpFrame" parent="UIParent" hidden="true">
|
||||
<Size><AbsDimension x="220" y="20"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOP" relativeTo="UIParent" relativePoint="TOP">
|
||||
<Offset><AbsDimension x="0" y="-15"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<Scripts>
|
||||
<OnLoad>
|
||||
-- UPDATE_WORLD_STATES is the reference's own event for this list (0x20e). The
|
||||
-- entering-world fire is what paints the readout on a login that lands inside a
|
||||
-- scope the server has already sent states for.
|
||||
this:RegisterEvent("UPDATE_WORLD_STATES");
|
||||
this:RegisterEvent("PLAYER_ENTERING_WORLD");
|
||||
</OnLoad>
|
||||
<OnEvent>
|
||||
WorldStateAlwaysUpFrame_OnEvent();
|
||||
</OnEvent>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
</Ui>
|
||||
@@ -210,6 +210,11 @@ CastingBar.xml
|
||||
# MIRROR_TIMER_* events `crate::ui_mirror` fires. Needs only Fonts (GameFontHighlight)
|
||||
# before it; kept beside the cast bar, whose border art it shares.
|
||||
MirrorTimer.xml
|
||||
# The always-up world-state readout (decision 1586, report B190): the tower/flag/base counters an
|
||||
# outdoor-PvP zone or a battleground shows at top-centre, driven by the UPDATE_WORLD_STATES event
|
||||
# `crate::world_state_ui` fires. Needs Fonts (GameFontNormalSmall) and GameTooltip; sits beside the
|
||||
# mirror timers, which share its top-centre lane.
|
||||
WorldStateFrame.xml
|
||||
# The player buff/debuff bar (decisions 0255/0257 — the player aura arc): the HUD row of
|
||||
# aura icons under the minimap, driven by the UnitAura feed (`crate::ui_aura`). Needs Fonts
|
||||
# (its FontStrings) and ActionBar's `BENILLA_FALLBACK_ICON` global (both already loaded).
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//! The shared `AreaPOI.dbc` catalog — the world's named points of interest, and the **one** copy
|
||||
//! of that table in the process.
|
||||
//!
|
||||
//! It used to live inside `MinimapAssets`, because the minimap's nearest-3 landmark blips
|
||||
//! (decision 0203 phase 3) were its only reader. The world map is the second (decision 1586: the
|
||||
//! `0x4a67a0` landmark pass — city icons, the "Under Attack" indicators, the Eastern Plaguelands
|
||||
//! towers), and two owners of one DBC is how the two surfaces drift apart on what a POI *is*. So
|
||||
//! it is hoisted here, beside [`crate::area::AreaTableRes`], on 0287's precedent — the same move,
|
||||
//! for the same reason, on the sibling table.
|
||||
//!
|
||||
//! Loaded once at Startup; absent if the DBC failed to read, which every consumer takes as
|
||||
//! `Option` and goes quiet about (no landmark blips, no map icons, everything else intact).
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
|
||||
use benilla_formats::AreaPoiCatalog;
|
||||
|
||||
/// The shared `AreaPOI.dbc` catalog, in file order (see [`AreaPoiCatalog`]).
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct AreaPoiRes(pub(crate) AreaPoiCatalog);
|
||||
|
||||
/// Startup: load the shared POI catalog off the patch chain.
|
||||
fn load_area_poi(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
|
||||
let Some(assets) = assets else { return };
|
||||
let loaded = {
|
||||
let mut chain = assets.chain.lock_recover();
|
||||
benilla_formats::load_area_poi_catalog(&mut chain)
|
||||
};
|
||||
match loaded {
|
||||
Ok(cat) => {
|
||||
info!("area_poi: {} rows in the shared AreaPOI catalog", cat.len());
|
||||
commands.insert_resource(AreaPoiRes(cat));
|
||||
}
|
||||
Err(e) => warn!("area_poi: AreaPOI.dbc failed — no map/minimap landmarks: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct AreaPoiPlugin;
|
||||
|
||||
impl Plugin for AreaPoiPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Startup, load_area_poi.after(AssetSet::Open));
|
||||
}
|
||||
}
|
||||
@@ -206,10 +206,7 @@ pub(crate) fn drive_fx_view(
|
||||
true, // the fixture plants at a world point — ground quads decal onto the terrain
|
||||
// The fixture previews kit effects, which are attached models — but it hangs on
|
||||
// nothing (there is no host model in a preview), so its pool keeps the drain.
|
||||
EffectHost {
|
||||
attached: true,
|
||||
parent: None,
|
||||
},
|
||||
EffectHost { parent: None },
|
||||
// `WOW_FX_HOLD=1` previews a PERSISTENT instance, so it must show the persistent
|
||||
// lifecycle — birth then `Hold` — or the instrument would report a freeze the game
|
||||
// does not have. Without it the fixture previews a one-shot, which runs its birth once
|
||||
|
||||
@@ -786,7 +786,6 @@ pub(super) fn attach_entity_visuals(
|
||||
particles::EmitterFrames {
|
||||
owner: Some(owner),
|
||||
// A unit's OWN model is not an attached model (`[model+0x17c]` = 0).
|
||||
attach: None,
|
||||
// The cloud anchors at the unit; bones compose births only.
|
||||
anchor: Some(entity),
|
||||
// The unit's model going away IS this emitter's model going away
|
||||
|
||||
@@ -641,7 +641,6 @@ fn spawn_slot(
|
||||
benilla_world::particles::EmitterFrames {
|
||||
owner: Some(owner),
|
||||
// A held item is an attached model — the flame fans with the swing.
|
||||
attach: Some(root),
|
||||
// The cloud anchors at the MODEL; the bone composes births only.
|
||||
anchor: Some(root),
|
||||
// The item model is destroyed when the item is replaced or unequipped, and the
|
||||
|
||||
@@ -296,10 +296,7 @@ pub(super) fn attach_item_glows(
|
||||
// is chained to that item root and through it to the wearer: the glow fades in
|
||||
// with the body instead of blazing over a character that has not appeared yet,
|
||||
// and it dies with the weapon instead of hanging in the air behind it (0833).
|
||||
EffectHost {
|
||||
attached: true,
|
||||
parent: Some(root),
|
||||
},
|
||||
EffectHost { parent: Some(root) },
|
||||
// An `ItemVisuals` glow is armed by a different leg than `PlaySpellVisualKit`
|
||||
// (0805) and carries no kit stage — the plain single-clip arm, as before.
|
||||
None,
|
||||
|
||||
@@ -158,11 +158,6 @@ fn fx_part_material(
|
||||
/// alpha source and no owner (decision 0833).
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(crate) struct EffectHost {
|
||||
/// Is this an **attached** model in the client's sense (`[model+0x17c] ≠ 0`)? It sets the
|
||||
/// emitters' attach frame `A` — the birth orientation a kit effect fans by (wow-re
|
||||
/// `part-kit-effect-attach-orient.md`, decision 0395). It does **not** decide ride-vs-trail:
|
||||
/// that is the emitter's own file flag `0x10`, on every host class alike (1578).
|
||||
pub attached: bool,
|
||||
/// The model instance this one is **chained to** ([`benilla_world::model_fade::ParentModel`]): the
|
||||
/// unit a kit effect is hung on, the item root a weapon glow rides. `None` for a model that
|
||||
/// belongs to no other — a missile, the fixture preview — which is also what keeps the 0202
|
||||
@@ -210,7 +205,6 @@ pub(crate) fn attach_effect_visuals(
|
||||
let Some(parts) = dm.parts.as_ref() else {
|
||||
return false; // still loading — attach on a later pass
|
||||
};
|
||||
let attach = host.attached.then_some(root);
|
||||
// Chain this instance onto the model it hangs from, so its effects compose through the
|
||||
// parent's computed alpha the way `0x714000` does — a weapon's glow through the item, the
|
||||
// item through its wearer (decision 0833).
|
||||
@@ -422,9 +416,9 @@ pub(crate) fn attach_effect_visuals(
|
||||
Transform::IDENTITY,
|
||||
particles::EmitterFrames {
|
||||
owner: Some(owner),
|
||||
attach,
|
||||
// The cloud anchors at the instance root — the emitter's bone only composes births
|
||||
// (the food sparkle's bone orbits; the risen stars must not swirl with it).
|
||||
// The cloud SORTS at the instance root — the emitter's bone only composes births.
|
||||
// Since 1585 that is all this is: a world-mode store is frozen at birth, so
|
||||
// neither this root nor the bone can move a particle that already exists.
|
||||
anchor: Some(root),
|
||||
// Free with the model when this instance belongs to one, drain when it stands
|
||||
// alone (the impacting missile's trail — 0202's case). See `on_owner_loss` above.
|
||||
@@ -933,10 +927,7 @@ pub(super) fn attach_spell_fx(
|
||||
// A kit instance on a unit is an attached model, chained to that unit: it fades
|
||||
// with the body it is cast on, and it is freed with it (0833) — where before, a
|
||||
// gear change that tore the unit's visual down left its cloud in the air.
|
||||
EffectHost {
|
||||
attached: true,
|
||||
parent: Some(unit),
|
||||
},
|
||||
EffectHost { parent: Some(unit) },
|
||||
// A kit effect IS a `CEffect`: it runs the stage's animation lifecycle.
|
||||
Some(inst.stage),
|
||||
&mut wow_materials,
|
||||
|
||||
@@ -350,7 +350,6 @@ pub(super) fn spawn_wmo_gameobject_props(
|
||||
placement,
|
||||
particles::EmitterFrames {
|
||||
owner: Some(owner),
|
||||
attach: None, // a placed prop is never an attached model
|
||||
// The cloud anchors at the PROP: the boat carries the risen flame (the
|
||||
// reference re-anchors every cloud to the emitter's live position),
|
||||
// while an animated bone still never drags it.
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
|
||||
pub mod addon_harness;
|
||||
mod area;
|
||||
mod area_poi;
|
||||
mod area_trigger;
|
||||
#[cfg(feature = "dev")]
|
||||
mod asset_churn;
|
||||
@@ -157,6 +158,7 @@ mod ui_world_map;
|
||||
mod video;
|
||||
mod vplates;
|
||||
mod world_state;
|
||||
mod world_state_ui;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use blob_shadow::BlobShadowPlugin;
|
||||
@@ -537,6 +539,8 @@ pub fn run(build: BuildId) -> AppExit {
|
||||
// The shared AreaTable catalog + the ZONE_CHANGED event family / zone-text host globals
|
||||
// behind GetZoneText & co. (the zone-entry splash arc, decision 0287).
|
||||
.add_plugins(area::AreaPlugin)
|
||||
.add_plugins(area_poi::AreaPoiPlugin)
|
||||
.add_plugins(world_state_ui::WorldStateUiPlugin)
|
||||
// The `AreaTrigger.dbc` volumes + the per-frame containment check that reports walking into
|
||||
// one (`CMSG_AREATRIGGER`) — the client's whole part in portals, instance entrances and
|
||||
// explore objectives; the server owns what each trigger means.
|
||||
|
||||
@@ -272,6 +272,7 @@ pub(super) type BlipInputs<'w, 's> = (
|
||||
Res<'w, GameObjectTemplates>,
|
||||
Option<Res<'w, crate::go_templates::Locks>>,
|
||||
Res<'w, crate::poi_marker::PoiMarker>,
|
||||
Option<Res<'w, crate::area_poi::AreaPoiRes>>,
|
||||
);
|
||||
|
||||
/// Every streamed object the tracking classifier considers (our own avatar excluded — the
|
||||
|
||||
@@ -42,7 +42,7 @@ use bevy::prelude::*;
|
||||
use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
|
||||
use benilla_assets::minimap_grid::group_axis_grid;
|
||||
use benilla_assets::WmoModel;
|
||||
use benilla_formats::{tile_to_world, world_to_tile, AreaPoiCatalog, MinimapTranslate};
|
||||
use benilla_formats::{tile_to_world, world_to_tile, MinimapTranslate};
|
||||
|
||||
use interior::{interior_group_selection, wmo_minimap_stem};
|
||||
|
||||
@@ -253,9 +253,6 @@ struct MinimapAssets {
|
||||
/// The unit-blip atlas (`Interface\Minimap\ObjectIcons`, five 32-px dot cells) — the
|
||||
/// quest-giver dots.
|
||||
object_icons: Option<Handle<Image>>,
|
||||
/// The `AreaPOI.dbc` catalog the landmark selection draws from; `None` = the DBC failed
|
||||
/// to load (no landmark blips, everything else intact).
|
||||
pois: Option<AreaPoiCatalog>,
|
||||
/// `SpellShapeshiftForm.dbc` — the tracking dots' creature-type override (a cat-form
|
||||
/// druid is a Beast; decision 0564). `None` = no override (unshifted resolution only).
|
||||
forms: Option<HashMap<u32, benilla_formats::ShapeshiftForm>>,
|
||||
@@ -298,16 +295,6 @@ fn setup_minimap(
|
||||
}
|
||||
let object_icons =
|
||||
assets.sprite_texture("Interface\\Minimap\\ObjectIcons", &mut images);
|
||||
let pois = {
|
||||
let mut chain = assets.chain.lock_recover();
|
||||
match benilla_formats::load_area_poi_catalog(&mut chain) {
|
||||
Ok(cat) => Some(cat),
|
||||
Err(e) => {
|
||||
warn!("minimap: AreaPOI.dbc failed — no landmark blips: {e:#}");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
if mask.is_none() {
|
||||
warn!("minimap: MinimapMask.blp missing — the map will draw square");
|
||||
}
|
||||
@@ -328,7 +315,6 @@ fn setup_minimap(
|
||||
poi,
|
||||
rim_arrows,
|
||||
object_icons,
|
||||
pois,
|
||||
forms,
|
||||
});
|
||||
}
|
||||
@@ -423,6 +409,7 @@ fn emit_minimap(
|
||||
go_templates,
|
||||
locks,
|
||||
poi_marker,
|
||||
pois,
|
||||
) = blip_inputs;
|
||||
// Hover resets every frame; the blip pass below re-establishes it while the map draws.
|
||||
*blip_hover = blips::MinimapBlipHover::None;
|
||||
@@ -720,7 +707,7 @@ fn emit_minimap(
|
||||
.iter()
|
||||
.filter(|k| assets.rim_arrows.get(**k).is_some())
|
||||
.count(),
|
||||
assets.pois.as_ref().map(|c| c.len()).unwrap_or(0),
|
||||
pois.as_ref().map(|c| c.0.len()).unwrap_or(0),
|
||||
map.0,
|
||||
tracking.creatures,
|
||||
tracking.resources,
|
||||
@@ -764,7 +751,7 @@ fn emit_minimap(
|
||||
if assets.rim_arrows.any() {
|
||||
blips::emit_landmarks(
|
||||
ctx,
|
||||
assets.pois.as_ref(),
|
||||
pois.as_ref().map(|p| &p.0),
|
||||
poi_marker.on_map(map.0),
|
||||
map.0,
|
||||
&assets.rim_arrows,
|
||||
|
||||
@@ -51,9 +51,9 @@ use quests::{
|
||||
};
|
||||
use spells::{
|
||||
action_buttons, aura_duration, cancel_auto_repeat, cast_result, channel_start, channel_update,
|
||||
clear_cooldown, cooldown_cheat, cooldown_event, item_cooldown, learned_spell, spell_book,
|
||||
spell_chain_targets, spell_cooldowns, spell_delayed, spell_failed_other, spell_go, spell_start,
|
||||
superceded_spell,
|
||||
clear_cooldown, cooldown_cheat, cooldown_event, item_cooldown, learned_spell, removed_spell,
|
||||
spell_book, spell_chain_targets, spell_cooldowns, spell_delayed, spell_failed_other, spell_go,
|
||||
spell_start, superceded_spell,
|
||||
};
|
||||
|
||||
/// Which unit's cooldown store a wire cooldown packet addresses (decision 0982).
|
||||
@@ -820,6 +820,7 @@ pub(super) fn apply_net_updates(
|
||||
} => spell_book(spell_ids, cooldowns, &mut ui_actions.0, &mut ui_actions.10),
|
||||
SessionEvent::ActionButtons { buttons } => action_buttons(buttons, &mut ui_actions.0),
|
||||
SessionEvent::SpellLearned { spell_id } => learned_spell(spell_id, &mut ui_actions.0),
|
||||
SessionEvent::SpellRemoved { spell_id } => removed_spell(spell_id, &mut ui_actions.0),
|
||||
SessionEvent::SpellSuperceded {
|
||||
old_spell_id,
|
||||
new_spell_id,
|
||||
|
||||
@@ -57,6 +57,25 @@ pub(super) fn learned_spell(spell_id: u32, actions: &mut PlayerActions) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A spell taken back out of the book (`SMSG_REMOVED_SPELL`, decision 1584) — the inverse of
|
||||
/// [`learned_spell`] above, and the packet a **talent wipe** arrives as: vmangos's `ResetTalents`
|
||||
/// walks every talent of the class and calls `RemoveSpell` on every rank, each of which tails into
|
||||
/// `Player::SendSpellRemoved`. Until this arm existed all of them were dropped, so the respec's
|
||||
/// only visible effect was the points coming back — the talent window went on drawing the ranks it
|
||||
/// had, because [`crate::ui_talent`] derives rank from exactly this set.
|
||||
///
|
||||
/// The insert's mirror image, and deliberately no more than that: the spellbook, talent and pet
|
||||
/// feeds all diff `spells` and fire their own refresh events, and
|
||||
/// [`crate::ui_action::LearnedAbilities`] re-derives off the same change (the reference's own
|
||||
/// unlearn write site, `0x4b2c50`). What happens to a **bar button** still pointing at the removed
|
||||
/// spell is a separate law this arm deliberately does not invent — see 1584's scope note.
|
||||
pub(super) fn removed_spell(spell_id: u32, actions: &mut PlayerActions) {
|
||||
debug!("net: removed spell {spell_id}");
|
||||
if actions.spells.remove(&spell_id) {
|
||||
actions.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// A rank-up (`SMSG_SUPERCEDED_SPELL`): the new rank replaces the old **in the book** (decision
|
||||
/// 0237). The server sends no fresh `SMSG_ACTION_BUTTONS` for this (VERIFIED vmangos
|
||||
/// `Player::learnSpell` — the `supercededOld` path touches only the spell store), so the bar has to
|
||||
@@ -1439,4 +1458,22 @@ mod tests {
|
||||
"the cancel acks the server (CMSG_CANCEL_AUTO_REPEAT_SPELL, the 0x6ea0c6 send)"
|
||||
);
|
||||
}
|
||||
|
||||
/// `SMSG_REMOVED_SPELL` shrinks the book and dirties the feeds (decision 1584) — the packet a
|
||||
/// talent wipe arrives as, one per rank of every talent. The dirty flag is a real EDGE, not a
|
||||
/// blanket set: a wipe sends removals for ranks the character never learned too (vmangos walks
|
||||
/// the whole class tree), and a repaint per no-op would be a repaint per talent in the game.
|
||||
#[test]
|
||||
fn a_removal_shrinks_the_book_and_dirties_the_feeds() {
|
||||
let mut actions = PlayerActions::default();
|
||||
actions.spells.extend([14522, 14788, 14789]);
|
||||
|
||||
removed_spell(14788, &mut actions);
|
||||
assert!(!actions.spells.contains(&14788));
|
||||
assert!(actions.dirty);
|
||||
|
||||
actions.dirty = false;
|
||||
removed_spell(14788, &mut actions);
|
||||
assert!(!actions.dirty, "a spell we never knew is not a repaint");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,9 +61,12 @@ pub(super) fn weather(
|
||||
}
|
||||
|
||||
/// `SMSG_INIT_WORLD_STATES` / `SMSG_UPDATE_WORLD_STATE` — both wires funnel into the one setter,
|
||||
/// as the reference's own handler does. An init does NOT clear first: what its `(map, zone)` dwords
|
||||
/// drive is unrecorded, so we log the scope rather than act on it (rationale on
|
||||
/// [`crate::world_state`]).
|
||||
/// as the reference's own handler does.
|
||||
///
|
||||
/// An **init clears the table first** and records its `(map, zone)` as the world-state UI's display
|
||||
/// filter — the reference's `0x4c5650`, which runs before the pair loop (wow-re
|
||||
/// `system/ui/scratch/worldstate-ui-law.md`; rationale on [`crate::world_state`]). The order below
|
||||
/// is that handler's: clear + scope, then the packet's pairs.
|
||||
pub(super) fn world_states(
|
||||
scope: Option<(u32, u32)>,
|
||||
states: Vec<(u32, u32)>,
|
||||
@@ -74,6 +77,7 @@ pub(super) fn world_states(
|
||||
"world states: map {map} zone {zone}, {} entries",
|
||||
states.len()
|
||||
);
|
||||
world_states.init_scope(map, zone);
|
||||
}
|
||||
world_states.write(&states);
|
||||
}
|
||||
|
||||
@@ -239,7 +239,6 @@ pub(super) fn spawn_booth_effects(
|
||||
Transform::IDENTITY,
|
||||
benilla_world::particles::EmitterFrames {
|
||||
owner: Some(owner),
|
||||
attach: Some(host), // an attached model — its fan swings with the item
|
||||
anchor: Some(host), // the cloud anchors at the MODEL; bones compose births only
|
||||
// A booth rider's host is torn down with the bake it belongs to.
|
||||
on_owner_loss: benilla_world::particles::OwnerLoss::Free,
|
||||
@@ -325,7 +324,6 @@ pub(super) fn spawn_booth_own_emitters(
|
||||
Transform::IDENTITY,
|
||||
benilla_world::particles::EmitterFrames {
|
||||
owner: Some(owner),
|
||||
attach: None, // this model IS the host — nothing it hangs off swings it
|
||||
anchor: Some(root),
|
||||
// The bake root is torn down and rebuilt as a whole.
|
||||
on_owner_loss: benilla_world::particles::OwnerLoss::Free,
|
||||
|
||||
@@ -46,6 +46,51 @@ pub(super) fn diag_to_vert(fov: f32, aspect: f32) -> f32 {
|
||||
fov / (aspect * aspect + 1.0).sqrt()
|
||||
}
|
||||
|
||||
/// The **authored aspect** of every 1.12 glue composition: `4/3`.
|
||||
///
|
||||
/// It is the client's own reference frame, not a guess. The Lua screen is `768a × 768`
|
||||
/// (wow-re `ui/scratch/modelframe-camera-law.md` §12.1), so at the era's default `a = 4/3` the
|
||||
/// design space is exactly `1024×768` — the resolution every `UI_<Race>` diorama and its camera-0
|
||||
/// framing were authored against.
|
||||
pub(super) const GLUE_AUTHORED_ASPECT: f32 = 4.0 / 3.0;
|
||||
|
||||
/// The **glue scene's** vertical opening angle — the authored 4:3 view box, never cropped.
|
||||
///
|
||||
/// The reference law and why we do not ship it: a glue screen's `<ModelFFX>` is `setAllPoints` to
|
||||
/// `GlueParent`, which is `setAllPoints` to the screen, so the pane rect *is* the window and
|
||||
/// `0x5c3cc0`'s single `aspect` is the display's own (wow-re §12.1: `aspect = (W/H)·(a/a_screen)`,
|
||||
/// which is `a` for a full-screen pane — the `gxResolution`/`widescreen` CVar pair, normally the
|
||||
/// live mode). Feeding that straight into [`diag_to_vert`] makes the *diagonal* angle the invariant,
|
||||
/// so every pixel of extra width is paid for out of height: `0.600·fov` at 4:3, `0.492·fov` at 16:9,
|
||||
/// `0.386·fov` at 21:9. On a 3440×1440 panel that is a **1.55× zoom** over the authored framing, and
|
||||
/// the character's head and feet leave the frame (B242, decision 1587). The reference does the same
|
||||
/// thing on the same monitor — it is a convention from before 21:9 panels existed, not a mechanism
|
||||
/// worth reproducing.
|
||||
///
|
||||
/// It is also the only layer of ours that behaves that way. [`crate::glue::screen_scale`] lays the
|
||||
/// 2-D GlueXML chrome out on `height/768` alone, so the panels in *front* of the diorama hold their
|
||||
/// vertical composition at every width while the diorama behind them shrank.
|
||||
///
|
||||
/// So: pin the authored 4:3 view box and give the extra to whichever axis the window actually has.
|
||||
/// At `a ≥ 4/3` the vertical opening is frozen at its 4:3 value and the width grows (hor+, what
|
||||
/// every modern client does); below it the *horizontal* extent is frozen instead, so a tall or
|
||||
/// narrow window opens upward rather than cropping the diorama's sides. Continuous at `4/3`, where
|
||||
/// both legs give `0.6·fov`.
|
||||
///
|
||||
/// Scoped to the full-screen glue scene on purpose. A `<PlayerModel>` pane renders into a fixed
|
||||
/// rect that is *not* the window, so its crop is its own `W/H` and stays verbatim ([`diag_to_vert`],
|
||||
/// decisions 1089/1543).
|
||||
pub(super) fn glue_scene_vert_fov(fov: f32, window_aspect: f32) -> f32 {
|
||||
let authored = diag_to_vert(fov, GLUE_AUTHORED_ASPECT);
|
||||
if window_aspect >= GLUE_AUTHORED_ASPECT || window_aspect <= 0.0 {
|
||||
authored
|
||||
} else {
|
||||
// Hold the authored *horizontal* half-extent `tan(authored/2)·(4/3)` and solve for the
|
||||
// vertical that a narrower window needs to keep it.
|
||||
2.0 * ((authored * 0.5).tan() * GLUE_AUTHORED_ASPECT / window_aspect).atan()
|
||||
}
|
||||
}
|
||||
|
||||
/// The real client's portrait/model **projection**: gxumath `0x5c3cc0`, a *diagonal-FOV*
|
||||
/// perspective — half-angle `θ = (fov/2)/√(aspect²+1)`, `m11 = 1/tan θ`, `m00 = m11/aspect`.
|
||||
///
|
||||
@@ -630,6 +675,62 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// **B242 / decision 1587.** The full-screen glue scene holds the authored 4:3 view box on
|
||||
/// every window shape, instead of spending height on width the way the reference's
|
||||
/// `aspect = a_screen` does.
|
||||
///
|
||||
/// The three numbers that matter: the authored `0.6·fov` survives at 4:3, at 16:9 and at the
|
||||
/// 3440×1440 panel the report came from — where the un-pinned law gives `0.386·fov`, a 1.55×
|
||||
/// zoom, and the character's head and feet leave the frame.
|
||||
#[test]
|
||||
fn the_glue_scene_holds_its_authored_vertical_on_every_wide_window() {
|
||||
let close = |a: f32, b: f32| (a - b).abs() < 5e-4;
|
||||
let authored = diag_to_vert(1.0, GLUE_AUTHORED_ASPECT);
|
||||
assert!(
|
||||
close(authored, 0.6),
|
||||
"the authored 4:3 opening is the 0.6 legend"
|
||||
);
|
||||
for wide in [
|
||||
GLUE_AUTHORED_ASPECT,
|
||||
16.0 / 10.0,
|
||||
16.0 / 9.0,
|
||||
3440.0 / 1440.0,
|
||||
32.0 / 9.0,
|
||||
] {
|
||||
assert!(
|
||||
close(glue_scene_vert_fov(1.0, wide), authored),
|
||||
"a{wide} must keep the authored vertical opening"
|
||||
);
|
||||
}
|
||||
// What we are NOT doing any more — the reference's own numbers, kept here so the size of
|
||||
// the correction stays legible.
|
||||
assert!(close(diag_to_vert(1.0, 16.0 / 9.0), 0.490_26));
|
||||
assert!(close(diag_to_vert(1.0, 3440.0 / 1440.0), 0.386_14));
|
||||
}
|
||||
|
||||
/// The other side of the same clamp: a window NARROWER than 4:3 opens upward rather than
|
||||
/// cropping the diorama's sides, so the authored horizontal half-extent is the invariant there.
|
||||
/// Continuous at 4:3, where both legs of [`glue_scene_vert_fov`] agree.
|
||||
#[test]
|
||||
fn a_narrow_glue_window_holds_its_authored_width_instead() {
|
||||
let half_width = |vert: f32, a: f32| (vert * 0.5).tan() * a;
|
||||
let authored = half_width(
|
||||
diag_to_vert(1.0, GLUE_AUTHORED_ASPECT),
|
||||
GLUE_AUTHORED_ASPECT,
|
||||
);
|
||||
for narrow in [5.0 / 4.0, 1.0, 3.0 / 4.0] {
|
||||
let got = half_width(glue_scene_vert_fov(1.0, narrow), narrow);
|
||||
assert!(
|
||||
(got - authored).abs() < 5e-4,
|
||||
"a{narrow}: authored half-width {authored} vs {got}"
|
||||
);
|
||||
}
|
||||
// …and it never *narrows* the view: a tall window sees more, never less.
|
||||
assert!(glue_scene_vert_fov(1.0, 1.0) > glue_scene_vert_fov(1.0, GLUE_AUTHORED_ASPECT));
|
||||
// A degenerate window (zero height, mid-resize) must not produce a NaN fov.
|
||||
assert!(glue_scene_vert_fov(1.0, 0.0).is_finite());
|
||||
}
|
||||
|
||||
/// **The portrait bake's projection, pinned against the real client's own GL stream**
|
||||
/// (decision 1543). Two apitrace captures of 1.12.1 — `WoW-wade-northshire-20260708.trace` at
|
||||
/// 1152×648 (calls 1073945–1075996) and `WoW.trace` at 1280×800 (calls 5592698–5594820) — both
|
||||
|
||||
@@ -25,7 +25,7 @@ use crate::entities::Creatures;
|
||||
use benilla_assets::m2_url;
|
||||
use benilla_assets::materials::WowModelMaterial;
|
||||
|
||||
use super::framing::{attachment_point, diag_to_vert, PORTRAIT_ASPECT};
|
||||
use super::framing::{attachment_point, diag_to_vert, glue_scene_vert_fov, PORTRAIT_ASPECT};
|
||||
use super::{
|
||||
aim, body_frame, new_target_image, spawn_booth_effects, spawn_booth_model, Booth,
|
||||
BoothBillboardSpec, BoothCam, BoothEffects, BoothLight, BoothMotion, BoothPart, BoothRider,
|
||||
@@ -829,13 +829,15 @@ pub(super) fn sync_glue_scene(
|
||||
.unwrap_or(4.0 / 3.0);
|
||||
let fwd = (cam.target - cam.eye).normalize_or_zero();
|
||||
let up = Quat::from_axis_angle(fwd, cam.roll) * Vec3::Y;
|
||||
// The record fov is the client's *diagonal* convention → vertical = fov/√(aspect²+1)
|
||||
// (the portrait path's 0.6 factor is exactly this at 4/3). Far kept generous: fog is not
|
||||
// The record fov is the client's *diagonal* convention. The scene is a FULL-SCREEN pane, so
|
||||
// the reference feeds that conversion the display's own aspect and spends height on width —
|
||||
// a 1.55× zoom at 21:9 that crops head and feet (B242). [`glue_scene_vert_fov`] pins the
|
||||
// authored 4:3 view box instead; its doc carries the law. Far kept generous: fog is not
|
||||
// rendered yet, so the authored far (27.8 on Orc) would slice unfogged geometry.
|
||||
let rig = (
|
||||
Transform::from_translation(cam.eye).looking_at(cam.target, up),
|
||||
Projection::from(PerspectiveProjection {
|
||||
fov: cam.fov / (aspect * aspect + 1.0).sqrt(),
|
||||
fov: glue_scene_vert_fov(cam.fov, aspect),
|
||||
near: cam.near,
|
||||
far: cam.far.max(1000.0),
|
||||
..default()
|
||||
|
||||
@@ -29,6 +29,10 @@ mod language;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// The joined-channel roster + the `ChatChannels.dbc` catalog. Read outside this module by the
|
||||
/// world-state readout ([`crate::world_state_ui`]), whose `Type == 1` gate is "has the player
|
||||
/// joined a zone-dependent defense channel".
|
||||
pub(crate) use edit::ChannelState;
|
||||
/// Test-only: `ui_script::chat_tests` checks every name we fire against the live `ChatTypeInfo`
|
||||
/// table, which lives on that side of the tree. The app itself calls it through `event::` — the
|
||||
/// router is the only production caller and it is inside this module.
|
||||
|
||||
@@ -1979,6 +1979,100 @@ fn both_dock_windows_carry_the_same_chrome() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The two dock windows must come from ONE declaration.** The structural guard that ends the
|
||||
/// class (decision 1588).
|
||||
///
|
||||
/// Three rounds of the same bug: window 2 shipped with no tab (1575), then with no rect and no
|
||||
/// border art (1579), then with no `OnUpdate` and no scroll column (1588) — each time because it
|
||||
/// was a hand-mirrored partial copy of window 1 and the mirroring missed a part. The behavioural
|
||||
/// tests each catch ONE missed part after someone notices it; this catches the mirroring itself,
|
||||
/// which is the only check that can fire before a part is missing.
|
||||
///
|
||||
/// So: both windows inherit the same virtual template (the reference's own shape —
|
||||
/// `FloatingChatFrameTemplate`), and neither declares any content of its own beyond its seat. A
|
||||
/// `<Layers>`, `<Frames>` or `<Scripts>` block on either instance is the defect, whatever is in it.
|
||||
#[test]
|
||||
fn the_two_dock_windows_are_one_declaration() {
|
||||
use benilla_ui::framexml::TopLevel;
|
||||
let xml = std::fs::read_to_string(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/ChatFrame.xml"),
|
||||
)
|
||||
.expect("ChatFrame.xml");
|
||||
let doc = benilla_ui::framexml::parse(&xml).expect("parse");
|
||||
|
||||
let mut seen = 0;
|
||||
for item in &doc.items {
|
||||
let TopLevel::Instance(el) = item else {
|
||||
continue;
|
||||
};
|
||||
let Some(name) = el.attr("name") else {
|
||||
continue;
|
||||
};
|
||||
if name != "ChatFrame1" && name != "ChatFrame2" {
|
||||
continue;
|
||||
}
|
||||
seen += 1;
|
||||
assert_eq!(
|
||||
el.attr("inherits"),
|
||||
Some("BenillaDockedChatFrameTemplate"),
|
||||
"{name} must inherit the dock template, not restate it"
|
||||
);
|
||||
for child in &el.children {
|
||||
assert!(
|
||||
matches!(child.tag.as_str(), "Size" | "Anchors"),
|
||||
"{name} declares its own <{}> — that is the mirroring this test exists to stop; \
|
||||
it belongs in BenillaDockedChatFrameTemplate where BOTH windows get it",
|
||||
child.tag
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
seen, 2,
|
||||
"both dock windows must be declared in ChatFrame.xml"
|
||||
);
|
||||
}
|
||||
|
||||
/// The dock's driver may not ride a frame the dock hides — the root cause of 1588 stated at the
|
||||
/// level it lived at. `FCF_OnUpdate` drives dock-GLOBAL state (one `reveal`, one selected tab,
|
||||
/// both windows' tabs) and it rode `ChatFrame1`'s `OnUpdate`; selecting the Combat Log hides
|
||||
/// ChatFrame1, and a hidden frame gets no `OnUpdate`, so the whole dock froze on first use.
|
||||
#[test]
|
||||
fn the_dock_driver_does_not_ride_a_dock_window() {
|
||||
let xml = std::fs::read_to_string(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/ChatFrame.xml"),
|
||||
)
|
||||
.expect("ChatFrame.xml");
|
||||
let doc = benilla_ui::framexml::parse(&xml).expect("parse");
|
||||
|
||||
// The one element whose OnUpdate calls it, found by walking rather than by name.
|
||||
fn driver<'a>(el: &'a benilla_ui::framexml::Element, out: &mut Vec<&'a str>) {
|
||||
if el.tag == "OnUpdate" && el.body.contains("FCF_OnUpdate") {
|
||||
out.push("hit");
|
||||
}
|
||||
for c in &el.children {
|
||||
driver(c, out);
|
||||
}
|
||||
}
|
||||
let mut hosts = Vec::new();
|
||||
for item in &doc.items {
|
||||
let el = match item {
|
||||
benilla_ui::framexml::TopLevel::Instance(el)
|
||||
| benilla_ui::framexml::TopLevel::Template(el) => el,
|
||||
_ => continue,
|
||||
};
|
||||
let mut hits = Vec::new();
|
||||
driver(el, &mut hits);
|
||||
if !hits.is_empty() {
|
||||
hosts.push(el.attr("name").unwrap_or("<unnamed>"));
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
hosts,
|
||||
["BenillaChatDockDriver"],
|
||||
"FCF_OnUpdate is dock-global — it must ride the one frame the dock cannot hide"
|
||||
);
|
||||
}
|
||||
|
||||
/// **No docked chat window may appear in `UIParent.xml`'s managed-position table.**
|
||||
///
|
||||
/// The structural guard on the root cause. That pass owns a frame's whole seat — it
|
||||
|
||||
@@ -784,3 +784,137 @@ fn a_chat_view_at_the_bottom_stops_rewriting_the_flash() {
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **Selecting the Combat Log must not stop the dock.** The falsification for decision 1588.
|
||||
///
|
||||
/// `FCF_OnUpdate` is the dock's whole driver — the hover reveal, both tabs' alphas, the label
|
||||
/// settle, the whisper blink — and it used to ride `ChatFrame1`'s own `OnUpdate`.
|
||||
/// `BenillaFCF_SelectDock(2)` hides ChatFrame1, and a hidden frame gets no `OnUpdate`
|
||||
/// ([`UiScript::tick`] runs only effectively-visible frames, which is the reference's rule), so the
|
||||
/// driver died at the exact moment the feature it drives was first used: the tabs froze mid-write,
|
||||
/// still showing General as the selected one. That is what the director saw and reported, and every
|
||||
/// test we had passed straight through it — including this file's two dock tests, because both only
|
||||
/// ever exercise window 1.
|
||||
///
|
||||
/// The trap this walks around: calling `FCF_OnUpdate()` by hand — which the first live probe did,
|
||||
/// and which every earlier test does — bypasses the visibility gate and reports healthy alphas on a
|
||||
/// dead driver. So this drives the clock and nothing else.
|
||||
#[test]
|
||||
fn selecting_the_combat_log_keeps_the_dock_driver_running() {
|
||||
let mut s = chat_frame();
|
||||
// Hover the dock and let the reveal settle: General at full, Combat Log at half.
|
||||
let (x, y): (f32, f32) = s
|
||||
.eval(
|
||||
"return (ChatFrame1:GetLeft() + ChatFrame1:GetRight()) / 2, \
|
||||
(ChatFrame1:GetBottom() + ChatFrame1:GetTop()) / 2",
|
||||
)
|
||||
.unwrap();
|
||||
s.mouse_move(x, y);
|
||||
for _ in 0..25 {
|
||||
s.tick(0.016);
|
||||
s.resolve();
|
||||
}
|
||||
assert!(
|
||||
close(
|
||||
s.eval::<f32>("return ChatFrame1Tab:GetAlpha()").unwrap(),
|
||||
1.0
|
||||
) && close(
|
||||
s.eval::<f32>("return ChatFrame2Tab:GetAlpha()").unwrap(),
|
||||
0.5
|
||||
),
|
||||
"the hovered dock starts with General selected — this test must not pass vacuously"
|
||||
);
|
||||
|
||||
// Click the Combat Log tab, then advance the clock WITHOUT touching FCF_OnUpdate.
|
||||
s.run("BenillaFCF_TabClick(2)").unwrap();
|
||||
for _ in 0..10 {
|
||||
s.tick(0.016);
|
||||
s.resolve();
|
||||
}
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
|
||||
assert!(
|
||||
s.eval::<bool>("return ChatFrame2:IsShown() and not ChatFrame1:IsShown()")
|
||||
.unwrap(),
|
||||
"the click swapped the windows"
|
||||
);
|
||||
let (a1, a2): (f32, f32) = s
|
||||
.eval("return ChatFrame1Tab:GetAlpha(), ChatFrame2Tab:GetAlpha()")
|
||||
.unwrap();
|
||||
assert!(
|
||||
close(a1, 0.5) && close(a2, 1.0),
|
||||
"the selected tab is the one at full alpha — got General {a1}, Combat Log {a2}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The other half of the same freeze, and the one a screenshot cannot show: with the driver dead,
|
||||
/// `reveal` never decayed either, so the chat box and both tabs stayed lit after the cursor left.
|
||||
#[test]
|
||||
fn the_dock_still_fades_out_with_the_combat_log_selected() {
|
||||
let mut s = chat_frame();
|
||||
let (x, y): (f32, f32) = s
|
||||
.eval(
|
||||
"return (ChatFrame1:GetLeft() + ChatFrame1:GetRight()) / 2, \
|
||||
(ChatFrame1:GetBottom() + ChatFrame1:GetTop()) / 2",
|
||||
)
|
||||
.unwrap();
|
||||
s.mouse_move(x, y);
|
||||
for _ in 0..25 {
|
||||
s.tick(0.016);
|
||||
s.resolve();
|
||||
}
|
||||
s.run("BenillaFCF_TabClick(2)").unwrap();
|
||||
for _ in 0..10 {
|
||||
s.tick(0.016);
|
||||
s.resolve();
|
||||
}
|
||||
assert!(
|
||||
close(
|
||||
s.eval::<f32>("return ChatFrame2Tab:GetAlpha()").unwrap(),
|
||||
1.0
|
||||
),
|
||||
"revealed before the cursor leaves"
|
||||
);
|
||||
|
||||
s.mouse_move(1500.0, 850.0); // away from the dock
|
||||
for _ in 0..20 {
|
||||
s.tick(0.016);
|
||||
s.resolve();
|
||||
}
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
let a2: f32 = s.eval("return ChatFrame2Tab:GetAlpha()").unwrap();
|
||||
assert!(
|
||||
a2 < 1e-6,
|
||||
"the dock conceals itself again on leave — got Combat Log tab alpha {a2}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The Combat Log window has the scroll column too — the third part of window 2 that was simply
|
||||
/// never authored (after the tab, 1575, and the border art, 1579). It is a behavioural check, not
|
||||
/// a name check: scroll up and the bottom button's flash must start blinking on window 2's own
|
||||
/// button, which only happens if window 2 both HAS the button and runs `ChatFrame_OnUpdate`.
|
||||
#[test]
|
||||
fn the_combat_log_window_runs_its_own_bottom_button_blink() {
|
||||
let mut s = chat_frame();
|
||||
s.run("BenillaFCF_TabClick(2)").unwrap();
|
||||
for i in 0..40 {
|
||||
s.add_chat_message("ChatFrame2", &format!("line {i}"), 1.0, 1.0, 1.0);
|
||||
}
|
||||
s.run("ChatFrame2:ScrollUp()").unwrap();
|
||||
assert!(
|
||||
!s.eval::<bool>("return ChatFrame2:AtBottom()").unwrap(),
|
||||
"scrolled off the bottom — this test must not pass vacuously"
|
||||
);
|
||||
|
||||
// CHAT_BUTTON_FLASH_TIME is 0.5 s; 40 frames of 16 ms crosses it.
|
||||
for _ in 0..40 {
|
||||
s.tick(0.016);
|
||||
s.resolve();
|
||||
}
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
assert!(
|
||||
s.eval::<bool>("return ChatFrame2BottomButtonFlash:IsVisible()")
|
||||
.unwrap(),
|
||||
"window 2's bottom-button flash blinks while it is scrolled up"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -850,6 +850,8 @@ mod quest_timer_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod durability_tests;
|
||||
#[cfg(test)]
|
||||
mod world_state_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod screenshot_tests;
|
||||
|
||||
@@ -223,6 +223,12 @@ mod tests {
|
||||
("ui_script/extract/mod.rs", "last_dpi"),
|
||||
// Pushed unconditionally every frame; there is nothing remembered to go stale.
|
||||
("ui_script/mod.rs", "smoothed"),
|
||||
// A fact about the CHAT ROSTER, not about the VM: whether the player has joined a
|
||||
// zone-dependent defense channel (the world-state readout's `Type == 1` gate). It is
|
||||
// recomputed whenever `ChannelState` changes and is the same answer either side of a
|
||||
// login; the push it feeds is memoized beside it in a real `VmMemo`, which is what
|
||||
// re-seats the new VM.
|
||||
("world_state_ui.rs", "defense_channel"),
|
||||
// The cursor systems, both platform arms: these track the OS cursor and an `NSCursor` raw
|
||||
// pointer. The OS keeps that state across the VM's death and rebirth, so re-seating them at
|
||||
// a login would re-assert a cursor nothing changed. (`last_set` is the key we last handed
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
//! The always-up world-state readout (`assets/ui/WorldStateFrame.xml`) against the shipped XML —
|
||||
//! report B190's second half, driven the way the client drives it: rows pushed, then
|
||||
//! `UPDATE_WORLD_STATES` fired.
|
||||
//!
|
||||
//! What these pin is the *frame's* half of the contract — that it reads the ten returns in the
|
||||
//! right order, swaps the dynamic icon on the row's own state, and empties itself when a scope
|
||||
//! admits nothing. Which rows exist at all is [`crate::world_state_ui`]'s, tested there against
|
||||
//! the real `WorldStateUI.dbc`.
|
||||
|
||||
use benilla_ui::script::{QuadContent, ScriptValue, UiScript, WorldStateUiView};
|
||||
|
||||
fn load_xml(s: &UiScript, file: &str) {
|
||||
let text = std::fs::read_to_string(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("assets/ui")
|
||||
.join(file),
|
||||
)
|
||||
.unwrap();
|
||||
let doc = benilla_ui::framexml::parse(&text).unwrap();
|
||||
let report = benilla_ui::loader::load(s, &doc, &|_| None);
|
||||
assert!(
|
||||
report.errors.is_empty(),
|
||||
"{file}: loader errors: {:?}",
|
||||
report.errors
|
||||
);
|
||||
}
|
||||
|
||||
fn harness() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
for f in [
|
||||
"Fonts.xml",
|
||||
"MoneyFrame.xml",
|
||||
"UiPanels.xml",
|
||||
"UIParent.xml",
|
||||
"GameTooltip.xml",
|
||||
"WorldStateFrame.xml",
|
||||
] {
|
||||
load_xml(&s, f);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// An Eastern Plaguelands-shaped row.
|
||||
fn row(icon: &str, text: &str, tooltip: &str) -> WorldStateUiView {
|
||||
WorldStateUiView {
|
||||
ui_state: 1,
|
||||
text: text.into(),
|
||||
icon: icon.into(),
|
||||
tooltip: tooltip.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Every shown text string, in draw order.
|
||||
fn texts(s: &mut UiScript) -> Vec<String> {
|
||||
s.resolve();
|
||||
s.extract()
|
||||
.iter()
|
||||
.filter_map(|q| match &q.content {
|
||||
QuadContent::Text { text: Some(t), .. } if !t.is_empty() => Some(t.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every shown texture path, in draw order.
|
||||
fn textures(s: &mut UiScript) -> Vec<String> {
|
||||
s.resolve();
|
||||
s.extract()
|
||||
.iter()
|
||||
.filter_map(|q| match &q.content {
|
||||
QuadContent::Texture { path: Some(p), .. } => Some(p.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn push(s: &mut UiScript, rows: Vec<WorldStateUiView>) {
|
||||
s.set_world_state_ui(rows);
|
||||
s.tick(0.0);
|
||||
s.fire_event("UPDATE_WORLD_STATES", vec![]);
|
||||
}
|
||||
|
||||
/// The Eastern Plaguelands readout: two labelled tower counters, each with its faction icon.
|
||||
#[test]
|
||||
fn the_tower_counters_draw_with_their_icons() {
|
||||
let mut s = harness();
|
||||
s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Str("".into())]);
|
||||
assert!(
|
||||
!texts(&mut s).iter().any(|t| t.contains("Towers")),
|
||||
"nothing before a push"
|
||||
);
|
||||
|
||||
push(
|
||||
&mut s,
|
||||
vec![
|
||||
row(
|
||||
"Interface\\WorldStateFrame\\AllianceTower",
|
||||
"Towers Controlled: 3",
|
||||
"Alliance Towers Controlled",
|
||||
),
|
||||
row(
|
||||
"Interface\\WorldStateFrame\\HordeTower",
|
||||
"Towers Controlled: 1",
|
||||
"Horde Towers Controlled",
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
let shown = texts(&mut s);
|
||||
assert!(
|
||||
shown.contains(&"Towers Controlled: 3".to_string())
|
||||
&& shown.contains(&"Towers Controlled: 1".to_string()),
|
||||
"both counters on screen: {shown:?}"
|
||||
);
|
||||
let art = textures(&mut s);
|
||||
assert!(
|
||||
art.iter().any(|p| p.contains("AllianceTower"))
|
||||
&& art.iter().any(|p| p.contains("HordeTower")),
|
||||
"both faction icons on screen: {art:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A scope that admits nothing empties the readout — and the pooled rows from the busier scope
|
||||
/// are hidden rather than left painting a stale count.
|
||||
#[test]
|
||||
fn leaving_the_zone_clears_the_readout() {
|
||||
let mut s = harness();
|
||||
s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Str("".into())]);
|
||||
push(
|
||||
&mut s,
|
||||
vec![row(
|
||||
"Interface\\WorldStateFrame\\AllianceTower",
|
||||
"Towers Controlled: 3",
|
||||
"Alliance Towers Controlled",
|
||||
)],
|
||||
);
|
||||
assert!(texts(&mut s).contains(&"Towers Controlled: 3".to_string()));
|
||||
|
||||
push(&mut s, Vec::new());
|
||||
assert!(
|
||||
!texts(&mut s).iter().any(|t| t.contains("Towers")),
|
||||
"no stale count left painting: {:?}",
|
||||
texts(&mut s)
|
||||
);
|
||||
assert!(
|
||||
!textures(&mut s).iter().any(|p| p.contains("AllianceTower")),
|
||||
"and no orphaned icon"
|
||||
);
|
||||
}
|
||||
|
||||
/// The dynamic icon: a row whose own state is live draws the alternate art (Warsong Gulch's enemy
|
||||
/// flag) instead of its static icon, and reverts when the state drops. `uiState` is what decides,
|
||||
/// which is the whole reason the first return is a number and not the text.
|
||||
#[test]
|
||||
fn the_dynamic_icon_follows_the_rows_own_state() {
|
||||
let mut s = harness();
|
||||
s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Str("".into())]);
|
||||
|
||||
let flag_row = |ui_state| WorldStateUiView {
|
||||
ui_state,
|
||||
text: "2/3".into(),
|
||||
icon: "Interface\\TargetingFrame\\UI-PVP-Alliance".into(),
|
||||
dynamic_icon: "Interface\\WorldStateFrame\\HordeFlag".into(),
|
||||
tooltip: "Alliance flag captures".into(),
|
||||
dynamic_tooltip: "Horde flag has been picked up".into(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
push(&mut s, vec![flag_row(0)]);
|
||||
let art = textures(&mut s);
|
||||
assert!(
|
||||
art.iter().any(|p| p.contains("UI-PVP-Alliance")),
|
||||
"state down — the static icon: {art:?}"
|
||||
);
|
||||
assert!(!art.iter().any(|p| p.contains("HordeFlag")));
|
||||
|
||||
push(&mut s, vec![flag_row(1)]);
|
||||
let art = textures(&mut s);
|
||||
assert!(
|
||||
art.iter().any(|p| p.contains("HordeFlag")),
|
||||
"state up — the enemy flag replaces it: {art:?}"
|
||||
);
|
||||
assert!(!art.iter().any(|p| p.contains("UI-PVP-Alliance")));
|
||||
}
|
||||
|
||||
/// A row with no icon at all (the Eastern Plaguelands progress line) still draws its text — the
|
||||
/// icon is optional, and the row must not collapse when the DBC column is empty.
|
||||
#[test]
|
||||
fn a_row_without_an_icon_still_shows_its_text() {
|
||||
let mut s = harness();
|
||||
s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Str("".into())]);
|
||||
push(
|
||||
&mut s,
|
||||
vec![WorldStateUiView {
|
||||
ui_state: 1,
|
||||
text: "Progress: 60".into(),
|
||||
extended_ui: "CAPTUREPOINT".into(),
|
||||
extended_ui_state: [60, 40, 0],
|
||||
..Default::default()
|
||||
}],
|
||||
);
|
||||
assert!(texts(&mut s).contains(&"Progress: 60".to_string()));
|
||||
}
|
||||
|
||||
/// The bindings' own edges, read from Lua the way an addon would. Two are easy to get wrong and
|
||||
/// both are the reference's (`0x4c5a40`/`0x4c5a70`): an out-of-range index answers exactly one
|
||||
/// value — the number `0`, not nil and not ten nils — and a non-number argument raises.
|
||||
#[test]
|
||||
fn the_bindings_answer_the_reference_shape() {
|
||||
let mut s = harness();
|
||||
s.set_world_state_ui(vec![WorldStateUiView {
|
||||
ui_state: 7,
|
||||
text: "Towers Controlled: 3".into(),
|
||||
icon: "Interface\\WorldStateFrame\\AllianceTower".into(),
|
||||
tooltip: "Alliance Towers Controlled".into(),
|
||||
extended_ui_state: [1, 2, 3],
|
||||
..Default::default()
|
||||
}]);
|
||||
|
||||
assert_eq!(s.eval::<i64>("return GetNumWorldStateUI()").unwrap(), 1);
|
||||
// Ten values, in order, with no nils among the strings.
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
"local a,b,c,d,e,f,g,h,i,j = GetWorldStateUIInfo(1)\n\
|
||||
return a..'|'..b..'|'..c..'|'..d..'|'..e..'|'..f..'|'..g..'|'..h..'|'..i..'|'..j"
|
||||
)
|
||||
.unwrap(),
|
||||
"7|Towers Controlled: 3|Interface\\WorldStateFrame\\AllianceTower||\
|
||||
Alliance Towers Controlled|||1|2|3",
|
||||
"empty columns are empty strings, never nil"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', GetWorldStateUIInfo(1))")
|
||||
.unwrap(),
|
||||
10
|
||||
);
|
||||
|
||||
// Out of range: ONE value, the number 0.
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', GetWorldStateUIInfo(2))")
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(s.eval::<i64>("return GetWorldStateUIInfo(2)").unwrap(), 0);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetWorldStateUIInfo(0)").unwrap(),
|
||||
0,
|
||||
"the index is 1-based"
|
||||
);
|
||||
|
||||
// A non-number argument raises rather than answering quietly.
|
||||
assert!(
|
||||
s.eval::<i64>("return GetWorldStateUIInfo('nope')").is_err(),
|
||||
"a non-numeric argument is an error, not a nil"
|
||||
);
|
||||
// ...but a numeric string coerces, as `lua_isnumber` does.
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return (GetWorldStateUIInfo('1'))").unwrap(),
|
||||
7
|
||||
);
|
||||
}
|
||||
@@ -375,4 +375,19 @@ mod tests {
|
||||
let holey: BTreeSet<u32> = [102].into_iter().collect();
|
||||
assert_eq!(rank_of(&t, &holey), 3);
|
||||
}
|
||||
|
||||
/// **The respec, at the seam that made it look like it hadn't happened** (decision 1584). The
|
||||
/// window's rank is a read of the known-spell set and nothing else, so a wipe only shows here
|
||||
/// once the server's `SMSG_REMOVED_SPELL` burst has emptied it — one packet per rank, all of
|
||||
/// them dropped before 1584. That is why the director's screenshot could show a 20-point
|
||||
/// Discipline tree beside "Talent Points: 51": the points are a descriptor field and arrived,
|
||||
/// the ranks are this set and did not. The arm that empties it has its own test beside it
|
||||
/// (`net::apply::spells::a_removal_shrinks_the_book_and_dirties_the_feeds`).
|
||||
#[test]
|
||||
fn an_emptied_book_reads_as_a_wiped_tree() {
|
||||
let t = talent(1, 201, 0, 0, &[14522, 14788, 14789]);
|
||||
let spent: BTreeSet<u32> = [14522, 14788, 14789].into_iter().collect();
|
||||
assert_eq!(rank_of(&t, &spent), 3);
|
||||
assert_eq!(rank_of(&t, &BTreeSet::new()), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
//! player's `(continent, zone)` from `CurrentArea` through the AreaTable parent chain, and
|
||||
//! pushes the trio + facing. (The client matches its zone-level area global directly —
|
||||
//! `0x4a6650`; our MCNK `CurrentArea` is the leaf sub-area, so the parent walk lands on the
|
||||
//! same zone.)
|
||||
//! same zone.) It also runs the **landmark pass** — the reference's `0x4a67a0` builder
|
||||
//! ([`landmark_gates_pass`], decision 1586): the `AreaPOI.dbc` rows the displayed level admits,
|
||||
//! then the guard-directions marker. That pass is keyed rather than per-frame ([`LandmarkKey`]),
|
||||
//! because the reference rebuilds it on events, not on a clock.
|
||||
//!
|
||||
//! …and one dev affordance beside them, [`dev_map_jump`]: **Alt+click the map to go there.**
|
||||
|
||||
@@ -245,6 +248,171 @@ fn load_world_map_ui(
|
||||
});
|
||||
}
|
||||
|
||||
/// Which of the map's three levels is displayed — the reference's `(continent, zone)` globals
|
||||
/// `[0x84506c]`/`[0x845070]` reduced to the thing every landmark gate actually branches on.
|
||||
///
|
||||
/// The reference has a fourth, `ORPHAN` (`continent == -2`, a map selected directly rather than
|
||||
/// through the continent/zone pair). We have no way to reach it: [`UiScript::world_map_selection`]
|
||||
/// is a `(u32, u32)` pair with `0` for "whole", so a direct-area selection is unrepresentable.
|
||||
/// Where the reference's gates test "zone **or** orphan", ours test zone — the same answer for
|
||||
/// every state our map can be in.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum MapLevel {
|
||||
/// Both continents on one sheet (the reference's `continent == -1`).
|
||||
World,
|
||||
/// One continent, no zone selected (`continent >= 0, zone == -1`).
|
||||
Continent,
|
||||
/// One zone (`continent >= 0, zone >= 0`).
|
||||
Zone,
|
||||
}
|
||||
|
||||
impl MapLevel {
|
||||
/// Our 1-based `(continent, zone)` selection, `0` = "whole", mapped onto the reference's
|
||||
/// `-1`-for-whole pair.
|
||||
fn of(continent: u32, zone: u32) -> Self {
|
||||
match (continent, zone) {
|
||||
(0, _) => Self::World,
|
||||
(_, 0) => Self::Continent,
|
||||
_ => Self::Zone,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The inputs the landmark list is a pure function of — the rebuild edge.
|
||||
///
|
||||
/// The reference does not rebuild per frame: `0x4a67a0` runs from thirteen callers, and they are
|
||||
/// exactly this set — a map selection change, a world-state push (`0x48fa0d`), a
|
||||
/// `PLAYER_EXPLORED_ZONES` descriptor change (`0x4a6477`, registered as an UpdateFields callback
|
||||
/// on byte offset `0xe6c`), and the three gossip-marker set/clear sites. Keying on them keeps our
|
||||
/// pass event-driven the same way, which is what stops a 339-row walk and ~40 string clones from
|
||||
/// running 60 times a second with nothing changed.
|
||||
///
|
||||
/// `explored` is held whole rather than hashed: it is 64 dwords, it is compared far more often
|
||||
/// than it is stored, and a hash would trade an exact answer for a collision risk on the one
|
||||
/// input that silently hides map icons when it is wrong.
|
||||
#[derive(PartialEq, Eq)]
|
||||
struct LandmarkKey {
|
||||
selection: (u32, u32),
|
||||
map: u32,
|
||||
states: u64,
|
||||
/// Owned rather than borrowed, and compared before it is ever cloned — see
|
||||
/// [`LandmarkKey::matches`]. Cloning it every frame to build a throwaway key would have cost
|
||||
/// the allocation this whole mechanism exists to avoid.
|
||||
explored: Vec<u32>,
|
||||
/// The guard-directions marker's identity — `(continent, pos, icon)`. Its position is held as
|
||||
/// raw `f32` bits: "the same marker" here means the same wire packet, and the coordinates are
|
||||
/// copied from it rather than computed, so a bit compare is the exact question.
|
||||
marker: Option<(u32, [u32; 3], u32)>,
|
||||
}
|
||||
|
||||
impl LandmarkKey {
|
||||
/// Is this key still the current one? Takes `explored` by slice so the common case — nothing
|
||||
/// changed — compares without allocating.
|
||||
fn matches(
|
||||
&self,
|
||||
selection: (u32, u32),
|
||||
map: u32,
|
||||
states: u64,
|
||||
explored: &[u32],
|
||||
marker: Option<(u32, [u32; 3], u32)>,
|
||||
) -> bool {
|
||||
self.selection == selection
|
||||
&& self.map == map
|
||||
&& self.states == states
|
||||
&& self.marker == marker
|
||||
&& self.explored == explored
|
||||
}
|
||||
}
|
||||
|
||||
/// The builder's near-zero skip (`0x4a6868`/`0x4a687a`): a landmark whose projected UV is `0` on
|
||||
/// **both** axes is dropped.
|
||||
///
|
||||
/// That is the level/continent filter, not a paranoia check. [`map_proj::zone_uv`] answers
|
||||
/// `(0, 0)` for a position outside the displayed rect or on the wrong continent, and `world_uv`'s
|
||||
/// caller answers `None` for a map with no `WorldMapContinent` row — so "did it project to zero"
|
||||
/// IS "does this POI belong on the map that is showing". Both axes, never either: a POI genuinely
|
||||
/// on the top-left edge of a rect (`u = 0`, `v = 0.5`) survives, as it does in the reference.
|
||||
///
|
||||
/// The epsilon is the binary's own `2.384e-7`.
|
||||
fn is_degenerate(uv: (f32, f32)) -> bool {
|
||||
const EPS: f32 = 2.384e-7;
|
||||
uv.0.abs() < EPS && uv.1.abs() < EPS
|
||||
}
|
||||
|
||||
/// The three gates `0x4a67a0`'s AreaPOI walk applies, in the reference's order (wow-re
|
||||
/// `system/ui/scratch/gossip-poi-marker.md` §7 + §8.2).
|
||||
///
|
||||
/// 1. **The level flag** (`0x4a79b0`'s fall-through chain, `0x4a79cb`–`0x4a7a05`): a row must
|
||||
/// carry `0x04` to show at zone level, `0x08` at continent level, and **both `0x10` and
|
||||
/// `0x08`** at world level — the chain is not a switch, so the world leg falls through the
|
||||
/// continent leg. The shipped data agrees and could have disagreed: of the eleven `Flags`
|
||||
/// values in `AreaPOI.dbc`, the only two carrying `0x10` (`0x1d`, `0x98`) also carry `0x08`.
|
||||
/// 2. **Exploration** (`0x4a6890`–`0x4a68f3`): a row whose `AreaID` is `> 0` (signed — `-1`
|
||||
/// arrives here as `u32::MAX` and means continent-wide) must resolve in `AreaTable`, and if
|
||||
/// that row's `ExplorationLevel` is `>= 0` the player must have discovered it. This is why
|
||||
/// Stormwind's icon is on the map from the first login (its row is continent-wide) while a
|
||||
/// tower in a zone you have never walked is not.
|
||||
/// 3. **World state** (`0x4a6903`): a row carrying a `WorldStateID` shows only while
|
||||
/// [`crate::world_state::WorldStates`] reads that key non-zero. This is the whole Eastern
|
||||
/// Plaguelands tower mechanism — rows 1749–1777 are gated on states 2352–2379, one row per
|
||||
/// (tower, owner, contested/progressing) combination, so the server flipping a state swaps
|
||||
/// which icon exists rather than editing one.
|
||||
///
|
||||
/// **Not tested here, and deliberately:** `ContinentID`, `Importance`, `FactionID`, `Icon`. The
|
||||
/// reference's loop reads none of them — the continent filter is the projection (see
|
||||
/// [`is_degenerate`]), and `Importance` gates nothing on the world map at all.
|
||||
fn landmark_gates_pass(
|
||||
poi: &benilla_formats::AreaPoi,
|
||||
level: MapLevel,
|
||||
areas: &benilla_formats::AreaTableCatalog,
|
||||
explored: &[u32],
|
||||
world_states: &crate::world_state::WorldStates,
|
||||
) -> bool {
|
||||
let level_ok = match level {
|
||||
MapLevel::Zone => poi.flags & 0x04 != 0,
|
||||
MapLevel::Continent => poi.flags & 0x08 != 0,
|
||||
MapLevel::World => poi.flags & 0x10 != 0 && poi.flags & 0x08 != 0,
|
||||
};
|
||||
if !level_ok {
|
||||
return false;
|
||||
}
|
||||
// `AreaID > 0`, signed. A row the table does not carry cannot be tested for exploration, so
|
||||
// it passes — the reference's own bounds check on `[0xc0e048]`/`[0xc0e04c]` guards the read,
|
||||
// not the landmark. (Moot on 5875 data: all 171 gated rows resolve.)
|
||||
if poi.area_id as i32 > 0 {
|
||||
if let Some(area) = areas.get(poi.area_id) {
|
||||
if area.exploration_level >= 0 && !explored_bit(explored, area.explore_flag) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
poi.world_state_id == 0 || world_states.get(poi.world_state_id) != 0
|
||||
}
|
||||
|
||||
/// `PLAYER_EXPLORED_ZONES` bit `bit` — the reference's `0x4a9a40`, which indexes the descriptor
|
||||
/// bitfield bytewise (`[base + bit/8] & (1 << (bit & 7))`). Our slots are the same bits as
|
||||
/// little-endian dwords, so the dword form below is the same test.
|
||||
fn explored_bit(explored: &[u32], bit: u32) -> bool {
|
||||
let (slot, within) = (bit as usize / 32, bit % 32);
|
||||
explored.get(slot).is_some_and(|w| w & (1 << within) != 0)
|
||||
}
|
||||
|
||||
/// `GetMapLandmarkInfo`'s `textureIndex` leg (`0x4a8848`–`0x4a8877`): the row's own `Icon`, except
|
||||
/// that at **zone** level a row without `Flags & 0x80` is substituted with the constant **15**.
|
||||
///
|
||||
/// The substitution and the world-state gate are one designed mechanism, and the shipped data
|
||||
/// proves it: across all 339 rows, `Flags & 0x80` is set exactly when `WorldStateID != 0`. A
|
||||
/// battleground node or an Eastern Plaguelands tower whose icon *means* something — Alliance vs
|
||||
/// Horde vs contested — has to escape a substitution that would flatten all of them to one cell.
|
||||
fn landmark_texture_index(poi: &benilla_formats::AreaPoi, level: MapLevel) -> u32 {
|
||||
/// The generic zone-level POI cell of `Interface\Minimap\POIIcons`.
|
||||
const ZONE_SUBSTITUTE: u32 = 15;
|
||||
match poi.flags & 0x80 != 0 || level != MapLevel::Zone {
|
||||
true => poi.icon,
|
||||
false => ZONE_SUBSTITUTE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Per frame: selection read-back → projection → feed push (see the module doc).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn feed_world_map(
|
||||
@@ -262,23 +430,32 @@ fn feed_world_map(
|
||||
group: Res<crate::ui_party::GroupState>,
|
||||
guids: Res<crate::net::GuidIndex>,
|
||||
unit_pos: Query<&GlobalTransform, With<crate::net::NetEntity>>,
|
||||
pois: Option<Res<crate::area_poi::AreaPoiRes>>,
|
||||
world_states: Res<crate::world_state::WorldStates>,
|
||||
mut last_explored: Local<crate::ui_script::VmMemo<Option<Vec<u32>>>>,
|
||||
mut last_landmarks: Local<crate::ui_script::VmMemo<Option<LandmarkKey>>>,
|
||||
) {
|
||||
let (Some(mut script), Some(data), Some(map), Some(areas)) = (script, data, map, areas) else {
|
||||
return;
|
||||
};
|
||||
let last_explored = last_explored.get(&script);
|
||||
|
||||
// The discovery bitset (PLAYER_EXPLORED_ZONES, PRIVATE — only our own avatar carries it):
|
||||
// push on change (including the first stream-in); the engine setter queues the
|
||||
// WORLD_MAP_UPDATE that fills newly explored art into an open zone map.
|
||||
if let Some(store) = self_q.iter().next() {
|
||||
let explored: Vec<u32> = (0..benilla_protocol::messages::PLAYER_EXPLORED_ZONES_SLOTS)
|
||||
.map(|i| store.0.player_explored_zone_slot(i))
|
||||
.collect();
|
||||
if last_explored.as_ref() != Some(&explored) {
|
||||
// WORLD_MAP_UPDATE that fills newly explored art into an open zone map. It is also the
|
||||
// landmark pass's exploration gate below, so it is resolved before either use.
|
||||
let explored: Vec<u32> = self_q
|
||||
.iter()
|
||||
.next()
|
||||
.map(|store| {
|
||||
(0..benilla_protocol::messages::PLAYER_EXPLORED_ZONES_SLOTS)
|
||||
.map(|i| store.0.player_explored_zone_slot(i))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
{
|
||||
let last_explored = last_explored.get(&script);
|
||||
if !explored.is_empty() && last_explored.as_ref() != Some(&explored) {
|
||||
*last_explored = Some(explored.clone());
|
||||
script.set_world_map_explored(explored);
|
||||
script.set_world_map_explored(explored.clone());
|
||||
}
|
||||
}
|
||||
let wow = bevy_to_wow(player.pos);
|
||||
@@ -334,28 +511,66 @@ fn feed_world_map(
|
||||
)
|
||||
});
|
||||
|
||||
// The map's POI icons (`GetNumMapLandmarks`) — today just the guard-directions marker
|
||||
// (`crate::poi_marker`), projected through the same law as the two blips above. A landmark
|
||||
// list carries no `(0,0)` hide sentinel for its Lua to read (unlike the blips), so off-map is
|
||||
// dropped here instead — including the `(0,0)` `zone_uv` answers with for a position outside
|
||||
// the displayed rect. That last one is a deliberate divergence: the reference hands its
|
||||
// landmark position back unclamped and filters only on the RECORD's position being non-zero,
|
||||
// so a marker in the next zone over lands a flag on the sheet's top-left corner (wow-re
|
||||
// `gossip-poi-marker.md`, `0x4a8740`). Dropping it is the same call as 1514's map stamp.
|
||||
let landmarks: Vec<WorldMapLandmarkView> = poi_marker
|
||||
.poi
|
||||
.iter()
|
||||
.filter_map(|poi| {
|
||||
let uv = project(poi.continent_id, poi.pos[0], poi.pos[1])?;
|
||||
(uv != (0.0, 0.0)).then(|| WorldMapLandmarkView {
|
||||
name: poi.name.clone(),
|
||||
description: poi.description.clone(),
|
||||
texture_index: poi.icon,
|
||||
uv,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
script.set_world_map_landmarks(landmarks);
|
||||
// ── The map's POI icons (`GetNumMapLandmarks`) — the reference's landmark-list builder
|
||||
// `0x4a67a0`, rebuilt only when one of its inputs moves (see [`LandmarkKey`]).
|
||||
let marker = poi_marker
|
||||
.on_map(map.0)
|
||||
.map(|m| (m.continent_id, m.pos.map(f32::to_bits), m.icon));
|
||||
let states_gen = world_states.generation();
|
||||
let unchanged = last_landmarks
|
||||
.get(&script)
|
||||
.as_ref()
|
||||
.is_some_and(|k| k.matches((c, z), map.0, states_gen, &explored, marker));
|
||||
if !unchanged {
|
||||
let level = MapLevel::of(c, z);
|
||||
let mut landmarks = Vec::new();
|
||||
// The DBC rows first, in file order, each through the builder's gate chain — that order
|
||||
// IS the Lua landmark index (`0x4a6819`'s walk over `[0xc0e054]`).
|
||||
if let Some(pois) = pois.as_ref() {
|
||||
for (_, poi) in pois.0.rows() {
|
||||
if !landmark_gates_pass(poi, level, &areas.0, &explored, &world_states) {
|
||||
continue;
|
||||
}
|
||||
let Some(uv) = project(poi.continent_id, poi.pos[0], poi.pos[1]) else {
|
||||
continue;
|
||||
};
|
||||
if is_degenerate(uv) {
|
||||
continue;
|
||||
}
|
||||
landmarks.push(WorldMapLandmarkView {
|
||||
name: poi.name.clone(),
|
||||
description: poi.description.clone(),
|
||||
texture_index: landmark_texture_index(poi, level),
|
||||
uv,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Then the guard-directions marker (`crate::poi_marker`), appended last exactly as
|
||||
// `0x4a69c7` does and — like it — exempt from every gate above: no level flag, no
|
||||
// exploration bit, no world state, its `Icon` verbatim with no level-15 substitution
|
||||
// (wow-re `gossip-poi-marker.md` §8, the `+0x10 == 1` element). Only the same
|
||||
// non-degenerate projection test applies.
|
||||
if let Some(poi) = poi_marker.on_map(map.0) {
|
||||
if let Some(uv) = project(poi.continent_id, poi.pos[0], poi.pos[1]) {
|
||||
if !is_degenerate(uv) {
|
||||
landmarks.push(WorldMapLandmarkView {
|
||||
name: poi.name.clone(),
|
||||
description: poi.description.clone(),
|
||||
texture_index: poi.icon,
|
||||
uv,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
*last_landmarks.get(&script) = Some(LandmarkKey {
|
||||
selection: (c, z),
|
||||
map: map.0,
|
||||
states: states_gen,
|
||||
explored: explored.clone(),
|
||||
marker,
|
||||
});
|
||||
script.set_world_map_landmarks(landmarks);
|
||||
}
|
||||
|
||||
// The party slots' blips (report B320) — `party1..4` in the same order the frames and the
|
||||
// unit tokens use (`GroupState::party_slots`).
|
||||
@@ -493,3 +708,302 @@ impl Plugin for WorldMapUiPlugin {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::world_state::WorldStates;
|
||||
use benilla_formats::AreaPoi;
|
||||
|
||||
/// A bare row; each test sets only the columns its gate reads.
|
||||
fn poi(flags: u32, area_id: u32, world_state_id: u32) -> AreaPoi {
|
||||
AreaPoi {
|
||||
importance: 0,
|
||||
icon: 4,
|
||||
faction_id: 0,
|
||||
pos: [0.0, 0.0, 0.0],
|
||||
continent_id: 0,
|
||||
flags,
|
||||
area_id,
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
world_state_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_areas() -> benilla_formats::AreaTableCatalog {
|
||||
benilla_formats::AreaTableCatalog::from_rows(Vec::new())
|
||||
}
|
||||
|
||||
/// The level-flag chain, in the shape §7 says it has: `0x04` for zone, `0x08` for continent,
|
||||
/// and world demanding **both** `0x10` and `0x08` because the tests fall through rather than
|
||||
/// switch. A row with `0x10` alone is invisible everywhere — the case the shipped data can't
|
||||
/// exhibit (no 5875 row sets `0x10` without `0x08`) and the one a switch would get wrong.
|
||||
#[test]
|
||||
fn the_level_flag_chain_falls_through_at_world_level() {
|
||||
let (areas, states, explored) = (empty_areas(), WorldStates::default(), Vec::new());
|
||||
let pass = |flags, level| {
|
||||
landmark_gates_pass(&poi(flags, 0, 0), level, &areas, &explored, &states)
|
||||
};
|
||||
|
||||
// A town (0x0d = 0x01|0x04|0x08): zone and continent, not the world sheet.
|
||||
assert!(pass(0x0d, MapLevel::Zone));
|
||||
assert!(pass(0x0d, MapLevel::Continent));
|
||||
assert!(!pass(0x0d, MapLevel::World));
|
||||
|
||||
// A capital (0x1d = 0x0d|0x10): all three.
|
||||
assert!(pass(0x1d, MapLevel::Zone));
|
||||
assert!(pass(0x1d, MapLevel::Continent));
|
||||
assert!(pass(0x1d, MapLevel::World));
|
||||
|
||||
// An Eastern Plaguelands tower (0x87 = 0x01|0x02|0x04|0x80): zone only.
|
||||
assert!(pass(0x87, MapLevel::Zone));
|
||||
assert!(!pass(0x87, MapLevel::Continent));
|
||||
assert!(!pass(0x87, MapLevel::World));
|
||||
|
||||
// `0x10` alone: the fall-through means world level still wants `0x08`, so nowhere.
|
||||
assert!(!pass(0x10, MapLevel::World));
|
||||
assert!(!pass(0x10, MapLevel::Continent));
|
||||
assert!(!pass(0x10, MapLevel::Zone));
|
||||
}
|
||||
|
||||
/// The world-state gate — the Eastern Plaguelands tower mechanism. A row with no
|
||||
/// `WorldStateID` is ungated; a row with one appears exactly while that key reads non-zero,
|
||||
/// so flipping the state swaps which of the sibling rows exists.
|
||||
#[test]
|
||||
fn a_world_state_row_appears_only_while_its_key_is_set() {
|
||||
let (areas, explored) = (empty_areas(), Vec::new());
|
||||
let mut states = WorldStates::default();
|
||||
// 2372 / 2373 are the real pair for Northpass Tower: Alliance-held / Horde-held.
|
||||
let alliance = poi(0x87, 0, 2372);
|
||||
let horde = poi(0x87, 0, 2373);
|
||||
let plain = poi(0x87, 0, 0);
|
||||
let pass = |p: &AreaPoi, st: &WorldStates| {
|
||||
landmark_gates_pass(p, MapLevel::Zone, &areas, &explored, st)
|
||||
};
|
||||
|
||||
assert!(
|
||||
pass(&plain, &states),
|
||||
"an ungated row is always a candidate"
|
||||
);
|
||||
assert!(!pass(&alliance, &states), "nothing received yet");
|
||||
assert!(!pass(&horde, &states));
|
||||
|
||||
states.write(&[(2372, 1)]);
|
||||
assert!(pass(&alliance, &states));
|
||||
assert!(!pass(&horde, &states));
|
||||
|
||||
// The server flips the tower: the Alliance row's state goes to 0, the Horde row's to 1.
|
||||
states.write(&[(2372, 0), (2373, 1)]);
|
||||
assert!(!pass(&alliance, &states));
|
||||
assert!(pass(&horde, &states));
|
||||
}
|
||||
|
||||
/// The exploration gate and its two exemptions: a continent-wide row (`AreaID` `-1`, which
|
||||
/// arrives as `u32::MAX`) and a row whose area has `ExplorationLevel < 0` are never gated;
|
||||
/// everything else waits for the bit.
|
||||
#[test]
|
||||
fn the_exploration_gate_reads_the_right_bit_and_exempts_the_right_rows() {
|
||||
use benilla_formats::AreaTableRow;
|
||||
let row = |explore_flag, exploration_level| AreaTableRow {
|
||||
map_id: 0,
|
||||
zone_id: 0,
|
||||
explore_flag,
|
||||
flags: 0,
|
||||
faction_group_mask: 0,
|
||||
exploration_level,
|
||||
name: String::new(),
|
||||
};
|
||||
let areas = benilla_formats::AreaTableCatalog::from_rows(vec![
|
||||
(139, row(40, 0)), // gated
|
||||
(200, row(41, -1)), // exempt by ExplorationLevel
|
||||
]);
|
||||
let states = WorldStates::default();
|
||||
// Bit 40 → slot 1, bit 8 within it.
|
||||
let unexplored = vec![0u32; 4];
|
||||
let explored = {
|
||||
let mut v = vec![0u32; 4];
|
||||
v[1] = 1 << 8;
|
||||
v
|
||||
};
|
||||
let pass =
|
||||
|p: &AreaPoi, ex: &[u32]| landmark_gates_pass(p, MapLevel::Zone, &areas, ex, &states);
|
||||
|
||||
assert!(!pass(&poi(0x04, 139, 0), &unexplored), "not discovered yet");
|
||||
assert!(pass(&poi(0x04, 139, 0), &explored));
|
||||
assert!(
|
||||
pass(&poi(0x04, 200, 0), &unexplored),
|
||||
"ExplorationLevel -1 exempts the row"
|
||||
);
|
||||
assert!(
|
||||
pass(&poi(0x04, u32::MAX, 0), &unexplored),
|
||||
"AreaID -1 (continent-wide) is not > 0, so no gate"
|
||||
);
|
||||
assert!(pass(&poi(0x04, 0, 0), &unexplored), "AreaID 0 likewise");
|
||||
assert!(
|
||||
pass(&poi(0x04, 999, 0), &unexplored),
|
||||
"an AreaID the table does not carry cannot be tested"
|
||||
);
|
||||
}
|
||||
|
||||
/// `GetMapLandmarkInfo`'s texture leg: the level-15 substitution bites only at zone level and
|
||||
/// only on a row without `Flags & 0x80` — which is exactly the rows with no live world state.
|
||||
#[test]
|
||||
fn the_zone_level_icon_substitution_spares_the_world_state_rows() {
|
||||
let town = poi(0x0d, 0, 0);
|
||||
let tower = poi(0x87, 0, 2372);
|
||||
assert_eq!(landmark_texture_index(&town, MapLevel::Zone), 15);
|
||||
assert_eq!(
|
||||
landmark_texture_index(&town, MapLevel::Continent),
|
||||
town.icon
|
||||
);
|
||||
assert_eq!(landmark_texture_index(&town, MapLevel::World), town.icon);
|
||||
for level in [MapLevel::Zone, MapLevel::Continent, MapLevel::World] {
|
||||
assert_eq!(
|
||||
landmark_texture_index(&tower, level),
|
||||
tower.icon,
|
||||
"Flags & 0x80 escapes the substitution at every level"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The near-zero skip is on **both** axes, not either — a POI on the top edge of a rect
|
||||
/// (`v == 0`) stays on the map.
|
||||
#[test]
|
||||
fn only_a_both_axes_zero_projection_is_dropped() {
|
||||
assert!(is_degenerate((0.0, 0.0)));
|
||||
assert!(is_degenerate((1e-8, -1e-8)));
|
||||
assert!(!is_degenerate((0.0, 0.5)));
|
||||
assert!(!is_degenerate((0.5, 0.0)));
|
||||
assert!(!is_degenerate((1e-6, 0.0)));
|
||||
}
|
||||
|
||||
/// Our `(continent, zone)` pair, `0` = whole, onto the reference's levels.
|
||||
#[test]
|
||||
fn the_selection_pair_maps_onto_the_reference_levels() {
|
||||
assert_eq!(MapLevel::of(0, 0), MapLevel::World);
|
||||
assert_eq!(MapLevel::of(0, 3), MapLevel::World, "continent 0 wins");
|
||||
assert_eq!(MapLevel::of(2, 0), MapLevel::Continent);
|
||||
assert_eq!(MapLevel::of(2, 7), MapLevel::Zone);
|
||||
}
|
||||
|
||||
/// The REAL `AreaPOI.dbc`, run through the whole gate chain the way the feed does — the
|
||||
/// report B190 asks about, answered off the shipped table rather than a fixture.
|
||||
///
|
||||
/// Two claims: the Eastern Plaguelands towers exist as world-state-gated zone-level rows and
|
||||
/// change with the state; and the capitals are continent-level rows that need no exploration
|
||||
/// bit (which is why a fresh character sees Stormwind on the Eastern Kingdoms map).
|
||||
/// Skips without client data.
|
||||
#[test]
|
||||
fn the_real_table_gates_the_epl_towers_and_the_city_icons() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("chain");
|
||||
let pois = benilla_formats::load_area_poi_catalog(&mut chain).expect("AreaPOI");
|
||||
let areas = benilla_formats::load_area_table_catalog(&mut chain).expect("AreaTable");
|
||||
let nothing_explored = vec![0u32; 64];
|
||||
|
||||
// ── The towers. Northpass Tower's Alliance-held row (1768, state 2372).
|
||||
let (_, tower) = pois
|
||||
.rows()
|
||||
.find(|(id, _)| *id == 1768)
|
||||
.expect("AreaPOI 1768");
|
||||
assert_eq!(tower.name, "Northpass Tower");
|
||||
assert_eq!(tower.world_state_id, 2372);
|
||||
assert_eq!(
|
||||
tower.flags & 0x80,
|
||||
0x80,
|
||||
"escapes the level-15 substitution"
|
||||
);
|
||||
assert_eq!(tower.area_id, 139, "Eastern Plaguelands");
|
||||
|
||||
let mut states = WorldStates::default();
|
||||
let explored_epl = {
|
||||
let bit = areas.get(139).expect("EPL area row").explore_flag;
|
||||
let mut v = vec![0u32; 64];
|
||||
v[bit as usize / 32] |= 1 << (bit % 32);
|
||||
v
|
||||
};
|
||||
let tower_shows = |st: &WorldStates, ex: &[u32]| {
|
||||
landmark_gates_pass(tower, MapLevel::Zone, &areas, ex, st)
|
||||
};
|
||||
assert!(
|
||||
!tower_shows(&states, &explored_epl),
|
||||
"no world states received — no tower icon (this IS report B190)"
|
||||
);
|
||||
states.write(&[(2372, 1)]);
|
||||
assert!(tower_shows(&states, &explored_epl), "Alliance holds it");
|
||||
assert!(
|
||||
!tower_shows(&states, ¬hing_explored),
|
||||
"and it still needs the zone discovered"
|
||||
);
|
||||
assert!(
|
||||
!landmark_gates_pass(tower, MapLevel::Continent, &areas, &explored_epl, &states),
|
||||
"a tower is a zone-level row (Flags 0x87 carries no 0x08)"
|
||||
);
|
||||
|
||||
// ── The capitals: continent-level, and exploration-exempt.
|
||||
let states = WorldStates::default();
|
||||
for (id, name) in [
|
||||
(16u32, "Stormwind"),
|
||||
(8, "Ironforge"),
|
||||
(18, "The Undercity"),
|
||||
] {
|
||||
let (_, city) = pois.rows().find(|(r, _)| *r == id).expect("city row");
|
||||
assert_eq!(city.name, name);
|
||||
assert_eq!(
|
||||
city.area_id,
|
||||
u32::MAX,
|
||||
"continent-wide, so no exploration gate"
|
||||
);
|
||||
for level in [MapLevel::World, MapLevel::Continent, MapLevel::Zone] {
|
||||
assert!(
|
||||
landmark_gates_pass(city, level, &areas, ¬hing_explored, &states),
|
||||
"{name} shows at every level on a fresh character"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
landmark_texture_index(city, MapLevel::Continent),
|
||||
city.icon,
|
||||
"the city icon is its own at continent level"
|
||||
);
|
||||
assert_eq!(
|
||||
landmark_texture_index(city, MapLevel::Zone),
|
||||
15,
|
||||
"and the generic cell inside a zone map"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// File order is the landmark index: the catalog must hand rows back in the DBC's own record
|
||||
/// order, not a hash permutation (which would also make the feed's change-diff fire forever).
|
||||
/// Skips without client data.
|
||||
#[test]
|
||||
fn the_catalog_preserves_dbc_file_order() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("chain");
|
||||
let a: Vec<u32> = benilla_formats::load_area_poi_catalog(&mut chain)
|
||||
.expect("AreaPOI")
|
||||
.rows()
|
||||
.map(|(id, _)| id)
|
||||
.collect();
|
||||
let b: Vec<u32> = benilla_formats::load_area_poi_catalog(&mut chain)
|
||||
.expect("AreaPOI")
|
||||
.rows()
|
||||
.map(|(id, _)| id)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
a, b,
|
||||
"two loads agree — the order is the file's, not a hash's"
|
||||
);
|
||||
assert!(a.len() > 300);
|
||||
assert_ne!(
|
||||
a,
|
||||
{
|
||||
let mut sorted = a.clone();
|
||||
sorted.sort_unstable();
|
||||
sorted
|
||||
},
|
||||
"and it is genuinely file order, not id order"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
//! The world-state table — the server's key/value scoreboard, fed by `SMSG_INIT_WORLD_STATES` and
|
||||
//! `SMSG_UPDATE_WORLD_STATE` ([`benilla_protocol::messages::world_state`] for both wires).
|
||||
//!
|
||||
//! Its one reader today is the NPC-text expander's `$<n>w` / `$<n>e` tokens
|
||||
//! ([`crate::npc_text`]): the reference renders a hit as `%d` and a **miss as `"0"`**, so every
|
||||
//! such token printed `"0"` until this landed — faithful while the table was empty, wrong the
|
||||
//! moment a zone sent real states. A battleground/PvP scoreboard is the other consumer, later.
|
||||
//! Two readers:
|
||||
//!
|
||||
//! - the NPC-text expander's `$<n>w` / `$<n>e` tokens ([`crate::npc_text`]) — the reference
|
||||
//! renders a hit as `%d` and a **miss as `"0"`**, so every such token printed `"0"` until this
|
||||
//! landed: faithful while the table was empty, wrong the moment a zone sent real states;
|
||||
//! - the **world map's landmark pass** ([`crate::ui_world_map`], decision 1586) — an `AreaPOI.dbc`
|
||||
//! row carrying a `WorldStateID` shows only while that state reads non-zero, which is what makes
|
||||
//! the Eastern Plaguelands tower icons and the capitals' "Under Attack" markers appear and
|
||||
//! change side. [`WorldStates::generation`] is the edge it rebuilds on.
|
||||
//!
|
||||
//! Shape is the reference's, not a convenience: it keeps an open hash keyed by the **raw wire
|
||||
//! dword** (`[0xb71ec8]`, bucket `mask & key`, value at node `+0x18`, miss → `0` at `0x4c5867`).
|
||||
//! Raw matters because `$<n>e` reads the table at the *negated* key — `$2077e` looks up
|
||||
//! `0xFFFFF7E3`, which only resolves if nothing re-interpreted the id on the way in.
|
||||
//!
|
||||
//! **One thing is deliberately not modelled, because it is not known.** The reference hands
|
||||
//! `SMSG_INIT_WORLD_STATES`' two leading dwords (map, zone) to `0x4c5650` before the pair loop
|
||||
//! runs, and wow-re has not carved that function — so whether an init *clears* the table first is
|
||||
//! unrecorded. We do not clear: with no evidence either way, inventing a reset is the speculative
|
||||
//! act, and vmangos re-sends its full default set on every zone change (`Player.cpp:8267-8270`),
|
||||
//! which makes a stale key from a previous zone hard to reach in practice. If `0x4c5650` turns out
|
||||
//! to reset, this is a one-line change in [`WorldStates::write`]'s caller.
|
||||
//! **An init CLEARS the table first** — carved 2026-08-25 (wow-re
|
||||
//! `system/ui/scratch/worldstate-ui-law.md`), correcting the assumption this module shipped with.
|
||||
//! `0x4c5650(ecx = map, edx = area)`, the function `SMSG_INIT_WORLD_STATES`' two leading dwords go
|
||||
//! to before the pair loop runs, does four things in order: it drains every entry (the all-entries
|
||||
//! list, then every bucket), stores the pair as the **display filter** `[0xb71e84]`/`[0xb71ea8]`,
|
||||
//! rebuilds the world-state UI list against that filter, and only then does the handler apply the
|
||||
//! packet's pairs. The bucket array itself survives; only entries are freed.
|
||||
//!
|
||||
//! That filter pair is [`WorldStates::scope`], and it is the reference's own notion of "where the
|
||||
//! player is" for this UI — **the server's last init, never the avatar's position**. Its only two
|
||||
//! writers image-wide are this clear and the logout reset.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -27,9 +36,23 @@ use bevy::prelude::*;
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct WorldStates {
|
||||
values: HashMap<u32, u32>,
|
||||
generation: u64,
|
||||
scope: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
impl WorldStates {
|
||||
/// Bumped on every [`Self::write`] — the "the table moved" edge a reader rebuilds on.
|
||||
///
|
||||
/// This is the reference's own trigger shape, not a cache trick bolted on: the bulk
|
||||
/// world-state handler re-runs the world-map landmark builder from *inside itself*
|
||||
/// (`0x48fa0d` → `0x4a67a0`, wow-re `gossip-poi-marker.md` §8.2), so a landmark list is
|
||||
/// rebuilt **because a world state arrived**, never once a frame. Counted per `write` rather
|
||||
/// than per changed value for the same reason: the handler re-runs the builder once for the
|
||||
/// whole packet, not once per pair.
|
||||
pub(crate) fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
|
||||
/// The reference's getter `0x4c5810`, including its miss leg: an absent key reads **0**, which
|
||||
/// is why an un-received world state renders `"0"` rather than blank or the literal token.
|
||||
///
|
||||
@@ -39,11 +62,33 @@ impl WorldStates {
|
||||
self.values.get(&key).copied().unwrap_or(0) as i32
|
||||
}
|
||||
|
||||
/// The `(map, area)` the last `SMSG_INIT_WORLD_STATES` scoped the table to — the reference's
|
||||
/// `[0xb71e84]`/`[0xb71ea8]`, and the filter its world-state UI list is built against. `None`
|
||||
/// before any init has arrived, which is the reference's `-1` (its rebuild trigger refuses to
|
||||
/// run until an init has been seen).
|
||||
pub(crate) fn scope(&self) -> Option<(u32, u32)> {
|
||||
self.scope
|
||||
}
|
||||
|
||||
/// `SMSG_INIT_WORLD_STATES`' leading half — the reference's `0x4c5650`: **drop every entry**,
|
||||
/// then record the `(map, area)` filter. The caller applies the packet's pairs after; the
|
||||
/// order is the reference's, and it is what makes a zone change forget the previous zone's
|
||||
/// keys rather than leaving them readable.
|
||||
pub(crate) fn init_scope(&mut self, map: u32, area: u32) {
|
||||
self.values.clear();
|
||||
self.scope = Some((map, area));
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// Apply a run of `(id, value)` writes — the reference's setter `0x4c5870`, which both opcodes
|
||||
/// funnel into. Init's trailing `(0, 0)` terminator is written like any other pair, exactly as
|
||||
/// the reference's count loop does; it is data, not a sentinel.
|
||||
/// the reference's count loop does; it is data, not a sentinel — and `Set(k, 0)` **stores**
|
||||
/// rather than deleting (`edx` is never tested across `[0x4c5870, 0x4c5a31)`), which is why a
|
||||
/// state that goes to zero stays a present key reading `0`. A repeated key overwrites in place
|
||||
/// (`0x4c58bb`); there is never a second node.
|
||||
pub(crate) fn write(&mut self, states: &[(u32, u32)]) {
|
||||
self.values.extend(states.iter().copied());
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
//! The always-up world-state readout (`WorldStateFrame`) — the app half behind
|
||||
//! `assets/ui/WorldStateFrame.xml` and benilla-ui's `script/worldstate.rs` bindings.
|
||||
//!
|
||||
//! This is report **B190**'s second half: the alliance↔horde progress UI. `WorldStateUI.dbc` says
|
||||
//! which world states are *displayed*, where, and with what label; [`crate::world_state`] holds
|
||||
//! what the server actually sent; this module joins them and pushes the result.
|
||||
//!
|
||||
//! Everything below is the reference's, carved 2026-08-25 (wow-re
|
||||
//! `system/ui/scratch/worldstate-ui-law.md`).
|
||||
//!
|
||||
//! ## The list builder (`0x4c56e0`)
|
||||
//!
|
||||
//! Walks the whole DBC and admits a row on three gates, each failure skipping the row rather than
|
||||
//! aborting the walk:
|
||||
//!
|
||||
//! 1. **Map** — `MapID == -1` (a wildcard with *zero* shipped rows) or `MapID` equals the scope's
|
||||
//! map;
|
||||
//! 2. **Area** — `AreaID == 0` (wildcard) or equal to the scope's area;
|
||||
//! 3. **Type** — `0` accepts unconditionally, `1` accepts only while [`defense_channel_joined`],
|
||||
//! `2` (the battleground scoreboard columns) always rejects.
|
||||
//!
|
||||
//! **The scope is the server's last `SMSG_INIT_WORLD_STATES`, not the player's position**
|
||||
//! ([`crate::world_state::WorldStates::scope`]). That is not a simplification: the two globals it
|
||||
//! mirrors have exactly two writers each image-wide, the init clear and the logout reset, so a
|
||||
//! plain zone change moves nothing here. It matters — walk into Eastern Plaguelands and the readout
|
||||
//! appears when the *server* says so, which is also when the states it would read arrive.
|
||||
//!
|
||||
//! ## Why the towers need a chat channel
|
||||
//!
|
||||
//! The `Type == 1` gate is the surprising one, and it is the whole of what the world-PvP rows
|
||||
//! (Eastern Plaguelands' three, Silithus's two) hang on. The reference recomputes a flag by
|
||||
//! scanning the channels the player has **joined** and asking whether any of their
|
||||
//! `ChatChannels.dbc` rows carries **both** `ZONE_DEP` and `DEFENSE` — which in the shipped table
|
||||
//! is row 22 alone, the zone-scoped defense channel. Leave that channel and the tower readout
|
||||
//! disappears; the map icons, which are gated on the world states themselves, stay.
|
||||
//!
|
||||
//! ## The text (`0x508560`)
|
||||
//!
|
||||
//! Exactly one of the ten returned values is expanded, and by an expander that is **not** the
|
||||
//! NPC-text one — different function, different grammar, sharing only the table getter. See
|
||||
//! [`expand`].
|
||||
|
||||
use bevy::ecs::system::NonSendMut;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_formats::{chat_channel_flags as chan, WorldStateUiCatalog, WorldStateUiRow};
|
||||
use benilla_ui::script::{UiScript, WorldStateUiView};
|
||||
|
||||
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
|
||||
|
||||
use crate::world_state::WorldStates;
|
||||
|
||||
/// The shared `WorldStateUI.dbc` catalog. Absent if the DBC failed to read, which degrades to an
|
||||
/// empty readout rather than an error.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct WorldStateUiRes(pub(crate) WorldStateUiCatalog);
|
||||
|
||||
/// Startup: load the table off the patch chain.
|
||||
fn load_world_state_ui(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
|
||||
let Some(assets) = assets else { return };
|
||||
let loaded = {
|
||||
let mut chain = assets.chain.lock_recover();
|
||||
benilla_formats::load_world_state_ui_catalog(&mut chain)
|
||||
};
|
||||
match loaded {
|
||||
Ok(cat) => {
|
||||
info!("world-state UI: {} rows in WorldStateUI.dbc", cat.len());
|
||||
commands.insert_resource(WorldStateUiRes(cat));
|
||||
}
|
||||
Err(e) => warn!("world-state UI: WorldStateUI.dbc failed — no readout: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `Type == 1` gate: has the player joined a **zone-dependent defense** channel?
|
||||
///
|
||||
/// The reference scans its joined-channel array and tests each channel's `ChatChannels.dbc`
|
||||
/// `Flags` for `0x2 | 0x10000` (`0x49bd9b`/`0x49bda2`) — both bits, not either. Exactly one shipped
|
||||
/// row qualifies: id 22, flags `0x010003`. Our joined list is names, so each resolves back to its
|
||||
/// row the same way the server resolves it (`row_for_name`); a custom channel resolves to nothing
|
||||
/// and cannot open the gate, which is right.
|
||||
fn defense_channel_joined(channels: &crate::ui_chat::ChannelState) -> bool {
|
||||
const REQUIRED: u32 = chan::ZONE_DEP | chan::DEFENSE;
|
||||
channels
|
||||
.joined
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter_map(|name| channels.channels.row_for_name(name))
|
||||
.any(|row| row.flags & REQUIRED == REQUIRED)
|
||||
}
|
||||
|
||||
/// Expand a `WorldStateUI` label — the reference's `0x508560`, and **not** the NPC-text expander
|
||||
/// ([`crate::npc_text`]). The two share only the world-state getter: every `call` in this one
|
||||
/// resolves to a CRT helper, the getter, or the string primitives, and the quest-text token
|
||||
/// handler `0x5070a0` is not among them.
|
||||
///
|
||||
/// The grammar is `%<digits>W|w` **and nothing else** — no `$` sigil, and no `e` (negated-key)
|
||||
/// form. A hit reads the table at the parsed id and prints the value through `"%d"`. Anything else
|
||||
/// after a `%` emits a literal `"%"`, sets an error flag the caller ignores, and **leaves the
|
||||
/// offending character unconsumed** so it falls through as ordinary text — the digits scanned ahead
|
||||
/// of it are consumed either way. No shipped row reaches that leg (all nine macro-bearing rows are
|
||||
/// well-formed), so it is reproduced rather than relied upon.
|
||||
///
|
||||
/// The reference writes into a 260-byte buffer with a 256-byte limit, so a label longer than that
|
||||
/// is truncated. Kept, because a UI string silently growing past what the reference would show is
|
||||
/// a difference the frame would render.
|
||||
fn expand(text: &str, states: &WorldStates) -> String {
|
||||
/// The reference's `strncat` bound — `0x508560`'s caller passes `0x100`.
|
||||
const LIMIT: usize = 0x100 - 1;
|
||||
|
||||
fn push(s: &str, out: &mut String) {
|
||||
for ch in s.chars() {
|
||||
if out.len() + ch.len_utf8() > LIMIT {
|
||||
return;
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let bytes = text.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] != b'%' {
|
||||
let ch = text[i..].chars().next().expect("char boundary");
|
||||
push(ch.encode_utf8(&mut [0u8; 4]), &mut out);
|
||||
i += ch.len_utf8();
|
||||
continue;
|
||||
}
|
||||
let digits_at = i + 1;
|
||||
let mut j = digits_at;
|
||||
while j < bytes.len() && bytes[j].is_ascii_digit() {
|
||||
j += 1;
|
||||
}
|
||||
match bytes.get(j) {
|
||||
Some(b'w' | b'W') => {
|
||||
// `atoi` over the run — an empty run is 0, which reads the table at key 0.
|
||||
let id: u32 = text[digits_at..j].parse().unwrap_or(0);
|
||||
push(&states.get(id).to_string(), &mut out);
|
||||
i = j + 1;
|
||||
}
|
||||
// The error leg: a literal `%`, and the letter is left for the next pass.
|
||||
_ => {
|
||||
push("%", &mut out);
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Build the rows for one scope — the builder's gates, then `GetWorldStateUIInfo`'s resolution of
|
||||
/// each admitted row into the ten values it answers with.
|
||||
fn build(
|
||||
catalog: &WorldStateUiCatalog,
|
||||
states: &WorldStates,
|
||||
defense_channel: bool,
|
||||
) -> Vec<WorldStateUiView> {
|
||||
let Some((map, area)) = states.scope() else {
|
||||
// No init seen. The reference's rebuild trigger refuses to run before one arrives, and its
|
||||
// filter globals read `-1`, which no row matches.
|
||||
return Vec::new();
|
||||
};
|
||||
catalog
|
||||
.rows()
|
||||
.filter(|(_, row)| admits(row, map, area, defense_channel))
|
||||
.map(|(_, row)| resolve(row, states))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The three gates, in the builder's order (see the module doc).
|
||||
fn admits(row: &WorldStateUiRow, map: u32, area: u32, defense_channel: bool) -> bool {
|
||||
let map_ok = row.map_id == u32::MAX || row.map_id == map;
|
||||
let area_ok = row.area_id == 0 || row.area_id == area;
|
||||
let type_ok = match row.ui_type {
|
||||
0 => true,
|
||||
1 => defense_channel,
|
||||
_ => false,
|
||||
};
|
||||
map_ok && area_ok && type_ok
|
||||
}
|
||||
|
||||
/// `GetWorldStateUIInfo`'s ten values for one row (`0x4c5a70`). Only [`WorldStateUiRow::text`] is
|
||||
/// expanded; every other string is the DBC column verbatim, and the three extended ids answer
|
||||
/// their *resolved values*.
|
||||
fn resolve(row: &WorldStateUiRow, states: &WorldStates) -> WorldStateUiView {
|
||||
WorldStateUiView {
|
||||
// A row with no `StateVariable` answers the constant 1, not 0 — the miss leg is a literal
|
||||
// `1.0` at `0x4c5ad8`, so "no state of its own" reads as on.
|
||||
ui_state: match row.state_variable {
|
||||
0 => 1,
|
||||
id => states.get(id),
|
||||
},
|
||||
text: expand(&row.text, states),
|
||||
icon: row.icon.clone(),
|
||||
dynamic_icon: row.dynamic_icon.clone(),
|
||||
tooltip: row.tooltip.clone(),
|
||||
dynamic_tooltip: row.dynamic_tooltip.clone(),
|
||||
extended_ui: row.extended_ui.clone(),
|
||||
extended_ui_state: row.extended_ui_state.map(|id| states.get(id)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the readout when any of its inputs moves. The setter diffs before firing
|
||||
/// `UPDATE_WORLD_STATES`, so a frame in which nothing changed costs one table walk and no event.
|
||||
fn feed_world_state_ui(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
catalog: Option<Res<WorldStateUiRes>>,
|
||||
states: Res<WorldStates>,
|
||||
channels: Res<crate::ui_chat::ChannelState>,
|
||||
mut defense_channel: Local<bool>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<(u64, bool)>>>,
|
||||
) {
|
||||
// Recomputed only when the roster moves. The scan resolves each joined NAME back to its DBC
|
||||
// row, which allocates per row — cheap once a login, wasteful sixty times a second. A system
|
||||
// that has never run sees every resource as changed, so the first frame computes it.
|
||||
if channels.is_changed() {
|
||||
*defense_channel = defense_channel_joined(&channels);
|
||||
}
|
||||
let defense_channel = *defense_channel;
|
||||
let (Some(mut script), Some(catalog)) = (script, catalog) else {
|
||||
return;
|
||||
};
|
||||
// The reference's own two rebuild triggers: a world-state packet (which is also the only thing
|
||||
// that moves the scope) and the defense-channel flag flipping.
|
||||
let key = (states.generation(), defense_channel);
|
||||
if *last.get(&script) == Some(key) {
|
||||
return;
|
||||
}
|
||||
*last.get(&script) = Some(key);
|
||||
script.set_world_state_ui(build(&catalog.0, &states, defense_channel));
|
||||
}
|
||||
|
||||
pub(crate) struct WorldStateUiPlugin;
|
||||
|
||||
impl Plugin for WorldStateUiPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Startup, load_world_state_ui.after(AssetSet::Open))
|
||||
// After the script tick, like every other feed: the queued event dispatches next tick.
|
||||
.add_systems(Update, feed_world_state_ui.after(crate::ui_script::UiInput));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn row(map_id: u32, area_id: u32, ui_type: u32) -> WorldStateUiRow {
|
||||
WorldStateUiRow {
|
||||
map_id,
|
||||
area_id,
|
||||
icon: String::new(),
|
||||
text: String::new(),
|
||||
tooltip: String::new(),
|
||||
state_variable: 0,
|
||||
ui_type,
|
||||
dynamic_icon: String::new(),
|
||||
dynamic_tooltip: String::new(),
|
||||
extended_ui: String::new(),
|
||||
extended_ui_state: [0; 3],
|
||||
}
|
||||
}
|
||||
|
||||
/// The `%<digits>w` grammar: a hit prints the table value through `%d`, a miss prints `0`
|
||||
/// (the getter's own miss leg), and case does not matter.
|
||||
#[test]
|
||||
fn the_macro_expands_world_state_values() {
|
||||
let mut states = WorldStates::default();
|
||||
states.write(&[(2327, 3), (2328, 1)]);
|
||||
assert_eq!(
|
||||
expand("Towers Controlled: %2327w", &states),
|
||||
"Towers Controlled: 3"
|
||||
);
|
||||
assert_eq!(expand("%2327W/%2328w", &states), "3/1");
|
||||
assert_eq!(
|
||||
expand("Bases: %1779w Resources: %1776w/%1780w", &states),
|
||||
"Bases: 0 Resources: 0/0",
|
||||
"an un-received state reads 0, never blank"
|
||||
);
|
||||
assert_eq!(
|
||||
expand("Graveyards Assaulted", &states),
|
||||
"Graveyards Assaulted"
|
||||
);
|
||||
assert_eq!(expand("", &states), "");
|
||||
}
|
||||
|
||||
/// A value with the top bit set prints negative — the `%d` width, same as `$<n>w`.
|
||||
#[test]
|
||||
fn a_top_bit_value_prints_negative() {
|
||||
let mut states = WorldStates::default();
|
||||
states.write(&[(2327, 0xFFFF_FFFF)]);
|
||||
assert_eq!(expand("%2327w", &states), "-1");
|
||||
}
|
||||
|
||||
/// The error leg: a `%` not followed by an optional digit run and a `w` emits a literal `%`
|
||||
/// and leaves the offending character to fall through as text. It is unreachable on shipped
|
||||
/// data; it is here so the behaviour is decided rather than accidental.
|
||||
#[test]
|
||||
fn a_malformed_macro_emits_a_literal_percent() {
|
||||
let states = WorldStates::default();
|
||||
assert_eq!(expand("100% done", &states), "100% done");
|
||||
assert_eq!(expand("%d", &states), "%d");
|
||||
assert_eq!(expand("%", &states), "%");
|
||||
assert_eq!(
|
||||
expand("%123x", &states),
|
||||
"%x",
|
||||
"the digits scanned ahead of the bad letter are consumed either way"
|
||||
);
|
||||
}
|
||||
|
||||
/// The reference's 256-byte buffer bound.
|
||||
#[test]
|
||||
fn the_output_is_capped_at_the_reference_buffer() {
|
||||
let states = WorldStates::default();
|
||||
let long = "a".repeat(600);
|
||||
assert_eq!(expand(&long, &states).len(), 0xFF);
|
||||
}
|
||||
|
||||
/// The three builder gates. Map `-1` and area `0` are wildcards; `Type` 2 never enters this
|
||||
/// list at all, and `Type` 1 waits for the defense channel.
|
||||
#[test]
|
||||
fn the_builder_gates_admit_the_right_rows() {
|
||||
// Eastern Plaguelands: map 0, area 139.
|
||||
assert!(admits(&row(0, 139, 0), 0, 139, false));
|
||||
assert!(!admits(&row(0, 139, 0), 0, 1377, false), "wrong area");
|
||||
assert!(!admits(&row(0, 139, 0), 1, 139, false), "wrong map");
|
||||
assert!(admits(&row(0, 0, 0), 0, 139, false), "area 0 is a wildcard");
|
||||
assert!(
|
||||
admits(&row(u32::MAX, 139, 0), 5, 139, false),
|
||||
"map -1 is a wildcard"
|
||||
);
|
||||
|
||||
// Type: 0 unconditional, 1 on the channel, 2 never.
|
||||
assert!(admits(&row(0, 139, 1), 0, 139, true));
|
||||
assert!(
|
||||
!admits(&row(0, 139, 1), 0, 139, false),
|
||||
"the world-PvP rows need the zone-defense channel"
|
||||
);
|
||||
for joined in [true, false] {
|
||||
assert!(
|
||||
!admits(&row(0, 139, 2), 0, 139, joined),
|
||||
"a scoreboard column is never an always-up row"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Before any `SMSG_INIT_WORLD_STATES` the readout is empty — the reference's filter globals
|
||||
/// read `-1` and its rebuild trigger will not run at all.
|
||||
#[test]
|
||||
fn nothing_shows_before_the_first_init() {
|
||||
let catalog = WorldStateUiCatalog::from_rows(vec![(136, row(0, 139, 0))]);
|
||||
let states = WorldStates::default();
|
||||
assert!(build(&catalog, &states, true).is_empty());
|
||||
}
|
||||
|
||||
/// A row with no `StateVariable` answers `uiState = 1`, not `0` — the miss leg is a literal
|
||||
/// `1.0`, so "no state of its own" reads as on rather than off.
|
||||
#[test]
|
||||
fn a_row_without_a_state_variable_reads_one() {
|
||||
let states = WorldStates::default();
|
||||
assert_eq!(resolve(&row(0, 0, 0), &states).ui_state, 1);
|
||||
|
||||
let mut with_state = row(0, 0, 0);
|
||||
with_state.state_variable = 2339;
|
||||
assert_eq!(
|
||||
resolve(&with_state, &states).ui_state,
|
||||
0,
|
||||
"a state that HAS an id and reads 0 is 0"
|
||||
);
|
||||
let mut states = WorldStates::default();
|
||||
states.write(&[(2339, 1)]);
|
||||
assert_eq!(resolve(&with_state, &states).ui_state, 1);
|
||||
}
|
||||
|
||||
/// The extended-UI ids are answered as resolved *values*, not as the ids the DBC holds.
|
||||
#[test]
|
||||
fn the_extended_ui_ids_are_resolved_to_values() {
|
||||
let mut states = WorldStates::default();
|
||||
states.write(&[(2427, 42), (2428, 7)]);
|
||||
let mut r = row(0, 139, 1);
|
||||
r.extended_ui = "CAPTUREPOINT".into();
|
||||
r.extended_ui_state = [2427, 2428, 0];
|
||||
assert_eq!(resolve(&r, &states).extended_ui_state, [42, 7, 0]);
|
||||
}
|
||||
|
||||
/// The whole join, against the REAL table: in Eastern Plaguelands, with the zone-defense
|
||||
/// channel joined and the server's tower counts in, the readout is the two labelled tower rows
|
||||
/// plus the capture-point progress row — and without the channel it is empty. Skips without
|
||||
/// client data.
|
||||
#[test]
|
||||
fn the_real_table_builds_the_eastern_plaguelands_readout() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("chain");
|
||||
let catalog =
|
||||
benilla_formats::load_world_state_ui_catalog(&mut chain).expect("WorldStateUI");
|
||||
|
||||
let mut states = WorldStates::default();
|
||||
states.init_scope(0, 139); // the server scoping us to Eastern Plaguelands
|
||||
states.write(&[(2327, 3), (2328, 1), (2426, 1), (2427, 60), (2428, 40)]);
|
||||
|
||||
assert!(
|
||||
build(&catalog, &states, false).is_empty(),
|
||||
"no zone-defense channel — the world-PvP rows are all Type 1 (this IS report B190's \
|
||||
second half, and its least obvious gate)"
|
||||
);
|
||||
|
||||
let rows = build(&catalog, &states, true);
|
||||
assert_eq!(
|
||||
rows.len(),
|
||||
3,
|
||||
"the two tower counters plus the progress bar"
|
||||
);
|
||||
assert_eq!(rows[0].text, "Towers Controlled: 3");
|
||||
assert_eq!(rows[0].icon, "Interface\\WorldStateFrame\\AllianceTower");
|
||||
assert_eq!(rows[0].tooltip, "Alliance Towers Controlled");
|
||||
assert_eq!(rows[0].ui_state, 1, "no StateVariable of its own");
|
||||
assert_eq!(rows[1].text, "Towers Controlled: 1");
|
||||
assert_eq!(rows[1].icon, "Interface\\WorldStateFrame\\HordeTower");
|
||||
assert_eq!(rows[2].text, "Progress: 60");
|
||||
assert_eq!(rows[2].extended_ui, "CAPTUREPOINT");
|
||||
assert_eq!(rows[2].extended_ui_state, [60, 40, 0]);
|
||||
assert_eq!(rows[2].ui_state, 1, "state 2426 reads 1");
|
||||
|
||||
// The server flips a tower: the same rows, new numbers, no rebuild needed.
|
||||
states.write(&[(2327, 2), (2328, 2)]);
|
||||
let rows = build(&catalog, &states, true);
|
||||
assert_eq!(rows[0].text, "Towers Controlled: 2");
|
||||
assert_eq!(rows[1].text, "Towers Controlled: 2");
|
||||
|
||||
// Elsewhere on the same continent, nothing — the area gate.
|
||||
let mut elsewhere = WorldStates::default();
|
||||
elsewhere.init_scope(0, 12); // Elwynn Forest
|
||||
assert!(build(&catalog, &elsewhere, true).is_empty());
|
||||
|
||||
// Warsong Gulch: Type 0, so no channel needed, and its rows carry the dynamic flag icons.
|
||||
let mut wsg = WorldStates::default();
|
||||
wsg.init_scope(489, 0);
|
||||
wsg.write(&[(1581, 2), (1582, 1), (1601, 3), (2339, 1)]);
|
||||
let rows = build(&catalog, &wsg, false);
|
||||
assert_eq!(rows.len(), 2, "Type 0 needs no channel");
|
||||
assert_eq!(rows[0].text, "2/3");
|
||||
assert_eq!(
|
||||
rows[0].dynamic_icon,
|
||||
"Interface\\WorldStateFrame\\HordeFlag"
|
||||
);
|
||||
assert_eq!(rows[0].ui_state, 1, "state 2339 — the Horde flag is up");
|
||||
assert_eq!(rows[1].text, "1/3");
|
||||
assert_eq!(rows[1].ui_state, 0, "state 2338 — the Alliance flag is not");
|
||||
}
|
||||
|
||||
/// The `Type == 1` gate is only worth building if the channel it waits on is one the client
|
||||
/// actually joins — otherwise the whole Eastern Plaguelands readout ships dead. Against the
|
||||
/// real `ChatChannels.dbc`: exactly one row carries both `ZONE_DEP` and `DEFENSE`, it is
|
||||
/// `LocalDefense - %s`, and it is an auto-join row. `WorldDefense` is the control that could
|
||||
/// have made this pass for the wrong reason — it carries `DEFENSE` without `ZONE_DEP`, and
|
||||
/// must not open the gate. Skips without client data.
|
||||
#[test]
|
||||
fn the_gates_channel_is_one_the_client_joins_by_itself() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("chain");
|
||||
let channels = benilla_formats::load_chat_channels_catalog(&mut chain).expect("channels");
|
||||
const REQUIRED: u32 = chan::ZONE_DEP | chan::DEFENSE;
|
||||
|
||||
let qualifying: Vec<_> = channels
|
||||
.rows()
|
||||
.iter()
|
||||
.filter(|r| r.flags & REQUIRED == REQUIRED)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
qualifying.len(),
|
||||
1,
|
||||
"one row opens the gate: {qualifying:?}"
|
||||
);
|
||||
assert_eq!(qualifying[0].id, 22);
|
||||
assert_eq!(qualifying[0].pattern, "LocalDefense - %s");
|
||||
assert!(
|
||||
qualifying[0].is_auto_join(),
|
||||
"and the client joins it unasked — otherwise the readout would never appear"
|
||||
);
|
||||
|
||||
let world_defense = channels
|
||||
.rows()
|
||||
.iter()
|
||||
.find(|r| r.id == 23)
|
||||
.expect("WorldDefense");
|
||||
assert_ne!(
|
||||
world_defense.flags & REQUIRED,
|
||||
REQUIRED,
|
||||
"the realm-wide defense channel carries DEFENSE without ZONE_DEP and must NOT qualify"
|
||||
);
|
||||
}
|
||||
|
||||
/// An init CLEARS the table (the C2 correction): a state received in the previous zone must
|
||||
/// not still read through after the server re-scopes us.
|
||||
#[test]
|
||||
fn an_init_forgets_the_previous_zone() {
|
||||
let mut states = WorldStates::default();
|
||||
states.init_scope(0, 139);
|
||||
states.write(&[(2327, 3)]);
|
||||
assert_eq!(states.get(2327), 3);
|
||||
|
||||
states.init_scope(0, 12);
|
||||
assert_eq!(states.get(2327), 0, "the previous zone's key is gone");
|
||||
assert_eq!(states.scope(), Some((0, 12)));
|
||||
}
|
||||
}
|
||||
@@ -60,20 +60,28 @@ pub struct AreaPoi {
|
||||
pub world_state_id: u32,
|
||||
}
|
||||
|
||||
/// `AreaPOI.dbc` rows keyed by `ID`.
|
||||
/// `AreaPOI.dbc` — every row in **file order**, plus the id index.
|
||||
///
|
||||
/// File order is not tidiness: the reference's world-map landmark builder `0x4a67a0` walks the
|
||||
/// whole table by record (`[0xc0e054]`, count `[0xc0e058]`, stride `0x74`) and appends survivors
|
||||
/// in that order, so the landmark *index* a Lua `GetMapLandmarkInfo(i)` reads is the DBC's own
|
||||
/// order. A hash iteration would also make our landmark list a different permutation every frame,
|
||||
/// which the world map's change-diff would read as a change and repaint on.
|
||||
pub struct AreaPoiCatalog {
|
||||
rows: HashMap<u32, AreaPoi>,
|
||||
rows: Vec<(u32, AreaPoi)>,
|
||||
by_id: HashMap<u32, usize>,
|
||||
}
|
||||
|
||||
impl AreaPoiCatalog {
|
||||
pub fn get(&self, id: u32) -> Option<&AreaPoi> {
|
||||
self.rows.get(&id)
|
||||
self.by_id.get(&id).map(|&i| &self.rows[i].1)
|
||||
}
|
||||
|
||||
/// Every row, in no particular order — the nearest-3 POI selection (decision 0203 phase 3)
|
||||
/// filters this by `continent_id` + distance itself.
|
||||
/// Every row in **file order** — the reference builder's walk (see the type doc). The
|
||||
/// nearest-3 minimap selection (decision 0203 phase 3) filters this by `continent_id` +
|
||||
/// distance itself; the world map's landmark pass runs the `0x4a67a0` gate chain over it.
|
||||
pub fn rows(&self) -> impl Iterator<Item = (u32, &AreaPoi)> {
|
||||
self.rows.iter().map(|(&id, poi)| (id, poi))
|
||||
self.rows.iter().map(|(id, poi)| (*id, poi))
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
@@ -114,7 +122,8 @@ fn schema() -> Schema {
|
||||
pub fn load_area_poi_catalog(chain: &mut Chain) -> Result<AreaPoiCatalog> {
|
||||
let bytes = chain.read_file(AREA_POI).context("reading AreaPOI.dbc")?;
|
||||
let rs = parse(&bytes, schema(), "AreaPOI")?;
|
||||
let mut rows = HashMap::with_capacity(rs.records().len());
|
||||
let mut rows = Vec::with_capacity(rs.records().len());
|
||||
let mut by_id = HashMap::with_capacity(rs.records().len());
|
||||
for r in rs.records() {
|
||||
let Some(id) = u32_at(r, 0) else { continue };
|
||||
let (Some(x), Some(y), Some(z)) = (f32_at(r, 4), f32_at(r, 5), f32_at(r, 6)) else {
|
||||
@@ -132,9 +141,10 @@ pub fn load_area_poi_catalog(chain: &mut Chain) -> Result<AreaPoiCatalog> {
|
||||
description: str_at(&rs, r, 19).unwrap_or_default(),
|
||||
world_state_id: u32_at(r, 28).unwrap_or(0),
|
||||
};
|
||||
rows.insert(id, poi);
|
||||
by_id.insert(id, rows.len());
|
||||
rows.push((id, poi));
|
||||
}
|
||||
Ok(AreaPoiCatalog { rows })
|
||||
Ok(AreaPoiCatalog { rows, by_id })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
//! Layout — VERIFIED against build 5875 (2026-07-07, real-row decode: Northshire Valley id 9 →
|
||||
//! zone 12 = Elwynn Forest; Booty Bay id 35 → zone 33 = Stranglethorn Vale; cross-checked against
|
||||
//! mangoszero's `AreaTableEntry`): **25 × u32 cols**: `ID(0), MapID(1), ZoneID(2 — the parent
|
||||
//! area, 0 = this row IS a top-level zone), ExploreFlag(3), Flags(4), … AreaName(11)` + loc
|
||||
//! block, `FactionGroupMask(20)`. Sibling readers of the same file: `quest_headers.rs` (id →
|
||||
//! name only), `area_sound.rs` (the audio columns).
|
||||
//! area, 0 = this row IS a top-level zone), ExploreFlag(3), Flags(4), … ExplorationLevel(10, the
|
||||
//! binary's `+0x28`), AreaName(11)` + loc block, `FactionGroupMask(20)`. Sibling readers of the
|
||||
//! same file: `quest_headers.rs` (id → name only), `area_sound.rs` (the audio columns).
|
||||
//!
|
||||
//! The PvP columns (zone-splash arc, decision 0287) — census of the real 5875 table:
|
||||
//! `FactionGroupMask` is 2/4/0 (Alliance/Horde/neither) on zone rows and ~always 0 on subzone
|
||||
@@ -42,6 +42,13 @@ pub struct AreaTableRow {
|
||||
/// `FactionGroup.dbc` mask of the owning side (col 20): 2 = Alliance, 4 = Horde, 0 = neither
|
||||
/// (contested — or a subzone row deferring to its zone).
|
||||
pub faction_group_mask: u32,
|
||||
/// `ExplorationLevel` (col 10, the binary's `AreaTable+0x28`) — **signed**, and read only as
|
||||
/// `>= 0` vs `< 0`. It is the world-map landmark builder's exploration gate: a landmark whose
|
||||
/// `AreaID` lands on a row with `>= 0` here stays hidden until the player has explored that
|
||||
/// area ([`Self::explore_flag`]'s bit); `-1` exempts it (wow-re `gossip-poi-marker.md` §8.2,
|
||||
/// `0x4a6890`–`0x4a68f3`). The 5875 table is `-1` on exactly one row and `>= 0` on the other
|
||||
/// 1080, so in practice the gate applies wherever an `AreaID` resolves at all.
|
||||
pub exploration_level: i32,
|
||||
/// The localized display name ("Elwynn Forest").
|
||||
pub name: String,
|
||||
}
|
||||
@@ -52,6 +59,15 @@ pub struct AreaTableCatalog {
|
||||
}
|
||||
|
||||
impl AreaTableCatalog {
|
||||
/// A catalog over rows given directly, rather than read off the chain — for a caller that
|
||||
/// needs a table of exactly known shape (the map-arc gate tests build two-row ones, so that
|
||||
/// what a gate does with `ExplorationLevel -1` is asserted rather than hoped for).
|
||||
pub fn from_rows(rows: Vec<(u32, AreaTableRow)>) -> Self {
|
||||
Self {
|
||||
by_id: rows.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, id: u32) -> Option<&AreaTableRow> {
|
||||
self.by_id.get(&id)
|
||||
}
|
||||
@@ -143,8 +159,9 @@ impl AreaTableCatalog {
|
||||
}
|
||||
}
|
||||
|
||||
/// 25 u32-wide columns; only `ID/MapID/ZoneID/ExploreFlag/Flags/AreaName/FactionGroupMask` are
|
||||
/// read (see module doc).
|
||||
/// 25 u32-wide columns; only
|
||||
/// `ID/MapID/ZoneID/ExploreFlag/Flags/ExplorationLevel/AreaName/FactionGroupMask` are read (see
|
||||
/// module doc).
|
||||
fn schema() -> Schema {
|
||||
let mut s = Schema::new("AreaTable");
|
||||
for i in 0..25 {
|
||||
@@ -175,6 +192,9 @@ pub fn load_area_table_catalog(chain: &mut Chain) -> Result<AreaTableCatalog> {
|
||||
continue;
|
||||
};
|
||||
let faction_group_mask = u32_at(r, 20).unwrap_or(0);
|
||||
// Signed: `-1` is the "no exploration requirement" row. Absent reads as `-1` for the
|
||||
// same reason — an unreadable column must not invent a gate.
|
||||
let exploration_level = u32_at(r, 10).map_or(-1, |v| v as i32);
|
||||
by_id.insert(
|
||||
id,
|
||||
AreaTableRow {
|
||||
@@ -183,6 +203,7 @@ pub fn load_area_table_catalog(chain: &mut Chain) -> Result<AreaTableCatalog> {
|
||||
explore_flag,
|
||||
flags,
|
||||
faction_group_mask,
|
||||
exploration_level,
|
||||
name,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -256,6 +256,8 @@ pub use world_map_continent::{
|
||||
};
|
||||
mod area_poi;
|
||||
pub use area_poi::{load_area_poi_catalog, AreaPoi, AreaPoiCatalog};
|
||||
mod world_state_ui;
|
||||
pub use world_state_ui::{load_world_state_ui_catalog, WorldStateUiCatalog, WorldStateUiRow};
|
||||
mod area_table;
|
||||
pub use area_table::{load_area_table_catalog, AreaTableCatalog, AreaTableRow};
|
||||
mod chat_channels;
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
//! `WorldStateUI.dbc` — the rows behind the always-up PvP status readout and the battleground
|
||||
//! scoreboard's columns (`GetNumWorldStateUI` / `GetWorldStateUIInfo`).
|
||||
//!
|
||||
//! **Client-only: vmangos carries no struct for this table.** The server's whole contribution is
|
||||
//! the world-state *values* (`SMSG_INIT_WORLD_STATES` / `SMSG_UPDATE_WORLD_STATE`,
|
||||
//! [`crate::messages::world_state`](benilla_protocol) on the wire); which of them are *displayed*,
|
||||
//! where, and with what label is entirely this file's.
|
||||
//!
|
||||
//! Layout pinned by inspection of the shipped 5875 file (2026-08-25): **20 × 39 × 156 B**. The
|
||||
//! field boundaries are not assumed from a later build's schema — they are proven by the
|
||||
//! localized-string signature, which could have come out otherwise: a loc block in this era is
|
||||
//! nine dwords (the enUS string + seven other-locale slots + a flags dword), and a census of all
|
||||
//! 20 rows finds fields 5-11, 14-20 and 27-33 **uniformly zero** with 12, 21 and 34 each holding a
|
||||
//! locale-flags mask (`0x3F00BE` and two siblings). That fixes three loc blocks at 4-12, 13-21 and
|
||||
//! 26-34, and everything between them follows:
|
||||
//!
|
||||
//! `ID(0), MapID(1), AreaID(2), Icon(3, a path string), Text_lang(4-12), Tooltip_lang(13-21),
|
||||
//! <unread>(22), StateVariable(23), Type(24), DynamicIcon(25, a path string),
|
||||
//! DynamicTooltip_lang(26-34), ExtendedUI(35, a token string), ExtendedUIStateVariable[3](36-38)`.
|
||||
//!
|
||||
//! Those names are the **binary's**, confirmed 2026-08-25 by wow-re's carve of the loader and the
|
||||
//! two bindings (`system/ui/scratch/worldstate-ui-law.md`): the loader's own dimension asserts
|
||||
//! (`0x553a9e cmp eax,0x27`, `0x553ad6 cmp eax,0x9c`) fix 39 columns of 156 bytes, and
|
||||
//! `GetWorldStateUIInfo 0x4c5a70` reads exactly `+0x0c` Icon, `+0x10` Text, `+0x34` Tooltip,
|
||||
//! `+0x5c` StateVariable, `+0x64` DynamicIcon, `+0x68` DynamicTooltip, `+0x8c` ExtendedUI,
|
||||
//! `+0x90/94/98` the three extended ids. **Field 22 (`+0x58`) is written by the loader and read by
|
||||
//! nothing** — none of the table's four consumers touch it — so it is not carried here; its
|
||||
//! shipped values are `0` on the three Eastern Plaguelands rows and `-1` on the other seventeen.
|
||||
//!
|
||||
//! The shipped rows read exactly as that naming predicts: the Eastern Plaguelands pair carries
|
||||
//! `Icon = Interface\WorldStateFrame\{Alliance,Horde}Tower` with `StateVariable = "Towers
|
||||
//! Controlled: %2327w"` — the same world-state macro grammar the NPC-text expander already
|
||||
//! speaks, over the two ids vmangos's `OutdoorPvPEP` pushes; Warsong Gulch's two carry a
|
||||
//! `DynamicIcon` of the *enemy* flag with `WorldState` 2338/2339, the "flag has been picked up"
|
||||
//! keys; and Alterac Valley's `Type = 2` rows are the scoreboard's column headers, not an
|
||||
//! always-up readout.
|
||||
//!
|
||||
//! What the client *does* with these rows — which it admits, and how [`WorldStateUiRow::text`]
|
||||
//! becomes a displayed string — is [`crate::world_state_ui`](benilla_app)'s, not this module's.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use benilla_dbc::{FieldType, Schema, SchemaField};
|
||||
|
||||
use crate::dbc::{parse, str_at, u32_at};
|
||||
use crate::Chain;
|
||||
|
||||
const WORLD_STATE_UI: &str = "DBFilesClient\\WorldStateUI.dbc";
|
||||
|
||||
/// One `WorldStateUI.dbc` row.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WorldStateUiRow {
|
||||
/// `Map.dbc` id this row belongs to — `0`/`1` for the two outdoor-PvP continents, otherwise a
|
||||
/// battleground map (30 Alterac Valley, 489 Warsong Gulch, 529 Arathi Basin).
|
||||
pub map_id: u32,
|
||||
/// `AreaTable.dbc` id narrowing the row within its map, or `0` for the whole map. Only the
|
||||
/// outdoor rows use it: `139` (Eastern Plaguelands) and `1377` (Silithus).
|
||||
pub area_id: u32,
|
||||
/// The static icon's texture path (`Interface\WorldStateFrame\AllianceTower`), empty when the
|
||||
/// row draws no icon.
|
||||
pub icon: String,
|
||||
/// The label, as authored — a format string in the `%<id>w` world-state grammar
|
||||
/// (`"Towers Controlled: %2327w"`, `"Bases: %1779w Resources: %1776w/%1780w"`), or plain text
|
||||
/// on the scoreboard-column rows (`"Graveyards Assaulted"`). This is the **only** column the
|
||||
/// client expands; every other string below is handed to Lua verbatim.
|
||||
pub text: String,
|
||||
/// The tooltip line (`"Alliance Towers Controlled"`).
|
||||
pub tooltip: String,
|
||||
/// `StateVariable` (+0x5c) — the world-state id whose *value* the client returns as the row's
|
||||
/// `uiState`, or `0` for "no state", which the binding answers as the constant `1.0`. Set on
|
||||
/// three rows: `2338`/`2339` on the Warsong Gulch pair (the "enemy flag picked up" keys that
|
||||
/// swap in [`Self::dynamic_icon`]) and `2426` on the Eastern Plaguelands progress row.
|
||||
pub state_variable: u32,
|
||||
/// `Type` (+0x60) — which list the row belongs to, and the builder's third gate:
|
||||
///
|
||||
/// - **0** — always-up, unconditional. The four Warsong Gulch / Arathi Basin status rows.
|
||||
/// - **1** — always-up, but only while the player has joined a **zone-dependent defense**
|
||||
/// chat channel ([`crate::chat_channel_flags`]' `ZONE_DEP | DEFENSE`, which is row 22 alone
|
||||
/// in the shipped table). The five world-PvP rows: Eastern Plaguelands' three and
|
||||
/// Silithus's two.
|
||||
/// - **2** — a battleground **scoreboard column**, which never enters the always-up list at
|
||||
/// all; it is built by a different handler off the same table.
|
||||
pub ui_type: u32,
|
||||
/// The alternate icon shown while [`Self::state_variable`] is live — the enemy flag, on the two
|
||||
/// Warsong Gulch rows that have one. Empty elsewhere.
|
||||
pub dynamic_icon: String,
|
||||
/// The tooltip that goes with [`Self::dynamic_icon`] (`"Horde flag has been picked up"`).
|
||||
pub dynamic_tooltip: String,
|
||||
/// A token naming an extra UI widget the row drives — `"CAPTUREPOINT"`, on exactly one row
|
||||
/// (the Eastern Plaguelands progress bar). Empty elsewhere.
|
||||
pub extended_ui: String,
|
||||
/// The world-state **ids** that widget reads — the binding answers their resolved *values*.
|
||||
/// Nonzero on exactly one row: id 138 carries `(2427, 2428, 0)`, so the whole extended-UI
|
||||
/// mechanism in 1.12 exists for Eastern Plaguelands' capture-point progress bar alone.
|
||||
pub extended_ui_state: [u32; 3],
|
||||
}
|
||||
|
||||
/// `WorldStateUI.dbc` rows in **file order**, with their ids.
|
||||
pub struct WorldStateUiCatalog {
|
||||
rows: Vec<(u32, WorldStateUiRow)>,
|
||||
}
|
||||
|
||||
impl WorldStateUiCatalog {
|
||||
/// A catalog over rows given directly — for a caller that needs a table of exactly known
|
||||
/// shape (the consumer's gate tests build one-row ones, so that "no init yet ⇒ empty readout"
|
||||
/// is asserted rather than hoped for).
|
||||
pub fn from_rows(rows: Vec<(u32, WorldStateUiRow)>) -> Self {
|
||||
Self { rows }
|
||||
}
|
||||
|
||||
/// Every row in file order. Order is kept because a UI list built from this table is indexed
|
||||
/// by position, and the table's own order is the only one that is not an invention.
|
||||
pub fn rows(&self) -> impl Iterator<Item = (u32, &WorldStateUiRow)> {
|
||||
self.rows.iter().map(|(id, row)| (*id, row))
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.rows.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rows.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 39 fields; the three loc blocks are nine dwords each (see the module doc for how their bounds
|
||||
/// were proven rather than assumed).
|
||||
fn schema() -> Schema {
|
||||
let mut s = Schema::new("WorldStateUI");
|
||||
for name in ["ID", "MapID", "AreaID"] {
|
||||
s.add_field(SchemaField::new(name, FieldType::UInt32));
|
||||
}
|
||||
s.add_field(SchemaField::new("Icon", FieldType::String));
|
||||
let loc = |s: &mut Schema, name: &str| {
|
||||
s.add_field(SchemaField::new(name, FieldType::String)); // enUS (locale 0)
|
||||
s.add_field(SchemaField::new_array(
|
||||
format!("{name}OtherLocales"),
|
||||
FieldType::String,
|
||||
7,
|
||||
));
|
||||
s.add_field(SchemaField::new(format!("{name}Flags"), FieldType::UInt32));
|
||||
};
|
||||
loc(&mut s, "Text");
|
||||
loc(&mut s, "Tooltip");
|
||||
// +0x58: the loader writes it, no consumer reads it. Named for what is known about it.
|
||||
for name in ["UnreadCol22", "StateVariable", "Type"] {
|
||||
s.add_field(SchemaField::new(name, FieldType::UInt32));
|
||||
}
|
||||
s.add_field(SchemaField::new("DynamicIcon", FieldType::String));
|
||||
loc(&mut s, "DynamicTooltip");
|
||||
s.add_field(SchemaField::new("ExtendedUI", FieldType::String));
|
||||
s.add_field(SchemaField::new_array(
|
||||
"ExtendedUIStateVariable",
|
||||
FieldType::UInt32,
|
||||
3,
|
||||
));
|
||||
s
|
||||
}
|
||||
|
||||
/// Read `WorldStateUI.dbc` off the patch chain.
|
||||
pub fn load_world_state_ui_catalog(chain: &mut Chain) -> Result<WorldStateUiCatalog> {
|
||||
let bytes = chain
|
||||
.read_file(WORLD_STATE_UI)
|
||||
.context("reading WorldStateUI.dbc")?;
|
||||
let rs = parse(&bytes, schema(), "WorldStateUI")?;
|
||||
let mut rows = Vec::with_capacity(rs.records().len());
|
||||
for r in rs.records() {
|
||||
let Some(id) = u32_at(r, 0) else { continue };
|
||||
rows.push((
|
||||
id,
|
||||
WorldStateUiRow {
|
||||
map_id: u32_at(r, 1).unwrap_or(0),
|
||||
area_id: u32_at(r, 2).unwrap_or(0),
|
||||
icon: str_at(&rs, r, 3).unwrap_or_default(),
|
||||
text: str_at(&rs, r, 4).unwrap_or_default(),
|
||||
tooltip: str_at(&rs, r, 13).unwrap_or_default(),
|
||||
state_variable: u32_at(r, 23).unwrap_or(0),
|
||||
ui_type: u32_at(r, 24).unwrap_or(0),
|
||||
dynamic_icon: str_at(&rs, r, 25).unwrap_or_default(),
|
||||
dynamic_tooltip: str_at(&rs, r, 26).unwrap_or_default(),
|
||||
extended_ui: str_at(&rs, r, 35).unwrap_or_default(),
|
||||
extended_ui_state: [
|
||||
u32_at(r, 36).unwrap_or(0),
|
||||
u32_at(r, 37).unwrap_or(0),
|
||||
u32_at(r, 38).unwrap_or(0),
|
||||
],
|
||||
},
|
||||
));
|
||||
}
|
||||
Ok(WorldStateUiCatalog { rows })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The real 5875 table proves the layout — and each assertion below could have failed if a
|
||||
/// field boundary were off by one, because every one of them is a *string* landing where a
|
||||
/// number would garble it, or a number landing in a range only that column occupies.
|
||||
/// Skips without client data.
|
||||
#[test]
|
||||
fn real_world_state_ui_layout_sanity() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_world_state_ui_catalog(&mut chain).expect("load WorldStateUI");
|
||||
assert_eq!(cat.len(), 20, "all 20 rows load");
|
||||
|
||||
let by_id = |id: u32| {
|
||||
cat.rows()
|
||||
.find(|(r, _)| *r == id)
|
||||
.map(|(_, row)| row)
|
||||
.unwrap_or_else(|| panic!("row {id}"))
|
||||
};
|
||||
|
||||
// ── The Eastern Plaguelands pair: report B190's own subject.
|
||||
let alliance = by_id(136);
|
||||
assert_eq!((alliance.map_id, alliance.area_id), (0, 139));
|
||||
assert_eq!(alliance.icon, "Interface\\WorldStateFrame\\AllianceTower");
|
||||
assert_eq!(alliance.text, "Towers Controlled: %2327w");
|
||||
assert_eq!(alliance.tooltip, "Alliance Towers Controlled");
|
||||
assert_eq!(alliance.ui_type, 1);
|
||||
let horde = by_id(137);
|
||||
assert_eq!(horde.icon, "Interface\\WorldStateFrame\\HordeTower");
|
||||
assert_eq!(horde.text, "Towers Controlled: %2328w");
|
||||
// Type 1 — the world-PvP kind, gated on the zone-defense channel by its consumer.
|
||||
assert_eq!((alliance.ui_type, horde.ui_type), (1, 1));
|
||||
assert_eq!(
|
||||
(alliance.state_variable, horde.state_variable),
|
||||
(0, 0),
|
||||
"no uiState of their own — the binding answers 1.0"
|
||||
);
|
||||
|
||||
// ── The one extended-UI row, and the only user of the trailing id array.
|
||||
let progress = by_id(138);
|
||||
assert_eq!(progress.extended_ui, "CAPTUREPOINT");
|
||||
assert_eq!(progress.extended_ui_state, [2427, 2428, 0]);
|
||||
assert_eq!(progress.state_variable, 2426);
|
||||
assert_eq!(progress.text, "Progress: %2427w");
|
||||
|
||||
// ── Warsong Gulch: the dynamic-icon columns, the only rows that carry them.
|
||||
let ws_alliance = by_id(2);
|
||||
assert_eq!(ws_alliance.map_id, 489);
|
||||
assert_eq!(ws_alliance.state_variable, 2339);
|
||||
assert_eq!(
|
||||
ws_alliance.dynamic_icon,
|
||||
"Interface\\WorldStateFrame\\HordeFlag"
|
||||
);
|
||||
assert_eq!(ws_alliance.dynamic_tooltip, "Horde flag has been picked up");
|
||||
assert_eq!(ws_alliance.text, "%1581w/%1601w");
|
||||
assert_eq!(ws_alliance.ui_type, 0, "unconditional always-up");
|
||||
|
||||
// ── An Alterac Valley scoreboard column: type 2, no icon path of the WorldStateFrame
|
||||
// kind, plain text rather than a macro.
|
||||
let graveyards = by_id(63);
|
||||
assert_eq!(graveyards.map_id, 30);
|
||||
assert_eq!(graveyards.ui_type, 2);
|
||||
assert_eq!(graveyards.text, "Graveyards Assaulted");
|
||||
assert!(!graveyards.text.contains('%'));
|
||||
|
||||
// ── Whole-table shape, each number a fact that could have come out otherwise.
|
||||
let mut with_area = 0;
|
||||
let mut with_macro = 0;
|
||||
for (_, row) in cat.rows() {
|
||||
assert!(
|
||||
matches!(row.ui_type, 0..=2),
|
||||
"Type is a small enum: {}",
|
||||
row.ui_type
|
||||
);
|
||||
assert!(
|
||||
row.state_variable == 0 || (2000..3000).contains(&row.state_variable),
|
||||
"StateVariable lands in the live world-state id band: {}",
|
||||
row.state_variable
|
||||
);
|
||||
assert!(!row.text.is_empty(), "every row is labelled");
|
||||
assert_ne!(
|
||||
row.map_id,
|
||||
u32::MAX,
|
||||
"the MapID -1 wildcard has no shipped row — every row names a real map"
|
||||
);
|
||||
assert!(
|
||||
row.icon.is_empty() || row.icon.starts_with("Interface\\"),
|
||||
"Icon is a texture path or nothing: {:?}",
|
||||
row.icon
|
||||
);
|
||||
if row.area_id != 0 {
|
||||
with_area += 1;
|
||||
}
|
||||
if row.text.contains('%') {
|
||||
with_macro += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(with_area, 5, "only the two outdoor zones narrow by area");
|
||||
assert_eq!(
|
||||
with_macro, 9,
|
||||
"the always-up rows read the world-state table"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -482,6 +482,10 @@ pub enum SessionEvent {
|
||||
/// A spell added to the book after login (`SMSG_LEARNED_SPELL`): trainer/quest/level-up, widened
|
||||
/// from the wire `u16`. The first post-login spell-book mutation (decision 0237).
|
||||
SpellLearned { spell_id: u32 },
|
||||
/// A spell taken back out of the book (`SMSG_REMOVED_SPELL`), widened from the wire `u16`:
|
||||
/// a talent wipe, an abandoned profession, a GM `.unlearn`. Every rank of every talent arrives
|
||||
/// as one of these after a respec, and until decision 1584 none of them did.
|
||||
SpellRemoved { spell_id: u32 },
|
||||
/// A rank-up (`SMSG_SUPERCEDED_SPELL`): the new rank replaces the old in the book and on the
|
||||
/// action bar. Both ids widened from the wire `u16`.
|
||||
SpellSuperceded {
|
||||
|
||||
@@ -68,6 +68,9 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
|
||||
ServerPacket::LearnedSpell { spell_id } => vec![SessionEvent::SpellLearned {
|
||||
spell_id: u32::from(spell_id),
|
||||
}],
|
||||
ServerPacket::RemovedSpell { spell_id } => vec![SessionEvent::SpellRemoved {
|
||||
spell_id: u32::from(spell_id),
|
||||
}],
|
||||
ServerPacket::SupercededSpell {
|
||||
old_spell_id,
|
||||
new_spell_id,
|
||||
|
||||
@@ -151,6 +151,12 @@ pub const SMSG_INITIAL_SPELLS: u16 = 0x012A; // 298
|
||||
// Bodies in [`super::spells`] (decision 0237).
|
||||
pub const SMSG_LEARNED_SPELL: u16 = 0x012B; // 299
|
||||
pub const SMSG_SUPERCEDED_SPELL: u16 = 0x012C; // 300
|
||||
|
||||
/// The **third** member of that family and the only one that shrinks the book (VERIFIED vmangos
|
||||
/// `Opcodes_1_12_1.h`: 515; `Player::SendSpellRemoved`, the tail of `Player::RemoveSpell`). Sent
|
||||
/// once per spell the server takes away — a talent wipe sends one for every rank of every talent,
|
||||
/// which is the whole reason decision 1584 exists. Body in [`super::spellbook`]: one `u16`.
|
||||
pub const SMSG_REMOVED_SPELL: u16 = 0x0203; // 515
|
||||
pub const SMSG_CAST_RESULT: u16 = 0x0130; // 304
|
||||
|
||||
// The pet action bar's inbound wire (decision 0982; VERIFIED vmangos `Opcodes_1_12_1.h`:
|
||||
|
||||
@@ -461,6 +461,12 @@ pub enum ServerPacket {
|
||||
LearnedSpell {
|
||||
spell_id: u16,
|
||||
},
|
||||
/// `SMSG_REMOVED_SPELL` — the server took a spell out of the book (layout in
|
||||
/// [`super::spellbook::read_removed_spell`]). One per spell: a talent wipe sends a burst of
|
||||
/// them, one for every rank of every talent (decision 1584).
|
||||
RemovedSpell {
|
||||
spell_id: u16,
|
||||
},
|
||||
/// `SMSG_SUPERCEDED_SPELL` — a rank-up replaced its predecessor in the book + action bar
|
||||
/// (layout in [`super::spellbook::read_superceded_spell`]).
|
||||
SupercededSpell {
|
||||
@@ -1303,6 +1309,7 @@ impl ServerPacket {
|
||||
ServerPacket::InitialSpells { .. } => "SMSG_INITIAL_SPELLS".into(),
|
||||
ServerPacket::ActionButtons { .. } => "SMSG_ACTION_BUTTONS".into(),
|
||||
ServerPacket::LearnedSpell { .. } => "SMSG_LEARNED_SPELL".into(),
|
||||
ServerPacket::RemovedSpell { .. } => "SMSG_REMOVED_SPELL".into(),
|
||||
ServerPacket::SupercededSpell { .. } => "SMSG_SUPERCEDED_SPELL".into(),
|
||||
ServerPacket::CastResult { .. } => "SMSG_CAST_RESULT".into(),
|
||||
ServerPacket::PetSpells(_) => "SMSG_PET_SPELLS".into(),
|
||||
|
||||
@@ -424,6 +424,9 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
|
||||
opcode::SMSG_LEARNED_SPELL => ServerPacket::LearnedSpell {
|
||||
spell_id: spellbook::read_learned_spell(&mut r)?,
|
||||
},
|
||||
opcode::SMSG_REMOVED_SPELL => ServerPacket::RemovedSpell {
|
||||
spell_id: spellbook::read_removed_spell(&mut r)?,
|
||||
},
|
||||
opcode::SMSG_SUPERCEDED_SPELL => {
|
||||
let (old_spell_id, new_spell_id) = spellbook::read_superceded_spell(&mut r)?;
|
||||
ServerPacket::SupercededSpell {
|
||||
|
||||
@@ -68,6 +68,14 @@ pub(super) fn read_learned_spell(r: &mut impl Read) -> io::Result<u16> {
|
||||
Ok(spell_id)
|
||||
}
|
||||
|
||||
/// Read `SMSG_REMOVED_SPELL` (vmangos `RemovedSpell::AppendBodyTo`, `Server/Packets/Spell.cpp:181`):
|
||||
/// a bare `u16 spellId`, and **no action-bar slot** — unlike its `SMSG_LEARNED_SPELL` sibling above,
|
||||
/// which pads one on. The spell is gone from the book; what happens to a bar button still pointing
|
||||
/// at it is a separate law (decision 1584).
|
||||
pub(super) fn read_removed_spell(r: &mut impl Read) -> io::Result<u16> {
|
||||
read_u16_le(r)
|
||||
}
|
||||
|
||||
/// Read `SMSG_SUPERCEDED_SPELL` (vmangos `SupercededSpell::AppendBodyTo`, `Spell.cpp:169-173`): `u16
|
||||
/// oldSpellId, u16 newSpellId` — a rank-up replaces the old spell with the new one in both the book
|
||||
/// and the action bar (decision 0237).
|
||||
@@ -125,6 +133,27 @@ pub(super) fn read_cooldown_cheat(r: &mut impl Read) -> io::Result<u64> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The three incremental book packets, side by side, because their bodies differ in exactly the
|
||||
/// way that bites: LEARNED pads an unused action-bar slot after the id, REMOVED does not pad
|
||||
/// anything, and SUPERCEDED carries a pair. Reading REMOVED with LEARNED's reader would work by
|
||||
/// accident on a 4-byte body and starve on the real 2-byte one.
|
||||
#[test]
|
||||
fn the_three_book_deltas_have_three_different_bodies() {
|
||||
// SMSG_LEARNED_SPELL: u16 spell + u16 slot (dropped).
|
||||
let body: Vec<u8> = [1752u16.to_le_bytes(), 0u16.to_le_bytes()].concat();
|
||||
assert_eq!(read_learned_spell(&mut &body[..]).unwrap(), 1752);
|
||||
|
||||
// SMSG_REMOVED_SPELL: the bare u16, and nothing after it.
|
||||
let body = 1752u16.to_le_bytes().to_vec();
|
||||
let mut r = &body[..];
|
||||
assert_eq!(read_removed_spell(&mut r).unwrap(), 1752);
|
||||
assert!(r.is_empty(), "the removal body is two bytes, full stop");
|
||||
|
||||
// SMSG_SUPERCEDED_SPELL: the pair.
|
||||
let body: Vec<u8> = [1752u16.to_le_bytes(), 1757u16.to_le_bytes()].concat();
|
||||
assert_eq!(read_superceded_spell(&mut &body[..]).unwrap(), (1752, 1757));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooldown_bodies_golden() {
|
||||
// SMSG_SPELL_COOLDOWN: raw u64 guid, then (u32 spell, u32 ms) pairs to end-of-body — NO
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
//! - **The reference binary** (wow-re `system/ui/scratch/questtext-macro-expander.md` §3): the one
|
||||
//! handler `0x48f690` holds the only two callers of the table setter `0x4c5870`, registered
|
||||
//! against exactly these two opcodes at `0x48f515-0x48f52f`. `0x2C2` is *two leading dwords*
|
||||
//! (passed to the unexamined `0x4c5650`), a `u16` count, then `count × (id:u32, value:u32)`;
|
||||
//! `0x2C3` is a single such pair.
|
||||
//! (handed to `0x4c5650`), a `u16` count, then `count × (id:u32, value:u32)`; `0x2C3` is a
|
||||
//! single such pair. **`0x4c5650` is carved** (2026-08-25, wow-re
|
||||
//! `system/ui/scratch/worldstate-ui-law.md`): it CLEARS the table, stores the two dwords as the
|
||||
//! world-state UI's display filter, and rebuilds that list — all before the pair loop runs. See
|
||||
//! [`InitWorldStates::zone`].
|
||||
//! - **vmangos's writers** (`Player::SendInitWorldStates`, `Player.cpp:8219-8293`, and
|
||||
//! `WorldPackets::Misc::UpdateWorldState::AppendBodyTo`, `Misc.cpp:685-694`): `u32 mapId`,
|
||||
//! `u32 zoneId` (the zone dword is `#if SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_11_2`, so 1.12.1
|
||||
@@ -32,9 +35,13 @@ use crate::wire::{read_u16_le, read_u32_le};
|
||||
/// (vmangos `Player::UpdateZone` → `SendInitWorldStates`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct InitWorldStates {
|
||||
/// The map the states are scoped to. The reference hands this and `zone` to `0x4c5650` and
|
||||
/// nothing else; what that call does with them is **not recorded** (see [`read_init_world_states`]).
|
||||
/// The map the states are scoped to — the client's `[0xb71e84]`, and the first half of the
|
||||
/// filter its world-state UI list is built against (see the module doc).
|
||||
pub map: u32,
|
||||
/// The second leading dword. Named `zone` for the field vmangos fills it from; the client
|
||||
/// stores it as `[0xb71ea8]` and matches it against **`WorldStateUI.dbc`'s `AreaID`** when it
|
||||
/// decides which always-up rows a scope admits, so "whatever the server put here" is exactly
|
||||
/// the comparand either way.
|
||||
pub zone: u32,
|
||||
/// `(id, value)` verbatim, including the trailing `(0, 0)` terminator.
|
||||
pub states: Vec<(u32, u32)>,
|
||||
|
||||
@@ -134,6 +134,7 @@ mod types;
|
||||
mod unit;
|
||||
mod weapon_enchant;
|
||||
mod worldmap;
|
||||
mod worldstate;
|
||||
mod worn_display;
|
||||
|
||||
pub use action::{ActionSlot, ActionState};
|
||||
@@ -219,6 +220,7 @@ pub use worldmap::{
|
||||
WorldMapContinentView, WorldMapLandmarkView, WorldMapOverlayView, WorldMapState,
|
||||
WorldMapZoneView,
|
||||
};
|
||||
pub use worldstate::WorldStateUiView;
|
||||
pub use worn_display::WornDisplay;
|
||||
|
||||
use mlua::Lua;
|
||||
@@ -588,6 +590,7 @@ impl UiScript {
|
||||
cooldown::install(&lua)?;
|
||||
tooltip::install(&lua)?;
|
||||
worldmap::install(&lua)?;
|
||||
worldstate::install(&lua)?;
|
||||
net_stats::install(&lua)?;
|
||||
diagnostics::install(&lua)?;
|
||||
|
||||
|
||||
@@ -1197,6 +1197,9 @@ pub(crate) struct Model {
|
||||
/// The world-map seam ([`worldmap`](super::worldmap)): the pushed catalog/feed + the
|
||||
/// engine-owned selection.
|
||||
pub(crate) worldmap: super::worldmap::WorldMapState,
|
||||
/// The always-up world-state readout's rows ([`worldstate`](super::worldstate)), already
|
||||
/// gated and resolved app-side.
|
||||
pub(crate) worldstate: super::worldstate::WorldStateUiState,
|
||||
/// Events (name + args) queued by Lua bindings (`SetMapZoom` → `WORLD_MAP_UPDATE`;
|
||||
/// `PickupContainerItem`/`ClearCursor`/… → `CURSOR_UPDATE`/`ITEM_LOCK_CHANGED`/
|
||||
/// `DELETE_ITEM_CONFIRM`, decision 0216 §4/§5) to fire at the next
|
||||
@@ -1555,6 +1558,7 @@ impl Model {
|
||||
quest_log_watched: Vec::new(),
|
||||
server_unix_time: None,
|
||||
worldmap: super::worldmap::WorldMapState::default(),
|
||||
worldstate: super::worldstate::WorldStateUiState::default(),
|
||||
pending_events: Vec::new(),
|
||||
cursor_pos: (0.0, 0.0),
|
||||
chat_sends: Vec::new(),
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
//! discovered sub-area's overlay art (`GetNumMapOverlays`/`GetMapOverlayInfo`, filtered by the
|
||||
//! pushed `PLAYER_EXPLORED_ZONES` bitset) fills it in — the reference's own overlay pool draws
|
||||
//! the returned pieces. The landmark family (`GetNumMapLandmarks`/`GetMapLandmarkInfo`) answers
|
||||
//! with the guard-directions marker (decision 1514); the `AreaPOI.dbc` rows that also belong in
|
||||
//! it are 0203's still-deferred slice.
|
||||
//! with the `AreaPOI.dbc` rows the displayed level admits, then the guard-directions marker
|
||||
//! (decisions 1586, 1514).
|
||||
|
||||
use mlua::{Lua, MultiValue, Value};
|
||||
|
||||
@@ -57,12 +57,15 @@ pub struct WorldMapZoneView {
|
||||
/// One **map landmark** — a POI icon on the displayed map (`GetNumMapLandmarks` /
|
||||
/// `GetMapLandmarkInfo`). The app projects it, so what arrives here is already map UV.
|
||||
///
|
||||
/// Today the only landmark is the guard-directions marker (`SMSG_GOSSIP_POI` — the flag a guard
|
||||
/// drops when you ask where the warrior trainer is), and the reference routes it through this same
|
||||
/// list: its landmark-list builder `0x4a67a0` appends the marker as the one element with
|
||||
/// `+0x10 == 1`, exempt from every gate a real AreaPOI must pass (VERIFIED, wow-re
|
||||
/// `system/ui/scratch/gossip-poi-marker.md`). The `AreaPOI.dbc` rows that also belong here are
|
||||
/// 0203's still-deferred landmark slice, and land in this list when they do.
|
||||
/// Two sources feed it, in the reference's own order (its builder `0x4a67a0`, VERIFIED in wow-re
|
||||
/// `system/ui/scratch/gossip-poi-marker.md` §8):
|
||||
///
|
||||
/// - the **`AreaPOI.dbc` rows** that survive the builder's level-flag, exploration and
|
||||
/// world-state gates — the town and capital icons, the capitals' "Under Attack" markers, and the
|
||||
/// Eastern Plaguelands towers (decision 1586);
|
||||
/// - then the **guard-directions marker** (`SMSG_GOSSIP_POI` — the flag a guard drops when you ask
|
||||
/// where the warrior trainer is), appended last as the one element with `+0x10 == 1` and exempt
|
||||
/// from every gate above.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct WorldMapLandmarkView {
|
||||
/// The label its tooltip shows ("Stormwind Warrior Trainer").
|
||||
@@ -620,9 +623,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetNumMapLandmarks() — how many POI icons the displayed map carries. Today that is the
|
||||
// guard-directions marker and nothing else; the `AreaPOI.dbc` rows are 0203's deferred
|
||||
// landmark slice, and they join this same list when it lands.
|
||||
// GetNumMapLandmarks() — how many POI icons the displayed map carries: the `AreaPOI.dbc` rows
|
||||
// that pass the builder's gates, then the guard-directions marker.
|
||||
g.set(
|
||||
"GetNumMapLandmarks",
|
||||
lua.create_function(|lua, ()| {
|
||||
@@ -633,11 +635,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
// GetMapLandmarkInfo(i) → name, description, textureIndex, x, y — the i-th (1-based) landmark
|
||||
// (return shape VERIFIED at `0x4a8740`, wow-re `system/ui/scratch/gossip-poi-marker.md`).
|
||||
// `textureIndex` indexes `Interface\Minimap\POIIcons`' 8×8 grid — for the guard's marker it is
|
||||
// the packet's own `Icon`, verbatim, exempt from the substitution an ordinary landmark gets at
|
||||
// zone level; x/y are `[0,1]` UV on the displayed map, the same space `GetPlayerMapPosition`
|
||||
// answers in. A landmark with no description answers **nil** there, as the reference does
|
||||
// (`0x6f3890` pushes nil on a NULL string) — the guard's marker never carries one.
|
||||
// `textureIndex` indexes `Interface\Minimap\POIIcons`' 8×8 grid, already resolved app-side
|
||||
// through the reference's own leg (`0x4a8848`): a row's `Icon`, or the constant 15 at zone
|
||||
// level unless it carries `Flags & 0x80`; the guard's marker is its packet `Icon` verbatim.
|
||||
// x/y are `[0,1]` UV on the displayed map, the same space `GetPlayerMapPosition` answers in. A
|
||||
// landmark with no description answers **nil** there, as the reference does (`0x6f3890` pushes
|
||||
// nil on a NULL string) — the guard's marker never carries one, and neither do the ~2/3 of
|
||||
// AreaPOI rows with no live status text.
|
||||
g.set(
|
||||
"GetMapLandmarkInfo",
|
||||
lua.create_function(|lua, i: i64| {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
//! The world-state UI seam — `GetNumWorldStateUI` / `GetWorldStateUIInfo`, the two bindings behind
|
||||
//! the always-up PvP readout (`WorldStateFrame`; decision 1586).
|
||||
//!
|
||||
//! The list is built app-side ([`crate::world_state_ui`](benilla_app)) because every one of its
|
||||
//! gates reads app state — the last init's `(map, area)` scope, the joined chat channels, the live
|
||||
//! world-state table — and pushed here as already-resolved rows. That is the same split every
|
||||
//! other feed uses; what the reference keeps in a process global (`0xb71e7c`, an array of DBC row
|
||||
//! ids re-walked on demand) we keep as the resolved answer.
|
||||
//!
|
||||
//! **Return shape VERIFIED** at `0x4c5a70` (wow-re `system/ui/scratch/worldstate-ui-law.md`).
|
||||
//! Worth stating what it is *not*: this is a **ten**-value return, and it carries **no `uiType` and
|
||||
//! no `hidden`** — those are a later expansion of the API, and a client that answered twelve values
|
||||
//! here would hand `WorldStateFrame` its icon path where it expects a number.
|
||||
|
||||
use mlua::{Lua, MultiValue, Value};
|
||||
|
||||
use super::Model;
|
||||
|
||||
/// One row of the always-up world-state readout — the ten values `GetWorldStateUIInfo` returns,
|
||||
/// in its order.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct WorldStateUiView {
|
||||
/// `uiState` — the row's `StateVariable` world state resolved through the table, or **`1`**
|
||||
/// when it carries none (`0x4c5ad8`: the miss is a literal `1.0`, not `0`, so a row without a
|
||||
/// state reads as "on" rather than "off").
|
||||
pub ui_state: i32,
|
||||
/// The label, already macro-expanded (`"Towers Controlled: 3"`). The **only** expanded string
|
||||
/// of the ten.
|
||||
pub text: String,
|
||||
/// The static icon's texture path, or `""`.
|
||||
pub icon: String,
|
||||
/// The icon that replaces it while the row's state is live (Warsong Gulch's enemy flag), or
|
||||
/// `""`.
|
||||
pub dynamic_icon: String,
|
||||
pub tooltip: String,
|
||||
pub dynamic_tooltip: String,
|
||||
/// A token naming an extra widget the row drives — `"CAPTUREPOINT"` on the one row that has
|
||||
/// one — or `""`.
|
||||
pub extended_ui: String,
|
||||
/// That widget's three world states, **resolved to values** (the DBC holds ids; the binding
|
||||
/// answers what they read).
|
||||
pub extended_ui_state: [i32; 3],
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct WorldStateUiState {
|
||||
pub(crate) rows: Vec<WorldStateUiView>,
|
||||
}
|
||||
|
||||
impl super::UiScript {
|
||||
/// Push the always-up world-state readout's rows (see
|
||||
/// [`WorldStateUiView`](WorldStateUiView)). On change, queues
|
||||
/// **`UPDATE_WORLD_STATES`** — event `0x20e`, the reference's own name for it.
|
||||
///
|
||||
/// **Named divergence, in the *trigger*, not the shape.** The reference fires that event from
|
||||
/// exactly two sites (`0x48fa12`, after an init rebuilds the list; `0x49bdd6`, when the
|
||||
/// zone-defense channel membership flips) and, verified by a whole-`.text` scan for the id,
|
||||
/// nowhere else — so a plain `SMSG_UPDATE_WORLD_STATE` changing a tower count fires nothing.
|
||||
/// It can afford that because its Lua re-reads live values off the table at whatever moment it
|
||||
/// repaints; we push resolved values, so a silent value change would simply never reach the
|
||||
/// frame. We therefore fire on any change to what the readout would *display*, which is the
|
||||
/// reference's two triggers plus the value updates its own frame must already be reacting to
|
||||
/// by some means the binary does not show.
|
||||
pub fn set_world_state_ui(&mut self, rows: Vec<WorldStateUiView>) {
|
||||
let mut model = self.model_mut();
|
||||
if model.worldstate.rows != rows {
|
||||
model.worldstate.rows = rows;
|
||||
model
|
||||
.pending_events
|
||||
.push(("UPDATE_WORLD_STATES".to_string(), Vec::new()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
|
||||
// GetNumWorldStateUI() — how many always-up rows the current scope admits (`0x4c5a40`: pushes
|
||||
// the count, no validation, always one return).
|
||||
g.set(
|
||||
"GetNumWorldStateUI",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(model.worldstate.rows.len() as i64)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetWorldStateUIInfo(index) — the i-th (1-based) row, ten values (`0x4c5a70`).
|
||||
//
|
||||
// Two legs of the reference's behaviour are easy to get wrong and are reproduced here:
|
||||
//
|
||||
// - a **non-number** argument RAISES `"Usage: GetWorldStateUIInfo(index)"`. The binding's
|
||||
// `luaL_error` leg looks like it returns — its callee has a `ret` — but that `ret` is an
|
||||
// MSVC epilogue, and closing the range two levels down (`luaG_errormsg`, then `luaD_throw`,
|
||||
// neither of which contains a single `ret`) proves the longjmp. So this is an error, not a
|
||||
// quiet nil.
|
||||
// - an **out-of-range** index returns exactly ONE value, the number `0` — not nil, and not a
|
||||
// run of ten nils (`0x4c5be5`). The index falls through a row-id substitution of `0`, whose
|
||||
// id-index slot is NULL, and lands on that shared bail.
|
||||
//
|
||||
// No string return is ever nil: an empty DBC column resolves to the string block's leading
|
||||
// NUL, so Lua gets `""`. The `__ftol` on the index truncates toward zero.
|
||||
g.set(
|
||||
"GetWorldStateUIInfo",
|
||||
lua.create_function(|lua, index: Value| {
|
||||
let index = match index {
|
||||
Value::Integer(i) => i as f64,
|
||||
Value::Number(n) => n,
|
||||
// The reference accepts a numeric STRING too — `lua_isnumber` coerces.
|
||||
Value::String(ref s) => match s.to_str().ok().and_then(|s| s.trim().parse().ok()) {
|
||||
Some(n) => n,
|
||||
None => return Err(mlua::Error::runtime("Usage: GetWorldStateUIInfo(index)")),
|
||||
},
|
||||
_ => return Err(mlua::Error::runtime("Usage: GetWorldStateUIInfo(index)")),
|
||||
};
|
||||
let row = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
// Truncate toward zero, then 1-based.
|
||||
usize::try_from(index.trunc() as i64)
|
||||
.ok()
|
||||
.and_then(|i| i.checked_sub(1))
|
||||
.and_then(|i| model.worldstate.rows.get(i).cloned())
|
||||
};
|
||||
let Some(r) = row else {
|
||||
return Ok(MultiValue::from_vec(vec![Value::Integer(0)]));
|
||||
};
|
||||
let s = |v: &str| -> mlua::Result<Value> { Ok(Value::String(lua.create_string(v)?)) };
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
Value::Integer(i64::from(r.ui_state)),
|
||||
s(&r.text)?,
|
||||
s(&r.icon)?,
|
||||
s(&r.dynamic_icon)?,
|
||||
s(&r.tooltip)?,
|
||||
s(&r.dynamic_tooltip)?,
|
||||
s(&r.extended_ui)?,
|
||||
Value::Integer(i64::from(r.extended_ui_state[0])),
|
||||
Value::Integer(i64::from(r.extended_ui_state[1])),
|
||||
Value::Integer(i64::from(r.extended_ui_state[2])),
|
||||
]))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -70,6 +70,24 @@ pub fn world_uv(proj: WorldProj, wx: f32, wy: f32) -> (f32, f32) {
|
||||
/// numerator short-circuit to 0 (an input exactly on the right/bottom edge yields 0, not 1).
|
||||
/// Then the combined range check zeroes both outputs if either leaves [0,1] — the `(0,0)` the
|
||||
/// reference FrameXML reads as "not on this map, hide the blip".
|
||||
///
|
||||
/// **That range check was contested and is now settled** (2026-08-25): wow-re's
|
||||
/// `scratch/gossip-poi-marker.md` §8.3 had recorded that neither axis is clamped and that "a
|
||||
/// re-implementation that clamps diverges", against `geometry.md`'s diffed reading. The re-carve
|
||||
/// found §8.3's window stopped **two bytes short** of the clamp, which lives at
|
||||
/// `[0x4a74d6, 0x4a7533)` — four `fcomp`s against `0.0` (`0x7ffd74`) and `1.0` (`0x7ff9d8`), any
|
||||
/// failure zeroing *both* outputs. §8.3 is corrected; this function was already right, and it is
|
||||
/// what makes the world map's landmark pass show a zone only its own POIs rather than scattering
|
||||
/// the continent's off-art.
|
||||
///
|
||||
/// Two residual differences from the bytes, both named rather than chased:
|
||||
///
|
||||
/// - the compares are **inclusive** — exactly `0.0` and exactly `1.0` pass, which is what
|
||||
/// `(0.0..=1.0)` does — but an **unordered** (NaN) compare passes all four in the reference and
|
||||
/// is zeroed here. No DBC-sourced or wire-sourced position can produce one.
|
||||
/// - the reference compares its two axes at **different precisions**: the first against its `f32`
|
||||
/// store, the second as the live 80-bit `st0` the `fsubr` left. An `f32` reimplementation cannot
|
||||
/// have that asymmetry, and it can only matter within an ulp of an edge.
|
||||
pub fn zone_uv(rect: ZoneRect, wx: f32, wy: f32) -> (f32, f32) {
|
||||
fn axis(input: f32, low: f32, span: f32) -> f32 {
|
||||
if input - low != 0.0 && span != 0.0 {
|
||||
|
||||
@@ -86,21 +86,24 @@ impl Default for ParticleTuning {
|
||||
}
|
||||
}
|
||||
|
||||
/// One live particle. Its frame depends on the emitter's space mode (wow-re
|
||||
/// `part-simspace-fields.md` + its `1f40db0b` corrections — the reference re-anchors EVERY cloud
|
||||
/// to the emitter's current position each frame; there is no world-frozen trail mode):
|
||||
/// - **Anchored mode** (file flag 0x10 clear — every placed prop): `pos`/`vel` are **world-oriented
|
||||
/// Bevy axes, relative to the [`ParticleEmitter::anchor`]** (the MODEL, not the bone). Bone and
|
||||
/// model rotation are baked at birth; the anchor translation follows the model — a running
|
||||
/// kobold carries its candle flame, while an animated bone never drags the risen cloud.
|
||||
/// Gravity acts on Bevy `-Y` (world up). On an **attached** model ([`ParticleEmitter::attach`])
|
||||
/// the same coords are stored with the live attach rotation divided out at birth and re-applied
|
||||
/// at draw (wow-re `part-kit-effect-attach-orient.md`, byte-verified: birth folds `A(t₀)⁻¹`,
|
||||
/// draw folds the live `A(t₁)` — a turning host swings its frozen cloud by the heading change
|
||||
/// since each particle's birth; a stationary host is bit-identical to the plain path).
|
||||
/// - **Model mode** (0x10 set): `pos`/`vel` are the emitter's **local WoW model space** (Z up);
|
||||
/// rendering folds the whole live placement transform every frame (rotation and all — the
|
||||
/// chandelier's flames rigidly ride the swing).
|
||||
/// One live particle. Its frame is the emitter's storage space, which the file flag `0x10` picks
|
||||
/// — and the two spaces are the whole of the ride-vs-trail law (wow-re `part-emitter-motion.md`
|
||||
/// §2c-B, byte-settled; decision 1585):
|
||||
/// - **World mode** (flag `0x10` CLEAR — ~70 % of the corpus): `pos`/`vel` are **absolute world
|
||||
/// coordinates, Bevy axes**. The birth bakes everything — bone pose, model rotation, scale,
|
||||
/// position — through the live emitter matrix (`0x7b8acf`/`0x7b8b0f`), and the draw folds
|
||||
/// **nothing** back (`0x7b3f48` omits `rt+0x1fc`). Once a particle exists, no later act of its
|
||||
/// host can reach it: run, turn, or play any animation and the cloud hangs exactly where it was
|
||||
/// born, which is what makes a trail of length `host speed × particle lifetime`. Gravity is
|
||||
/// plain world down (Bevy `-Y`).
|
||||
/// - **Model mode** (`0x10` SET): `pos`/`vel` are the emitter's **local WoW model space** (Z up)
|
||||
/// and the draw re-applies the whole live placement transform every frame, rotation included
|
||||
/// (`0x7b8aa5`/`0x7b3efb`) — the rigid ride a carried torch (`Club_1H_Torch_A_01.m2` = `0x0011`)
|
||||
/// is flagged for, and the chandelier's flames riding its swing.
|
||||
///
|
||||
/// Storing world-relative coordinates and re-composing them against a live frame — which is what
|
||||
/// this comment described until 1585 — cannot express the CLEAR case: it can be made to cancel a
|
||||
/// host's translation, but its rotation still reaches every particle already born.
|
||||
struct Particle {
|
||||
pos: Vec3,
|
||||
vel: Vec3,
|
||||
@@ -161,17 +164,6 @@ pub struct ParticleEmitter {
|
||||
/// this drain reproduces that defer-until-drained shape from the owner side; see
|
||||
/// `ribbons::RibbonTrail::owner` for the one OPEN half.
|
||||
draining: bool,
|
||||
/// The **attach frame** for an emitter on an ATTACHED model (a spell-kit instance root, a held
|
||||
/// item) — the entity whose live world rotation is the reference's attachment matrix `A`
|
||||
/// (`[model+0x17c]`, wow-re `part-kit-effect-attach-orient.md`). Anchored-mode particles are
|
||||
/// stored attach-local (birth divides `A` out) and drawn through the CURRENT `A`, so a host
|
||||
/// that turns mid-effect fans its spray by the heading-since-birth — the Eviscerate/Feint
|
||||
/// scatter. `None` for everything unattached (doodads, creatures' own models, missiles):
|
||||
/// `A = identity`, the plain world-frozen path.
|
||||
attach: Option<Entity>,
|
||||
/// The live attach rotation (identity when [`Self::attach`] is `None` or gone) — refreshed
|
||||
/// each frame before births/draw.
|
||||
attach_rot: Quat,
|
||||
/// The model instance whose [`crate::model_fade::ModelAlpha`] this cloud is multiplied by —
|
||||
/// the reference's `emitter+0x1a8`, a per-frame copy of that model's `+0x19c` (`0x718960`
|
||||
/// @`0x719073`), folded into each particle's ALPHA by the over-life sampler (`0x7b9b10`
|
||||
@@ -182,16 +174,12 @@ pub struct ParticleEmitter {
|
||||
alpha_src: Option<Entity>,
|
||||
/// This frame's value of that alpha (1.0 until read).
|
||||
alpha: f32,
|
||||
/// The **cloud anchor** for anchored mode: the entity whose live translation carries the
|
||||
/// live pool (the MODEL — a creature root, an effect-instance root, a held item's root),
|
||||
/// NOT the emitter's bone joint. The joint composes each particle's birth (position and
|
||||
/// rotation baked, the reference's birth transforms) and then must never move the cloud
|
||||
/// again: an emitter riding an ANIMATED bone — the food sparkle orbits on a global-sequence
|
||||
/// spin — would otherwise drag every risen star in a circle (the director's swirl).
|
||||
/// `None` = anchor at the spawn placement (a placed doodad whose emitter rides a joint
|
||||
/// anchors at the doodad, not the bone). How much of the anchor's own motion the live pool
|
||||
/// keeps is the emitter's file flag `0x10`, not this frame — see
|
||||
/// [`world_motion_kept`](crate::particles::sim) (decision 1578).
|
||||
/// The **cloud anchor**: the entity whose live translation is this pool's SORT point (the
|
||||
/// MODEL — a creature root, an effect-instance root, a held item's root), NOT the emitter's
|
||||
/// bone joint. Since 1585 it carries no particle position — a world-mode store is frozen at
|
||||
/// birth and a sort key is not a vertex — so this is a depth-ordering input only.
|
||||
/// `None` = sort at the spawn placement (a placed doodad whose emitter rides a joint sorts at
|
||||
/// the doodad, not the bone).
|
||||
anchor: Option<Entity>,
|
||||
/// The anchor's last-known world translation (kept when the entity vanishes so the pool
|
||||
/// drains in place; init = the spawn placement's translation).
|
||||
@@ -202,11 +190,6 @@ pub struct ParticleEmitter {
|
||||
/// refreshed EVERY frame — `0x7b5230` @0x7b5265): the one-frame Δ source for both
|
||||
/// emitter-motion terms (follow-delta, velocity inherit). `None` until the first frame.
|
||||
emitter_prev: Option<Vec3>,
|
||||
/// The **anchor's** last-frame world position, on the same one-frame refresh — what the
|
||||
/// anchored store's own ride amounts to, and the term the world-motion carry subtracts (see
|
||||
/// the carry block in [`sim`](crate::particles::sim)). Distinct from `emitter_prev`: an
|
||||
/// emitter orbiting an animated bone moves while its model — the anchor — stands still.
|
||||
anchor_prev: Option<Vec3>,
|
||||
/// Velocity-inherit state (file flag 0x40, wow-re `part-emitter-motion.md` §1): the ~30 Hz
|
||||
/// trigger accumulator (rt+0x254) and the held inherit velocity (rt+0x258.., world frame) —
|
||||
/// recomputed only at a trigger, births read the held value between.
|
||||
@@ -330,8 +313,6 @@ pub struct EmitterFrames {
|
||||
/// path exactly). `None` for a static doodad (fixed placement, despawned by the placement's
|
||||
/// own entity list).
|
||||
pub owner: Option<(Entity, [f32; 3])>,
|
||||
/// The attachment matrix `A` frame ([`ParticleEmitter::attach`]).
|
||||
pub attach: Option<Entity>,
|
||||
/// The cloud anchor — the MODEL, never the bone ([`ParticleEmitter::anchor`]).
|
||||
pub anchor: Option<Entity>,
|
||||
/// The MODEL INSTANCE whose render alpha multiplies these particles
|
||||
@@ -412,7 +393,7 @@ impl ParticleEmitter {
|
||||
.iter()
|
||||
.map(|p| {
|
||||
if anchored {
|
||||
self.anchor_pos + self.attach_rot * p.pos
|
||||
p.pos
|
||||
} else {
|
||||
self.placement
|
||||
.transform_point(benilla_assets::coords::wow_to_bevy([
|
||||
@@ -657,8 +638,6 @@ pub fn spawn_emitter(
|
||||
owner,
|
||||
on_owner_loss: frames.on_owner_loss,
|
||||
draining: false,
|
||||
attach: frames.attach,
|
||||
attach_rot: Quat::IDENTITY,
|
||||
alpha_src: frames.alpha,
|
||||
alpha: 1.0,
|
||||
anchor: frames.anchor,
|
||||
@@ -666,7 +645,6 @@ pub fn spawn_emitter(
|
||||
particles: Vec::new(),
|
||||
accumulator: 0.0,
|
||||
emitter_prev: None,
|
||||
anchor_prev: None,
|
||||
inherit_accum: 0.0,
|
||||
inherit_vel: Vec3::ZERO,
|
||||
gate_prev: false,
|
||||
|
||||
@@ -194,8 +194,9 @@ pub(super) fn update_model_particles(
|
||||
rgba[3] *= emitter.render_alpha();
|
||||
let tf = if anchored {
|
||||
Transform {
|
||||
translation: emitter.anchor_pos + emitter.attach_rot * p.pos,
|
||||
rotation: emitter.attach_rot * p.quat,
|
||||
// World mode: the instance transform IS the stored one (decision 1585).
|
||||
translation: p.pos,
|
||||
rotation: p.quat,
|
||||
scale: Vec3::splat(size * inst_scale),
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -62,8 +62,6 @@ pub(super) struct CamBasis {
|
||||
/// coordinates reach the world (the anchored/model split of [`super::Particle`]'s doc).
|
||||
pub(super) struct DrawFrame {
|
||||
pub(crate) anchored: bool,
|
||||
pub(crate) anchor: Vec3,
|
||||
pub(crate) attach_rot: Quat,
|
||||
/// The owning MODEL's render alpha, folded into every particle's alpha channel — the
|
||||
/// reference's `emitter+0x1a8` (decision 0827). 1.0 for a model that isn't fading.
|
||||
pub(crate) alpha: f32,
|
||||
@@ -74,8 +72,11 @@ pub(super) struct DrawFrame {
|
||||
/// re-derivation that can drift.
|
||||
pub(super) fn particle_center(frame: &DrawFrame, placement: &Transform, p: &Particle) -> Vec3 {
|
||||
if frame.anchored {
|
||||
frame.anchor + frame.attach_rot * p.pos
|
||||
// WORLD mode (`0x10` CLEAR): the store IS world and the draw folds nothing — the
|
||||
// reference's `0x7b3f48`, which omits `rt+0x1fc` (decision 1585, wow-re §2c-B).
|
||||
p.pos
|
||||
} else {
|
||||
// MODEL mode (`0x10` SET): the live emitter matrix, re-applied every frame — `0x7b3efb`.
|
||||
placement.transform_point(wow_to_bevy([p.pos.x, p.pos.y, p.pos.z]))
|
||||
}
|
||||
}
|
||||
@@ -181,10 +182,8 @@ pub(super) fn expand_quads(
|
||||
// bytes; the byte buffer's SATURATION (a stack can never exceed 255) is restored at
|
||||
// the FFX chain's scene reads (`ffx_glow.wgsl`, the Frost Nova mist diagnosis) — a
|
||||
// too-bright additive stack is never this ramp's bug, do not "fix" it here.
|
||||
// Anchored positions ride the emitter's live translation — through the CURRENT attach
|
||||
// rotation on an attached model (`DrawMx = A·Translate·V`, the heading-since-birth
|
||||
// fan); model mode folds the whole live placement transform (the reference's rt-0x100
|
||||
// render fold, `0x7b3d20`).
|
||||
// World-mode positions are already world and are drawn raw; model mode folds the whole
|
||||
// live placement transform (the reference's rt-0x100 render fold, `0x7b3d20`).
|
||||
let center = particle_center(frame, placement, p);
|
||||
// `size` is the half-extent: the reference quad corners are ±1.0 (verified in wow-5875-re,
|
||||
// `quad_expand`), so a vertex sits at `center ± size` and the world quad edge spans 2·size.
|
||||
@@ -250,7 +249,7 @@ pub(super) fn expand_quads(
|
||||
if def.head_tail >= 1 {
|
||||
let ((u0, u1), (v0, v1)) = cell_uv(ol.tail_cell);
|
||||
let vel_world = if anchored {
|
||||
frame.attach_rot * p.vel
|
||||
p.vel
|
||||
} else {
|
||||
placement.rotation * (placement.scale * wow_to_bevy(p.vel.to_array()))
|
||||
};
|
||||
@@ -339,8 +338,6 @@ mod tests {
|
||||
let shoot = |alpha| {
|
||||
let frame = DrawFrame {
|
||||
anchored: true,
|
||||
anchor: Vec3::ZERO,
|
||||
attach_rot: Quat::IDENTITY,
|
||||
alpha,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -80,76 +80,33 @@ fn integrate_particle(p: &mut Particle, env: &StepEnv) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// **How much of the emitter's per-frame world motion a live particle keeps** — the ride-vs-trail
|
||||
/// law, in one place (`speed` is `|Δ| / dt`, the yd/s the authored follow response is keyed on).
|
||||
/// **The follow-delta fraction** (file flag `0x4000`, rt `0x40000`) — the share of the emitter's
|
||||
/// per-frame world translation that is added to every live particle on top of whatever its
|
||||
/// storage space already gives it (`speed` is `|Δ| / dt`, the yd/s the authored response is
|
||||
/// keyed on). Zero when unauthored, and zero for a degenerate response (equal authored speeds,
|
||||
/// which the reference answers by zeroing both).
|
||||
///
|
||||
/// **The discriminator is the emitter's own FILE FLAG `0x10`, not the host class** (wow-re
|
||||
/// `part-emitter-motion.md` §2c, byte-settled by a three-worker §5 with objdump arbitration — the
|
||||
/// round benilla's 0986 dispatched, which *refuted* the host-class reading 0986 shipped). Spawn and
|
||||
/// draw are the two halves of one storage-space choice:
|
||||
/// **The ride-vs-trail baseline is NOT here** — it is the storage space itself (decision 1585).
|
||||
/// wow-re `part-emitter-motion.md` §2c-B: file `0x10` SET stores the raw emitter-LOCAL pos/vel
|
||||
/// (`0x7b8aa5`) and the draw folds the live emitter matrix `rt+0x1fc` back in every frame
|
||||
/// (`0x7b3efb`) ⇒ a rigid ride, keep `1.0`, for free. CLEAR bakes pos/vel through that matrix
|
||||
/// into WORLD at birth (`0x7b8acf`/`0x7b8b0f`) and the draw does *not* fold `rt+0x1fc`
|
||||
/// (`0x7b3f48`) ⇒ world-frozen, keep `0.0`, also for free. Spending a per-frame correction term
|
||||
/// to *simulate* either baseline is what decision 1578 did, and it could only ever approximate
|
||||
/// the translation half — see 1585.
|
||||
///
|
||||
/// - `0x10` **SET** (rt `0x100`): the spawn stores the raw emitter-LOCAL pos/vel (`0x7b8aa5`) and
|
||||
/// the draw folds the live emitter matrix `rt+0x1fc` back in every frame (`0x7b3efb`) ⇒ a
|
||||
/// **rigid ride**, keep `1.0`. A carried torch (`Club_1H_Torch_A_01.m2` = `0x0011`) is flagged
|
||||
/// for exactly this.
|
||||
/// - `0x10` **CLEAR**: the spawn bakes pos/vel through the emitter matrix into WORLD (`0x7b8acf`/
|
||||
/// `0x7b8b0f`) and the draw does *not* fold `rt+0x1fc` (`0x7b3f48`) ⇒ **world-frozen**, keep
|
||||
/// `0.0`: a trail of length `host speed × particle lifetime`. Sprint's `RibbonTrail` (`0x0029`)
|
||||
/// and Multi-Shot's flares (`0x0309`) are this, and so is the kobold's head candle (`0x0001`) —
|
||||
/// whose 0.2 s life makes its ~1 yd smear invisible, which is what let two earlier rounds read
|
||||
/// the null as a refutation and build the host-class law on it.
|
||||
///
|
||||
/// The host class is not consulted at all: `rt+0x1fc` is a world matrix for **every** host
|
||||
/// (`0x719090`–`0x719164`, no per-class branch), because the device matrix it composes is
|
||||
/// `scene+0x9c` followed by `scene+0xdc = inverse(scene+0x9c)` — it cancels exactly.
|
||||
///
|
||||
/// **The follow-delta term** (file `0x4000`, §2) then ADDS the authored two-point response
|
||||
/// ([`benilla_formats::ParticleEmitterDef::follow_line`]) on top, *independently of `0x10`*:
|
||||
/// `0x7b5303` builds `rt+0x26c` and `0x7b2744` adds `rt+0x278` whatever `0x100` says. Over the
|
||||
/// CLEAR baseline that recovers the ride fraction — ArcaneShot's head glow, 0.1 @ 2.5 yd/s → 0.9
|
||||
/// @ 16.7, riding the arrowhead while its three unflagged siblings hang behind it. Over a SET
|
||||
/// baseline (12 emitters corpus-wide carry both) it genuinely leads, and we reproduce that
|
||||
/// literally. A degenerate response — equal authored speeds, which the reference answers by
|
||||
/// zeroing both — is no follow term at all.
|
||||
fn world_motion_kept(def: &benilla_formats::ParticleEmitterDef, speed: f32) -> f32 {
|
||||
let baseline = if def.model_space() { 1.0 } else { 0.0 };
|
||||
/// This term is read independently of `0x10`: `0x7b5303` builds `rt+0x26c` and `0x7b2744` adds
|
||||
/// `rt+0x278` whatever `0x100` says. Over the CLEAR baseline it recovers a ride fraction —
|
||||
/// ArcaneShot's head glow, 0.1 @ 2.5 yd/s → 0.9 @ 16.7, riding the arrowhead while its three
|
||||
/// unflagged siblings hang frozen behind it. Over a SET baseline (12 emitters corpus-wide carry
|
||||
/// both) it genuinely leads, and we reproduce that literally.
|
||||
fn follow_fraction(def: &benilla_formats::ParticleEmitterDef, speed: f32) -> f32 {
|
||||
if !def.follow_emitter() {
|
||||
return baseline;
|
||||
return 0.0;
|
||||
}
|
||||
baseline
|
||||
+ def.follow_line().map_or(0.0, |(slope, intercept)| {
|
||||
(slope * speed + intercept).clamp(0.0, 1.0)
|
||||
})
|
||||
}
|
||||
|
||||
/// The **world-frame correction** the live pool needs this frame: the law says a particle's world
|
||||
/// displacement is `keep · Δemitter` ([`world_motion_kept`]), and each storage frame already
|
||||
/// supplies part of that for free — this is the remainder.
|
||||
///
|
||||
/// Conflating the two "for free" terms is a real bug, not a rounding one:
|
||||
///
|
||||
/// - **Anchored** (`0x10` clear): positions are anchor-relative, so the pool rides the MODEL —
|
||||
/// `Δanchor`. For a standing model with an emitter orbiting an animated bone that is **zero**
|
||||
/// while `Δemitter` is not: the births already bake the bone pose and the risen stars must then
|
||||
/// hold still (decision 0396's eat sparkle). Subtracting `Δemitter` here would counter-orbit the
|
||||
/// whole cloud — 0396's helix, mirrored.
|
||||
/// - **Model mode** (`0x10` set): positions are emitter-local and the draw re-applies the live
|
||||
/// emitter matrix, so the pool already rides the full `Δemitter` — exactly the `keep = 1` the
|
||||
/// flag buys. What is left is only whatever a follow response adds on top.
|
||||
fn motion_carry(
|
||||
def: &benilla_formats::ParticleEmitterDef,
|
||||
anchored: bool,
|
||||
emitter_delta: Vec3,
|
||||
anchor_delta: Vec3,
|
||||
dt: f32,
|
||||
) -> Vec3 {
|
||||
let keep = world_motion_kept(def, emitter_delta.length() / dt);
|
||||
let carried = if anchored {
|
||||
anchor_delta
|
||||
} else {
|
||||
emitter_delta
|
||||
};
|
||||
keep * emitter_delta - carried
|
||||
def.follow_line().map_or(0.0, |(slope, intercept)| {
|
||||
(slope * speed + intercept).clamp(0.0, 1.0)
|
||||
})
|
||||
}
|
||||
|
||||
/// The velocity-inherit trigger (wow-re `part-emitter-motion.md` §1, `0x7b5230` bytes
|
||||
@@ -190,7 +147,6 @@ fn drive_child(
|
||||
scale: f32,
|
||||
dt: f32,
|
||||
anchored: bool,
|
||||
attach_inv: Quat,
|
||||
placement: &Transform,
|
||||
) {
|
||||
let origin = Vec3::from(child.def.position);
|
||||
@@ -209,10 +165,11 @@ fn drive_child(
|
||||
let (base, dir) = emit_local(&child.def, now, &mut child.rng);
|
||||
let local = base - origin;
|
||||
let speed = now.emission_speed * (1.0 + now.speed_variation * rand_s11(&mut child.rng));
|
||||
// The parent particle's position is world in world mode (1585), so the child's
|
||||
// offset/velocity fold is rotation+scale into WORLD — no frame is divided out.
|
||||
let fold = |v: Vec3| {
|
||||
if anchored {
|
||||
attach_inv
|
||||
* (placement.rotation * (placement.scale * wow_to_bevy(v.to_array())))
|
||||
placement.rotation * (placement.scale * wow_to_bevy(v.to_array()))
|
||||
} else {
|
||||
v
|
||||
}
|
||||
@@ -718,8 +675,6 @@ pub(super) fn simulate_particles(
|
||||
owner,
|
||||
on_owner_loss,
|
||||
draining,
|
||||
attach,
|
||||
attach_rot,
|
||||
alpha_src,
|
||||
alpha,
|
||||
anchor,
|
||||
@@ -727,7 +682,6 @@ pub(super) fn simulate_particles(
|
||||
particles,
|
||||
accumulator,
|
||||
emitter_prev,
|
||||
anchor_prev,
|
||||
inherit_accum,
|
||||
inherit_vel,
|
||||
gate_prev,
|
||||
@@ -813,14 +767,6 @@ pub(super) fn simulate_particles(
|
||||
commands.entity(entity).despawn();
|
||||
continue;
|
||||
}
|
||||
// The live attach rotation `A(t)` (attached models only — see the field doc). A vanished
|
||||
// attach entity keeps the last rotation: the pool drains in its final frame.
|
||||
if let Some(a) = *attach {
|
||||
if let Ok(gt) = owners.transforms.get(a) {
|
||||
let (_, rot, _) = gt.to_scale_rotation_translation();
|
||||
*attach_rot = rot;
|
||||
}
|
||||
}
|
||||
// The MODEL's render alpha for this frame (decision 0827 — the reference's per-frame
|
||||
// `emitter+0x1a8 = Model+0x19c` copy at `0x718960` @`0x719073`). Two disjoint sources, the
|
||||
// same slot the reference writes both through: an entity-owned cloud takes its OWN model
|
||||
@@ -830,7 +776,6 @@ pub(super) fn simulate_particles(
|
||||
// already applies as a hard stop.
|
||||
*alpha = alpha_src.map_or(1.0, |e| model_alphas.get(e))
|
||||
* fade.map_or(1.0, |f| f.distance_alpha(cam_pos));
|
||||
let attach_inv = attach_rot.inverse();
|
||||
// The cloud anchor (see the field doc): the model's live translation, or the last-known
|
||||
// one while the pool drains. A whole-model owner keeps anchor == owner — identical math.
|
||||
match *anchor {
|
||||
@@ -857,32 +802,31 @@ pub(super) fn simulate_particles(
|
||||
let emitter_world = placement.transform_point(wow_to_bevy(def.position));
|
||||
let emitter_delta = emitter_prev.map_or(Vec3::ZERO, |prev| emitter_world - prev);
|
||||
*emitter_prev = Some(emitter_world);
|
||||
// …and the ANCHOR's, on the same one-frame refresh. It is what the anchored store carries
|
||||
// for free (see the carry block below), and it is NOT the emitter's: an emitter orbiting
|
||||
// an animated bone inside a standing model has `emitter_delta ≠ 0` with the model — and so
|
||||
// the cloud — perfectly still (0396's eat sparkle).
|
||||
let anchor_delta = anchor_prev.map_or(Vec3::ZERO, |prev| *anchor_pos - prev);
|
||||
*anchor_prev = Some(*anchor_pos);
|
||||
// NOTE on the R(+Z,90°) emitter-frame law (`emit_local`'s tail): we apply R at EMISSION,
|
||||
// so stored vectors are already post-R and every stored↔world fold here stays R-free —
|
||||
// unlike the reference, which stores pre-R and folds R inside its draw matrix. The two
|
||||
// compositions are equivalent; ours keeps a single application point.
|
||||
let to_stored = |world: Vec3, attach_inv: Quat, placement: &Transform| {
|
||||
// World → the particles' STORED frame. In world mode (`0x10` CLEAR) the store IS world,
|
||||
// so this is the identity — the whole point of 1585: no live matrix is ever re-applied to
|
||||
// an already-born particle, so nothing the host does afterwards (translate, turn, or play
|
||||
// an animation that swings the emitter's bone) can reach it.
|
||||
let to_stored = |world: Vec3, placement: &Transform| {
|
||||
if anchored {
|
||||
attach_inv * world
|
||||
world
|
||||
} else {
|
||||
Vec3::from(bevy_to_wow(
|
||||
(placement.rotation.inverse() * world) / placement.scale.max(Vec3::splat(1e-6)),
|
||||
))
|
||||
}
|
||||
};
|
||||
// The per-frame world-motion carry ([`motion_carry`]), folded into the stored frame and
|
||||
// applied to every live particle after its first integrate (the fresh-bit skip).
|
||||
let carry = motion_carry(def, anchored, emitter_delta, anchor_delta, dt);
|
||||
let follow = if carry == Vec3::ZERO {
|
||||
Vec3::ZERO // nothing moved, or the store already rides exactly right
|
||||
// The per-frame follow-delta ([`follow_fraction`]), folded into the stored frame and added
|
||||
// to every live particle after its first integrate (the fresh-bit skip). The ride-vs-trail
|
||||
// baseline is not here — the storage space supplies it (1585).
|
||||
let fraction = follow_fraction(def, emitter_delta.length() / dt);
|
||||
let follow = if fraction == 0.0 || emitter_delta == Vec3::ZERO {
|
||||
Vec3::ZERO
|
||||
} else {
|
||||
to_stored(carry, attach_inv, placement)
|
||||
to_stored(fraction * emitter_delta, placement)
|
||||
};
|
||||
// VELOCITY INHERIT (file 0x40): the ~30 Hz trigger holds the inherit velocity births
|
||||
// read; the live gate (rt+0x64) zeroes it while nothing is live.
|
||||
@@ -941,14 +885,14 @@ pub(super) fn simulate_particles(
|
||||
let g = now.gravity;
|
||||
let drag = def.drag;
|
||||
// Sphere KILL-OUTBOUND emitters: the emitter origin in the stored frame — `def.position`
|
||||
// composed exactly like a birth (anchored: the live placement/attach fold; model mode:
|
||||
// raw local). Every corpus author converges inward (negative speed); this is what stops
|
||||
// the stream at the centre instead of spraying it out the far side.
|
||||
// composed exactly like a birth (world mode: the live world point, which is
|
||||
// `emitter_world` itself; model mode: raw local). Every corpus author converges inward
|
||||
// (negative speed); this is what stops the stream at the centre instead of spraying it
|
||||
// out the far side. This one term is read LIVE in world mode by design: it is the
|
||||
// emitter's current origin, not a birth property of any particle.
|
||||
let kill_origin = def.kill_outbound().then(|| {
|
||||
if anchored {
|
||||
attach_inv
|
||||
* (placement.translation - *anchor_pos
|
||||
+ placement.rotation * (placement.scale * wow_to_bevy(def.position)))
|
||||
emitter_world
|
||||
} else {
|
||||
Vec3::from(def.position)
|
||||
}
|
||||
@@ -1002,35 +946,29 @@ pub(super) fn simulate_particles(
|
||||
let (base, dir) = emit_local(def, &now, rng);
|
||||
let speed =
|
||||
now.emission_speed * (1.0 + now.speed_variation * (rand01(rng) * 2.0 - 1.0));
|
||||
// Anchored mode bakes the emitter's ROTATION + scale at birth (the reference's
|
||||
// `0x7bca80`/`0x7bcb40` birth transforms) but stores relative to its position — the
|
||||
// per-frame anchor supplies the translation at render; on an attached model the
|
||||
// birth additionally divides out the live attach rotation (the reference's
|
||||
// `worldMx = palette·view⁻¹·A⁻¹`, CLEAR mode), stored attach-local. Model mode
|
||||
// stores raw local.
|
||||
// WORLD mode (`0x10` CLEAR) bakes the birth all the way into world through the live
|
||||
// emitter matrix — the reference's `0x7b8b0f 0x7bca80(pos, E)` (with translation) and
|
||||
// `0x7b8acf 0x7bcb40(vel, E)` (3×3). `placement` is the emitter's BONE transform in
|
||||
// world, so `transform_point` is that matrix. Nothing is stored relative to anything
|
||||
// that can later move. MODEL mode (`0x10` SET) stores raw emitter-local and the draw
|
||||
// re-applies the live matrix instead (`0x7b8aa5`/`0x7b3efb`).
|
||||
let (mut pos, vel) = if anchored {
|
||||
(
|
||||
attach_inv
|
||||
* (placement.translation - *anchor_pos
|
||||
+ placement.rotation
|
||||
* (placement.scale * wow_to_bevy(base.to_array()))),
|
||||
attach_inv
|
||||
* (placement.rotation
|
||||
* (placement.scale * wow_to_bevy((dir * speed).to_array()))),
|
||||
placement.transform_point(wow_to_bevy(base.to_array())),
|
||||
placement.rotation * (placement.scale * wow_to_bevy((dir * speed).to_array())),
|
||||
)
|
||||
} else {
|
||||
(base, dir * speed)
|
||||
};
|
||||
// GROUND SNAP (file 0x2000, [`benilla_formats::ParticleEmitterDef::ground_snap`]):
|
||||
// at spawn only, anchored mode only, probe 20 yd straight down against
|
||||
// terrain/WMO/doodad geometry (the walking collision audience — the reference's
|
||||
// 0x100111 flag set); on a hit the particle stands ON the surface, lifted by its
|
||||
// birth over-life SIZE. A miss leaves the spawn position untouched.
|
||||
// at spawn only, world mode only, probe 20 yd straight down against terrain/WMO/
|
||||
// doodad geometry (the walking collision audience — the reference's 0x100111 flag
|
||||
// set); on a hit the particle stands ON the surface, lifted by its birth over-life
|
||||
// SIZE. A miss leaves the spawn position untouched. `pos` is already world here, so
|
||||
// the probe reads and writes it directly.
|
||||
if anchored && def.ground_snap() {
|
||||
let world = *anchor_pos + *attach_rot * pos;
|
||||
if let Some(hit) = spatial.cast_ray(world, Dir3::NEG_Y, 20.0, true, &snap_filter) {
|
||||
let lifted = world.y - hit.distance + def.over_life.sample(0.0).size;
|
||||
pos = attach_inv * (Vec3::new(world.x, lifted, world.z) - *anchor_pos);
|
||||
if let Some(hit) = spatial.cast_ray(pos, Dir3::NEG_Y, 20.0, true, &snap_filter) {
|
||||
pos.y = pos.y - hit.distance + def.over_life.sample(0.0).size;
|
||||
}
|
||||
}
|
||||
// VELOCITY INHERIT consumption (the shape kernels' closing block, gated on the
|
||||
@@ -1038,7 +976,7 @@ pub(super) fn simulate_particles(
|
||||
// stored frame (the block runs after the reference's space fold).
|
||||
let vel = if def.inherits_emitter_motion() && *inherit_vel != Vec3::ZERO {
|
||||
vel + (1.0 + now.speed_variation * rand_s11(rng))
|
||||
* to_stored(*inherit_vel, attach_inv, placement)
|
||||
* to_stored(*inherit_vel, placement)
|
||||
} else {
|
||||
vel
|
||||
};
|
||||
@@ -1069,7 +1007,7 @@ pub(super) fn simulate_particles(
|
||||
// yaw appended to the frame rotation in both modes.
|
||||
let r90 = Quat::from_rotation_y(std::f32::consts::FRAC_PI_2);
|
||||
let quat = if anchored {
|
||||
attach_inv * placement.rotation * r90
|
||||
placement.rotation * r90
|
||||
} else {
|
||||
r90
|
||||
};
|
||||
@@ -1110,7 +1048,6 @@ pub(super) fn simulate_particles(
|
||||
density * dist_lod,
|
||||
dt,
|
||||
anchored,
|
||||
attach_inv,
|
||||
placement,
|
||||
);
|
||||
let c_env = StepEnv {
|
||||
@@ -1146,8 +1083,6 @@ pub(super) fn simulate_particles(
|
||||
*entity_global = GlobalTransform::from(*entity_tf);
|
||||
let frame = DrawFrame {
|
||||
anchored,
|
||||
anchor,
|
||||
attach_rot: *attach_rot,
|
||||
alpha: *alpha,
|
||||
};
|
||||
let cam = CamBasis {
|
||||
@@ -1305,8 +1240,8 @@ pub(super) fn simulate_particles(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
booth_frozen, inherit_trigger, integrate_particle, is_above, motion_carry,
|
||||
world_motion_kept, ChildEmitter, Particle, StepEnv, Vec3,
|
||||
booth_frozen, follow_fraction, inherit_trigger, integrate_particle, is_above, ChildEmitter,
|
||||
Particle, StepEnv, Vec3,
|
||||
};
|
||||
use bevy::prelude::{Quat, Transform};
|
||||
|
||||
@@ -1404,26 +1339,26 @@ mod tests {
|
||||
/// `0x4000` adds the authored response on top; a degenerate response adds nothing. The host
|
||||
/// class is not an input — the same unflagged def trails whoever carries it.
|
||||
#[test]
|
||||
fn world_motion_kept_reads_the_file_flag_then_the_follow_response() {
|
||||
fn follow_fraction_is_the_authored_response_and_nothing_else() {
|
||||
let plain = crate::particles::tests::plain_def(); // no flags
|
||||
assert_eq!(
|
||||
world_motion_kept(&plain, 30.0),
|
||||
follow_fraction(&plain, 30.0),
|
||||
0.0,
|
||||
"unflagged: world-frozen at any speed — Sprint's sparkles and Multi-Shot's flares \
|
||||
hang where they were born, and so does the kobold's candle (its 0.2 s life is what \
|
||||
hides the smear)"
|
||||
"no 0x4000: no per-frame term at any speed — ride-vs-trail is the storage space, not \
|
||||
a correction (1585)"
|
||||
);
|
||||
let riding = benilla_formats::ParticleEmitterDef {
|
||||
flags: 0x0011, // the carried torch, Club_1H_Torch_A_01.m2
|
||||
..crate::particles::tests::plain_def()
|
||||
};
|
||||
assert_eq!(
|
||||
world_motion_kept(&riding, 30.0),
|
||||
1.0,
|
||||
"file 0x10: the rigid ride, at any speed"
|
||||
follow_fraction(&riding, 30.0),
|
||||
0.0,
|
||||
"file 0x10 alone adds NO term: model mode's draw already re-applies the live emitter \
|
||||
matrix, which is the whole 100% ride"
|
||||
);
|
||||
// ArcaneShot's authored pair: 0.1 @ 2.5 yd/s, 0.9 @ 16.667 — the head glow catching up
|
||||
// over the unflagged (world-frozen) baseline its three siblings keep.
|
||||
// over the world-frozen baseline its three siblings keep.
|
||||
let following = benilla_formats::ParticleEmitterDef {
|
||||
flags: 0x4000,
|
||||
follow_speed1: 2.5,
|
||||
@@ -1432,25 +1367,11 @@ mod tests {
|
||||
follow_scale2: 0.9,
|
||||
..crate::particles::tests::plain_def()
|
||||
};
|
||||
assert!(
|
||||
(world_motion_kept(&following, 2.5) - 0.1).abs() < 1e-3,
|
||||
"the follow response over the world-frozen baseline IS the fraction"
|
||||
);
|
||||
assert!((follow_fraction(&following, 2.5) - 0.1).abs() < 1e-3);
|
||||
assert_eq!(
|
||||
world_motion_kept(&following, 40.0),
|
||||
follow_fraction(&following, 40.0),
|
||||
1.0,
|
||||
"clamped at a rigid ride on a fast missile — over this baseline it never leads"
|
||||
);
|
||||
// Both flags (12 emitters corpus-wide): the reference reads them independently, so the
|
||||
// follow add lands on top of a locally-stored — already riding — position and genuinely
|
||||
// leads. Reproduced literally (§2c-E).
|
||||
let both = benilla_formats::ParticleEmitterDef {
|
||||
flags: 0x4010,
|
||||
..following.clone()
|
||||
};
|
||||
assert!(
|
||||
(world_motion_kept(&both, 2.5) - 1.1).abs() < 1e-3,
|
||||
"0x10 + 0x4000: the ride, plus the authored fraction on top"
|
||||
"clamped: on a fast missile the head glow rides its emitter exactly"
|
||||
);
|
||||
// Equal authored speeds: the reference zeroes both, so nothing is added.
|
||||
let degenerate = benilla_formats::ParticleEmitterDef {
|
||||
@@ -1459,62 +1380,66 @@ mod tests {
|
||||
follow_speed2: 4.0,
|
||||
..crate::particles::tests::plain_def()
|
||||
};
|
||||
assert_eq!(world_motion_kept(°enerate, 30.0), 0.0);
|
||||
let degenerate_riding = benilla_formats::ParticleEmitterDef {
|
||||
flags: 0x4010,
|
||||
..degenerate.clone()
|
||||
};
|
||||
assert_eq!(world_motion_kept(°enerate_riding, 30.0), 1.0);
|
||||
assert_eq!(follow_fraction(°enerate, 30.0), 0.0);
|
||||
}
|
||||
|
||||
/// [`motion_carry`] — the storage-frame algebra the law is served through, and the guard on
|
||||
/// 0396: the term an ANCHORED store already carries is the MODEL's motion, never the
|
||||
/// emitter's. An emitter orbiting an animated bone inside a standing model has a live
|
||||
/// `Δemitter` and a zero `Δanchor`, and its risen stars must not be dragged by either.
|
||||
/// **B154's regression, at the draw**: a world-mode particle is frozen at birth, so NOTHING
|
||||
/// its host subsequently does — translate, turn, or swing the emitter's bone through an
|
||||
/// animation — may move it. Decision 1578 cancelled the host's *translation* with a per-frame
|
||||
/// correction and left its *rotation* reaching every live particle, which is what welded
|
||||
/// Sprint's sparkle trail to the runner's chest bone (wow-re §2c-B: the CLEAR draw at
|
||||
/// `0x7b3f48` folds `rt+0x1fc` back in NOT AT ALL).
|
||||
#[test]
|
||||
fn motion_carry_subtracts_what_each_store_already_rides() {
|
||||
let plain = crate::particles::tests::plain_def(); // 0x10 clear -> anchored, keep 0
|
||||
let dt = 1.0 / 60.0;
|
||||
// The eat sparkle: the model stands, the emitter bone orbits. Nothing to correct — the
|
||||
// pool is already exactly where the world-frozen law wants it.
|
||||
assert_eq!(
|
||||
motion_carry(&plain, true, Vec3::X * 0.02, Vec3::ZERO, dt),
|
||||
Vec3::ZERO,
|
||||
"an orbiting bone inside a standing model moves no live particle"
|
||||
);
|
||||
// The sprinting rogue: model and emitter travel together, and the anchored store rides
|
||||
// the model — so the carry cancels it exactly, leaving the sparkles in the air.
|
||||
let step = Vec3::X * 0.175; // ~10.5 yd/s at 60 fps
|
||||
assert_eq!(
|
||||
motion_carry(&plain, true, step, step, dt),
|
||||
-step,
|
||||
"world-frozen: the store's own ride is subtracted in full"
|
||||
);
|
||||
// The carried torch (0x10 set, model mode): the store already re-applies the live emitter
|
||||
// matrix, which IS the 100% ride the flag buys. Nothing to correct.
|
||||
let riding = benilla_formats::ParticleEmitterDef {
|
||||
flags: 0x0011,
|
||||
..crate::particles::tests::plain_def()
|
||||
fn a_world_mode_particle_ignores_everything_its_host_does_after_birth() {
|
||||
use crate::particles::quads::{particle_center, DrawFrame};
|
||||
let born_at = Vec3::new(12.0, 3.0, -40.0);
|
||||
let p = particle(born_at, Vec3::ZERO);
|
||||
let frame = DrawFrame {
|
||||
anchored: true, // 0x10 CLEAR — the world store
|
||||
alpha: 1.0,
|
||||
};
|
||||
assert_eq!(
|
||||
motion_carry(&riding, false, step, step, dt),
|
||||
Vec3::ZERO,
|
||||
"model mode rides for free"
|
||||
);
|
||||
// A follow emitter over the world-frozen baseline recovers `fraction` of the motion.
|
||||
let following = benilla_formats::ParticleEmitterDef {
|
||||
flags: 0x4000,
|
||||
follow_speed1: 2.5,
|
||||
follow_scale1: 0.1,
|
||||
follow_speed2: 16.667,
|
||||
follow_scale2: 0.9,
|
||||
..crate::particles::tests::plain_def()
|
||||
// Every host pose we can think of, including ones no bone reaches.
|
||||
for placement in [
|
||||
Transform::IDENTITY,
|
||||
Transform::from_translation(Vec3::new(500.0, -20.0, 900.0)),
|
||||
Transform::from_rotation(Quat::from_rotation_y(std::f32::consts::PI)),
|
||||
Transform::from_rotation(Quat::from_euler(bevy::math::EulerRot::YXZ, 1.2, -0.7, 2.9))
|
||||
.with_translation(Vec3::new(-7.0, 44.0, 3.0))
|
||||
.with_scale(Vec3::splat(3.5)),
|
||||
] {
|
||||
assert_eq!(
|
||||
particle_center(&frame, &placement, &p),
|
||||
born_at,
|
||||
"a world-mode particle moved when its host did"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The other half of the same law: model mode (`0x10` SET) rides its host rigidly, rotation
|
||||
/// included — the carried torch. Storage space is the whole discriminator, so these two tests
|
||||
/// together are the ride/trail law at the draw.
|
||||
#[test]
|
||||
fn a_model_mode_particle_rides_its_host_rigidly() {
|
||||
use crate::particles::quads::{particle_center, DrawFrame};
|
||||
let p = particle(Vec3::new(1.0, 0.0, 0.0), Vec3::ZERO); // WoW axes, 1 yd out on +X
|
||||
let frame = DrawFrame {
|
||||
anchored: false, // 0x10 SET — the emitter-local store
|
||||
alpha: 1.0,
|
||||
};
|
||||
let slow = Vec3::X * (2.5 * dt);
|
||||
let carry = motion_carry(&following, true, slow, slow, dt);
|
||||
let moved = Transform::from_translation(Vec3::new(10.0, 0.0, 0.0));
|
||||
assert_eq!(
|
||||
particle_center(&frame, &moved, &p) - particle_center(&frame, &Transform::IDENTITY, &p),
|
||||
Vec3::new(10.0, 0.0, 0.0),
|
||||
"model mode follows its host's translation in full"
|
||||
);
|
||||
let turned = Transform::from_rotation(Quat::from_rotation_y(std::f32::consts::FRAC_PI_2));
|
||||
assert!(
|
||||
(carry - (0.1 - 1.0) * slow).length() < 1e-6,
|
||||
"keeps 0.1 of the emitter's motion over a store that rides all of it"
|
||||
particle_center(&frame, &turned, &p).distance(particle_center(
|
||||
&frame,
|
||||
&Transform::IDENTITY,
|
||||
&p
|
||||
)) > 1.0,
|
||||
"model mode follows its host's ROTATION too — the one thing world mode must not do"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1600,7 +1525,6 @@ mod tests {
|
||||
1.0,
|
||||
0.1,
|
||||
true,
|
||||
Quat::IDENTITY,
|
||||
&Transform::IDENTITY,
|
||||
);
|
||||
assert_eq!(child.particles.len(), 20, "one rate·dt per live parent");
|
||||
@@ -1626,7 +1550,6 @@ mod tests {
|
||||
1.0,
|
||||
0.1,
|
||||
true,
|
||||
Quat::IDENTITY,
|
||||
&Transform::IDENTITY,
|
||||
);
|
||||
assert_eq!(child.particles.len(), n, "no parents, no births");
|
||||
|
||||
@@ -218,7 +218,6 @@ pub(super) fn spawn_emitters_for(
|
||||
transform,
|
||||
particles::EmitterFrames {
|
||||
owner,
|
||||
attach: None, // placed doodads/props are never attached models
|
||||
anchor: None, // anchor at the placement: an animated bone never drags the cloud
|
||||
// An animated doodad's rig goes when its placement unloads, and that IS this
|
||||
// model being destroyed — free the pool with it, the same rule the WMO-prop lane
|
||||
|
||||
Reference in New Issue
Block a user