the auction house opens, the Honor tab lands, and a day of fixes

- the auction house end to end: right-clicking an auctioneer opens Browse/Bids/Auctions on the server's reply — search, the category tree from your own DBCs, the reference's 50-a-page pager, multi-key sorting (directions corrected at the bytes), bid/buyout, the sell slot with deposit and duration, cancel with its fee popup; prices sit on the reference's own money-frame kit in lined-up columns, the twenty auction messages split refusals-red / outcomes-to-chat, and a won or sold auction reads as a proper mail receipt — the window's Lua re-worded in our own terms after the overlap ratchet caught the first cut leaning on the reference's text
- the PvP honor system whole: the Honor tab and the inspect page over a real wire model, with current rank and highest-ever rank correctly two different fields
- a day of reported fixes: WASD survives a /logout re-entry, addon settings save on a window close and Cmd+Q (exactly once — a mac close could fire the tail twice), clams open again, random-enchant items name themselves on every surface, the loot window's rows hold their seats with legible counts, idle players stop sinking through building floors, the shooter re-pulls between Auto Shots, portraits loosen ~19% to the client's own aspect-1.0 matrix, and big creatures thump the camera off the CameraShakes table
- character select clicks like 1.12 — captions whiten on hover, buttons fire on release, Humans face straight ahead off the stage bone's real yaw, the facing is per-character, and hunter/warlock pets stand beside you at their family-ramp size with their own emitters (the imp's flames), the Voidwalker's sequence-hidden shackles culled
- the smaller player-facing set: nameplate on/off rows in Options with the CanAttack category gate (emissaries stop plating), the bonus bar slides up with igBonusBarOpen, the camera's framing height glides on a shapeshift, the open letter evicts the centre seat, the far-band fog hull stops printing a pale horizon stripe at low view distance, the guide arrow is gold with per-source rim art, error text fades through its shadow too, and /convertraid converts the party
- README: the auction house and PvP honor move out of "not built yet"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-22 19:04:27 +07:00
co-authored by Claude Fable 5
parent 6aa78bc468
commit cf581aa2ac
170 changed files with 20573 additions and 1578 deletions
Generated
+1
View File
@@ -749,6 +749,7 @@ dependencies = [
"bytemuck",
"image",
"libc",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
]
+4 -4
View File
@@ -33,8 +33,8 @@ and no bundled game assets.
- **Movement:** a WoW-feel controller, networked movement in both directions, the server-granted
modes from slow fall to roots, a follow camera with collision, boats, zeppelins and taxi flights.
- **Networking:** SRP6 auth through world-session crypto, the object mirror into the ECS, and live
wire coverage from movement and chat through spells, party, quests, mail, trade, vendors, bank
and loot.
wire coverage from movement and chat through spells, party, quests, mail, trade, vendors, bank,
loot, the auction house and PvP honor.
- **UI:** a from-scratch FrameXML + Lua engine driving the built-in interface, from the login and
character screens through the full HUD, the classic windows (guild, macros and key bindings
included), chat, nameplates, floating combat text and tooltips; third-party addons load from
@@ -46,8 +46,8 @@ and no bundled game assets.
## Not built yet
Battlegrounds and honor, and the auction house. Third-party addons are partial: AtlasLoot and
Bagnon run, and the addon API fills in as other addons are tried.
Battlegrounds. Third-party addons are partial: AtlasLoot and Bagnon run, and the addon API
fills in as other addons are tried.
## Running it
+269 -129
View File
@@ -20,8 +20,10 @@
SCOPE / real-client divergences (noted, not bugs):
- No frame scale: the real bar is UIParent-scaled; we render 1:1. Geometry is otherwise exact.
- Page up/down + the page number are ART ONLY (we have no bar paging — only the bonus-bar
offset; ref-ActionBarFrame.xml l.159-217). The page number renders a static "1".
- Page up/down page the bar for real (`CURRENT_ACTIONBAR_PAGE`, below) and the numeral
follows through `ChangeActionBarPage`, not through the ref's ACTIONBAR_PAGE_CHANGED —
which we never fire, so an addon LISTENING for the page change hears nothing (it is a
Rust-side event with no Lua producer). Addons that drive paging call the verb.
- HotKey renders the LIVE binding label, abbreviated the ref's own way since 1008
(GetBindingText(key, "KEY_", 1) — UIParent.xml carries our own version; "s-2" for
SHIFT-2, "·" for a 2+-modifier chord). A slot with NO binding wears the ref's
@@ -87,10 +89,10 @@
-- than here among the true constants. A frozen 1 would still be a lie; this one moves.
NUM_ACTIONBAR_PAGES = 6
NUM_ACTIONBAR_BUTTONS = 12
BOTTOMLEFT_ACTIONBAR_PAGE = 6
BOTTOMRIGHT_ACTIONBAR_PAGE = 5
LEFT_ACTIONBAR_PAGE = 4
RIGHT_ACTIONBAR_PAGE = 3
LEFT_ACTIONBAR_PAGE = 4
BOTTOMRIGHT_ACTIONBAR_PAGE = 5
BOTTOMLEFT_ACTIONBAR_PAGE = 6
-- Ref ActionButton.lua:4 / UIParent.lua: the flash blink period and the range-check cadence.
ATTACK_BUTTON_FLASH_TIME = 0.4
@@ -172,10 +174,13 @@
--
-- Upstream it is a client binding; here it is FrameXML, deliberately. Our paged action id
-- is computed in Lua by `BenillaActionBar_ActionFor`, so the page IS this global and the
-- only work left for the verb is the repaint the reference gets by firing
-- ACTIONBAR_PAGE_CHANGED. Addons CALL this, they never replace it, so the divergence is
-- unobservable from Lua — unlike a name we shadow, which is 1222's class.
-- only work left for the verb is the work the reference gets by firing
-- ACTIONBAR_PAGE_CHANGED: repaint the twelve buttons, and restamp the page numeral (the
-- ref's own listener for that event is the up arrow's OnEvent, l.176-181 — the numeral is
-- the ONLY thing it does there). Addons CALL this, they never replace it, so the
-- divergence is unobservable from Lua — unlike a name we shadow, which is 1222's class.
function ChangeActionBarPage()
MainMenuBarPageNumber:SetText(CURRENT_ACTIONBAR_PAGE)
for i = 1, table.getn(BENILLA_ACTION_BUTTONS) do
BenillaActionButton_Update(BENILLA_ACTION_BUTTONS[i])
end
@@ -206,8 +211,7 @@
if VIEWABLE_ACTION_BAR_PAGES[i] then prevPage = i break end
end
end
CURRENT_ACTIONBAR_PAGE = prevPage
ChangeActionBarPage()
CURRENT_ACTIONBAR_PAGE = prevPage; ChangeActionBarPage()
end
-- Seat a TOP-LEVEL frame that anchors INTO the bar art (the bag bar + backpack toggle,
@@ -291,12 +295,112 @@
if frame and not show and frame:IsShown() then frame:Hide() end
end
-- ── The bonus-bar slide (decision 1524; ref BonusActionBarFrame.lua:1-98) ──────────────
--
-- The reference never re-pages its main bar: entering a form slides a 505x43 REPLICA of
-- the bar (own art, own 12 buttons on the bonus page) up out of the screen-bottom edge
-- over 0.15s, plays igBonusBarOpen when it lands, and leaves it covering the button strip
-- for as long as the form is up; leaving the form slides it back down carrying the OLD
-- form's icons (lastBonusBar) and hides it — no sound on the way down. benilla re-pages
-- the main bar (1223) — so here the overlay is the TRANSITION VEHICLE and the faithful
-- addon-visible state (`BonusActionBarFrame:IsShown()` is true exactly while a form is
-- up, as on the real client): the main bar keeps painting the OLD page under the rising
-- overlay (BenillaActionBar_DisplayedOffset) and adopts the bonus page when it lands.
--
-- Ref timing/geometry, quoted: SLIDETIME 0.15, YPOS 43 (= the frame's own height, so it
-- rises exactly out of the screen edge), XPOS 4 (the ref Lua re-anchors every tick at 4,
-- overriding its own XML's 40; the art's left TexCoord is trimmed 4px to line back up).
-- Ours slides a PROGRESS fraction rather than the ref's mode+timer pair: flipping form
-- mid-slide turns the bar around from where it is, where the ref's timer arithmetic
-- mirror-jumps the position (a quirk, not the mechanism).
BONUSACTIONBAR_SLIDETIME = 0.15
BONUSACTIONBAR_YPOS = 43
BONUSACTIONBAR_XPOS = 4
-- Ref-named verb (BonusActionBarFrame.lua:74): addons call it. Begin (or continue toward)
-- the up-slide; a bar already landed is a no-op — a direct form→form swap (warrior stance
-- dance, cat→bear powershift) repaints without re-sliding or re-sounding, ref-verbatim.
function ShowBonusActionBar()
local frame = BonusActionBarFrame
if not frame:IsShown() then
frame.slideProgress = 0
frame:Show()
end
if (frame.slideProgress or 0) < 1 then
frame.mode = "show"
end
end
-- Ref-named verb (l.87): begin the down-slide. Nothing to do when already hidden; a
-- mid-show flip turns around from the current position.
function HideBonusActionBar()
local frame = BonusActionBarFrame
if frame:IsShown() and frame.mode ~= "hide" then
frame.mode = "hide"
end
end
function BenillaBonusBar_OnLoad(frame)
frame.mode = "none"
frame.slideProgress = 0
-- Ref field (l.17/23, read by ActionButton_GetPagedID:449-451): the last NONZERO
-- offset, so the down-slide keeps painting the form's icons after the offset is 0.
frame.lastBonusBar = 1
end
function BonusActionBar_OnUpdate(elapsed)
local frame = BonusActionBarFrame
if frame.mode == "show" then
frame.slideProgress = frame.slideProgress + elapsed / BONUSACTIONBAR_SLIDETIME
if frame.slideProgress >= 1 then
frame.slideProgress = 1
frame.mode = "none"
frame:SetPoint("TOPLEFT", MainMenuBar, "BOTTOMLEFT",
BONUSACTIONBAR_XPOS, BONUSACTIONBAR_YPOS)
-- The landing edge IS the sound (ref l.61-64: played on slide-up completion,
-- never on the way down), and the moment the main bar under the overlay
-- adopts the bonus page (DisplayedOffset stops suppressing).
PlaySound("igBonusBarOpen")
for i = 1, table.getn(BENILLA_ACTION_BUTTONS) do
BenillaActionButton_Update(BENILLA_ACTION_BUTTONS[i])
end
else
frame:SetPoint("TOPLEFT", MainMenuBar, "BOTTOMLEFT",
BONUSACTIONBAR_XPOS, frame.slideProgress * BONUSACTIONBAR_YPOS)
end
elseif frame.mode == "hide" then
frame.slideProgress = frame.slideProgress - elapsed / BONUSACTIONBAR_SLIDETIME
if frame.slideProgress <= 0 then
frame.slideProgress = 0
frame.mode = "none"
frame:SetPoint("TOPLEFT", MainMenuBar, "BOTTOMLEFT", BONUSACTIONBAR_XPOS, 0)
frame:Hide()
else
frame:SetPoint("TOPLEFT", MainMenuBar, "BOTTOMLEFT",
BONUSACTIONBAR_XPOS, frame.slideProgress * BONUSACTIONBAR_YPOS)
end
end
end
-- The offset the MAIN bar paints. Live, except during the up-slide: the old page keeps
-- painting under the rising overlay and the bonus page arrives when it lands (the ref's
-- main bar shows page 1 the whole time a form is up — ours adopts the bonus page at
-- landing instead, 1223's re-paging model; 1524). The getglobal guard: buttons paint
-- during load, before BonusActionBarFrame (declared after the bar) exists.
function BenillaActionBar_DisplayedOffset()
local frame = getglobal("BonusActionBarFrame")
if frame and frame.mode == "show" then
return 0
end
return GetBonusBarOffset()
end
-- The wire/API action id the MAIN bar's button i displays (the vanilla page formula).
-- INTERNAL to BenillaActionButton_Action below — the ONLY caller. A button path that
-- reaches past it computes a MAIN-bar id for a multibar button (base ignored), which
-- reads as "the slot below": the tooltip hover did exactly that until 2026-07-21.
function BenillaActionBar_ActionFor(index)
local offset = GetBonusBarOffset()
local offset = BenillaActionBar_DisplayedOffset()
local base = 0
if offset > 0 then
base = (6 + offset - 1) * 12
@@ -361,6 +465,19 @@
if button.base then
return button.base + index
end
-- The overlay's own buttons (ref ActionButton_GetPagedID:447-452): the LIVE bonus
-- page — with lastBonusBar standing in once the offset has dropped, so the
-- down-slide keeps painting (and a click mid-descent keeps using) the old form's
-- slots, ref-verbatim. Distinct from the main bar's DisplayedOffset suppression:
-- these buttons show the NEW page from the first slide frame.
if button.isBonus then
local offset = GetBonusBarOffset()
if offset == 0 then
local frame = getglobal("BonusActionBarFrame")
offset = (frame and frame.lastBonusBar) or 1
end
return (6 + offset - 1) * 12 + index
end
return BenillaActionBar_ActionFor(index)
end
@@ -388,11 +505,7 @@
local parent = button:GetParent()
local edgeBar = parent and (parent == MultiBarBottomRight
or parent == MultiBarRight or parent == MultiBarLeft)
if edgeBar then
GameTooltip:SetOwner(button, "ANCHOR_LEFT")
else
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
end
if edgeBar then GameTooltip:SetOwner(button, "ANCHOR_LEFT") else GameTooltip:SetOwner(button, "ANCHOR_RIGHT") end
else
GameTooltip_SetDefaultAnchor(GameTooltip, button)
end
@@ -486,15 +599,28 @@
-- they take the bar INDEX in both clients — so nothing here changes but the names and the
-- restored `onSelf`, which rides through to UseAction's third argument exactly as the ref's
-- l.56 does (our binding accepts and ignores it — `script/action.rs`).
-- Both route to the overlay's buttons while it is up — ref ActionButton.lua:15-61's own
-- fork, and the half of the swap that must NOT wait for the slide: the overlay is shown
-- from the first slide frame, so a key pressed the instant you enter stealth already
-- drives the stealth page (and, during the down-slide, still drives the old form's page —
-- lastBonusBar — exactly as the reference does).
function ActionButtonDown(id)
local button = getglobal("ActionButton" .. id)
if ( button:GetButtonState() == "NORMAL" ) then
button:SetButtonState("PUSHED")
if BonusActionBarFrame:IsShown() then
local button = getglobal("BonusActionButton" .. id)
if ( button:GetButtonState() == "NORMAL" ) then button:SetButtonState("PUSHED") end
return
end
local button = getglobal("ActionButton" .. id)
if ( button:GetButtonState() == "NORMAL" ) then button:SetButtonState("PUSHED") end
end
function ActionButtonUp(id, onSelf)
local button = getglobal("ActionButton" .. id)
local button
if BonusActionBarFrame:IsShown() then
button = getglobal("BonusActionButton" .. id)
else
button = getglobal("ActionButton" .. id)
end
if ( button:GetButtonState() == "PUSHED" ) then
button:SetButtonState("NORMAL")
UseAction(BenillaActionButton_Action(button), 0, onSelf)
@@ -507,11 +633,7 @@
-- while auto-attacking, the in-flight cast) or the live auto-repeat spell.
function BenillaActionButton_UpdateState(button)
local action = BenillaActionButton_Action(button)
if ( IsCurrentAction(action) or IsAutoRepeatAction(action) ) then
button:SetChecked(1)
else
button:SetChecked(0)
end
if ( IsCurrentAction(action) or IsAutoRepeatAction(action) ) then button:SetChecked(1) else button:SetChecked(0) end
end
-- Ref ActionButton_UpdateUsable:269-283, verbatim colors: usable white; out of power the
@@ -798,11 +920,11 @@
-- divergence (the SCOPE note) — its art has sockets; floating rings over bare
-- world would not read as a bar.
button.hotkey:SetText("")
if grid then
if not grid then
button:Hide()
else
button:SetNormalTexture("Interface\\Buttons\\UI-Quickslot")
button:Show()
else
button:Hide()
end
else
-- While the grid is up, an empty slot shows the ref's own "no action" ring
@@ -864,6 +986,20 @@
end
function BenillaActionBar_OnEvent(frame)
-- The form/stance/stealth edge (ref BonusActionBar_OnEvent:20-35): start the overlay
-- slide, then FALL THROUGH to the tail — the roster repaint under the new state is
-- the same statement for this event as for any other. Deliberately in the handler
-- that repaints, not a second OnEvent on the overlay frame: one handler means the
-- slide state is set before any button paints, so there is no one-frame flash of the
-- new page before the suppression lands.
if event == "UPDATE_BONUS_ACTIONBAR" then
if GetBonusBarOffset() <= 0 then
HideBonusActionBar()
else
BonusActionBarFrame.lastBonusBar = GetBonusBarOffset()
ShowBonusActionBar()
end
end
-- ACTIONBAR_SLOT_CHANGED names its action in arg1; repaint just the button showing it.
if event == "ACTIONBAR_SLOT_CHANGED" and arg1 then
for i = 1, table.getn(BENILLA_ACTION_BUTTONS) do
@@ -1170,28 +1306,24 @@
-- that the text is currently pinned on — which is what the bar's own OnLeave reads before
-- hiding it again. Note the Show verb only ever shows the text AT MAX LEVEL, where the
-- strip has replaced the XP bar and is tall enough to hold numerals; below that it falls
-- through to the Hide one. Both function names, both field names and the FontString are the
-- addon-visible contract, so these two bodies have almost no freedom left in them and read
-- close to the reference's — which is what a compatible implementation of a four-line lock
-- over fixed names looks like, not a transcription.
-- through to the Hide one. Both function names, both field names and the FontString are
-- the addon-visible contract; the shapes below (early exits where the ref nests) are ours.
function ShowWatchedReputationBarText(lock)
if lock then
ReputationWatchBar.cvarLocked = lock
end
if UnitLevel("player") == MAX_PLAYER_LEVEL and ReputationWatchBar:IsVisible() then
ReputationWatchStatusBarText:Show()
ReputationWatchBar.textLocked = 1
else
if lock then ReputationWatchBar.cvarLocked = lock end
if UnitLevel("player") ~= MAX_PLAYER_LEVEL or not ReputationWatchBar:IsVisible() then
HideWatchedReputationBarText()
return
end
ReputationWatchBar.textLocked = 1
ReputationWatchStatusBarText:Show()
end
function HideWatchedReputationBarText(unlock)
if unlock or not ReputationWatchBar.cvarLocked then
ReputationWatchBar.cvarLocked = nil
ReputationWatchStatusBarText:Hide()
ReputationWatchBar.textLocked = nil
end
-- Same gate as the ref's `unlock or not cvarLocked`, spun as the early exit.
if not unlock and ReputationWatchBar.cvarLocked then return end
ReputationWatchStatusBarText:Hide()
ReputationWatchBar.cvarLocked = nil
ReputationWatchBar.textLocked = nil
end
-- ── The watch bar's own handlers ────────────────────────────────────────────────────────
@@ -1227,11 +1359,7 @@
-- `script::cvars`, decision 1140, which carves exactly this).
function BenillaWatchBar_OnEvent(bar, event)
if event == "CVAR_UPDATE" and arg1 == "statusBarText" then
if arg2 == "1" then
ShowWatchedReputationBarText("lock")
else
HideWatchedReputationBarText("unlock")
end
if arg2 == "1" then ShowWatchedReputationBarText("lock") else HideWatchedReputationBarText("unlock") end
return
end
if event == "UPDATE_FACTION" and bar:IsVisible() and ReputationFrame_Update then
@@ -1248,9 +1376,7 @@
-- Coming up with the cvar set means the numerals were meant to be pinned on all along.
function BenillaWatchBar_OnShow()
if GetCVar("statusBarText") == "1" then
ShowWatchedReputationBarText("lock")
end
if GetCVar("statusBarText") == "1" then ShowWatchedReputationBarText("lock") end
UIParent_ManageFramePositions()
end
@@ -1316,9 +1442,7 @@
end
-- ref-MainMenuBar.lua l.51-54: at max level with a watched faction the strip the
-- tick rides is the REPUTATION bar, which has no rested pool — hide the tick.
if UnitLevel("player") == MAX_PLAYER_LEVEL and ReputationWatchBar:IsShown() then
ExhaustionTick:Hide()
end
if UnitLevel("player") == MAX_PLAYER_LEVEL and ReputationWatchBar:IsShown() then ExhaustionTick:Hide() end
end
-- The rest-state paint (only these two events repaint it, the ref's own split): one
-- rest state, one hue, worn by all three pieces at once — the strip's fill, the pale
@@ -1338,9 +1462,7 @@
-- REPLACED the XP bar (max level), so there is no XP strip left for the tick to sit on
-- whatever the event was. Distinct from the guard above, which only runs on the four
-- position events; this one runs on every one.
if ReputationWatchBar:IsShown() and not MainMenuExpBar:IsShown() then
ExhaustionTick:Hide()
end
if ReputationWatchBar:IsShown() and not MainMenuExpBar:IsShown() then ExhaustionTick:Hide() end
end
-- The tick's tooltip (ref-MainMenuBar.lua ExhaustionToolTipText, the live branches only —
@@ -1353,30 +1475,24 @@
function ExhaustionToolTipText()
if SHOW_NEWBIE_TIPS ~= "1" then
local x, y = ExhaustionTick:GetCenter()
if ExhaustionTick:IsVisible() then
if x >= (GetScreenWidth() / 2) then
GameTooltip:SetOwner(ExhaustionTick, "ANCHOR_LEFT")
else
GameTooltip:SetOwner(ExhaustionTick, "ANCHOR_RIGHT")
end
else
if not ExhaustionTick:IsVisible() then
GameTooltip_SetDefaultAnchor(GameTooltip, UIParent)
elseif x >= (GetScreenWidth() / 2) then
GameTooltip:SetOwner(ExhaustionTick, "ANCHOR_LEFT")
else
GameTooltip:SetOwner(ExhaustionTick, "ANCHOR_RIGHT")
end
end
local exhaustionStateID, exhaustionStateName, exhaustionStateMultiplier = GetRestState()
local exhaustionThreshold = GetXPExhaustion()
local exhaustionStateID, exhaustionStateName, exhaustionStateMultiplier = GetRestState()
exhaustionStateMultiplier = exhaustionStateMultiplier * 100
local exhaustionCountdown = nil
if GetTimeToWellRested() then
exhaustionCountdown = GetTimeToWellRested() / 60
end
-- nil while the wire has not answered; a number (minutes) once it has.
local exhaustionCountdown = GetTimeToWellRested() and GetTimeToWellRested() / 60
local tooltipText = format(EXHAUST_TOOLTIP1, exhaustionStateName,
exhaustionStateMultiplier)
local append = nil
if IsResting() then
if exhaustionThreshold and exhaustionCountdown then
append = format(EXHAUST_TOOLTIP4, exhaustionCountdown)
end
if exhaustionThreshold and exhaustionCountdown then append = format(EXHAUST_TOOLTIP4, exhaustionCountdown) end
elseif exhaustionStateID == 4 or exhaustionStateID == 5 then
append = EXHAUST_TOOLTIP2
end
@@ -1385,25 +1501,22 @@
end
if SHOW_NEWBIE_TIPS ~= "1" then
GameTooltip:SetText(tooltipText)
else
if GameTooltip.canAddRestStateLine then
GameTooltip:AddLine("\n" .. tooltipText)
GameTooltip:Show()
GameTooltip.canAddRestStateLine = nil
end
elseif GameTooltip.canAddRestStateLine then
GameTooltip:AddLine("\n" .. tooltipText)
GameTooltip.canAddRestStateLine = nil
GameTooltip:Show()
end
end
-- Driven from the XP BAR's OnUpdate (the ref wires it there, not on the tick — the timer
-- only ever counts while the mouse is on the bar, which is what arms it).
function ExhaustionTick_OnUpdate(elapsed)
if ExhaustionTick.timer then
if ExhaustionTick.timer < 0 then
ExhaustionToolTipText()
ExhaustionTick.timer = nil
else
ExhaustionTick.timer = ExhaustionTick.timer - elapsed
end
if not ExhaustionTick.timer then return end
if ExhaustionTick.timer >= 0 then
ExhaustionTick.timer = ExhaustionTick.timer - elapsed
else
ExhaustionTick.timer = nil
ExhaustionToolTipText()
end
end
@@ -1463,13 +1576,13 @@
end
frame.updateInterval = PERFORMANCEBAR_UPDATE_INTERVAL
local _, _, latency = GetNetStats()
local r, g, b = 0, 1, 0
if latency > PERFORMANCEBAR_MEDIUM_LATENCY then
MainMenuBarPerformanceBar:SetVertexColor(1, 0, 0)
r, g, b = 1, 0, 0
elseif latency > PERFORMANCEBAR_LOW_LATENCY then
MainMenuBarPerformanceBar:SetVertexColor(1, 1, 0)
else
MainMenuBarPerformanceBar:SetVertexColor(0, 1, 0)
r, g, b = 1, 1, 0
end
MainMenuBarPerformanceBar:SetVertexColor(r, g, b)
-- Held-open tooltips re-read the number on the same 10 s beat (the ref's own refresh).
if frame.hover then
BenillaPerformanceBar_ShowTooltip(frame)
@@ -1815,6 +1928,9 @@
<Anchors><Anchor point="BOTTOM" relativePoint="BOTTOM"><Offset><AbsDimension x="544" y="0"/></Offset></Anchor></Anchors>
<TexCoords left="1.0" right="0.0" top="0.0" bottom="1.0"/>
</Texture>
<!-- The page numeral. `text="1"` is the LOAD SEED the ref gets from the up
arrow's OnLoad (`SetText(CURRENT_ACTIONBAR_PAGE)`, which is 1 there
too); every change after that comes through `ChangeActionBarPage`. -->
<FontString name="MainMenuBarPageNumber" inherits="GameFontNormalSmall" text="1">
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="30" y="-5"/></Offset></Anchor>
@@ -1877,26 +1993,35 @@
<!-- Page up/down: 32x32 at CENTER of the art frame's TOPLEFT +(522,-22)/(522,-42)
(ref-ActionBarFrame.xml l.159-217). Live now that the bar pages — they were
art with no OnClick for as long as `CURRENT_ACTIONBAR_PAGE` did not exist. -->
art with no OnClick for as long as `CURRENT_ACTIONBAR_PAGE` did not exist.
The HitRectInsets are LOAD-BEARING, not decoration: the two 32x32 squares
are stacked only 20px apart, so their raw rects OVERLAP by 12px and the
hit-test (later sibling wins) would give that band — the bottom third of
the visible up arrow — to the DOWN button. The ref's ±6/±7 insets shrink
each to the 20x18 arrow it actually draws, which separates them and stops
the down arrow's rect hanging below the screen edge and over the XP bar. -->
<Button name="ActionBarUpButton">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="CENTER" relativeTo="MainMenuBarArtFrame" relativePoint="TOPLEFT"><Offset><AbsDimension x="522" y="-22"/></Offset></Anchor>
</Anchors>
<HitRectInsets><AbsInset left="6" right="6" top="7" bottom="7"/></HitRectInsets>
<NormalTexture file="Interface\MainMenuBar\UI-MainMenu-ScrollUpButton-Up"/>
<PushedTexture file="Interface\MainMenuBar\UI-MainMenu-ScrollUpButton-Down"/>
<HighlightTexture file="Interface\MainMenuBar\UI-MainMenu-ScrollUpButton-Highlight" alphaMode="ADD"/>
<Scripts><OnClick>ActionBar_PageUp()</OnClick></Scripts>
<Scripts><OnClick>ActionBar_PageUp() PlaySound("UChatScrollButton")</OnClick></Scripts>
</Button>
<Button name="ActionBarDownButton">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="CENTER" relativeTo="MainMenuBarArtFrame" relativePoint="TOPLEFT"><Offset><AbsDimension x="522" y="-42"/></Offset></Anchor>
</Anchors>
<HitRectInsets><AbsInset left="6" right="6" top="7" bottom="7"/></HitRectInsets>
<NormalTexture file="Interface\MainMenuBar\UI-MainMenu-ScrollDownButton-Up"/>
<PushedTexture file="Interface\MainMenuBar\UI-MainMenu-ScrollDownButton-Down"/>
<HighlightTexture file="Interface\MainMenuBar\UI-MainMenu-ScrollDownButton-Highlight" alphaMode="ADD"/>
<Scripts><OnClick>ActionBar_PageDown()</OnClick></Scripts>
<Scripts><OnClick>ActionBar_PageDown() PlaySound("UChatScrollButton")</OnClick></Scripts>
</Button>
</Frames>
</Frame>
@@ -1954,88 +2079,103 @@
</Scripts>
</Frame>
<!-- **The bonus action bar, built but never shown** — the same posture 1219 gave the vertical
multibars, and for the same reason: `ref-BonusActionBarFrame.xml` l.54 instantiates
`BonusActionBarFrame` as a real `parent="MainMenuBar"` frame carrying `hidden="true"`, with
`BonusActionButton1..12` inside (l.88+). It exists from load on the real client and shows
only while a form/stealth page is active.
<!-- **The bonus action bar** — declared with 1223 (addons reach for it by name: the largest
single session-start row in the corpus was `CT_BarMod\CT_BarModOptions.lua:154`'s
`getglobal("BonusActionButton" .. i):ClearAllPoints()`; five more addons index the frame,
1218's corrected ranking), and DRIVEN since 1524: it is the form/stance/stealth swap's
transition vehicle — the 505x43 replica of the button strip that slides up out of the
screen edge over 0.15s when a form comes up (igBonusBarOpen on landing), stays covering
the strip while the form holds (so `BonusActionBarFrame:IsShown()` reads
reference-faithfully), and slides back down carrying the old form's icons when it drops.
The machinery lives in this file's script block (ShowBonusActionBar / HideBonusActionBar /
BonusActionBar_OnUpdate, all ref names) and in BenillaActionBar_OnEvent's
UPDATE_BONUS_ACTIONBAR arm; benilla still models the bonus page by RE-PAGING THE MAIN BAR
(1223) — the overlay never replaces that, it covers the swap.
benilla models the bonus page by RE-PAGING THE MAIN BAR (`set_bonus_bar_offset` +
UPDATE_BONUS_ACTIONBAR — the main bar's own fork), so this bar is never shown by us. It is
declared because addons reach for it by name and die when it is absent: the largest single
session-start row in the corpus was `CT_BarMod\CT_BarModOptions.lua:154`,
getglobal("BonusActionButton" .. i):ClearAllPoints();
which is pure LAYOUT — four addons (Bongos_MapBar, Bongos_Options, Bongos_RollBar,
CT_BottomBar) only need the buttons to exist and accept ClearAllPoints/SetPoint. Five more
index `BonusActionBarFrame` (decision 1218's corrected ranking).
The buttons wire to the MAIN bar's paged slots — `BenillaActionButton_OnLoad(self, i)`,
exactly as ActionButton1..12 do. That is not a placeholder: our bonus model re-pages the
main bar, so a bonus button showing the main bar's current paged slot is the coherent
answer, and it is also the reference's binding story (vanilla's ACTIONBUTTON1-12 drive the
bonus bar while a form is up, so sharing the command prefix is faithful). Wiring them also
means the template's OnUpdate cannot meet a button with no `.index` if an addon shows one.
The buttons wire to the bonus page — `BenillaActionButton_OnLoad(self, i)` plus the ref's
own `isBonus` mark, which forks BenillaActionButton_Action to the live bonus page (with
lastBonusBar standing in during the down-slide). They share the ACTIONBUTTON command
prefix, the reference's binding story: vanilla's ACTIONBUTTON1-12 drive the bonus bar
while a form is up (ActionButtonDown/Up's IsShown fork above).
Geometry quoted from the reference: a 505x43 frame anchored TOPLEFT to MainMenuBar's
BOTTOMLEFT +(40,0); button 1 at BOTTOMLEFT +(5,4), each next LEFT-of-previous-RIGHT +6
(l.55-62, l.88-105). -->
BOTTOMLEFT +(40,0) as declared — every shown appearance re-anchors at x=4 (the ref Lua's
own BONUSACTIONBAR_XPOS, whose art trims 4px of left TexCoord to line back up); button 1
at BOTTOMLEFT +(5,4), each next LEFT-of-previous-RIGHT +6; the two OVERLAY art strips are
the dwarf sheet rows the main bar itself draws (ref l.55-105). -->
<Frame name="BonusActionBarFrame" frameStrata="HIGH" parent="MainMenuBar" enableMouse="true" toplevel="true" hidden="true">
<Size><AbsDimension x="505" y="43"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="MainMenuBar" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="40" y="0"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture name="BonusActionBarTexture0" file="Interface\MainMenuBar\UI-MainMenuBar-Dwarf">
<Size><AbsDimension x="252" y="43"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<TexCoords left="0.015625" right="1.0" top="0.83203125" bottom="1.0"/>
</Texture>
<Texture name="BonusActionBarTexture1" file="Interface\MainMenuBar\UI-MainMenuBar-Dwarf">
<Size><AbsDimension x="253" y="43"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="BonusActionBarTexture0" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0" right="0.98828125" top="0.58203125" bottom="0.75"/>
</Texture>
</Layer>
</Layers>
<Frames>
<CheckButton name="BonusActionButton1" inherits="BenillaActionButtonTemplate" id="1">
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="5" y="4"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 1)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 1) self.isBonus = 1</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BonusActionButton2" inherits="BenillaActionButtonTemplate" id="2">
<Anchors><Anchor point="LEFT" relativeTo="BonusActionButton1" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 2)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 2) self.isBonus = 1</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BonusActionButton3" inherits="BenillaActionButtonTemplate" id="3">
<Anchors><Anchor point="LEFT" relativeTo="BonusActionButton2" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 3)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 3) self.isBonus = 1</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BonusActionButton4" inherits="BenillaActionButtonTemplate" id="4">
<Anchors><Anchor point="LEFT" relativeTo="BonusActionButton3" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 4)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 4) self.isBonus = 1</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BonusActionButton5" inherits="BenillaActionButtonTemplate" id="5">
<Anchors><Anchor point="LEFT" relativeTo="BonusActionButton4" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 5)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 5) self.isBonus = 1</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BonusActionButton6" inherits="BenillaActionButtonTemplate" id="6">
<Anchors><Anchor point="LEFT" relativeTo="BonusActionButton5" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 6)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 6) self.isBonus = 1</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BonusActionButton7" inherits="BenillaActionButtonTemplate" id="7">
<Anchors><Anchor point="LEFT" relativeTo="BonusActionButton6" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 7)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 7) self.isBonus = 1</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BonusActionButton8" inherits="BenillaActionButtonTemplate" id="8">
<Anchors><Anchor point="LEFT" relativeTo="BonusActionButton7" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 8)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 8) self.isBonus = 1</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BonusActionButton9" inherits="BenillaActionButtonTemplate" id="9">
<Anchors><Anchor point="LEFT" relativeTo="BonusActionButton8" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 9)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 9) self.isBonus = 1</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BonusActionButton10" inherits="BenillaActionButtonTemplate" id="10">
<Anchors><Anchor point="LEFT" relativeTo="BonusActionButton9" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 10)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 10) self.isBonus = 1</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BonusActionButton11" inherits="BenillaActionButtonTemplate" id="11">
<Anchors><Anchor point="LEFT" relativeTo="BonusActionButton10" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 11)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 11) self.isBonus = 1</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BonusActionButton12" inherits="BenillaActionButtonTemplate" id="12">
<Anchors><Anchor point="LEFT" relativeTo="BonusActionButton11" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 12)</OnLoad></Scripts>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 12) self.isBonus = 1</OnLoad></Scripts>
</CheckButton>
</Frames>
<Scripts>
<OnLoad>BenillaBonusBar_OnLoad(self)</OnLoad>
<OnUpdate>BonusActionBar_OnUpdate(arg1)</OnUpdate>
</Scripts>
</Frame>
<!-- **The reputation watch bar** — built to `ref-ReputationFrame.xml:869-1036`, whose geometry
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -38,10 +38,12 @@
selection-highlight Frame, and the `button.craftIndex`/`Button:SetTextColor` engine-gap
substitutions (deviation 7 below).
FLAT layout (TradeSkillFrame.xml's own house constraint, restated here): a child <Frame> shown
at runtime does not render its own <Layers> FontStrings in this engine, so the detail-pane
FontStrings + the title live on BenillaCraftFrame's OWN <Layers> (OVERLAY, above the window's
own BACKGROUND/BORDER/ARTWORK art groups), never on a nested sub-frame. The ref's
FLAT layout — a CHOICE inherited from TradeSkillFrame.xml, which inherited it from
TrainerFrame.xml, which stated it as a house constraint it never was. A runtime-shown child
<Frame> DOES render its own <Layers> FontStrings in this engine (probed; decision 1517). The
detail-pane FontStrings + the title live on BenillaCraftFrame's OWN <Layers> (OVERLAY, above
the window's own BACKGROUND/BORDER/ARTWORK art groups) because that is how this window was
built, not because nesting fails. The ref's
`CraftDetailScrollFrame`/`CraftDetailScrollChildFrame` therefore don't exist here — the detail
is a FIXED (non-scrolling) pane, computed once from the flattened ScrollFrame+ScrollChild
offsets (TOPLEFT 20,-234 + each child's own ref-quoted offset — BYTE-IDENTICAL to TradeSkill's
+12 -10
View File
@@ -55,12 +55,13 @@
wire) that this button has never had. LootFrame.xml's own row template
(`BenillaLootButtonTemplate`) carries the same hover-cursor omission.
THE ITEM TOOLTIP uses `GameTooltip:SetItemById(itemId, name, quality)` (LootFrame.xml's own
`BenillaLootItem_OnEnter` idiom), not the reference's `SetLootRollItem` (no such tooltip
method here) — `itemId` is the benilla-extension 6th return of `GetLootRollItemInfo`
(see `loot_roll.rs`), always populated once a roll is known (unlike name/texture/quality, which
may be `nil` while the item template is still in flight). `GetLootRollItemLink` DOES exist now
(decision 1059) but is the click's, not the hover's: the tooltip wants the id, not the markup.
THE ITEM TOOLTIP uses `GameTooltip:SetLootRollItem(rollID)` — the reference's own binding
(decision 1547; it replaced a `SetItemById` that could not see the drop's random-suffix
roll and so printed `<Random enchantment>` on every rolled item). The `itemId` guard stays:
it is the benilla-extension 6th return of `GetLootRollItemInfo` (see `loot_roll.rs`),
always populated once a roll is known — unlike name/texture/quality, which may be `nil`
while the item template is still in flight. `GetLootRollItemLink` (decision 1059) is the
click's, not the hover's: the tooltip wants the roll, not the markup.
`toplevel="true"` (ref l.243) IS STILL OMITTED, but for a NEW reason — the old one ("`SetToplevel`
isn't in the v1 object model yet ... trivially re-added the day `SetToplevel` lands") has expired.
@@ -258,15 +259,16 @@
end
-- The item icon's hover tooltip (ref $parentIconFrame OnEnter/OnLeave, l.341-349, minus the
-- CursorUpdate/ResetCursor calls this engine has no counterpart for — see the header). Uses
-- the LootFrame.xml row idiom (SetItemById + the benilla itemId extension) rather than the
-- reference's SetLootRollItem, which doesn't exist here.
-- CursorUpdate/ResetCursor calls this engine has no counterpart for — see the header).
-- SetLootRollItem, the reference's own binding: the ROLL is the source, so the hover sees
-- the drop's random-suffix roll (through SetItemById it showed `<Random enchantment>` —
-- decision 1547).
function BenillaGroupLootFrame_Icon_OnEnter(button)
local frame = button:GetParent()
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
local _, name, _, quality, _, itemId = GetLootRollItemInfo(frame.rollID)
if itemId then
GameTooltip:SetItemById(itemId, name, quality)
GameTooltip:SetLootRollItem(frame.rollID)
end
end
+39 -24
View File
@@ -41,9 +41,13 @@
Paging (LootFrame.lua l.27-31,70-73,105-118): 4 rows/page; > 4 items spends one slot on the
pager, showing 3 rows + Up/Down. We keep the current page across content-change repaints
(LOOT_UPDATE) and reset it to 1 only on a fresh open (LOOT_OPENED) — the merchant SHOW/UPDATE
split. (This replaces Blizzard's per-button LOOT_SLOT_CLEARED collapse with a full
re-snapshot; the app fires LOOT_UPDATE on any content change, incl. an async item name/quality
landing and a looted/coin-cleared row.)
split. (The per-button LOOT_SLOT_CLEARED event is replaced by a full re-snapshot repaint; the
app fires LOOT_UPDATE on any content change, incl. an async item name/quality landing and a
looted/coin-cleared row. The OBSERVABLE matches the ref exactly: the slot list is fixed while
the window is open — GetNumLootItems counts cleared slots, which answer neither IsItem nor
IsCoin, so a looted row's button hides IN PLACE and the rows below never shift up — and a
page left with every button hidden advances to the next page when one exists, the ref's
LOOT_SLOT_CLEARED tail, l.38-50.)
UIPanel slot system (decision 0084 §2): registered in UiPanels.xml as area="left",
pushable=7 — so it carries NO hardcoded <Anchors>; ShowUIPanel SetPoints it (and, when a
@@ -133,11 +137,14 @@
LootSlot(button.slot)
end
-- Hover: the item name coloured by quality (loot rows carry it — GetLootSlotInfo's 4th
-- return), reusing loot_color. The coin pile has no item quality; its formatted text (already
-- the row name) shows white.
-- Hover: the plain engine item tooltip, ITEM rows only — the ref's own gate (its
-- LootButtonTemplate <OnEnter> runs SetLootItem only `if LootSlotIsItem(slot)`, so the
-- coin pile shows no tooltip at all). No hand-added lines: the real tooltip never shows a
-- stack size (the 0274 P1 'Stack Size' drop retired the same line from the bag hovers;
-- this file's copy predated it and survived).
function BenillaLootItem_OnEnter(button)
if not button.slot then return end
if not LootSlotIsItem(button.slot) then return end
local texture, item, quantity, quality, itemId = GetLootSlotInfo(button.slot)
button.itemId = itemId
if not item then return end
@@ -145,16 +152,11 @@
-- MerchantFrame.xml walks ANCHOR_RIGHT back by a negative x): 37px icon on a 160 row
-- → -123 lands the tip at the icon's right edge.
GameTooltip:SetOwner(button, "ANCHOR_RIGHT", -123, 0)
-- The shared item tooltip when the row is an ITEM (coin rows have no item id/stats).
local color = loot_color(quality)
if button.itemId and button.itemId ~= 0 then
GameTooltip:SetItemById(button.itemId, item, quality)
else
GameTooltip:SetText(item, color.r, color.g, color.b)
end
if (quantity or 1) > 1 then
GameTooltip:AddLine("Stack Size: " .. quantity, 1, 1, 1)
end
-- SetLootItem, like the ref's own LootButtonTemplate: the loot ROW is the source, not
-- just its item id, so the tooltip sees the drop's random-suffix roll. Through
-- SetItemById (a template source) a "… of the Monkey" showed `<Random enchantment>`
-- until the item reached a bag — decision 1547.
GameTooltip:SetLootItem(button.slot)
GameTooltip:Show()
end
@@ -171,8 +173,8 @@
if numItems > BENILLA_LOOT_NUMBUTTONS then
perPage = perPage - 1
end
-- Clamp the page (a shrunk loot can leave us past the end — benilla's replacement for
-- Blizzard's per-button collapse, l.38-50).
-- Clamp the page. numItems is constant while a window is open (cleared slots stay in
-- the count — the fixed slot layout), so this only guards a stray page value.
local maxPage = 1
if numItems > 0 then
maxPage = math.ceil(numItems / perPage)
@@ -250,8 +252,20 @@
ShowUIPanel(frame)
elseif event == "LOOT_UPDATE" then
-- A content change while open (name/quality landed, a row/coin removed): repaint,
-- keeping the current page.
-- keeping the current page. A looted slot's button hides IN PLACE (the slot stays
-- in the layout as a gap — the ref's LOOT_SLOT_CLEARED, l.22-37).
LootFrame_Update()
-- The ref's LOOT_SLOT_CLEARED tail (l.38-50): a page left with every button hidden
-- moves down to the next page when one exists.
local allHidden = true
for index = 1, BENILLA_LOOT_NUMBUTTONS do
if getglobal("LootButton" .. index):IsVisible() then
allHidden = false
end
end
if allHidden and getglobal("LootDownButton"):IsVisible() then
LootFrame_PageDown()
end
elseif event == "LOOT_CLOSED" then
HideUIPanel(frame)
end
@@ -331,12 +345,13 @@
</FontString>
</Layer>
<Layer level="OVERLAY">
<!-- "xN" stack count on the icon's bottom-right (ItemButtonTemplate $parentCount; only
shown when N > 1). -->
<FontString name="$parentCount" justifyH="RIGHT">
<Size><AbsDimension x="34" y="12"/></Size>
<!-- "xN" stack count INSIDE the icon's bottom-right (ItemButtonTemplate
$parentCount: NumberFontNormal, BOTTOMRIGHT -5,2 — the ref's button IS the
37px icon, ours spans the whole row, so the same inset re-bases off the
button's BOTTOMLEFT: 37-5=32. The bag slots' count is this exact shape). -->
<FontString name="$parentCount" inherits="NumberFontNormal" justifyH="RIGHT">
<Anchors>
<Anchor point="BOTTOMLEFT" relativePoint="LEFT"><Offset><AbsDimension x="4" y="-16"/></Offset></Anchor>
<Anchor point="BOTTOMRIGHT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="32" y="2"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
+196 -34
View File
@@ -86,9 +86,7 @@
-- reset side effect — wow-re §5); either way reset the form + play the page turn.
SendMailFrame_Reset()
PlaySound("igAbiliityPageTurn")
if SendMailFrame.sendMode == "reply" then
MailFrameTab_OnClick(1)
end
if SendMailFrame.sendMode == "reply" then MailFrameTab_OnClick(1) end
elseif event == "MAIL_FAILED" then
-- Fires on EVERY SMSG_SEND_MAIL_RESULT for a send (success included, wow-re §5): its
-- only job is to re-enable the button so the UI never stays blocked.
@@ -148,9 +146,10 @@
MailFrameTopLeft:SetPoint("TOPLEFT", "MailFrame", "TOPLEFT", skin.x, skin.y)
if not reading then
-- Arriving at the form repaints it and clears the mode a Reply had set.
SendMailFrame_Update()
-- Arriving at the form repaints it and clears the mode a Reply had set
-- (SendMailFrame_Update never reads the mode, so the order is free).
SendMailFrame.sendMode = "send"
SendMailFrame_Update()
end
PlaySound("igSpellBookOpen")
end
@@ -208,11 +207,11 @@
expireTime:Show()
-- COD tag (ref l.157-164) + money field.
if CODAmount > 0 then
getglobal("MailItem"..i.."ButtonCOD"):Show()
button.cod = CODAmount
getglobal("MailItem"..i.."ButtonCOD"):Show()
else
getglobal("MailItem"..i.."ButtonCOD"):Hide()
button.cod = nil
getglobal("MailItem"..i.."ButtonCOD"):Hide()
end
if money > 0 then button.money = money else button.money = nil end
if InboxFrame.openMailID == index then
@@ -221,10 +220,10 @@
button:SetChecked(nil)
end
else
getglobal("MailItem"..i.."Button"):Hide()
getglobal("MailItem"..i.."Sender"):SetText("")
getglobal("MailItem"..i.."Subject"):SetText("")
getglobal("MailItem"..i.."ExpireTime"):Hide()
getglobal("MailItem"..i.."Button"):Hide()
getglobal("MailItem"..i.."Subject"):SetText("")
getglobal("MailItem"..i.."Sender"):SetText("")
end
index = index + 1
end
@@ -240,14 +239,14 @@
-- Row click: open / close the letter (ref InboxFrame_OnClick l.201-213; this = the button).
function InboxFrame_OnClick(index)
if this:GetChecked() then
InboxFrame.openMailID = index
OpenMail_Update()
ShowUIPanel(OpenMailFrame)
PlaySound("igSpellBookOpen")
else
if not this:GetChecked() then
InboxFrame.openMailID = 0
HideUIPanel(OpenMailFrame)
else
InboxFrame.openMailID = index
OpenMail_Update()
PlaySound("igSpellBookOpen")
ShowUIPanel(OpenMailFrame)
end
InboxFrame_Update()
end
@@ -292,12 +291,81 @@
return
end
local pkg, sta, sender, subject, money, cod, days, itemID, wasRead, wasReturned, textCreated = GetInboxHeaderInfo(InboxFrame.openMailID)
if money == 0 and not itemID and textCreated then
DeleteInboxItem(InboxFrame.openMailID)
end
if money == 0 and not itemID and textCreated then DeleteInboxItem(InboxFrame.openMailID) end
InboxFrame.openMailID = 0
InboxFrame_Update()
PlaySound("igSpellBookClose")
InboxFrame_Update()
end
-- One GlobalString read — the player's own table, executed off their chain at boot and
-- absent in a bare-XML harness. (AuctionFrame.xml carries the twin of this; the static
-- labels below take theirs through `text=` instead, which the loader resolves the same way.)
local function gs(key, fallback)
local value = getglobal(key)
if value and value ~= "" then
return value
end
return fallback or ""
end
-- The auction house's RECEIPT (ref l.300-362). An auction mail's body is machine-written
-- bookkeeping, not prose — the engine parses it (`ui_mail::invoice`) and hands back seven
-- values; this lays them out. The guard is on `playerName`, the THIRD value, because that
-- is the one the miss tail nils: an invoice whose counterparty has not been named yet is
-- not renderable, and the row re-resolves when the name query lands.
--
-- Two shapes off one pane. A SELLER's is the full sum — sale price, plus what the deposit
-- refunds, minus the house's cut, ruled off and totalled. A BUYER's is a single line: what
-- you paid. The difference is not just which lines show, it is where the total and the
-- rule sit, so both get re-anchored every time.
local function OpenMail_UpdateInvoice(isInvoice, id)
local invoiceType, itemName, playerName, bid, buyout, deposit, consignment
if isInvoice then
invoiceType, itemName, playerName, bid, buyout, deposit, consignment =
GetInboxInvoiceInfo(id)
end
if not playerName then
OpenMailInvoiceFrame:Hide()
return
end
-- Paid the asking price, or won it on a bid? `bid == buyout` is the whole test.
local buyMode = "(" .. gs(bid == buyout and "BUYOUT" or "HIGH_BIDDER") .. ")"
if invoiceType == "buyer" then
OpenMailInvoiceItemLabel:SetText(gs("ITEM_PURCHASED_COLON") .. " " .. itemName .. " " .. buyMode)
OpenMailInvoicePurchaser:SetText(gs("SOLD_BY_COLON") .. " " .. playerName)
OpenMailInvoiceBuyMode:SetText("")
OpenMailInvoiceAmountReceived:SetText(gs("AMOUNT_PAID_COLON"))
-- One line, so the total rides up to where the sale price would have been.
OpenMailInvoiceAmountReceived:SetPoint("TOPRIGHT", "OpenMailInvoiceSalePrice", "TOPRIGHT", 0, 0)
OpenMailArithmeticLine:SetPoint("TOP", "OpenMailInvoicePurchaser", "BOTTOMLEFT", 125, 0)
MoneyFrame_Update("OpenMailTransactionAmountMoneyFrame", bid)
OpenMailInvoiceSalePrice:Hide()
OpenMailInvoiceDeposit:Hide()
OpenMailInvoiceHouseCut:Hide()
OpenMailSalePriceMoneyFrame:Hide()
OpenMailDepositMoneyFrame:Hide()
OpenMailHouseCutMoneyFrame:Hide()
else
OpenMailInvoiceItemLabel:SetText(gs("ITEM_SOLD_COLON") .. " " .. itemName)
OpenMailInvoicePurchaser:SetText(gs("PURCHASED_BY_COLON") .. " " .. playerName)
OpenMailInvoiceBuyMode:SetText(buyMode)
OpenMailInvoiceAmountReceived:SetText(gs("AMOUNT_RECEIVED_COLON"))
OpenMailArithmeticLine:SetPoint("TOP", "OpenMailInvoiceHouseCut", "BOTTOMRIGHT", 0, 9)
OpenMailInvoiceAmountReceived:SetPoint("TOPRIGHT", "OpenMailInvoiceHouseCut", "BOTTOMRIGHT", 0, -18)
MoneyFrame_Update("OpenMailDepositMoneyFrame", deposit)
MoneyFrame_Update("OpenMailSalePriceMoneyFrame", bid)
MoneyFrame_Update("OpenMailHouseCutMoneyFrame", consignment)
-- The cut is the one number that leaves — red, and the only recoloured line.
SetMoneyFrameColor("OpenMailHouseCutMoneyFrame", 1.0, 0, 0)
MoneyFrame_Update("OpenMailTransactionAmountMoneyFrame", bid + deposit - consignment)
OpenMailInvoiceSalePrice:Show()
OpenMailInvoiceDeposit:Show()
OpenMailInvoiceHouseCut:Show()
OpenMailSalePriceMoneyFrame:Show()
OpenMailDepositMoneyFrame:Show()
OpenMailHouseCutMoneyFrame:Show()
end
OpenMailInvoiceFrame:Show()
end
function OpenMail_Update()
@@ -312,8 +380,20 @@
if not sender then sender = "Unknown" end
OpenMailSender:SetText(sender)
OpenMailSubject:SetText(subject)
OpenMailFrameIcon:SetTexture(stationeryIcon) -- ref SetPortraitToTexture l.174
-- SetPortraitToTexture, not SetTexture (ref l.174). The stationery icon is an ordinary
-- square ITEM icon (`INV_Misc_Note_01`), and the portrait ring is a thin band with
-- transparent corners — set raw, the icon's dark border shows through them as four
-- little squares around the circle (director's report, 2026-08-22). The reference
-- reaches for the portrait verb here and nowhere else in this file for exactly that
-- reason: the INBOX window's ring holds `Mail-Icon`, purpose-drawn art that needs no
-- crop, and the reference leaves that one raw too (ref l.258, and so do we).
SetPortraitToTexture("OpenMailFrameIcon", stationeryIcon)
local bodyText, texture, isTakeable, isInvoice = GetInboxText(id)
OpenMail_UpdateInvoice(isInvoice, id)
-- Unconditional, exactly as the reference writes it (ref l.293) — and it is safe to be
-- unconditional because `bodyText` is **nil** for an invoice: the engine's own
-- `GetInboxText` carve-out, not this file's (decision 1527). The receipt therefore has
-- nothing to sit on top of.
OpenMailBodyText:SetText(bodyText)
if texture then
OpenStationeryBackgroundLeft:SetTexture(BENILLA_STATIONERY_PATH..texture.."1")
@@ -347,13 +427,13 @@
else
getglobal("OpenMailPackageButtonIcon"):SetVertexColor(1.0, 0.1, 0.1)
end
OpenMailPackageButton:Enable()
OpenMailPackageButton:Show()
OpenMailPackageButton:Enable()
OpenMailMoneyButton:SetPoint("LEFT", "OpenMailPackageButton", "RIGHT", 10, 0)
hasAttachments = 1
else
OpenMailFrame.itemName = nil
OpenMailPackageButton:Hide()
OpenMailFrame.itemName = nil
OpenMailMoneyButton:SetPoint("LEFT", "OpenMailPackageButton", "LEFT", 0, 0)
end
if CODAmount > 0 then OpenMailFrame.cod = CODAmount else OpenMailFrame.cod = nil end
@@ -491,11 +571,9 @@
SendMailFrame.previousItem = itemName
end
else
if SendMailSubjectEditBox:GetText() == SendMailFrame.previousItem then
SendMailSubjectEditBox:SetText("")
end
SendMailFrame.previousItem = ""
if SendMailSubjectEditBox:GetText() == SendMailFrame.previousItem then SendMailSubjectEditBox:SetText("") end
SendMailRadioButton_OnClick(1)
SendMailFrame.previousItem = ""
SendMailCODButton:Disable()
SendMailCODButtonText:SetTextColor(0.5, 0.5, 0.5)
end
@@ -541,21 +619,17 @@
SendMailErrorText:Hide()
SendMailErrorCoin:Hide()
end
if checks == checksRequired then
SendMailMailButton:Enable()
else
SendMailMailButton:Disable()
end
if checks == checksRequired then SendMailMailButton:Enable() else SendMailMailButton:Disable() end
end
function SendMailRadioButton_OnClick(index)
if index == 1 then
SendMailSendMoneyButton:SetChecked(1)
SendMailCODButton:SetChecked(nil)
SendMailSendMoneyButton:SetChecked(1)
SendMailMoneyText:SetText("Amount to send:") -- AMOUNT_TO_SEND
else
SendMailSendMoneyButton:SetChecked(nil)
SendMailCODButton:SetChecked(1)
SendMailSendMoneyButton:SetChecked(nil)
SendMailMoneyText:SetText("Cash on Delivery Amount:") -- COD_AMOUNT
end
PlaySound("igMainMenuOptionCheckBoxOn")
@@ -1468,6 +1542,78 @@
</FontString>
</Layer>
</Layers>
<Frames>
<!-- ref OpenMailInvoiceFrame l.1556-1727 — the auction house's RECEIPT, laid
over the letter page instead of it. A won/sold auction mail's body is not
prose, it is the auction house's colon-separated bookkeeping
(`6C:10000:10000:25:500`); the engine parses it (`ui_mail::invoice`) and
this pane is what the numbers are FOR. The seller's four lines add up —
sale price + deposit − house cut, over the arithmetic rule — while a
buyer's invoice hides all but the total. -->
<Frame name="OpenMailInvoiceFrame" setAllPoints="true" hidden="true">
<Layers>
<Layer level="BACKGROUND">
<FontString name="OpenMailInvoiceItemLabel" inherits="InvoiceTextFontNormal" justifyH="LEFT" text="ITEM_SOLD_COLON">
<Size><AbsDimension x="240" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="30" y="-35"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="OpenMailInvoicePurchaser" inherits="InvoiceTextFontNormal" justifyH="LEFT" text="PURCHASED_BY_COLON">
<Anchors><Anchor point="TOPLEFT" relativeTo="OpenMailInvoiceItemLabel" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-5"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="OpenMailInvoiceBuyMode" inherits="InvoiceTextFontSmall" justifyH="LEFT">
<Anchors><Anchor point="LEFT" relativeTo="OpenMailInvoicePurchaser" relativePoint="RIGHT"><Offset><AbsDimension x="5" y="0"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="OpenMailInvoiceSalePrice" inherits="InvoiceTextFontNormal" justifyH="LEFT" text="SALE_PRICE_COLON">
<Anchors><Anchor point="TOPRIGHT" relativeTo="OpenMailInvoicePurchaser" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="125" y="-26"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="OpenMailInvoiceDeposit" inherits="InvoiceTextFontNormal" justifyH="LEFT" text="DEPOSIT_COLON">
<Anchors><Anchor point="TOPRIGHT" relativeTo="OpenMailInvoiceSalePrice" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-13"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="OpenMailInvoiceHouseCut" inherits="InvoiceTextFontNormal" justifyH="LEFT" text="AUCTION_HOUSE_CUT_COLON">
<Anchors><Anchor point="TOPRIGHT" relativeTo="OpenMailInvoiceDeposit" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-13"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="OpenMailInvoiceAmountReceived" inherits="InvoiceTextFontNormal" justifyH="LEFT" text="AMOUNT_RECEIVED_COLON">
<Anchors><Anchor point="TOPRIGHT" relativeTo="OpenMailInvoiceHouseCut" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-13"/></Offset></Anchor></Anchors>
</FontString>
<!-- The rule the sum sits under (ref l.1625-1638). -->
<Texture name="OpenMailArithmeticLine" file="Interface\MailFrame\UI-MailFrame-InvoiceLine">
<Size><AbsDimension x="256" y="32"/></Size>
<Anchors><Anchor point="TOP" relativeTo="OpenMailInvoiceHouseCut" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="9"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
</Layers>
<Frames>
<!-- All four are AUCTION money: the price columns of a receipt line up
for the same reason the auction house's do. -->
<Frame name="OpenMailTransactionAmountMoneyFrame" inherits="SmallMoneyFrameTemplate">
<Anchors><Anchor point="RIGHT" relativeTo="OpenMailInvoiceAmountReceived" relativePoint="RIGHT"><Offset><AbsDimension x="130" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>SmallMoneyFrame_OnLoad() MoneyFrame_SetType("AUCTION")</OnLoad></Scripts>
</Frame>
<Frame name="OpenMailSalePriceMoneyFrame" inherits="SmallMoneyFrameTemplate">
<Anchors><Anchor point="RIGHT" relativeTo="OpenMailInvoiceSalePrice" relativePoint="RIGHT"><Offset><AbsDimension x="130" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>SmallMoneyFrame_OnLoad() MoneyFrame_SetType("AUCTION")</OnLoad></Scripts>
</Frame>
<Frame name="OpenMailDepositMoneyFrame" inherits="SmallMoneyFrameTemplate">
<Anchors><Anchor point="RIGHT" relativeTo="OpenMailInvoiceDeposit" relativePoint="RIGHT"><Offset><AbsDimension x="130" y="0"/></Offset></Anchor></Anchors>
<Layers><Layer level="BACKGROUND">
<FontString inherits="InvoiceTextFontNormal" text="+">
<Anchors><Anchor point="RIGHT" relativePoint="LEFT"><Offset><AbsDimension x="-5" y="0"/></Offset></Anchor></Anchors>
</FontString>
</Layer></Layers>
<Scripts><OnLoad>SmallMoneyFrame_OnLoad() MoneyFrame_SetType("AUCTION")</OnLoad></Scripts>
</Frame>
<Frame name="OpenMailHouseCutMoneyFrame" inherits="SmallMoneyFrameTemplate">
<Anchors><Anchor point="RIGHT" relativeTo="OpenMailInvoiceHouseCut" relativePoint="RIGHT"><Offset><AbsDimension x="130" y="0"/></Offset></Anchor></Anchors>
<Layers><Layer level="BACKGROUND">
<FontString inherits="InvoiceTextFontNormal" text="-">
<Anchors><Anchor point="RIGHT" relativePoint="LEFT"><Offset><AbsDimension x="-5" y="0"/></Offset></Anchor></Anchors>
</FontString>
</Layer></Layers>
<Scripts><OnLoad>SmallMoneyFrame_OnLoad() MoneyFrame_SetType("AUCTION")</OnLoad></Scripts>
</Frame>
</Frames>
</Frame>
</Frames>
</Frame>
<!-- ref OpenMailLetterButton l.1732-1757 / OpenMailPackageButton l.1758-1796 /
OpenMailMoneyButton l.1797-1835 (ItemButtonTemplate; inlined 37px icon + slot +
@@ -1564,6 +1710,22 @@
</Button>
</Frames>
<Scripts>
<!-- The letter EVICTS whatever holds the centre seat as it opens — the ref's own
OnShow (ref MailFrame.xml l.1903-1907), and the missing half of the
letter/centre-seat exclusion (decision 1520): the child-window loop (1507) hides
the letter when a frame ARRIVES at centre, and this hides the centre occupant
when the LETTER arrives — whichever comes second wins the space. Without it, a
character sheet pushed to centre (pushable 2, beside the open mailbox) sat ON TOP
of a freshly opened letter (director-reported, ref-checked). HideUIPanel of the
centre is a plain vacate — the evicted sheet closes outright, no slide — and only
this window and GuildControlPopupFrame do this in the whole 1.12 FrameXML (the
other two guild satellites deliberately don't). -->
<OnShow>
local centre = GetCenterFrame()
if centre then
HideUIPanel(centre)
end
</OnShow>
<OnHide>OpenMailFrame_OnHide()</OnHide>
</Scripts>
</Frame>
+25 -10
View File
@@ -16,23 +16,32 @@
by 9, and 13 declare a frame `inherits="SmallMoneyFrameTemplate"`. `MoneyTypeInfo` was one of
the top runtime walls the frame rename left standing.
── THE OTHER COIN KIT, and why there are two ────────────────────────────────────────────────
benilla's own eight windows (merchant, bank, trade, mail, quest, quest log, taxi, trainer) do
NOT use this file. They render coins through `BenillaMoney_Set`/`_Clear`/`_SetColor` in
── THE OTHER COIN KIT, and why there are still two ──────────────────────────────────────────
Eight of our windows (merchant, bank, trade, mail, quest, quest log, taxi, trainer) do NOT use
this file. They render coins through `BenillaMoney_Set`/`_Clear`/`_SetColor` in
MerchantFrame.xml: a three-SLOT chain (`<prefix>Coin1..3`) that fills only the non-zero
denominations in display order and re-flows, rather than three fixed GoldButton/SilverButton/
CopperButton frames that hide themselves. That kit takes its coin TexCoords, its collapse and
its shrink from the same specification as this one, and its layout is the one the director has
signed off across those eight windows.
So the two coexist deliberately, and the split is by AUDIENCE, not by accident:
· `BenillaMoney_*` + `<prefix>Coin1..3` — what our own windows are laid out on.
The auction house is the exception, and the reason the reconciliation started there rather
than anywhere else: the three-slot kit has no notion of a money TYPE, so it cannot express
`showSmallerCoins` or `fixedWidth` — and those two flags ARE the auction house's price column
(`MoneyTypeInfo["AUCTION"]`, declared below and, until AuctionFrame.xml moved, used by
nothing). On the three-slot kit "1g" showed as one lone gold coin where the reference shows
`1g 0s 0c`, and a column of prices packed tight instead of lining up. That is not a layout
preference to re-approve, it is the kit being the wrong kit. See decision 1522.
So the two still coexist, and the split is now by KIT CAPABILITY as much as by audience:
· `BenillaMoney_*` + `<prefix>Coin1..3` — the eight windows whose look is signed off and
whose money is all plain collapsing STATIC amounts, which that kit renders correctly.
· `MoneyFrame_*` + `MoneyTypeInfo` here — the names and structure an addon writes against,
and what an addon's `inherits="SmallMoneyFrameTemplate"` resolves to.
The reconciliation — migrating those eight windows onto `SmallMoneyFrameTemplate` +
`MoneyFrame_Update` and retiring `BenillaMoney_*` — is what the specification itself does and
is the right end state; it is a re-layout of hand-authored art in eight files (an approved
look), so it is named here rather than done in passing.
what an addon's `inherits="SmallMoneyFrameTemplate"` resolves to, and what the auction
house is laid out on.
Finishing the migration — moving those eight onto `SmallMoneyFrameTemplate` and retiring
`BenillaMoney_*` — is still the right end state; it is a re-layout of hand-authored art in
eight files (an approved look), so it stays named here rather than done in passing.
── WHAT IS NOT CARRIED, and why (each is guarded at its own site below) ─────────────────────
· THE COIN PICKUP. `OpenCoinPickupFrame`/`UpdateCoinPickupFrame`/`CoinPickupFrame` belong to
@@ -341,6 +350,12 @@
-- `math.mod`, which truncates towards zero, so `mod(-1, 100)` is -1 and not 99 — the two
-- forms part company the moment `money` goes negative (which `GetMoney()` minus a trade
-- offer can), and this one is the specified answer.
-- A STATIC/AUCTION frame's amount is whatever its owner last parked on it, and on the
-- very first paint — `MoneyFrame_SetType`'s own repaint, in OnLoad, before any owner has
-- filled it — that is nil. The specification divides straight into it and throws there;
-- we answer 0, because the throw would take the whole `<OnLoad>`/`<OnShow>` chain down
-- with it and an empty price column is the honest reading of "nothing set yet".
money = money or 0
local gold = floor(money / COPPER_PER_GOLD)
local silver = floor((money - gold * COPPER_PER_GOLD) / COPPER_PER_SILVER)
local copper = mod(money, COPPER_PER_SILVER)
+41 -7
View File
@@ -619,7 +619,10 @@
"RowEnableMusic", "RowEnableAmbience",
},
Graphics = { "RowUiScale", "RowFarclip", "RowWorldDetail", "RowVerticalSync" },
Nameplates = { "RowPlayerNames", "RowNpcNames", "RowOwnName" },
Nameplates = {
"RowEnemyPlates", "RowFriendlyPlates",
"RowPlayerNames", "RowNpcNames", "RowOwnName",
},
-- The stock globals that were already defined AND consumed when 1134 opened the store
-- (1136; Buff Durations 1139), plus the two equipment-display toggles (1472), in the
-- reference's own on-screen order. Its Basic Options Display box is three COLUMNS read
@@ -2742,22 +2745,53 @@
</Frames>
</Frame>
<!-- The Nameplates page (0992 — the page 0952 promised when the
category row joined): the three 1.12 UnitName* checkboxes over
the overhead-name gates (nameplates.rs NameConfig), rows in
the 1.12 Interface Options' own index order (21/30/67), labels
its GlobalStrings verbatim (SHOW_PLAYER_NAMES/SHOW_NPC_NAMES/
SHOW_OWN_NAME). -->
category row joined). Two groups, plates first: the V-key
HEALTH-BAR plates (vplates.rs VPlateMode — the engine bitmask
[0xc4da34] bit 0 / bit 3), then the three 1.12 UnitName*
checkboxes over the overhead-name gates (nameplates.rs
NameConfig), rows in the 1.12 Interface Options' own index
order (21/30/67), labels its GlobalStrings verbatim
(SHOW_PLAYER_NAMES/SHOW_NPC_NAMES/SHOW_OWN_NAME).
The plate pair leads because it is what "Nameplates" means to
a player pressing V, and it is the pair that had no surface at
all — 1.12's own options UI has NO nameplate row (its
UIOptionsFrame keeps NAMEPLATES_ON/FRIENDNAMEPLATES_ON as
RegisterForSave'd globals driven only by the keybinding, and
says so in a comment), so the rows are era grammar over a
1.12 knob — 0959's law, the WorldDetail dropdown's posture.
Labels take the 1.12 binding names' own two-word spelling
("Name Plates", BINDING_NAME_NAMEPLATES) with the era's
enemy/friendly split naming the two bits; no 1.12
OPTION_TOOLTIP string exists for either (nil, like Auto
Loot). -->
<Frame name="$parentNameplates" hidden="true">
<Anchors>
<Anchor point="TOPLEFT"/>
<Anchor point="BOTTOMRIGHT"/>
</Anchors>
<Frames>
<Frame name="$parentRowPlayerNames" inherits="OptionsCheckboxRowTemplate">
<Frame name="$parentRowEnemyPlates" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="10" y="-12"/></Offset></Anchor>
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-20" y="-12"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>OptionsRow_OnLoad(self, "nameplateShowEnemies",
"Enemy Name Plates")</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowFriendlyPlates" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowEnemyPlates" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowEnemyPlates" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>OptionsRow_OnLoad(self, "nameplateShowFriends",
"Friendly Name Plates")</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowPlayerNames" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowFriendlyPlates" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowFriendlyPlates" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>OptionsRow_OnLoad(self, "UnitNamePlayer", "Player Names",
"OPTION_TOOLTIP_SHOW_PLAYER_NAMES")</OnLoad></Scripts>
</Frame>
@@ -173,6 +173,10 @@
function BenillaScriptLogFrame_OnLoad(self)
getglobal("BenillaScriptLogListScrollFrame").updateFunc = BenillaScriptLog_Update
-- The ring's icon, through the portrait verb rather than left at its `file=` — see the
-- texture's own note. MailFrame.xml's open-letter icon is the same fix.
SetPortraitToTexture("BenillaScriptLogFramePortrait",
"Interface\\Icons\\INV_Misc_Note_01")
end
-- The shared helper steps the faux frame's bar, whose OnValueChanged runs the kit's
@@ -239,6 +243,10 @@
<Size><AbsDimension x="384" y="512"/></Size>
<Layers>
<Layer level="BACKGROUND">
<!-- The same square ITEM icon the mail window's ring holds, and it needs the same
portrait verb for the same reason (`SetPortraitToTexture` in OnLoad below):
`file=` alone draws it unmasked and its corners show through the ring's
transparent ones. -->
<Texture name="BenillaScriptLogFramePortrait" file="Interface\Icons\INV_Misc_Note_01">
<Size><AbsDimension x="60" y="60"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="7" y="-6"/></Offset></Anchor></Anchors>
+23 -2
View File
@@ -102,6 +102,25 @@
function ShapeshiftBar_Update()
local numForms = GetNumShapeshiftForms()
if numForms > 0 then
-- ── The shelf's LENGTH is the form count (ref l.161-175) ─────────────────────
-- The three strips are DECLARED as the >2-form chain (Left → Middle → Right), so
-- the one- and two-form shelves have to re-point the end cap. Hiding the middle
-- strip does NOT close its gap — a hidden region still resolves its rect, here
-- and in the reference — so without this the 42x50 right cap sits 38px past the
-- last button as a stray empty recess: a rogue's Stealth with a second, always-
-- empty slot beside it. One form pulls the cap back INSIDE the left cap (+12, a
-- 54px shelf around the single button); two butt the caps together; past two the
-- middle strip carries one slot per extra form and the cap chains off it.
if numForms == 1 then
ShapeshiftBarRight:SetPoint("LEFT", ShapeshiftBarLeft, "LEFT", 12, 0)
elseif numForms == 2 then
ShapeshiftBarRight:SetPoint("LEFT", ShapeshiftBarLeft, "RIGHT", 0, 0)
else
ShapeshiftBarMiddle:SetPoint("LEFT", ShapeshiftBarLeft, "RIGHT", 0, 0)
ShapeshiftBarMiddle:SetWidth(38 * (numForms - 2))
ShapeshiftBarMiddle:SetTexCoord(0, numForms - 2, 0, 1)
ShapeshiftBarRight:SetPoint("LEFT", ShapeshiftBarMiddle, "RIGHT", 0, 0)
end
ShapeshiftBarFrame:Show()
else
ShapeshiftBarFrame:Hide()
@@ -272,8 +291,10 @@
Geometry, anchor chain and TexCoords quoted whole: Left and Right are two crops of ONE
`ShapeshiftBarEnds` file (0..0.453125 and 0.453125..0.875), Middle its own, and Right
hangs off Middle's RIGHT exactly as the ref chains them — which is why hiding the
middle strip leaves its gap rather than closing it, the reference's own result.
hangs off Middle's RIGHT — the >2-form chain, which is the DECLARED state only. The
cap is re-pointed per form count by `ShapeshiftBar_Update` above (the ref's l.161-175):
hiding the middle strip leaves its 38px rect behind, so the declared chain alone
strands the cap past the last button.
`hidden="true"` is the RAISED state, and it is only the pre-pass value now:
`ShapeshiftBar_UpdatePosition` (above) shows and hides all three from the bottom-left
@@ -28,10 +28,11 @@
UIPanelButtonTemplate/close-button/item-button idioms (no such templates ship in this house
dialect — TalentFrame.xml's own ENGINE API note), the persistent selection-highlight Frame.
FLAT layout (TrainerFrame.xml's own house constraint, restated here): a child <Frame> shown at
runtime does not render its own <Layers> FontStrings in this engine, so the detail-pane
FontStrings + the title live on BenillaTradeSkillFrame's OWN <Layers> (OVERLAY, above the
window's own BACKGROUND/BORDER/ARTWORK art groups), never on a nested sub-frame. The ref's
FLAT layout — a CHOICE inherited from TrainerFrame.xml, which stated it as a house constraint
it never was. A runtime-shown child <Frame> DOES render its own <Layers> FontStrings in this
engine (probed; decision 1517). The detail-pane FontStrings + the title live on
BenillaTradeSkillFrame's OWN <Layers> (OVERLAY, above the window's own BACKGROUND/BORDER/
ARTWORK art groups) because that is how this window was built, not because nesting fails. The ref's
`TradeSkillDetailScrollFrame`/`TradeSkillDetailScrollChildFrame` therefore don't exist here —
the detail is a FIXED (non-scrolling) pane, computed once from the flattened ScrollFrame+
ScrollChild offsets (TOPLEFT 20,-234 + each child's own ref-quoted offset). Per-region regions
@@ -38,10 +38,14 @@
- COLLAPSE WORKS. The "All" tab collapses/expands every group (Collapse/ExpandTrainerSkillLine(0));
its +/- icon reflects whether all groups are folded (the backport's numHeaders/notExpanded).
FLAT layout (both windows' note): a child <Frame> shown at runtime does not render its own
<Layers> FontStrings in this engine, so the detail-pane FontStrings + the title/greeting live on
BenillaTrainerFrame's OWN <Layers>, never on a nested sub-frame. Per-row regions live on the row
Button template (Buttons render their own Layers). The detail is a FIXED (non-scrolling) pane
FLAT layout — a CHOICE, and it was written down here as a constraint that does not exist.
This block used to claim "a child <Frame> shown at runtime does not render its own <Layers>
FontStrings in this engine". That is FALSE, and it was propagated from here into two more
windows before anyone probed it (decision 1517): MailFrame's own SendMailTitleText lives in a
runtime-shown pane's Layers and has rendered correctly the whole time. The detail-pane
FontStrings + the title/greeting do live on BenillaTrainerFrame's OWN <Layers>, and that is
fine — it is simply one way to lay this out, not the only one available. Per-row regions live
on the row Button template. The detail is a FIXED (non-scrolling) pane
(our descriptions are empty for now) — no real ScrollFrame, unlike the ref's DetailScrollFrame.
Adaptations / deviations (flagged):
+84 -17
View File
@@ -11,12 +11,13 @@
reference's window arbitration (a different number is a different window order), and the
`UISpecialFrames` seeds. The code around that data is ours.
Scope: `left`, `center` and `full` are all live, and so is `UIChildWindows` (decision 1507
— all four of the reference's child windows ship now). `doublewide` is not — its one 1.12
tenant is the auction house, which benilla doesn't ship — but the registration table keeps
its exact shape so a later row drops in unchanged. **`UISpecialFrames` is not ours to write
into** (decision 1206): it is the table an ADDON puts its own window in, and 34 corpus call
sites do.
Scope: all four areas are live — `left`, `center`, `full` and `doublewide` — and so is
`UIChildWindows` (decision 1507: all four of the reference's child windows ship now).
`doublewide` was the last one out, on the honest grounds that its one 1.12 tenant is the
auction house and benilla didn't ship one; decision 1511 is the arc that does, so the slice
came with it rather than the window working around it. **`UISpecialFrames` is not ours to
write into** (decision 1206): it is the table an ADDON puts its own window in, and 34 corpus
call sites do.
Rows are keyed by the frame names we actually ship. Most of those ARE the reference's own
(`GossipFrame`, `MerchantFrame`, `LootFrame`); where ours differs, the row is `Benilla*` and
@@ -74,6 +75,13 @@
-- registration line (`UIPanelWindows["TaxiFrame"] = { area = "left", pushable = 0 }`),
-- quoted verbatim — same left-slot competition as Gossip/Merchant/Trainer above.
UIPanelWindows["TaxiFrame"] = { area = "left", pushable = 0 }
-- The auction house (decision 1511, AuctionFrame.xml): the reference's own registration,
-- quoted verbatim from `Blizzard_AuctionUI.lua` l.13 rather than UIParent.lua — the window
-- ships as a loadable addon there, so it registers its own row. **The one and only
-- `doublewide` row in all of 1.12**: an 832-wide window takes the left AND centre seats at
-- once, which is why the slice above exists at all. Its `pushable = 0` is quoted and inert
-- — ShowUIPanel's doublewide arm never consults it (l.392-397).
UIPanelWindows["AuctionFrame"] = { area = "doublewide", pushable = 0 }
-- The dressing room (decision 1060, DressUpFrame.xml): the ref's own UIParent.lua l.41
-- registration (`UIPanelWindows["DressUpFrame"] = { area = "left", pushable = 2 }`), quoted
-- verbatim — pushable=2 lets the NPC-session windows (pushable=0) shove it aside, which is
@@ -168,7 +176,7 @@
-- table instead of squatting the `UIParent` global name; a future `CreateFrame("Frame",
-- "UIParent")` then lands cleanly on an empty global rather than silently coexisting with our
-- state. Every function below is defined in this same chunk, so they all close over it.
local slots = { left = nil, center = nil, fullscreen = nil }
local slots = { left = nil, center = nil, fullscreen = nil, doublewide = nil }
-- SetLeftFrame(frame) — UIParent.lua l.809-824. Hides the old left occupant, SetPoints +
-- Shows the new one at the left slot's screen anchor (verified: TOPLEFT UIParent, 0, -104).
@@ -268,6 +276,41 @@
return slots.fullscreen
end
-- SetDoublewideFrame(frame) — UIParent.lua l.788-808. One frame takes the left AND center
-- seats at once: both incumbents are dropped (nil'd first, then hidden — the order matters,
-- since an OnHide that re-enters this manager must not find itself still seated), and the
-- new frame lands at the left slot's own screen anchor, because a doublewide window simply
-- starts where left starts and is wide enough to cover both.
--
-- Note what the reference does NOT do: `HideUIPanel` has no doublewide branch (l.754-786),
-- so nothing ever clears this seat — `UIParent.doublewide` keeps pointing at the auction
-- window forever once it has been opened, and every later ShowUIPanel re-hides an
-- already-hidden frame. We reproduce that rather than inventing a clear: `Hide()` on a
-- hidden frame fires nothing, so the difference is unobservable, and a "fix" here would be
-- a silent divergence in the one function every window's placement runs through.
function SetDoublewideFrame(frame)
local oldLeft = slots.left
local oldCenter = slots.center
slots.doublewide = frame
slots.left = nil
slots.center = nil
if oldLeft then
oldLeft:Hide()
end
if oldCenter then
oldCenter:Hide()
end
if frame then
frame:SetPoint("TOPLEFT", "UIParent", "TOPLEFT", 0, -104)
frame:Show()
end
end
-- GetDoublewideFrame — UIParent.lua l.924-926.
function GetDoublewideFrame()
return slots.doublewide
end
-- The area a frame is REGISTERED for — or nil, which is equally the answer for no frame at
-- all and for a frame this manager has never heard of. Four rules below turn on exactly
-- that question ("is a native-center window holding the screen?", "is the frame parked at
@@ -365,10 +408,11 @@
end
end
-- ShowUIPanel(frame) — UIParent.lua l.649-751, the left+center+full slice (pin §3 steps
-- 1,3-5, plus the native-center mode decision 0674 needed and the `full` slot the world
-- map took in 0203/0674). Still skipped by scope: `doublewide` (l.707-710 — no such row is
-- registered), which is why the last arm below is a bare Show.
-- ShowUIPanel(frame) — UIParent.lua l.649-751, now the WHOLE slice: left, center, full
-- and doublewide (pin §3 steps 1,3-5, plus the native-center mode decision 0674 needed and
-- the `full` slot the world map took in 0203/0674). `doublewide` (l.706-716) arrived with
-- the auction house, decision 1511 — the last arm below is now genuinely unreachable for a
-- registered row and stays only as the defensive fallback its comment says it is.
--
-- Two questions, in order: may it open at all, and where does it sit.
function ShowUIPanel(frame, force)
@@ -404,10 +448,25 @@
CloseWindows()
CloseAllBags()
SetCenterFrame(frame, 1)
elseif info.area == "doublewide" then
-- l.706-710: it wants both seats, so it takes both and nothing else is consulted
-- — including its own `pushable`, which is why the auction house's registered 0
-- never means anything.
SetDoublewideFrame(frame)
elseif info.area == "left" then
-- l.712-716: a doublewide incumbent is squatting the seat we are about to use, so
-- it goes first. In the reference this is a bare statement between the branches
-- rather than part of one, but every earlier arm has already returned by then, so
-- the LEFT path is the only one that reaches it — which is why it lives here. And
-- since `HideUIPanel` never clears the slot, this is a plain Hide of a frame that
-- may well already be hidden — see SetDoublewideFrame's note.
local doublewide = GetDoublewideFrame()
if doublewide then
doublewide:Hide()
end
SeatLeftAreaPanel(frame, info)
else
-- No `doublewide` row is registered — defensive fallback only.
-- An area string this manager does not know: defensive fallback only.
frame:Show()
end
end
@@ -464,16 +523,24 @@
-- behaviour on that path, cancel-less Hide and all.
UISpecialFrames = { "ItemRefTooltip", "ColorPickerFrame" }
-- CloseWindows(ignoreCenter) — UIParent.lua l.928-957. The doublewide slice stays dropped
-- (we have none); the UISpecialFrames loop at l.947-954 is now carried.
-- CloseWindows(ignoreCenter) — UIParent.lua l.928-957. Both once-dropped slices are now
-- carried: the UISpecialFrames loop at l.947-954, and the doublewide seat (decision 1511).
function CloseWindows(ignoreCenter)
local leftFrame = GetLeftFrame()
local centerFrame = GetCenterFrame()
local fullScreenFrame = GetFullScreenFrame()
local found = leftFrame or centerFrame or fullScreenFrame
local doublewideFrame = GetDoublewideFrame()
local centerFrame = GetCenterFrame()
local leftFrame = GetLeftFrame()
HideUIPanel(leftFrame)
HideUIPanel(fullScreenFrame)
-- l.938. HideUIPanel has no doublewide branch, so this lands on its "seated nowhere"
-- arm and is a plain Hide; the seat itself is never vacated (SetDoublewideFrame's note).
HideUIPanel(doublewideFrame)
-- l.934, quoted as-is: `found` deliberately does NOT consider the doublewide frame,
-- even though the line above closes it. Reproduced rather than corrected — ESC's "did
-- I close anything?" answer is this value, and changing it would change which key
-- press opens the game menu. (Computed off the locals read before any Hide landed.)
local found = leftFrame or centerFrame or fullScreenFrame
-- `ignoreCenter` spares a NATIVE-center frame and nothing else: a left-area panel
-- merely parked at the center seat closes with the rest of them.
if centerFrame and not (ignoreCenter and AreaOf(centerFrame) == "center") then
+17 -5
View File
@@ -169,11 +169,12 @@ UIPanelTemplates.xml
# (OptionsFrame.xml, far below): these are the shared names addons build panels out of.
OptionsFrameTemplates.xml
# The reference's money-frame kit: MoneyFrameTemplate/SmallMoneyFrameTemplate + MoneyTypeInfo
# + the MoneyFrame_* API. Needs only Fonts (NumberFontNormal/NumberFontNormalLarge), and
# nothing benilla ships consumes it yet — our own eight coin displays run on MerchantFrame.xml's
# BenillaMoney_* slot kit, and MoneyFrame.xml's header is where the two are reconciled. Seated
# beside the scroll kit (the other shared widget kit) and ahead of every window that shows
# coins, which is where the reference's own TOC puts it too.
# + the MoneyFrame_* API. Needs only Fonts (NumberFontNormal/NumberFontNormalLarge). AuctionFrame
# below is laid out on it (its price columns need a money TYPE, which the other kit has no notion
# of); our other eight coin displays still run on MerchantFrame.xml's BenillaMoney_* slot kit, and
# MoneyFrame.xml's header is where the two are reconciled. Seated beside the scroll kit (the other
# shared widget kit) and ahead of every window that shows coins, which is where the reference's own
# TOC puts it too.
MoneyFrame.xml
# The shared colour dialog + its opacity slider (`ColorPickerFrame`, `OpacitySliderFrame`,
# `ColorPickerOkayButton`, `ColorPickerCancelButton`). Last of the "shared FrameXML surface with no
@@ -339,6 +340,17 @@ BankFrame.xml
# Loaded right after TrainerFrame per the same "NPC session windows grouped" posture — it
# needs only Fonts/UiPanels/GameTooltip (SetTooltipMoney), both already loaded above.
TaxiFrame.xml
# The auction house (decision 1511): the three-tab doublewide window over the auction API
# (`benilla-ui/src/script/auction.rs`) + the app's feed/drain. Kept in the "NPC session
# windows grouped" cluster beside Trainer/Bank/Taxi, and loaded AFTER MoneyFrame (its eight
# money displays are SmallMoneyFrameTemplate, and it registers the AUCTION_DEPOSIT money type
# into that file's MoneyTypeInfo), after MerchantFrame (it reuses that file's global
# BenillaMoneyInput_* helpers for its four money inputs), after ScrollTemplates (four faux lists), after
# UIPanelTemplates (UIPanelButtonTemplate / UIPanelCloseButton / InputBoxTemplate /
# UICheckButtonTemplate), after UIDropDownMenu (the rarity capsule) and after UiPanels (the
# panel manager's `doublewide` seat, `CharacterFrameTabButtonTemplate`, the PanelTemplates
# tab kit and the StaticPopup engine its two confirmations register into) — all above.
AuctionFrame.xml
# The crafting window (decision 0437 phase 2): NOT an NPC session (opens off your own cast of
# an effect-47 opener spell, TradeSkillFrame.xml's own header comment) — loaded right after
# TrainerFrame per the same "player-state/profession window" grouping and because it depends
+573
View File
@@ -0,0 +1,573 @@
//! **Camera shake** — the thump a heavy creature's footfall puts through the camera, and the
//! one-off jolt as its body lands (B298; decision 1540).
//!
//! Not an animation event. `$SHK` exists in the M2 vocabulary but **`CGUnit_C::HandleAnimEvent`
//! does not decode it** — all 73 tags it dispatches were enumerated and `$SHK` is not among them;
//! the only two handlers that decode it hang off the GameObject (typemask `0x20`) and
//! DynamicObject (`0x40`) trampolines. A `$SHK` authored on a creature M2 logs
//! `UNHANDLEDANIMEVENT` and does nothing, so no creature shake can be `$SHK`-driven — and in fact
//! neither Ancient authors one.
//!
//! The real chain is **data, not animation**:
//!
//! - **`CreatureModelData.FootstepShakeSize`** (field 11) → a `CameraShakes.dbc` row id, fired from
//! the **visual** footfall channel — the per-foot plant tags (`$FL0`/`$FR0`/`$RL0`/`$RR0`…), the
//! same stream [`crate::footprints`] reads. **Not `$FSD`**, which is the sound handler's alone.
//! - **`CreatureModelData.DeathThudShakeSize`** (field 12) → the same table, fired on `$DTH`.
//!
//! Only 25 of the 430 shipped models carry a footstep shake, and the set is exactly the
//! thumping-giant list; 49 carry one of the two. See `benilla-extract shakecensus`.
//!
//! ## The law (wow-re `ui/scratch/camera-shake-law.md`, §5-verified)
//!
//! **Emission — the footstep**, gated in this order (`0x5fbf70`): not hovering · `BYTES_1` byte 3
//! bit `0x2` clear (our stealth bit) · not a player-ghost · **`|camera − footplant|² ≤ 2500`
//! (50 yd)**. The shake sits *outside* the `showfootprints` CVar gate and *outside* the 25 yd gate
//! that follows it, so it reaches twice as far as a footprint decal and does not turn off with
//! them. The position is the planted foot's world point, exactly as the decal derives it.
//!
//! **Emission — the death thud** (`0x625c30`): **no gates at all** — no hover, no ghost, no
//! distance, no CVar — at the unit's own world position.
//!
//! **Per live record, per frame** (`0x511760`/`0x5116e0`):
//!
//! ```text
//! t = (now − start) + phase // seconds; phase is a TIME pre-roll, not an angle
//! if !(t < duration) → retire
//! A = amplitude / 36 // the DBC column is inches; yards on the wire out
//! d² = |eye − pos|² // pos is snapshotted at spawn
//! if d² > 6400 → contribute nothing (the record survives)
//! if d² > 81 → A *= 0.7^((√d² − 9) / 9)
//! a = A · sin(2π · frequency · t)
//! if shake_type == 1 → a *= exp(−coefficient · t)
//! ```
//!
//! Three corrections to the conventional column map, all from the consumer: **`Phase` is a time
//! pre-roll in seconds**, not an angle — it advances the sine *and* the decay, and shortens the
//! real life to `duration − phase`; **`Duration` is a hard cutoff with no taper**, so a
//! `shake_type == 0` row is cut off mid-swing at full strength; and **`ShakeType` is a one-bit
//! decay switch**, not a type — `== 1` exactly enables the `exp` envelope, and `Coefficient` is
//! that rate in 1/s and is dead data on every `shake_type == 0` row. The decay is base **e**.
//!
//! **What moves.** A pure world-space **translation of the eye** — no rotation, no FOV change; the
//! look-at target is rebuilt as `eye + forward`, so it rides along. `Direction` selects an axis in
//! the **followed unit's body frame**, re-read every frame, which is why turning the player
//! rotates an in-flight horizontal shake:
//!
//! | `direction` | reference (WoW axes) | ours (Bevy) |
//! |---|---|---|
//! | 0 | `(a·cos φ, a·sin φ, 0)` | `a ·` forward |
//! | 1 | `(−a·sin φ, a·cos φ, 0)` | `a ·` left |
//! | 2 | `(0, 0, a)` | `a ·` up |
//!
//! (`bevy = (−wow.y, wow.z, −wow.x)` — decision 0002 — and our yaw about `+Y` equals the WoW
//! facing, so axis 0/1 land exactly on the unit's forward/left.) **Every creature row is
//! `direction = 2`**, so in practice a footstep shake is purely vertical; the other two axes are
//! reachable only from the spell-side table and are implemented for completeness. Axes 0 and 1 are
//! *not* independent — both sum into the horizontal plane.
//!
//! **Combination.** Three slots, one per axis, each keeping the single strongest live shake — same-axis
//! shakes do **not** sum, the losers are dropped outright, and at most three shakes play in a frame.
//! Ties keep the incumbent, and the walk is oldest→newest, so ties keep the **older** record.
//!
//! **Lifetime.** A record holds a *snapshot* position and no reference to its source, so a shake
//! fully outlives the creature that spawned it. The whole evaluation is skipped — offset zero,
//! nothing expiring — while the followed unit is **swimming**.
//!
//! Not modelled, deliberately: the reference's second wholesale free on `SetTarget(nullptr)`, and
//! its destructor's unlink-without-free (a shipped leak — our `Vec` cannot reproduce it). The
//! flying-spline (taxi) arm of the skip is noted in [`CameraShakes::evaluate`].
use std::f32::consts::TAU;
use bevy::prelude::*;
use benilla_formats::{CameraShake, CameraShakeCatalog};
use crate::creature_anim::{footfall_side, move_flags, AnimSoundEvent, MovementState};
use crate::entities::{BoneAttach, Creatures};
use crate::net::{Embodied, NetEntity, ObjectStore};
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
use benilla_world::schedule::WorldStage;
use benilla_world::view::WorldCamera;
/// Beyond this the record contributes nothing but is **not** retired (`0x5116e9`, `80 yd²`).
const CULL_DISTANCE_SQ: f32 = 6400.0;
/// Inside this the shake plays at full authored strength (`9 yd²`).
const FULL_DISTANCE_SQ: f32 = 81.0;
/// The falloff's half-life base and its per-9-yd exponent divisor.
const FALLOFF_BASE: f32 = 0.7;
const FALLOFF_SPAN: f32 = 9.0;
/// The footstep emitter's own radius, camera→footplant (`0x5fc00a`, `2500 yd²` = 50 yd). **The
/// camera, not the player** — the same correction wow-re applied to `dist2-gate.md` this round.
const EMIT_DISTANCE_SQ: f32 = 2500.0;
/// `CameraShakes.Amplitude` is authored in inches; the client scales it at spawn (`0x511d78`).
const INCHES_TO_YARDS: f32 = 1.0 / 36.0;
/// One live shake: the authored row, where it happened, and when it started.
struct LiveShake {
row: CameraShake,
/// World position, **snapshotted at spawn** — the record never looks at its source again.
pos: Vec3,
/// App-clock seconds at spawn.
start: f32,
}
/// The live shake set and the eye offset it composes for this frame.
#[derive(Resource, Default)]
pub(crate) struct CameraShakes {
live: Vec<LiveShake>,
}
impl CameraShakes {
/// Enqueue a shake at a world point. The row is copied in whole — the DBC is the only source
/// of shape, and nothing downstream re-reads the catalog.
fn add(&mut self, row: CameraShake, pos: Vec3, now: f32) {
self.live.push(LiveShake {
row,
pos,
start: now,
});
}
/// Retire what has expired and compose this frame's offset.
///
/// `swimming` is the reference's skip (`0x50ea87`/`0x50ea8b`): while the followed unit swims,
/// the whole block is bypassed — the offset is zero **and nothing expires**, so a shake
/// resumes mid-flight on surfacing. The sibling arm of that skip, a flying (taxi) spline, is
/// not modelled here: we have no flying-spline predicate at the camera seat, and the case is a
/// heavy creature's footfall within 50 yd of a taxi path. Recorded rather than guessed.
fn evaluate(&mut self, eye: Vec3, facing_yaw: f32, now: f32, swimming: bool) -> Vec3 {
if swimming {
return Vec3::ZERO;
}
// Retire on the *unattenuated* clock: distance decides contribution, never lifetime.
self.live.retain(|s| s.elapsed(now) < s.row.duration);
// One slot per axis, each holding (compare key, signed value). The walk is oldest→newest
// and the compare is strict, so a tie keeps the older record — the reference's own
// `jne`-skips-the-write shape.
let mut slots: [Option<(f32, f32)>; 3] = [None; 3];
for s in &self.live {
let Some((key, value)) = s.sample(eye, now) else {
continue;
};
let axis = s.row.direction as usize;
let Some(slot) = slots.get_mut(axis) else {
continue; // direction ≥ 3 writes nothing (and corrupts the reference's frame)
};
if slot.is_none_or(|(best, _)| key > best) {
*slot = Some((key, value));
}
}
// Axis 0/1 are the followed unit's forward/left, 2 is world up (module doc's table). Our
// yaw about +Y is the WoW facing, so forward is exactly `(−sin, 0, −cos)`.
let (sin, cos) = facing_yaw.sin_cos();
let forward = Vec3::new(-sin, 0.0, -cos);
let left = Vec3::new(-cos, 0.0, sin);
let value = |i: usize| slots[i].map_or(0.0, |(_, v)| v);
forward * value(0) + left * value(1) + Vec3::Y * value(2)
}
}
impl LiveShake {
/// Seconds into the shake, including `phase` — which is a **time pre-roll**, so it advances
/// both the sine and the decay and shortens the real life to `duration − phase`.
fn elapsed(&self, now: f32) -> f32 {
(now - self.start) + self.row.phase
}
/// This record's `(compare key, signed offset)` at `eye`, or `None` when out of cull range.
///
/// The compare key is the **distance-attenuated amplitude** — a per-record quantity that does
/// not swing with the sine — per wow-re's reading of the accumulator's stored slot.
fn sample(&self, eye: Vec3, now: f32) -> Option<(f32, f32)> {
let d2 = eye.distance_squared(self.pos);
if d2 > CULL_DISTANCE_SQ {
return None;
}
let mut amp = self.row.amplitude * INCHES_TO_YARDS;
if d2 > FULL_DISTANCE_SQ {
amp *= FALLOFF_BASE.powf((d2.sqrt() - FALLOFF_SPAN) / FALLOFF_SPAN);
}
let t = self.elapsed(now);
let mut value = amp * (TAU * self.row.frequency * t).sin();
// `shake_type == 1` exactly — the one-bit decay switch, base e, rate in 1/s.
if self.row.shake_type == 1 {
value *= (-self.row.coefficient * t).exp();
}
Some((amp, value))
}
}
pub(crate) struct CameraShakePlugin;
impl Plugin for CameraShakePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<CameraShakes>()
.add_systems(Startup, load_shakes.after(AssetSet::Open))
// Present, like the footprints and the footstep sounds: the same event stream, after
// the frame's animation drive and transforms have settled the foot bones.
//
// Capture-gated in step with the applier ([`crate::player`] schedules that one). The
// pairing is not cosmetic: the applier is what retires expired records, so an emitter
// still running while it is gated off would push a record per footfall that nothing
// ever reaps — a slow leak across a long capture.
.add_systems(
Update,
fire_shakes
.in_set(WorldStage::Present)
.run_if(not(resource_exists::<crate::run_mode::CaptureMode>)),
);
}
}
/// Read `CameraShakes.dbc` into its catalog resource.
fn load_shakes(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
let Some(assets) = assets else { return };
let table = {
let mut chain = assets.chain.lock_recover();
benilla_formats::load_camera_shakes(&mut chain)
};
match table {
Ok(t) => {
debug!("camera_shake: {} presets", t.len());
commands.insert_resource(Shakes(t));
}
Err(e) => warn!("camera_shake: CameraShakes.dbc failed to load: {e:#}"),
}
}
/// `CameraShakes.dbc`, loaded once.
#[derive(Resource)]
struct Shakes(CameraShakeCatalog);
/// Enqueue a shake for each qualifying footfall and death thud on the frame's event stream.
#[allow(clippy::too_many_arguments)]
fn fire_shakes(
mut events: MessageReader<AnimSoundEvent>,
time: Res<Time>,
units: Query<(
&NetEntity,
&GlobalTransform,
Option<&BoneAttach>,
Option<&benilla_world::rig_anim::RigPose>,
)>,
parents: Query<&ChildOf>,
roots: Query<(
Option<&ObjectStore>,
Option<&MovementState>,
Option<&NetEntity>,
)>,
joints: Query<&GlobalTransform>,
camera: Query<&GlobalTransform, With<WorldCamera>>,
creatures: Option<Res<Creatures>>,
shakes: Option<Res<Shakes>>,
mut live: ResMut<CameraShakes>,
) {
if events.is_empty() {
return;
}
let (Some(creatures), Some(shakes)) = (creatures, shakes) else {
return;
};
let Ok(eye) = camera.single().map(|t| t.translation()) else {
return;
};
let now = time.elapsed_secs();
for ev in events.read() {
let thud = &ev.ident == b"$DTH";
if !thud && footfall_side(&ev.ident).is_none() {
continue; // `$FSD` is the sound handler's; only the VISUAL channel shakes
}
let Ok((net, transform, attach, pose)) = units.get(ev.entity) else {
continue;
};
// The shake reads the ROOT unit's own model row — a mount's row is never stored there, so
// a rider on a kodo gets the kodo's *footprints* and not its shake (`0x607a00` writes only
// the decal fields). The root is also where the state gates live.
let mut root = ev.entity;
while let Ok(child_of) = parents.get(root) {
root = child_of.parent();
}
let Ok((store, movement, root_net)) = roots.get(root) else {
continue;
};
let display = root_net.unwrap_or(net).display_id;
let Some(id) = display.and_then(|d| {
if thud {
creatures.death_thud_shake(d)
} else {
creatures.footstep_shake(d)
}
}) else {
continue; // the overwhelming majority of models shake nothing
};
let Some(row) = shakes.0.get(id) else {
continue;
};
if thud {
// No gates whatsoever, and the unit's own world position — not a bone.
live.add(*row, transform.translation(), now);
continue;
}
// The footstep's three state gates, in the reference's order.
if movement.is_some_and(|m| m.flags & move_flags::HOVER != 0) {
continue;
}
if let Some(store) = store {
if store.0.unit_is_stealthed() || store.0.player_is_ghost() {
continue;
}
}
// The planted foot: the event's own marker through the live joint, exactly as the decal
// derives it. No marker/joint = the unit origin.
let foot = attach
.zip(pose)
.and_then(|(a, p)| {
let (bone, offset) = a.markers.get(&ev.ident).copied()?;
p.posed_point(joints.get(p.joints_root).ok()?, bone, offset)
})
.unwrap_or_else(|| transform.translation());
if eye.distance_squared(foot) > EMIT_DISTANCE_SQ {
continue;
}
live.add(*row, foot, now);
}
}
/// The applier's read of the followed unit: its facing (the shake's body frame) and whether it is
/// swimming (the reference's skip).
type FollowedUnit = (&'static Transform, Option<&'static MovementState>);
/// Add this frame's shake to the camera's seated eye.
///
/// Filtered on [`WorldCamera`], never on `Camera3d`: the portrait booths are `Camera3d`s too, and a
/// bare `Camera3d` query here would shake an off-screen booth — the same trap that once yanked the
/// booths to the scenario eye and blanked every portrait. `WorldCamera` and `FlyCam` sit on the one
/// entity `control` seats, which is the entity this must add to.
///
/// Runs **after** the camera is seated, which is what makes the falloff honest: `control` rewrites
/// the base pose every frame, so the transform this reads is the un-shaken eye rather than last
/// frame's shaken one. A zero offset writes nothing at all, preserving the camera's bit-equality
/// no-op gate (decision 1362) — a still camera stays bit-stable and its propagation stays quiet.
pub(crate) fn apply_camera_shake(
mut live: ResMut<CameraShakes>,
time: Res<Time>,
mut camera: Query<&mut Transform, With<WorldCamera>>,
player: Query<FollowedUnit, (With<Embodied>, Without<WorldCamera>)>,
) {
let Ok(mut cam) = camera.single_mut() else {
return;
};
let (yaw, swimming) = player.single().map_or((0.0, false), |(t, mv)| {
(
t.rotation.to_euler(EulerRot::YXZ).0,
mv.is_some_and(|m| m.flags & move_flags::SWIMMING != 0),
)
});
let offset = live.evaluate(cam.translation, yaw, time.elapsed_secs(), swimming);
if offset != Vec3::ZERO {
cam.translation += offset;
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `CameraShakes.dbc` row **1** — the Ancient Protector's / kodo's footstep, verbatim.
const FOOTSTEP_1: CameraShake = CameraShake {
id: 1,
shake_type: 1,
direction: 2,
amplitude: 2.0,
frequency: 3.0,
duration: 0.4,
phase: 0.06,
coefficient: 1.0,
};
/// Row **2** — the Ancient of Lore/War, the giants and the dragons. Amplitude 7.0.
const FOOTSTEP_2: CameraShake = CameraShake {
amplitude: 7.0,
..FOOTSTEP_1
};
fn at(row: CameraShake, pos: Vec3) -> CameraShakes {
let mut s = CameraShakes::default();
s.add(row, pos, 0.0);
s
}
/// The signed vertical offset a shake produces, with the eye at `d` yards from it.
fn vertical(row: CameraShake, d: f32, t: f32) -> f32 {
at(row, Vec3::ZERO).evaluate(Vec3::X * d, 0.0, t, false).y
}
/// `Phase` is a **time pre-roll in seconds**, not an angle: at `t = 0` the sine is already
/// advanced to `2π·f·phase`. Row 1 opens at `sin(2π·3·0.06) = +0.905`, so a footstep kicks the
/// eye UP first — the verdict's own observable.
#[test]
fn phase_is_a_time_preroll_and_the_first_kick_is_upward() {
let expected = (2.0 / 36.0) * (TAU * 3.0 * 0.06).sin() * (-0.06f32).exp();
let got = vertical(FOOTSTEP_1, 0.0, 0.0);
assert!((got - expected).abs() < 1e-6, "{got} vs {expected}");
assert!(got > 0.0, "the first kick is UP, not down: {got}");
// Read as an angle instead, the opening sample would be sin(0.06) ≈ 0.06 — 15× smaller.
let as_angle = (2.0 / 36.0) * 0.06f32.sin();
assert!(got > as_angle * 10.0, "phase must not be read as an angle");
}
/// `Phase` also shortens the real life: the record retires at `elapsed + phase >= duration`.
#[test]
fn phase_shortens_the_life() {
let mut s = at(FOOTSTEP_1, Vec3::ZERO);
// 0.33 s in: 0.33 + 0.06 = 0.39 < 0.4, still alive.
assert!(s.evaluate(Vec3::ZERO, 0.0, 0.33, false).y != 0.0);
assert_eq!(s.live.len(), 1);
// 0.35 s in: 0.41 >= 0.4 — gone, a full 0.05 s before its nominal duration.
assert_eq!(s.evaluate(Vec3::ZERO, 0.0, 0.35, false), Vec3::ZERO);
assert!(s.live.is_empty(), "retired at duration − phase");
}
/// `Duration` is a hard cutoff with **no taper** — a `shake_type == 0` row is still at full
/// authored strength on its last frame. (The creature rows all decay; this is the spell shape.)
#[test]
fn duration_is_a_cutoff_not_a_taper() {
let undecayed = CameraShake {
shake_type: 0,
phase: 0.0,
duration: 1.0,
frequency: 0.25, // a quarter-cycle at t = 1 → sine near its peak
..FOOTSTEP_1
};
let last = vertical(undecayed, 0.0, 0.999);
let peak = 2.0 / 36.0;
assert!(
(last - peak).abs() < 1e-3,
"no taper: {last} should still be ~{peak}"
);
assert_eq!(
vertical(undecayed, 0.0, 1.0),
0.0,
"and then it is simply gone"
);
}
/// The decay is base **e** at `coefficient` 1/s, and only when `shake_type == 1`.
#[test]
fn the_decay_switch_is_one_bit() {
let t = 0.2;
let decayed = vertical(FOOTSTEP_1, 0.0, t);
let plain = vertical(
CameraShake {
shake_type: 0,
..FOOTSTEP_1
},
0.0,
t,
);
let ratio = decayed / plain;
let expected = (-(t + 0.06)).exp();
assert!(
(ratio - expected).abs() < 1e-5,
"base-e decay on the full elapsed (phase included): {ratio} vs {expected}"
);
}
/// Full strength inside 9 yd; `0.7^((d−9)/9)` beyond; **nothing past 80 yd — but the record
/// survives**, so walking back into range resumes it.
#[test]
fn the_distance_falloff_culls_without_retiring() {
let near = vertical(FOOTSTEP_2, 0.0, 0.0);
assert_eq!(
vertical(FOOTSTEP_2, 9.0, 0.0),
near,
"≤ 9 yd is full strength"
);
let far = vertical(FOOTSTEP_2, 18.0, 0.0);
assert!(
(far / near - 0.7f32).abs() < 1e-5,
"one 9-yd span out = ×0.7, got {}",
far / near
);
let mut s = at(FOOTSTEP_2, Vec3::ZERO);
assert_eq!(s.evaluate(Vec3::X * 81.0, 0.0, 0.0, false), Vec3::ZERO);
assert_eq!(s.live.len(), 1, "culled, not retired");
}
/// Same-axis shakes do **not** sum — the strongest wins outright — and the key is the
/// distance-attenuated amplitude, so a tie keeps the OLDER record.
#[test]
fn same_axis_shakes_do_not_sum() {
let mut s = CameraShakes::default();
s.add(FOOTSTEP_1, Vec3::ZERO, 0.0); // amplitude 2
s.add(FOOTSTEP_2, Vec3::ZERO, 0.0); // amplitude 7 — wins
let both = s.evaluate(Vec3::ZERO, 0.0, 0.0, false).y;
let alone = vertical(FOOTSTEP_2, 0.0, 0.0);
assert!(
(both - alone).abs() < 1e-6,
"the loser is dropped, not added: {both} vs {alone}"
);
// A tie keeps the incumbent, which is the older — the reference's jne-skips-the-write.
let mut t = CameraShakes::default();
t.add(FOOTSTEP_1, Vec3::ZERO, 0.0);
t.add(FOOTSTEP_1, Vec3::X * 40.0, 0.0); // same row, further away ⇒ strictly smaller key
assert_eq!(t.evaluate(Vec3::ZERO, 0.0, 0.0, false).y, alone / 3.5);
}
/// `Direction` is an axis in the followed unit's **body frame**: 2 is up, 0 is forward, 1 is
/// left — so turning the player rotates an in-flight horizontal shake.
#[test]
fn direction_selects_the_body_frame_axis() {
let up = at(FOOTSTEP_1, Vec3::ZERO).evaluate(Vec3::ZERO, 0.0, 0.0, false);
assert!(
up.x.abs() < 1e-7 && up.z.abs() < 1e-7 && up.y > 0.0,
"{up:?}"
);
let surge = CameraShake {
direction: 0,
..FOOTSTEP_1
};
// Yaw 0: Bevy forward is −Z.
let f = at(surge, Vec3::ZERO).evaluate(Vec3::ZERO, 0.0, 0.0, false);
assert!(f.y.abs() < 1e-7 && f.x.abs() < 1e-6 && f.z < 0.0, "{f:?}");
// Yaw +90°: forward becomes −X.
let turned =
at(surge, Vec3::ZERO).evaluate(Vec3::ZERO, std::f32::consts::FRAC_PI_2, 0.0, false);
assert!(turned.z.abs() < 1e-6 && turned.x < 0.0, "{turned:?}");
let sway = CameraShake {
direction: 1,
..FOOTSTEP_1
};
let l = at(sway, Vec3::ZERO).evaluate(Vec3::ZERO, 0.0, 0.0, false);
assert!(l.y.abs() < 1e-7 && l.z.abs() < 1e-6 && l.x < 0.0, "{l:?}");
}
/// A `direction` the reference cannot index writes nothing (rather than panicking on our side).
#[test]
fn an_out_of_range_direction_contributes_nothing() {
let bad = CameraShake {
direction: 3,
..FOOTSTEP_1
};
assert_eq!(
at(bad, Vec3::ZERO).evaluate(Vec3::ZERO, 0.0, 0.0, false),
Vec3::ZERO
);
}
/// Swimming bypasses the whole block: zero offset, and **nothing expires** — the shake resumes
/// on surfacing.
#[test]
fn swimming_freezes_rather_than_retires() {
let mut s = at(FOOTSTEP_1, Vec3::ZERO);
assert_eq!(s.evaluate(Vec3::ZERO, 0.0, 10.0, true), Vec3::ZERO);
assert_eq!(s.live.len(), 1, "long past its duration, still not retired");
assert_eq!(s.evaluate(Vec3::ZERO, 0.0, 10.0, false), Vec3::ZERO);
assert!(s.live.is_empty(), "and it retires the moment we surface");
}
}
@@ -885,6 +885,14 @@ pub(super) fn seed_ui_fixture(
(34, 2), // UNIT_FIELD_LEVEL
(35, 1), // UNIT_FIELD_FACTIONTEMPLATE — human
(36, 0x0101), // UNIT_FIELD_BYTES_0 — race human, class warrior
// UNIT_FIELD_FLAGS bit 3 (`PLAYER_CONTROLLED`) — which every real player unit
// carries and this hand-built snapshot did not. `CanAttack` picks its terminal
// arm on this bit (1530): without it the observer reads as a creature, the pair
// takes the both-uncontrolled arm — which needs a HOSTILE reaction in one
// direction, and a wolf's is neutral — and the scenario's plate stopped being
// drawn at all. A fixture is a snapshot of the live component set; this field
// was simply missing from it.
(46, 0x8),
])),
crate::net::SelfPlayer,
crate::net::Guid(PLAYER_GUID),
+4
View File
@@ -81,12 +81,14 @@ mod fixtures;
mod live_shot;
mod phase_probe;
mod pick_probe;
mod probe_auction;
mod probe_bank;
mod probe_binder;
mod probe_book;
mod probe_castcancel;
mod probe_charcreate;
mod probe_chest;
mod probe_clam;
mod probe_crossing;
mod probe_guard_poi;
mod probe_mail;
@@ -102,12 +104,14 @@ use fixtures::seed_ui_fixture;
pub(crate) use live_shot::LiveShotPlugin;
pub(crate) use phase_probe::PhaseProbePlugin;
pub(crate) use pick_probe::PickProbePlugin;
pub(crate) use probe_auction::ProbeAuctionPlugin;
pub(crate) use probe_bank::ProbeBankPlugin;
pub(crate) use probe_binder::ProbeBinderPlugin;
pub(crate) use probe_book::ProbeBookPlugin;
pub(crate) use probe_castcancel::ProbeCastCancelPlugin;
pub(crate) use probe_charcreate::ProbeCharCreatePlugin;
pub(crate) use probe_chest::ProbeChestPlugin;
pub(crate) use probe_clam::ProbeClamPlugin;
pub(crate) use probe_crossing::ProbeCrossingPlugin;
pub(crate) use probe_guard_poi::ProbeGuardPoiPlugin;
pub(crate) use probe_mail::ProbeMailPlugin;
+27 -8
View File
@@ -43,7 +43,7 @@ use bevy::window::PrimaryWindow;
use super::probes::ProbeClock;
use benilla_assets::materials::WowModelMaterial;
use benilla_world::interact::{cast_pick_ray, PickParts, WorldObject};
use benilla_world::interact::{WorldObject, WorldPick};
use benilla_world::view::WorldCamera;
pub(crate) struct PickProbePlugin;
@@ -192,7 +192,10 @@ fn fire_pick(
// the probe keys on it too and stays a pure instrument: nothing outside this file reads it.
objects: Query<Entity, Pickable>,
names: HitNames,
parts: PickParts,
// Everything drawn, entity-owned or not ([`WorldPick`]). Without the entity-less lanes this
// probe reports `0 hits` over every absorbed tree, building and prop — which is exactly what
// it did between 1434's default flip and 1534.
pick: WorldPick,
) {
if probe.taken >= probe.count || probe.pixels.is_empty() || time.elapsed_secs() < probe.next_at
{
@@ -216,7 +219,7 @@ fn fire_pick(
// surface behind it, which is exactly what we came for. The cast reads each part's
// RESIDENT geometry (decision 0857) — the render meshes are `RENDER_WORLD`-only since
// 0834, so a `MeshRayCast` here reports nothing for any static model.
let hits = cast_pick_ray(ray, &pickable, &parts, true);
let hits = pick.cast(ray, &pickable, true);
// The camera's GLOBAL transform, bit-exact, on the line that carries the cast index — so
// "did the camera move on this frame?" is answerable against "was this frame dim?" without
// aligning two logs by hand. It is deliberately *this* transform: it is the one
@@ -244,7 +247,8 @@ fn fire_pick(
// while the along-ray gap stays large. So report the perpendicular distance from this hit's
// point to the previous hit's plane as well: that is the number a depth buffer sees.
let mut previous: Option<(f32, Vec3, Vec3)> = None;
for (i, (entity, hit)) in hits.iter().enumerate() {
for (i, found) in hits.iter().enumerate() {
let hit = &found.hit;
let gap = previous.map_or(String::new(), |(d, point, normal): (f32, Vec3, Vec3)| {
let perp = (hit.point - point).dot(normal).abs();
format!(
@@ -254,10 +258,25 @@ fn fire_pick(
)
});
previous = Some((hit.distance, hit.point, hit.normal));
let Ok((obj, part, mat, tag, card)) = names.identity.get(*entity) else {
info!(" {i:2} {:9.4} yd <untagged>{gap}", hit.distance);
continue;
// A CONSOLIDATED hit — a merge blob's member or a retained-pass item — has no entity,
// so the per-entity columns (material, bound texture, mesh tag, blend) are simply not
// there to read: its draw state lives in the lane's baked record, not in a component.
// It is still named, posed and ordered like every other hit, which is what a "what is
// at this pixel" reading is for; `CONSOLIDATED` says why the columns are bare.
let (part, mat, tag, card) = match found.entity.map(|e| names.identity.get(e)) {
Some(Ok((_, part, mat, tag, card))) => (part, mat, tag, card),
Some(Err(_)) => {
info!(" {i:2} {:9.4} yd <untagged>{gap}", hit.distance);
continue;
}
None => (None, None, None, false),
};
let lane = if found.entity.is_some() {
""
} else {
" CONSOLIDATED"
};
let obj = found.object.as_ref();
// The WMO authored batch order rides in the material's `sun_scale.y` (see
// `model_render`): 0 means "no bias applied", which for a WMO batch is itself a finding.
let resolved = mat.and_then(|m| names.materials.get(&m.0));
@@ -299,7 +318,7 @@ fn fire_pick(
},
);
info!(
" {i:2} {:9.4} yd {kind} {id:<10} bias {batch:3} {:?}{} {:5.1}° to the ray mat {:?} tex {tex} {label}{gap}",
" {i:2} {:9.4} yd {kind} {id:<10} bias {batch:3} {:?}{}{lane} {:5.1}° to the ray mat {:?} tex {tex} {label}{gap}",
hit.distance,
part.map(|p| p.blend),
if card { " CARD" } else { "" },
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,433 @@
//! The openable-item live probe (`WOW_PROBE_CLAM=1`) — **the clam instrument**: right-click a
//! LOOTABLE item in the bags and does a loot window actually open?
//!
//! The director, 2026-08-22: *"Clams don't open anymore. I right click them and they turn gray
//! correctly but no loot window opens ever."* Both halves of that sentence are readings this probe
//! takes separately, because they come from two different pre-send writes in the same emitter
//! (`0x5edc80`, wow-re `loot-anim-leg.md` §5 / `inventory-change-failure-display.md` §8):
//!
//! - the **grey lock** ([`PendingItemOps`], `0x4953e0` at `0x5edcd9`) — the half that kept working;
//! - the **loot latch** ([`LootLatch`], `0x5edcc0`) — the half decision 1477 left unmodelled, on
//! the reading that an item arm changes no pose. It changes no pose and it is still load-bearing:
//! vmangos answers `CMSG_OPEN_ITEM` with `SendLoot(item guid, LOOT_CORPSE)`, so
//! `SMSG_LOOT_RESPONSE` comes back **type 1** on the item's own guid, and 1477's admission gate
//! *refuses* a type-1 answer against a cold latch — bounces a `CMSG_LOOT_RELEASE` and opens
//! nothing. Grey clam, no window, forever. Decision 1531.
//!
//! **The window is a number here, not a picture.** The probe drives the click through the live UI
//! VM's own `UseContainerItem(0, slot)` — the same binding the bag button calls, so the whole
//! dispatcher (`ui_items::drain::drain_container_uses`) runs for real — and then reads
//! `LootFrame:IsShown()` plus the app-side session guid. Four readings, in order, which is what
//! makes it a regression test rather than a one-sided assertion:
//!
//! 1. **BEFORE** — the clam sitting in the bag, unclicked: no loot window. The control; a probe
//! that only ever sees a window cannot tell a fix from a window that was already up.
//! 2. **OPEN** — after the click: a window, whose session guid is the **clam's own item guid**
//! (not a corpse's, not zero), with the grey lock on its slot.
//! 3. **CLOSED** — `CloseLoot()` through the live VM: the window goes and the latch clears.
//! 4. **REOPEN** — the same clam clicked again: it opens a second time. The adjacent state that
//! catches a latch which arms once and never re-arms (the classic "works the first time" shape).
//!
//! ## The run recipe
//!
//! ```text
//! WOW_NOSOUND=1 WOW_USER=probe0 WOW_PASS=pprobe0 WOW_CHAR=Probezero \
//! WOW_PROBE_CLAM=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — `pool-N` → `probeN`/`pprobeN`/`Probe<N-spelled>`, `method.md`
//! "The local vmangos server"). `WOW_PROBE_CLAM=<entry>` aims it at a different openable template;
//! the default is a Small Barnacled Clam (7973 — `Flags` LOOTABLE with `LockID = 0`, so it is
//! openable the moment it exists: no key, no lockpicking). The probe `.additem`s its own copy and
//! subtracts it again on the way out, so it leaves the character as it found it.
//!
//! Auto-loot is forced **off** for the run: with it on, the clam's single row is taken at the open
//! edge and the last-row auto-release closes the window within a frame or two, which would read as
//! "no window" for the wrong reason.
//!
//! Grep `PROBE_CLAM:` for the verdict; the probe self-exits when it lands.
use bevy::prelude::*;
use benilla_ui::script::UiScript;
use super::probes::ProbeClock;
use crate::items::Items;
use crate::net::{ChatKind, ClientCommand, NetCommands, ObjectStore, SelfPlayer};
use crate::pending_item_ops::PendingItemOps;
use crate::ui_loot::{LootConfig, LootLatch, LootState};
/// The probe's default template: "Small Barnacled Clam" (entry 7973) — the director's own case, and
/// the `benilla-world --open-item` wire probe's. Its `item_loot_template` row (a Zesty Clam Meat)
/// is what the window must show.
const CLAM_ENTRY: u32 = 7973;
/// How long to wait for `.additem` to land the item and its template in the bags.
const STOCK_TIMEOUT_SECS: f64 = 20.0;
/// How long a click gets to produce a window before the run is called a failure.
const OPEN_TIMEOUT_SECS: f64 = 10.0;
/// How long `CloseLoot()` gets to take the window down.
const CLOSE_TIMEOUT_SECS: f64 = 10.0;
/// How long the `.additem -1` gets to take the probe's own copy back out of the bags.
const CLEANUP_TIMEOUT_SECS: f64 = 8.0;
/// How long the REOPEN round waits after the window goes down before clicking again.
///
/// **Not padding — a real race the reference shares.** `CloseLoot()` sends `CMSG_LOOT_RELEASE` and
/// clears the latch locally at the *send* (`0x48f2c9` in `CloseInteraction 0x48f200`); the server's
/// `SMSG_LOOT_RELEASE_RESPONSE` clears it again, **guid-matched** (`0x5ec0d4`). Re-open the *same*
/// object inside that round trip and the response's clear lands on the latch the re-open just
/// armed, so its type-1 answer is refused — guid-matching cannot separate them when both guids are
/// the same object. Both clear sites are the reference's own, so this is faithful rather than ours
/// to fix; a hand re-click never gets near the ~40 ms window, and a probe clicking on the next
/// frame does, every few runs. One second is a human's reaction time, generously.
const REOPEN_SETTLE_SECS: f64 = 1.0;
/// Frames the BEFORE control samples — long enough to outlast a transient, short enough to be free.
const CONTROL_FRAMES: usize = 60;
pub(crate) struct ProbeClamPlugin;
impl Plugin for ProbeClamPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ClamProbe>()
.add_systems(Update, clam_probe);
}
}
/// Which template the probe opens: `WOW_PROBE_CLAM=<entry>`, else [`CLAM_ENTRY`]. Anything
/// unparseable falls back to the default rather than failing the run — the common value is `1`.
fn target_entry() -> u32 {
std::env::var("WOW_PROBE_CLAM")
.ok()
.and_then(|raw| raw.trim().parse().ok())
.filter(|e| *e != 0 && *e != 1)
.unwrap_or(CLAM_ENTRY)
}
#[derive(Resource, Default)]
struct ClamProbe {
phase: Phase,
/// The clam's item guid and its 1-based Lua backpack slot, once the stock scan finds it.
clam: Option<u64>,
slot: u32,
/// Frames sampled with the clam untouched — every one must show no window.
control: Vec<bool>,
/// What each open round read: the session guid the window came up on, and whether the clicked
/// slot was grey at the same moment. Reported separately because the director's report split
/// exactly there — grey yes, window no.
opened: Vec<Option<u64>>,
greyed: Vec<bool>,
/// Whether the latch was clear after each `CloseLoot()`.
cleared: Vec<bool>,
fails: u32,
exited: bool,
}
/// The open rounds the probe runs: the first click, and the re-click that catches an arm which
/// fires once and never again.
const ROUNDS: u8 = 2;
#[derive(Default, Clone, Copy, PartialEq)]
enum Phase {
#[default]
Wait,
/// `.additem` issued; waiting for the item AND its template to reach the bags.
Stocking {
sent_at: f64,
},
/// Sampling with the clam untouched — the control window.
Before,
/// `UseContainerItem` queued; waiting for a loot window. `round` counts from 0.
WaitOpen {
since: f64,
round: u8,
},
/// `CloseLoot()` called; waiting for the window to actually go.
WaitClosed {
since: f64,
round: u8,
},
/// The window is down; letting the release round trip finish before the re-click. See
/// [`REOPEN_SETTLE_SECS`].
Settling {
since: f64,
round: u8,
},
/// `.additem <entry> -1` sent; waiting for the copy to actually leave the bags before exiting.
Cleanup {
since: f64,
},
Done,
}
/// Send the probe's own copy back and enter [`Phase::Cleanup`] — the one way out of the round loop,
/// so no exit path can skip the cleanup.
fn finish(net: &NetCommands, entry: u32, now: f64) -> Phase {
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
target: None,
text: format!(".additem {entry} -1"),
});
Phase::Cleanup { since: now }
}
/// Whether the loot window is up, asked of the **live UI** rather than of our own state: the window
/// is what the director looked for, and `LootFrame` shows only once the feed has fired
/// `LOOT_OPENED` off the wire. Falls back to the app-side session when the frame can't be read.
fn window_open(script: &UiScript, loot: &LootState) -> bool {
script
.eval::<bool>("return LootFrame:IsShown() and 1 or nil")
.unwrap_or_else(|_| loot.source().is_some())
}
/// Find the probe's own copy of `entry` in the backpack: its item guid and 1-based Lua slot. The
/// template has to have landed too — the click dispatcher's open arm is a **template** LOOTABLE
/// test, so a click made before the answer arrives falls through to a plain use and proves nothing.
fn find_in_backpack(
store: &ObjectStore,
entry: u32,
items: &mut Items,
net: &NetCommands,
) -> Option<(u64, u32)> {
let (i, guid) = (0..16u8)
.filter_map(|i| {
store
.0
.player_pack_slot(i)
.filter(|g| *g != 0)
.map(|g| (i, g))
})
.find(|(_, guid)| items.object(*guid).and_then(|f| f.object_entry()) == Some(entry))?;
// `template` asks the server once on a miss and answers `None` until the reply lands, which is
// exactly the "not stocked yet" state this returns.
items
.template(entry, guid, net)
.filter(|t| t.opens_loot())
.map(|_| (guid, u32::from(i) + 1))
}
#[allow(clippy::too_many_arguments)]
fn clam_probe(
time: ProbeClock,
mut probe: ResMut<ClamProbe>,
script: Option<NonSendMut<UiScript>>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
mut items: ResMut<Items>,
mut cfg: ResMut<LootConfig>,
loot: Res<LootState>,
latch: Res<LootLatch>,
pending: Res<PendingItemOps>,
net: Res<NetCommands>,
mut exit: MessageWriter<AppExit>,
) {
let Ok(store) = self_store.single() else {
return; // not in-world yet
};
let Some(mut script) = script else {
return; // no UI VM this build — nothing this probe can drive
};
let now = time.elapsed_secs_f64();
let entry = target_entry();
match probe.phase {
Phase::Wait => {
// The window must survive to be looked at: with auto-loot on, the single row is taken
// at the open edge and the last-row auto-release shuts it again immediately.
cfg.auto_loot = false;
info!("PROBE_CLAM: stocking one openable item (entry {entry}) — auto-loot forced off");
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
target: None,
text: format!(".additem {entry}"),
});
probe.phase = Phase::Stocking { sent_at: now };
}
Phase::Stocking { sent_at } => {
if let Some((guid, slot)) = find_in_backpack(store, entry, &mut items, &net) {
info!(
"PROBE_CLAM: item {entry} is guid {guid:#x} in backpack slot {slot} — \
sampling {CONTROL_FRAMES} control frames with it UNCLICKED"
);
probe.clam = Some(guid);
probe.slot = slot;
probe.phase = Phase::Before;
} else if now - sent_at > STOCK_TIMEOUT_SECS {
error!(
"PROBE_CLAM: FAIL — no LOOTABLE item {entry} with a landed template in the \
backpack within {STOCK_TIMEOUT_SECS}s. `.additem` needs a GM account, and it \
refuses silently when the backpack is full. This is NOT a passing run"
);
probe.fails += 1;
probe.phase = Phase::Done; // nothing was stocked — nothing to clean up
}
}
Phase::Before => {
probe.control.push(window_open(&script, &loot));
if probe.control.len() < CONTROL_FRAMES {
return;
}
click(&mut script, &mut probe, 0);
probe.phase = Phase::WaitOpen {
since: now,
round: 0,
};
}
Phase::WaitOpen { since, round } => {
if window_open(&script, &loot) {
let slot = probe.slot;
let source = loot.source();
let grey = pending.contains(0, slot);
info!(
"PROBE_CLAM: round {round} — window up on {:?} (latch {:?}, slot grey {grey})",
source.map(|g| format!("{g:#x}")),
latch.0.map(|g| format!("{g:#x}")),
);
probe.opened.push(source);
probe.greyed.push(grey);
let _ = script.eval::<()>("CloseLoot()");
probe.phase = Phase::WaitClosed { since: now, round };
} else if now - since > OPEN_TIMEOUT_SECS {
let grey = pending.contains(0, probe.slot);
error!(
"PROBE_CLAM: round {round} FAIL — no loot window within {OPEN_TIMEOUT_SECS}s \
of the click (latch {:?}, slot grey {grey}). A grey slot with no window is \
the refused type-1 response: the CMSG_OPEN_ITEM arm never latched the item \
guid",
latch.0.map(|g| format!("{g:#x}")),
);
probe.fails += 1;
probe.opened.push(None);
probe.greyed.push(grey);
probe.phase = finish(&net, entry, now);
}
}
Phase::Settling { since, round } => {
if now - since >= REOPEN_SETTLE_SECS {
info!("PROBE_CLAM: re-clicking the same item (round {round})");
click(&mut script, &mut probe, round);
probe.phase = Phase::WaitOpen { since: now, round };
}
}
Phase::WaitClosed { since, round } => {
if !window_open(&script, &loot) {
probe.cleared.push(latch.0.is_none());
let next = round + 1;
if next < ROUNDS {
probe.phase = Phase::Settling {
since: now,
round: next,
};
} else {
probe.phase = finish(&net, entry, now);
}
} else if now - since > CLOSE_TIMEOUT_SECS {
error!(
"PROBE_CLAM: FAIL — the loot window never closed within {CLOSE_TIMEOUT_SECS}s"
);
probe.fails += 1;
probe.phase = finish(&net, entry, now);
}
}
Phase::Cleanup { since } => {
// Put the character back as we found it, and **watch it land**: `AppExit` tears the net
// thread down within a frame or two, so a fire-and-forget `.additem -1` written on the
// way out never reaches the wire (the first run of this probe left its clam behind
// exactly that way). Leave when the copy is gone, or say plainly that it isn't.
let gone = probe
.clam
.is_some_and(|guid| (0..16u8).all(|i| store.0.player_pack_slot(i) != Some(guid)));
if gone {
probe.phase = Phase::Done;
} else if now - since > CLEANUP_TIMEOUT_SECS {
warn!(
"PROBE_CLAM: the probe's own copy of {entry} is still in the bags after \
{CLEANUP_TIMEOUT_SECS}s — remove it by hand (`.additem {entry} -1`)"
);
probe.phase = Phase::Done;
}
}
Phase::Done => {
if probe.exited {
return;
}
probe.exited = true;
report(&probe, entry);
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
// client holding the probe account.
exit.write(AppExit::Success);
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
warn!("PROBE_CLAM: still alive 5s after AppExit — hard exit");
std::process::exit(0);
});
}
}
}
/// Right-click the clam the way the bag button does — through the live VM's own
/// `UseContainerItem`, so `ui_items::drain::drain_container_uses` runs its real dispatcher (and its
/// real open arm) rather than the probe re-deciding which packet an openable item takes.
fn click(script: &mut UiScript, probe: &mut ClamProbe, round: u8) {
let slot = probe.slot;
info!("PROBE_CLAM: round {round} — UseContainerItem(0, {slot}) through the live VM");
if let Err(e) = script.run(&format!("UseContainerItem(0, {slot})")) {
error!("PROBE_CLAM: FAIL — the click chunk did not run: {e}");
probe.fails += 1;
}
}
/// The verdict, one line per reading, then the DONE tally.
fn report(probe: &ClamProbe, entry: u32) {
let mut fails = probe.fails;
let clam = probe.clam.unwrap_or_default();
let stray = probe.control.iter().filter(|open| **open).count();
if stray == 0 && !probe.control.is_empty() {
info!(
"PROBE_CLAM: BEFORE PASS — no loot window on any of {} control frames",
probe.control.len()
);
} else {
error!(
"PROBE_CLAM: BEFORE FAIL — a loot window was already up on {stray}/{} control frames; \
the open reading below means nothing",
probe.control.len()
);
fails += 1;
}
for round in 0..usize::from(ROUNDS) {
let label = if round == 0 { "OPEN" } else { "REOPEN" };
match probe.opened.get(round) {
Some(Some(source)) if *source == clam => info!(
"PROBE_CLAM: {label:<6} PASS — the window's session is the item's own guid \
{source:#x} (slot grey {})",
probe.greyed.get(round).copied().unwrap_or(false)
),
Some(Some(source)) => {
error!(
"PROBE_CLAM: {label:<6} FAIL — a window opened, but on {source:#x}, not the \
clam {clam:#x}"
);
fails += 1;
}
_ => {
error!("PROBE_CLAM: {label:<6} FAIL — no window on this click");
fails += 1;
}
}
match probe.cleared.get(round) {
Some(true) => {
info!("PROBE_CLAM: CLOSE{round} PASS — the latch cleared with the window")
}
Some(false) => {
error!(
"PROBE_CLAM: CLOSE{round} FAIL — the window went but the latch is still armed"
);
fails += 1;
}
None => {}
}
}
info!("PROBE_CLAM: DONE entry={entry} item={clam:#x} fail={fails}");
}
@@ -193,7 +193,7 @@ fn guard_poi_probe(
format!("POIIcons cell {} at its true spot", poi.icon)
}
true => "nothing (in range, no Flags&2)".to_string(),
false => "a rim arrow pointing at it".to_string(),
false => "the gold guide arrow on the rim, pointing at it (1519)".to_string(),
};
info!(
"PROBE guardpoi: marker \"{}\" at ({:.1}, {:.1}) icon {} flags {} — {d:.1} yd away \
@@ -828,6 +828,9 @@ mod tests {
},
flags: 0,
equipment: [Default::default(); 19],
pet_display_id: 0,
pet_level: 0,
pet_family: 0,
}
}
+21 -10
View File
@@ -53,8 +53,6 @@ pub(crate) const ALLIANCE: [u8; 4] = [1, 3, 4, 7]; // Human, Dwarf, Night Elf, G
const HORDE: [u8; 4] = [2, 5, 6, 8]; // Orc, Scourge, Tauren, Troll
/// The ref's initial model facing (`SetCharacterCreateFacing(-15)`), reset on every race switch.
const INITIAL_FACING: f32 = -15.0 * std::f32::consts::PI / 180.0;
/// The rotate buttons' hold rate — the ref's 2°-per-frame `CHARACTER_FACING_INCREMENT`, per-second.
const ROTATE_RATE: f32 = 120.0 * std::f32::consts::PI / 180.0;
/// The character-creation subsystem (decision 0423): the screen + its selection state.
pub(crate) struct CharCreatePlugin;
@@ -384,7 +382,8 @@ fn class_file(class: u8) -> &'static str {
/// re-running the whole dial/panel/icon refresh 60× a second.
#[allow(clippy::too_many_arguments)]
fn create_input(
presses: Query<(&CreateAction, &Interaction), Changed<Interaction>>,
buttons: Query<(Entity, &CreateAction)>,
clicks: Res<crate::glue::GlueClicks>,
mut keyboard: MessageReader<KeyboardInput>,
keys: Res<ButtonInput<KeyCode>>,
catalog: Option<Res<CharCreate>>,
@@ -407,8 +406,11 @@ fn create_input(
let mut changed_look = false;
let mut do_create = false;
for (action, interaction) in &presses {
if *interaction != Interaction::Pressed {
// Every control on this screen is a Button, and a Button fires on the RELEASE, over the
// button that took the press (1533, `crate::glue::glue_clicks`). The name box needs no
// press-to-focus: the screen has one field and it is always focused while up.
for (entity, action) in &buttons {
if !clicks.hit(entity) {
continue;
}
match *action {
@@ -537,24 +539,33 @@ fn refresh_name_box(
/// Rotate the preview: drag on the model pane (the ref's full-frame mouse rotation: `facing +=
/// Δcursor·CHARACTER_ROTATION_CONSTANT` — dragging right *increases* the facing, same sign as the
/// right rotate button; the engine constant's magnitude is unverified, ours ≈0.6°/px), or hold a
/// rotate button (the ref's ±2°-per-frame `RotateLeft/Right_OnUpdate`; left decrements the facing).
/// right rotate button), or hold a rotate button (the ref's ±2°-per-frame
/// `RotateLeft/Right_OnUpdate`; left decrements the facing).
///
/// The drag constant is the select screen's, because in the reference it is literally the same
/// number: `CHARACTER_ROTATION_CONSTANT = 0.6` is declared once in `CharacterSelect.lua` and read
/// by both screens' `OnUpdate`. This screen carried a bare `0.01` rad/px (0.573°/px) instead — a
/// 4.5 % drift from the screen next door, against a comment that already claimed 0.6 (1533).
fn rotate_model(
panes: Query<(&Interaction, &CreateAction)>,
motion: Res<AccumulatedMouseMotion>,
time: Res<Time>,
window: Query<&Window, With<bevy::window::PrimaryWindow>>,
mut preview: ResMut<GluePreview>,
) {
let window = window.single().ok();
for (interaction, action) in &panes {
if *interaction != Interaction::Pressed {
continue;
}
match action {
CreateAction::Model if motion.delta.x != 0.0 => {
preview.yaw += motion.delta.x * 0.01;
preview.yaw += crate::glue::drag_yaw(motion.delta.x, window);
}
CreateAction::RotateLeft => preview.yaw -= crate::glue::ROTATE_RATE * time.delta_secs(),
CreateAction::RotateRight => {
preview.yaw += crate::glue::ROTATE_RATE * time.delta_secs()
}
CreateAction::RotateLeft => preview.yaw -= ROTATE_RATE * time.delta_secs(),
CreateAction::RotateRight => preview.yaw += ROTATE_RATE * time.delta_secs(),
_ => {}
}
}
@@ -321,12 +321,15 @@ fn max_scroll(node: &ComputedNode) -> f32 {
/// `SetValue(GetValue() ± GetHeight()/2)`), the knob drags.
pub(super) fn scroll_drive(
motion: Res<AccumulatedMouseMotion>,
arrows: Query<(&ScrollArrow, &Interaction), Changed<Interaction>>,
arrows: Query<(Entity, &ScrollArrow)>,
clicks: Res<crate::glue::GlueClicks>,
thumbs: Query<(&ScrollThumb, &Interaction)>,
mut scrolls: Query<(&ComputedNode, &mut ScrollPosition), With<InfoScroll>>,
) {
for (arrow, interaction) in &arrows {
if *interaction != Interaction::Pressed {
// The arrow steps on the RELEASE — it is a `<Button>` with an `<OnClick>`, not a slider (1533).
// The knob below is the slider half, and a drag is a held press by definition.
for (entity, arrow) in &arrows {
if !clicks.hit(entity) {
continue;
}
let Ok((node, mut pos)) = scrolls.get_mut(arrow.scroll) else {
+14 -5
View File
@@ -587,8 +587,8 @@ pub(super) fn drive_addons_panel(
mouse: Res<ButtonInput<MouseButton>>,
mut wheel: MessageReader<bevy::input::mouse::MouseWheel>,
mut sounds: MessageWriter<GlueSound>,
presses: Query<(&AddonsAction, &Interaction), Changed<Interaction>>,
hovers: Query<(Entity, &AddonsAction, &Interaction)>,
clicks: Res<crate::glue::GlueClicks>,
hovers: Query<(Entity, &AddonsAction, Ref<Interaction>)>,
lit: Query<(&Interaction, &Children), With<HoverLit>>,
mut hilights: Query<&mut Visibility, With<Hilight>>,
band: Query<(&ComputedNode, &UiGlobalTransform), With<ScrollBand>>,
@@ -608,8 +608,17 @@ pub(super) fn drive_addons_panel(
let mut close_and_save = false;
let mut close_and_discard = false;
let mut bar_pressed = false;
for (action, interaction) in &presses {
if *interaction != Interaction::Pressed {
for (entity, action, interaction) in &hovers {
// **The scroll bar warps on the PRESS**, and only it: the reference's `CSimpleSlider`
// OnMouseDown (`0x789ca0`, 45 bytes, zero branches) warps the value from any press inside
// the hit rect — there is no thumb hit-test in the class (wow-re `ui.md`). Every *button*
// in this panel fires on the RELEASE, over the button that took the press (1533).
let click = if *action == AddonsAction::ScrollBar {
interaction.is_changed() && *interaction == Interaction::Pressed
} else {
clicks.hit(entity)
};
if !click {
continue;
}
match *action {
@@ -761,7 +770,7 @@ pub(super) fn drive_addons_panel(
}
let hover = hovers.iter().find_map(|(_, a, i)| {
if !matches!(i, Interaction::Hovered | Interaction::Pressed) {
if !matches!(*i, Interaction::Hovered | Interaction::Pressed) {
return None;
}
match a {
+5 -3
View File
@@ -99,7 +99,8 @@ pub(super) fn drive_delete_dialog(
keys: Res<ButtonInput<KeyCode>>,
mut keyboard: MessageReader<KeyboardInput>,
mut sounds: MessageWriter<GlueSound>,
presses: Query<(&DialogAction, &Interaction), Changed<Interaction>>,
buttons: Query<(Entity, &DialogAction)>,
clicks: Res<crate::glue::GlueClicks>,
mut okay: Query<(&DialogAction, &mut GlueDisabled)>,
// The five row items of the confirm box (segments + carets), painted by the shared
// `paint_glue_field`; plus the host pasteboard and the window handle its Wayland backend needs.
@@ -178,8 +179,9 @@ pub(super) fn drive_delete_dialog(
// The flows.
let mut do_delete = false;
let mut do_cancel = false;
for (action, interaction) in &presses {
if *interaction != Interaction::Pressed {
// Both buttons fire on the RELEASE (1533) — a confirm you can slide off and cancel.
for (entity, action) in &buttons {
if !clicks.hit(entity) {
continue;
}
match action {
+19 -14
View File
@@ -15,11 +15,8 @@ use super::dialog::DeleteDialog;
use super::screen::SelectAction;
use super::{class_name, send_pick, ClientState, Roster};
/// The ref's drag constant (`CHARACTER_ROTATION_CONSTANT = 0.6`, CharacterSelect.lua — degrees per
/// pixel; the facing setter is in degrees, deg→rad at the C boundary).
pub(crate) const ROTATION_PER_PX: f32 = 0.6 * std::f32::consts::PI / 180.0;
/// The rotate buttons' hold rate — the ref's 2°-per-frame `CHARACTER_FACING_INCREMENT`, per-second.
const ROTATE_RATE: f32 = 120.0 * std::f32::consts::PI / 180.0;
use crate::glue::{drag_yaw, ROTATE_RATE};
/// The double-click window (the ref rides the OS notion; this is the conventional interval).
const DOUBLE_CLICK_SECS: f32 = 0.4;
@@ -29,7 +26,8 @@ const DOUBLE_CLICK_SECS: f32 = 0.4;
/// sits over the buttons).
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
pub(super) fn select_input(
presses: Query<(&SelectAction, &Interaction), Changed<Interaction>>,
buttons: Query<(Entity, &SelectAction)>,
clicks: Res<crate::glue::GlueClicks>,
keys: Res<ButtonInput<KeyCode>>,
mut roster: ResMut<Roster>,
pick: Res<CharPick>,
@@ -48,8 +46,10 @@ pub(super) fn select_input(
let now = time.elapsed_secs();
let mut enter_world = false;
let mut back_to_login = false;
for (action, interaction) in &presses {
if *interaction != Interaction::Pressed {
// Buttons fire on the RELEASE, over the button that took the press — the reference's stock
// `<Button>` click mask is `LeftButtonUp` alone (1533, `crate::glue::glue_clicks`).
for (entity, action) in &buttons {
if !clicks.hit(entity) {
continue;
}
match *action {
@@ -58,10 +58,13 @@ pub(super) fn select_input(
// within the window is the double-click → enter world.
let double =
last_click.is_some_and(|(row, at)| row == i && now - at < DOUBLE_CLICK_SECS);
let was = roster.selected();
*last_click = Some((i, now));
if roster.selected != Some(i) {
roster.selected = Some(i);
} else if double {
// The ref selects on **every** click, the row you were already on included — which
// is what re-squares the facing (`Roster::select_seq`). Only the double still
// needs the "already selected" test.
roster.select(Some(i));
if was == Some(i) && double {
enter_world = true;
}
}
@@ -121,13 +124,13 @@ pub(super) fn select_input(
let back = keys.just_pressed(KeyCode::ArrowUp) || keys.just_pressed(KeyCode::ArrowLeft);
let fwd = keys.just_pressed(KeyCode::ArrowDown) || keys.just_pressed(KeyCode::ArrowRight);
if back || fwd {
let cur = roster.selected.unwrap_or(0);
let cur = roster.selected().unwrap_or(0);
let sel = if back {
(cur + n - 1) % n
} else {
(cur + 1) % n
};
roster.selected = Some(sel);
roster.select(Some(sel));
}
}
@@ -148,15 +151,17 @@ pub(super) fn rotate_model(
panes: Query<(&Interaction, &SelectAction)>,
motion: Res<AccumulatedMouseMotion>,
time: Res<Time>,
window: Query<&Window, With<bevy::window::PrimaryWindow>>,
mut preview: ResMut<GluePreview>,
) {
let window = window.single().ok();
for (interaction, action) in &panes {
if *interaction != Interaction::Pressed {
continue;
}
match action {
SelectAction::Scene if motion.delta.x != 0.0 => {
preview.yaw += motion.delta.x * ROTATION_PER_PX;
preview.yaw += drag_yaw(motion.delta.x, window);
}
SelectAction::RotateLeft => preview.yaw -= ROTATE_RATE * time.delta_secs(),
SelectAction::RotateRight => preview.yaw += ROTATE_RATE * time.delta_secs(),
+65 -10
View File
@@ -152,7 +152,17 @@ pub(crate) struct Roster {
pub(super) chars: Vec<Character>,
/// The selected row (the ref's `CharacterSelect.selectedIndex`, 0-based; `None` = empty list).
/// A fresh roster clamps it into range and defaults to the first row (the ref's law).
pub(super) selected: Option<usize>,
/// **Write it through [`Roster::select`]**, never directly — see [`Roster::select_seq`].
selected: Option<usize>,
/// Bumped by every [`Roster::select`], whether or not the row changed.
///
/// The ref's `SelectCharacter` zeroes the select facing **unconditionally**: `0x472950`'s
/// `mov ds:0xb4217c, 0` sits one instruction *above* the already-built discriminator, so it
/// dominates both legs, and the merged tail re-applies it geometrically — so re-clicking the
/// row you are already on snaps the character square again (wow-re
/// `glue/scratch/glue-preview-facing-law.md`, 1533). A counter rather than change-detection on
/// `selected`, because "the same index again" is a selection.
pub(super) select_seq: u64,
/// The guid we answered the IO thread with; `Some` = a login is requested/live.
pub(super) pending_pick: Option<u64>,
/// `WOW_CHAR`, when explicitly set: auto-pick this name on the FIRST roster (the dev fast
@@ -214,11 +224,23 @@ impl Roster {
.iter()
.position(|c| c.name.eq_ignore_ascii_case(name))
{
self.selected = Some(row);
self.select(Some(row));
self.just_created = None;
}
}
/// Select a row — the ref's `SelectCharacter`. Every selection goes through here so the facing
/// reset it owes cannot be forgotten at one of the seven call sites (see [`Self::select_seq`]).
pub(super) fn select(&mut self, row: Option<usize>) {
self.selected = row;
self.select_seq = self.select_seq.wrapping_add(1);
}
/// The selected row, if any.
pub(super) fn selected(&self) -> Option<usize> {
self.selected
}
/// The selected character, if any.
pub(super) fn selected_char(&self) -> Option<&Character> {
self.selected.and_then(|i| self.chars.get(i))
@@ -316,14 +338,15 @@ fn apply_roster_policy(
warn!(
"char select: created {name:?} is not on the fresh roster — selecting the last row"
);
roster.selected = roster.chars.len().checked_sub(1);
let last = roster.chars.len().checked_sub(1);
roster.select(last);
}
}
if roster.chars.is_empty() {
roster.selected = None;
roster.select(None);
} else {
let sel = roster.selected.unwrap_or(0).min(roster.chars.len() - 1);
roster.selected = Some(sel);
let sel = roster.selected().unwrap_or(0).min(roster.chars.len() - 1);
roster.select(Some(sel));
}
// `WOW_CHARSELECT_PICK=<name>` — **select** that row and stay on the screen, the
// deliberate opposite of `WOW_CHAR`'s enter-the-world fast path. Without it the screen is
@@ -338,7 +361,7 @@ fn apply_roster_policy(
{
Some(row) => {
info!("char select: WOW_CHARSELECT_PICK={name} — selecting row {row}");
roster.selected = Some(row);
roster.select(Some(row));
}
None => warn!("char select: WOW_CHARSELECT_PICK={name} not on this account"),
}
@@ -493,6 +516,14 @@ fn debug_glue_roundtrip(
/// the first — it streams a map the streamer has already torn down once — and a teardown that
/// forgets to reset its own bookkeeping fails exactly here and nowhere else. The world-audio
/// boundary this smoke was written for is still checked on the way through.
///
/// **It ends the way a player ends a session** — by closing the window, not by writing an
/// `AppExit` of its own (decision 1528). That is not cosmetic: the close is the *latest*
/// announcement Bevy makes (`exit_on_all_closed` in `PostUpdate`, a frame after the request), so
/// it is the only exit that actually tests whether the shutdown tail is reachable. Exiting by a
/// hand-written `AppExit` from `Update` skipped that question for this smoke's whole life, and the
/// bug it would have caught — every saved variable, every addon file and the camera pose lost on
/// every window close — shipped underneath it.
#[allow(clippy::too_many_arguments)] // a smoke test that drives the whole round trip
fn debug_logout_smoke(
state: Res<State<ClientState>>,
@@ -503,6 +534,8 @@ fn debug_logout_smoke(
streamer: Res<benilla_world::terrain_stream::TerrainStreamer>,
time: Res<Time>,
mut exit: MessageWriter<AppExit>,
mut close: MessageWriter<bevy::window::WindowCloseRequested>,
windows: Query<Entity, With<bevy::window::PrimaryWindow>>,
mut phase: Local<u8>,
mut mark: Local<f32>,
) {
@@ -551,15 +584,34 @@ fn debug_logout_smoke(
}
},
4 if *state.get() == ClientState::InWorld && player.active && now - *mark > 3.0 => {
// **The suppressor reading is the point of the second entry, not a decoration on it**
// (B306, decision 1542). This leg has crossed the boundary on every run since 1291 and
// could only ever report that it *happened* — tiles, UI rebuilds, error counts — none
// of which a character who re-entered unable to move would disturb. `scripts/smoke.sh`
// fails on anything but `none`, and separately reports whether the run's logout was
// the rooted kind at all: a GM probe gets vmangos's instant path, which is B306's
// precondition missing, so a green here is not a B306 regression on its own.
info!(
"logout-smoke: re-entered — {} tiles resident, done",
streamer.residency().1
"logout-smoke: re-entered — {} tiles resident, suppressors: {}, done",
streamer.residency().1,
player.movement_suppressors()
);
*phase = 5;
}
5 => {
info!("logout-smoke: done");
exit.write(AppExit::Success);
// The player's exit: request the window close and let `bevy_window` take it from
// there. A run with no window (there is none in the headless harness) has no such
// path, so it says so and falls back rather than hanging until the timeout.
match windows.single() {
Ok(window) => {
close.write(bevy::window::WindowCloseRequested { window });
}
Err(_) => {
warn!("logout-smoke: no primary window — exiting by AppExit instead");
exit.write(AppExit::Success);
}
}
*phase = 6;
}
_ => {}
@@ -741,6 +793,9 @@ mod tests {
},
flags: 0,
equipment: [benilla_protocol::CharEnumItem::default(); 19],
pet_display_id: 0,
pet_level: 0,
pet_family: 0,
}
}
+18 -2
View File
@@ -142,7 +142,7 @@ pub(super) fn refresh_banner_and_buttons(
let SelectAction::Row(i) = action else {
continue;
};
let lit = roster.selected == Some(*i) || *interaction != Interaction::None;
let lit = roster.selected() == Some(*i) || *interaction != Interaction::None;
for child in children {
if let Ok(mut vis) = hilights.get_mut(*child) {
*vis = if lit {
@@ -182,7 +182,23 @@ pub(super) fn refresh_banner_and_buttons(
/// race's (the ref's `SetBackgroundModel` on the enum fileString — Orc before a list arrives or
/// with an empty account, the ref's OnLoad default), the look its geared enum record. Runs every
/// frame (cheap compares inside `GluePreview` writers — the builder keys on value change).
pub(super) fn feed_glue_preview(roster: Res<Roster>, mut preview: ResMut<GluePreview>) {
pub(super) fn feed_glue_preview(
roster: Res<Roster>,
mut preview: ResMut<GluePreview>,
mut showing: Local<Option<u64>>,
) {
// **The facing belongs to the displayed character, not to the screen** (1533): turning one
// character must not hand its angle to the next one you click, and a fresh character faces the
// stage's own forward until you turn it. `GluePreview::yaw` is one resource shared by all three
// glue screens, so the reset lives at the selection edge rather than in the resource.
//
// Keyed on the selection **counter**, not on who is shown: the ref's `SelectCharacter` zeroes
// the facing unconditionally (`0x472950`, above the already-built discriminator), so clicking
// the row you are already on re-squares the character too. See `Roster::select_seq`.
if *showing != Some(roster.select_seq) {
*showing = Some(roster.select_seq);
preview.yaw = 0.0;
}
let (race, look) = match roster.selected_char() {
Some(c) => (c.race, Some(GlueLook::Select(SelectLook::from(c)))),
None => (2, None), // UI_Orc — the ref's initial/empty-account scene
+15 -8
View File
@@ -133,14 +133,21 @@ pub(crate) struct AutoRepeatArmed;
/// `shooter-stop-law.md` §J1.3). Deliberately NOT cleared on sheath change or volley end — the
/// client's bit persists latent.
///
/// **It gates nothing here, and that is the reference's own shape.** `0x400` is tested in
/// exactly one place image-wide — `0x5fc3f0`'s Hold self-loop gates (`test ah,0x6` for HoldBow
/// 109, `test ah,0x4` for 110/111/112) — and that dispatcher is never reached for a bow id
/// (`shooter-stop-law.md` §J4.1), so no `0x400` test ever executes for a shooter. Reading it as
/// a second *entry* into the drawn idle is what left a shooter aiming forever after one Serpent
/// Sting (decision 0991) and what kept a fabricated Hold twin alive (0994). It is kept modelled,
/// not deleted: §J4.1 is a proof-by-absence carrying an open `LIVE-CAPTURE` item, and this bit is
/// what a correction would need. Nothing may gate on it until that capture lands.
/// **It gates nothing here — but no longer for the reason this doc used to give.** `0x400` is
/// tested in exactly one place image-wide: `0x5fc3f0`'s Hold self-loop gates (`test ah,0x6` for
/// HoldBow 109, `test ah,0x4` for 110/111/112). 0994 recorded that the dispatcher is never
/// reached for a bow id (`shooter-stop-law.md` §J4.1) and concluded the bit is dead. **wow-re's
/// §5 refuted that absence proof** (decision 1544): the dispatcher has a second, deferred fire
/// site, so those gates DO execute — they are the Hold's own per-completion re-arm.
///
/// We still gate nothing on it, and that is now a *modelling* choice with a stated equivalence
/// rather than a claim about the binary. Our Hold is armed once and loops until a real recompute
/// (`select::ranged_hold_anim`), where the reference re-arms it at each completion while the gate
/// holds. The two diverge only where `0x400` holds *without* `0x200` — a remote shooter's latent
/// hold — and a remote shooter never enters the drawn idle at all (`0x5fd460` claims on `0x200`,
/// which only the local cast-send sets), so the case cannot arise. Reading the bit as a second
/// *entry* into the drawn idle remains wrong: that is what left a shooter aiming forever after
/// one Serpent Sting (decision 0991).
#[derive(Component)]
pub(crate) struct RangedHold;
@@ -283,21 +283,23 @@ pub(super) fn run(
);
drv.mode = Mode::Looping(sp);
}
} else if (oneshot_finished(player, anims, id, catalog)
&& !(select::is_ranged_fire(id) && auto_repeat))
|| mv.flags != drv.gait_flags
{
// The ranged fire clip is the ONE one-shot whose completion does not recompute
// the base (wow-re `shooter-stop-law.md` §J4, four converging lines): the
// completion dispatcher `0x5fc3f0` is never reached for a bow id, so AttackBow
// clamps and holds its authored tail — the quick re-draw — until a real
// recompute. Recomputing here instead re-picked the gait, which for an armed
// shooter is the Load clip, so **every shot replayed the full pull**. The
// hold-out is scoped to `auto_repeat` because that is the state the reference
// recomputes out of: the cancel `0x6ea080` ends with `RecomputeBaseAnim(-1)`,
// and dropping the arm re-opens this branch on the very next frame — the same
// observable, with no extra latch to own. A movement-flag change still
// recomputes through the second leg (opcode arms 0 and 1 both recompute).
} else if oneshot_finished(player, anims, id, catalog) || mv.flags != drv.gait_flags {
// A finished one-shot recomputes the base — the ranged FIRE clips included
// (decision 1544, superseding 0994 §1). 0994 held them out on
// `shooter-stop-law.md` §J4's claim that the completion dispatcher `0x5fc3f0`
// is never reached for a bow id; wow-re's §5 refuted that as an absence proof
// whose census could not see its second fire site — the natural-completion path
// enqueues the callback as a plain ARGUMENT (`0x7194f5` pushes mode 0) and
// `0x7074b0` invokes it later as `call [esi+4]`, so a scan for
// `call dword ptr [reg+0x70]` misses it by construction. 46/49/107 land on the
// dispatcher's slot 22: a bare `RecomputeBaseAnim(-1)`.
//
// That recompute is the whole mid-volley cycle: for an armed shooter the base
// re-picks the Load clip, whose own completion promotes to the Hold. Fire →
// re-pull → hold, once per shot — and the re-pull's `$BWP` is what puts the
// arrow back on the string, since it is the only clip that authors the tag.
// Holding it out left every shot after the first firing from an empty hand
// (bug B307).
// Finished — or a movement-flag change: the client's base re-arm lands on the
// change and blindly overwrites bone 0, one-shot or not (the same re-arm
// decision 0280 named for the un-finishable looping kit clip, and
@@ -378,17 +380,33 @@ pub(super) fn run(
let ready = (engaged && !moving).then(|| ready_anim(wielded.and_then(|w| w.main)));
// The ranged standing idle (0099 phase 5): the byte-verified entry gate
// ([`select::ranged_idle_gate`]) → the ranged weapon's Load clip, played
// ONCE and frozen at full draw. ENTRY is the local `0x200` ([`auto_repeat`])
// alone — `0x5fd460`'s own and only claim test besides the sheath. There is
// no HOLD twin and no promotion: the completion dispatcher that would play
// 109 is never reached for a bow id (wow-re `shooter-stop-law.md` §J4.1),
// and mid-volley the base is not re-picked at all — the fire clip clamps on
// its own authored tail (the `is_ranged_fire` hold-out in `Mode::Swing`).
// The pull replays only on a real recompute: movement start/stop, a sheath
// change, the auto-repeat cancel.
let ranged_load = (!moving
&& select::ranged_idle_gate(auto_repeat, drv.sheath_cur))
.then(|| select::ranged_load_anim(wielded.and_then(|w| w.ranged)));
// ONCE, then promoted to the Hold by its own completion. ENTRY is the local
// `0x200` ([`auto_repeat`]) alone — `0x5fd460`'s own and only claim test
// besides the sheath. The HOLD twin is real and this is where it comes back
// (decision 1544, superseding 0994 §2, which deleted it on a refuted absence
// proof): 105 → 109, 106 → 110, 112 → 111, unconditionally at the Load's
// completion. Mid-volley the base IS re-picked — every fire clip's completion
// recomputes — so the cycle is fire → re-pull → hold, per shot.
let ranged_load =
(!moving && select::ranged_idle_gate(auto_repeat, drv.sheath_cur)).then(|| {
let load = select::ranged_load_anim(wielded.and_then(|w| w.ranged));
let hold = select::ranged_hold_anim(load);
// The pull's own completion promotes it to the Hold — the dispatcher's
// slot 11/12/15 arm, UNCONDITIONAL ([`select::ranged_hold_anim`] carries
// which gate belongs to whom). Expressed as a CANDIDATE rather than a
// latch: once armed, the Hold re-selects itself every frame, and the
// volley's end drops `ranged_idle_gate` and recomputes straight out of it
// — so there is no second piece of state to keep in step, which is what
// 0409's re-latch was and why 0994 was right to delete that much.
let holding = drv.gait == Some(hold)
|| (drv.gait == Some(load)
&& oneshot_finished(player, anims, load, catalog));
if holding {
hold
} else {
load
}
});
let cands = gait_candidates(&mv, walk, ready, ranged_load);
// The stationary cast/channel hold pins its pose **full-body in the gait slot**
// (decision 0107 — the client's `[CGUnit+0xb4]` stationary-cast gate),
@@ -2871,28 +2871,82 @@ fn the_weapon_visual_hold_alone_never_puts_a_shooter_in_the_drawn_idle() {
}
/// The mid-volley half of the same report ("when it's on and I'm running it keeps repeating the
/// aim animation weirdly") — and the deeper cause of the aiming look the screenshot caught.
/// aim animation weirdly") — **re-derived, and inverted, by decision 1544.**
///
/// wow-re `shooter-stop-law.md` §J4: the anim-completion dispatcher `0x5fc3f0` is **never reached
/// for a bow id** (its sole fire site pushes a literal completion-mode `1`), so a finished
/// AttackBow triggers no `RecomputeBaseAnim(-1)`. It clamps on its own authored tail — the quick
/// re-draw — and stays there. Our machine instead recomputed on every one-shot completion, and
/// for an armed shooter the recompute re-picks the Load clip: **a full re-pull on every shot**,
/// with the arrow re-attached each time by 0409's re-latch. Four independent lines settle it
/// (the byte mechanism, vmangos sending `SMSG_SPELL_START` once per volley so shots 2..N carry no
/// PrecastKit, the asset census showing `$BWP` only in the Load clips, and the director's screen).
/// 0994 read `shooter-stop-law.md` §J4 as: the completion dispatcher `0x5fc3f0` is never reached
/// for a bow id, so a finished AttackBow recomputes nothing and clamps on its tail. wow-re's §5
/// refuted that absence proof — the dispatcher has a SECOND, deferred fire site (`0x719370`
/// enqueues the callback as a plain argument with mode 0; `0x7074b0` invokes it later as
/// `call [esi+4]`, which an instruction-encoding census cannot see) — and decoded its jump table:
/// 46/49/107 land on slot 22, a bare `RecomputeBaseAnim(-1)`, and a finished Load lands on slot
/// 11/12/15, which arms the Hold **unconditionally**.
///
/// The cancel is what recomputes out of it (`0x6ea080` ends with `RecomputeBaseAnim(-1)`), which
/// here is the arm simply dropping.
/// So the mid-volley cycle is **fire → re-pull → hold**, once per shot. That re-pull is the
/// "reload" of bug B307: `$BWP` lives only in the Load clips (verified on five shipped character
/// models), so it is the only thing that can put the arrow back on the string — and holding it
/// out left every shot after the first firing from an empty hand.
///
/// What 0994 got right and this keeps: the director's original complaint was about a *moving*
/// shooter, and locomotion still outranks the drawn idle, so nothing here re-pulls mid-run.
#[test]
fn a_mid_volley_fire_clip_does_not_replay_the_pull_and_the_cancel_recomputes() {
fn a_mid_volley_fire_clip_re_pulls_and_the_pull_promotes_to_the_hold() {
use bevy::animation::graph::{AnimationGraph, AnimationGraphHandle};
use bevy::animation::AnimationClip;
// Stand-in spans; only the ORDER of completions is load-bearing, not the numbers.
const PULL: f32 = 0.7;
const FIRE: f32 = 0.5;
let mut app = app();
let asset = |app: &mut App, secs: f32| {
let mut c = AnimationClip::default();
c.set_duration(secs);
app.world_mut()
.resource_mut::<Assets<AnimationClip>>()
.add(c)
};
let (stand, pull, fire, hold) = (
asset(&mut app, 1.0),
asset(&mut app, PULL),
asset(&mut app, FIRE),
asset(&mut app, 1.0),
);
let (graph, nodes) = AnimationGraph::from_clips([stand, pull, fire, hold]);
let graph_handle = app
.world_mut()
.resource_mut::<Assets<AnimationGraph>>()
.add(graph);
// A real shooter's model authors all four (HumanMale: 0, 105, 46, 109 — `benilla-extract
// … m2seq`), and 109 is the only one of them authored as a LOOP.
let mut stand_clip = clip(0, 0, true);
stand_clip.node = nodes[0];
let mut pull_clip = clip(105, 0, false);
pull_clip.node = nodes[1];
pull_clip.duration = PULL;
let mut fire_clip = clip(46, 0, false);
fire_clip.node = nodes[2];
fire_clip.duration = FIRE;
let mut hold_clip = clip(109, 0, true);
hold_clip.node = nodes[3];
hold_clip.duration = 1.0;
let unit = app
.world_mut()
.spawn((
archer_model(),
ModelAnimations {
graph: graph_handle.clone(),
clips: vec![stand_clip, pull_clip, fire_clip, hold_clip],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
pose: Default::default(),
},
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimationGraphHandle(graph_handle),
AnimDriver::default(),
crate::net::SelfPlayer,
Wielded {
@@ -2911,18 +2965,27 @@ fn a_mid_volley_fire_clip_does_not_replay_the_pull_and_the_cancel_recomputes() {
app.update();
let gait = |app: &App| app.world().entity(unit).get::<AnimDriver>().unwrap().gait;
let mode = |app: &App| app.world().entity(unit).get::<AnimDriver>().unwrap().mode;
assert_eq!(gait(&app), Some(105), "the volley opens with the pull");
// Shot 1 fires. AttackBow(46) takes the body as a one-shot; this model authors no 46, so it
// reads as finished the moment the machine looks at it — the state right after any fire clip.
{
let mut e = app.world_mut().entity_mut(unit);
let mut d = e.get_mut::<AnimDriver>().unwrap();
d.mode = super::super::select::Mode::Swing {
id: 46,
under: None,
};
}
// …which promotes to the HOLD on its own completion — slot 11, unconditional. The extra
// frame is the schedule, not a fudge: the driver runs in `Update` and Bevy advances the
// clips in `PostUpdate`, so a completion is visible to the machine on the frame AFTER the
// one that finished it.
advance(&mut app, 1000);
app.update();
assert_eq!(
gait(&app),
Some(109),
"a finished LoadBow yields HoldBow — the drawn pose the shooter sits in between shots"
);
// Shot 1: the fire clip takes the body as a one-shot, through the real message lane.
app.world_mut().write_message(EmoteAnim {
entity: unit,
anim_id: 46,
seq: 1,
});
app.update();
assert_eq!(
mode(&app),
@@ -2930,14 +2993,33 @@ fn a_mid_volley_fire_clip_does_not_replay_the_pull_and_the_cancel_recomputes() {
id: 46,
under: None,
},
"the fire clip's completion must NOT recompute the base — the reference has no completion \
dispatch for a bow id, so it clamps on its tail"
"AttackBow takes bone 0"
);
// Its completion recomputes — and for an armed shooter the base re-picks the pull. THIS is
// the reload the report was missing.
advance(&mut app, 1000);
app.update();
assert_eq!(
mode(&app),
super::super::select::Mode::Gait,
"the fire clip's completion recomputes the base (slot 22's bare RecomputeBaseAnim(-1))"
);
// The recompute clears the gait and re-picks it on the following frame (`drv.gait = None`).
app.update();
assert_eq!(
gait(&app),
Some(105),
"…and nothing re-armed the gait: no second pull, and never the HoldBow twin (109), which \
the reference never plays at all"
"…and the shooter RE-PULLS: the per-shot reload, whose $BWP re-nocks the arrow"
);
// …and settles back into the hold, closing the cycle.
advance(&mut app, 1000);
app.update();
assert_eq!(
gait(&app),
Some(109),
"fire → re-pull → hold, once per shot"
);
// The volley ends — the cancel's `RecomputeBaseAnim(-1)`.
@@ -2949,6 +3031,183 @@ fn a_mid_volley_fire_clip_does_not_replay_the_pull_and_the_cancel_recomputes() {
assert_eq!(
gait(&app),
Some(0),
"dropping the arm recomputes out of the fire clip and the shooter stands up"
"dropping the arm recomputes out of the hold and the shooter stands up"
);
}
/// **B307's driver half** — the link after the router: does an `EmoteAnim { anim_id: 46 }`
/// arriving on a self-player who is [`crate::creature_anim::AutoRepeatArmed`], drawn
/// (`sheath_cur == 2`) and standing in the pull (gait 105) actually ARM clip 46 on the body,
/// **shot after shot**?
///
/// Two guards sit on that path and each could silently eat shots 2 and 3 of a volley — leaving
/// exactly the reported symptom, a shooter that fires from a still pose:
///
/// 1. the **combat fast-path** (`0x5fe43c`): a combat clip requested while another combat clip
/// plays is not armed at all — the live clip doubles rate and the request parks. AttackBow is
/// NOT in the client's combat set (`0x5fcc10`: `10 | 16..=24 | 30 | 36 | 57..=59 | 85..=88 |
/// 95 | 117 | 118`), so it must never take this road;
/// 2. the **arm-level same-id dedup** (`0x5fdba0`): a requested id already occupying its slot
/// *and still playing* is not re-armed. Written when 0994's law held the base out of the
/// recompute, so shot 2 would find `Mode::Swing { id: 46 }` still set; decision 1544 restored
/// the recompute, so the mode is back in `Gait` by then. The dedup is checked here either way
/// — it is the guard that would swallow a shot if a fire clip ever were still live.
///
/// Driven through the real `EmoteAnim` lane (no poking `drv.mode`), with real clip assets so
/// Bevy completes them, and 3 s of clock between shots — a bow's cadence.
#[test]
fn every_shot_of_a_volley_re_arms_the_fire_clip_through_the_emote_lane() {
use bevy::animation::graph::{AnimationGraph, AnimationGraphHandle};
use bevy::animation::AnimationClip;
/// Stand-in spans for LoadBow and AttackBow. The exact numbers are not load-bearing and
/// are not claimed to be the real M2's; what matters is only that a fire clip is far
/// shorter than the ~3 s a bow puts between Auto Shots, so the dedup's "still playing"
/// test must read false by the next shot.
const PULL: f32 = 0.7;
const FIRE: f32 = 0.5;
let mut app = app();
let asset = |app: &mut App, secs: f32| {
let mut c = AnimationClip::default();
c.set_duration(secs);
app.world_mut()
.resource_mut::<Assets<AnimationClip>>()
.add(c)
};
let (stand, pull, fire) = (
asset(&mut app, 1.0),
asset(&mut app, PULL),
asset(&mut app, FIRE),
);
let (graph, nodes) = AnimationGraph::from_clips([stand, pull, fire]);
let graph_handle = app
.world_mut()
.resource_mut::<Assets<AnimationGraph>>()
.add(graph);
// The shooter's model. `archer_model` deliberately authors no 46; a real HumanMale.m2 does
// (sequences 46/49/105/106, `benilla-extract … m2seq`), and 46 has to exist for "was it
// armed?" to be an observable question at all.
let mut stand_clip = clip(0, 0, true);
stand_clip.node = nodes[0];
let mut pull_clip = clip(105, 0, false);
pull_clip.node = nodes[1];
pull_clip.duration = PULL;
let mut fire_clip = clip(46, 0, false);
fire_clip.node = nodes[2];
fire_clip.duration = FIRE;
let unit = app
.world_mut()
.spawn((
ModelAnimations {
graph: graph_handle.clone(),
clips: vec![stand_clip, pull_clip, fire_clip],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
pose: Default::default(),
},
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimationGraphHandle(graph_handle),
AnimDriver::default(),
crate::net::SelfPlayer,
Wielded {
ranged: Some((2, 0x2)), // bow
..Default::default()
},
crate::creature_anim::AutoRepeatArmed,
))
.id();
// The volley opens: the stance snaps drawn (`SMSG_SPELL_START`'s ranged snap) and the
// shooter pulls.
app.world_mut().write_message(SheathRequest {
entity: unit,
state: 2,
ceremony: false,
});
app.update();
app.update();
let gait = |app: &App| app.world().entity(unit).get::<AnimDriver>().unwrap().gait;
let mode = |app: &App| app.world().entity(unit).get::<AnimDriver>().unwrap().mode;
let deferred = |app: &App| {
app.world()
.entity(unit)
.get::<AnimDriver>()
.unwrap()
.deferred
};
let fire_running = |app: &App| {
app.world()
.entity(unit)
.get::<AnimationPlayer>()
.unwrap()
.animation(nodes[2])
.map(|a| !a.is_finished())
};
assert_eq!(gait(&app), Some(105), "the volley opens with the pull");
const FIRING: super::super::select::Mode = super::super::select::Mode::Swing {
id: 46,
under: None,
};
for shot in 1..=3u64 {
// `SMSG_SPELL_GO` → the router's cast kit → this message. Nothing else changes.
app.world_mut().write_message(EmoteAnim {
entity: unit,
anim_id: 46,
seq: shot,
});
app.update();
assert_eq!(mode(&app), FIRING, "shot {shot} takes bone 0");
assert_eq!(
fire_running(&app),
Some(true),
"shot {shot}'s release clip is armed and RUNNING — neither the combat fast-path nor \
the same-id dedup swallowed it"
);
assert_eq!(
deferred(&app),
None,
"shot {shot} was a normal arm, not a fast-path park"
);
// The ~3 s to the next shot. The clip plays out and CLAMPS on its authored tail: no
// recompute for a bow id under auto-repeat, so no second pull is armed between shots.
advance(&mut app, 3000);
assert_eq!(
fire_running(&app),
Some(false),
"shot {shot}'s clip finished long before the next — which is exactly what keeps the \
same-id dedup from eating shot {}",
shot + 1
);
assert_eq!(
mode(&app),
FIRING,
"…and the base never recomputed out of it"
);
assert_eq!(
gait(&app),
None,
"…so the pull was not replayed between shots"
);
}
// The volley ends — the cancel's `RecomputeBaseAnim(-1)`: the shooter stands up.
app.world_mut()
.entity_mut(unit)
.remove::<crate::creature_anim::AutoRepeatArmed>();
app.update();
app.update();
assert_eq!(
gait(&app),
Some(0),
"dropping the arm stands the shooter up"
);
}
+54 -17
View File
@@ -185,6 +185,24 @@ pub(crate) mod move_flags {
/// Any horizontal-movement direction bit (forward/back/strafe) — the client's `[9e8] & 0xf` gate.
pub const ANY_MOVE: u32 = FORWARD | BACKWARD | STRAFE_LEFT | STRAFE_RIGHT;
/// **Whether this mover is integrated at all** — the client's `0x20ff`, and the gate a mover
/// must pass before any physics runs on it. `CMovement::Update 0x616de0` opens its substep loop
/// with `0x616e20 test dword [esi+0x40],0x20ff; je 0x616f49` (none set ⇒ finalize, having
/// integrated nothing), and the movement manager tests the same mask before it ever calls the
/// integrator (`0x6166f5`) — the "idle gate" decision 0059 named by address. wow-re states the
/// consequence for a watched player outright: *"for a **STANDING remote** the integrator does
/// NOT run … a flag-less unit is not even in the mover list"*
/// (`collision/scratch/remote-air-facing.md` §A4; the loop gate is in `collision/collision.md`
/// and `scratch/spec-driver-A.md`/`-B.md`).
///
/// So a mover with none of these bits keeps the pose its last packet wrote, verbatim, until a
/// packet or a flag moves it — which is what decision 1545 makes benilla do. The bits: the four
/// direction bits, the two keyboard turn bits, and the two pitch bits benilla does not model
/// (together `0xff`), plus [`FALLING`] (`0x2000`). Note what is **absent**: [`SWIMMING`]
/// (`0x200000`) and every mode bit — a floating swimmer holding no direction is not integrated
/// either.
pub const INTEGRATED: u32 = 0xff | FALLING;
/// The **committed-lower-body** move mask that routes a one-shot to the masked overlay (decision
/// 0087): the client's `[9e8] & 0x20003f` test at `0x5fe6dc` — every direction bit (`0x3f`,
/// which folds in the keyboard turn keys) plus swim (`0x200000`). Any of these set ⇒ the legs are
@@ -519,13 +537,18 @@ pub(super) fn gait_candidates(
// (they can't co-occur — auto-shot never sets the engaged GUID) and before the chair loops
// (OUR ordering; the client's call-site order vs the chair resolver isn't pinned).
if let Some(l) = ranged_load {
// 109/110 (the Hold twins) are deliberately absent: `0x5fd460`'s jump table writes only
// 105/106/111/112, and the completion dispatcher that would promote a Load to its Hold is
// never reached for a bow id (wow-re `shooter-stop-law.md` §J4.1) — the reference plays
// no HoldBow, ever.
// The Hold twins 109/110 ARE reachable (decision 1544): `0x5fd460`'s own jump table writes
// only 105/106/111/112, but a finished Load is promoted to its Hold by the completion
// dispatch (`0x5fc3f0` slot 11/12/15), and the caller passes whichever of the pair
// currently owns the pose. 0994 left them out on §J4.1's absence proof — that the
// dispatcher is never reached for a bow id — which wow-re's §5 has since refuted.
// Each Hold falls back to its own Load first: a model that authors the pull but not the
// hold should freeze at full draw, not drop to ReadyUnarmed.
return match l {
105 => &[105, 25, 0],
106 => &[106, 25, 0],
109 => &[109, 105, 25, 0],
110 => &[110, 106, 25, 0],
111 => &[111, 25, 0],
112 => &[112, 25, 0],
_ => &[25, 0],
@@ -551,9 +574,14 @@ pub(super) fn gait_candidates(
/// (byte-verified, wow-re `ranged-shot-anim.md`): the RANGED-slot item's subclass picks a held
/// Load/Hold clip — Bow → LoadBow 105 · Gun/Crossbow → LoadRifle 106 · Thrown → LoadThrown 112 ·
/// Wand → HoldThrown 111 · anything else → ReadyUnarmed 25. **Not** ReadyBow/AttackBow: no code
/// in the client plays those rows, and the per-shot caster fire animation is a verified NEGATIVE
/// (an exhaustive `PlayAnimation` caller sweep) — the shot itself shows only the missile (and the
/// bow-prop string flex, a deferred polish).
/// in the client plays those rows.
///
/// (This doc used to carry `ranged-shot-anim.md` §(a)'s "the per-shot caster fire animation is a
/// verified NEGATIVE — the shot shows only the missile". That was REFUTED twice over: by the
/// weapon-visual merge, which plays the fire clip off the weapon's own substitute visual
/// (decision 0370/0986), and by the completion dispatch below (decision 1544). It is gone rather
/// than hedged — a stale negative in a doc is how a session concludes the missing animation is
/// correct, which is exactly what happened to bug B307.)
pub(super) fn ranged_load_anim(ranged: Option<(u8, u8)>) -> u16 {
match ranged {
Some((2, 2)) => 105, // Bow → LoadBow
@@ -571,17 +599,26 @@ pub(super) fn is_ranged_load(id: u16) -> bool {
matches!(id, 105 | 106 | 112)
}
/// Whether `id` is a ranged **fire** clip — AttackBow(46) · AttackRifle(49) · AttackThrown(107).
/// The **Hold** clip a finished ranged Load promotes to — the completion dispatch `0x5fc3f0`'s
/// slot 11/12/15 arms: LoadBow 105 → HoldBow 109 · LoadRifle 106 → HoldRifle 110 · LoadThrown 112
/// → HoldThrown 111. The wand's HoldThrown 111 is already the hold and re-arms itself, and
/// anything else (ReadyUnarmed 25) holds nothing and stays put.
///
/// These are the one-shots whose completion must **not** recompute the base (wow-re
/// `shooter-stop-law.md` §J4): the anim-completion dispatcher `0x5fc3f0` is never reached for a
/// bow id — its sole fire site `0x7122c1` pushes a literal completion-mode `1`, routing every bow
/// id to a bare epilogue — so `AttackBow`'s finish triggers no `RecomputeBaseAnim(-1)` and the
/// clip clamps on its last frame. Between shots the reference therefore stands in the fire clip's
/// **authored tail** (the quick re-draw), not in a re-pulled Load and not in a hold twin. Letting
/// the finish recompute is what replayed the full pull on every shot.
pub(super) fn is_ranged_fire(id: u16) -> bool {
matches!(id, 46 | 49 | 107)
/// **The promotion is UNCONDITIONAL** (wow-re §5, decision 1544): the `[+0xd24]` ranged-prop and
/// `[+0xd58] & 0x600` test at `0x5fc5bc` belongs to slot **13** — the Hold's own re-arm — not to
/// the Load's slot 11, which is the bare `mov eax,0x6d ; push eax ; call 0x5fe2f0` at `0x5fc5e9`.
/// Reading that gate onto the Load is the mistake that would leave a shooter frozen at full draw.
///
/// The Hold is the only clip in the ranged cycle authored as a **loop** (asset flag, verified on
/// five shipped character models); the fire and Load clips are clamp. That is why it can sit
/// between shots at all, and why nothing needs to re-arm it per frame.
pub(super) fn ranged_hold_anim(load: u16) -> u16 {
match load {
105 => 109, // LoadBow → HoldBow
106 => 110, // LoadRifle → HoldRifle
112 | 111 => 111, // LoadThrown → HoldThrown; the wand's hold re-arms itself
other => other,
}
}
/// Does the drawn ranged Load idle own this unit's standing pose? Byte-verified, wow-re
@@ -485,22 +485,24 @@ fn the_ranged_idle_is_entered_by_the_auto_repeat_bit_and_the_ranged_sheath_alone
assert!(!ranged_idle_gate(false, Some(2)));
}
/// The ranged **fire** clips ([`is_ranged_fire`]) — the one-shots whose completion must not
/// recompute the base (wow-re `shooter-stop-law.md` §J4: `0x5fc3f0` is never reached for a bow
/// id, so AttackBow clamps on its authored tail). Recomputing re-picks the gait, which for an
/// armed shooter is the Load clip — that is a full re-pull on every single shot.
/// The Load → Hold promotion ([`ranged_hold_anim`]) — the completion dispatch's slot 11/12/15
/// arms (decision 1544). Each ranged Load yields its weapon family's Hold, which is the one clip
/// in the cycle authored as a LOOP and therefore the one that can sit between shots.
///
/// This replaces 0994's `is_ranged_fire` law test, which asserted the opposite mechanism: that a
/// fire clip's completion must NOT recompute the base. wow-re's §5 refuted the absence proof it
/// rested on — the completion dispatcher has a second, deferred fire site — so the fire clips
/// recompute like every other one-shot and the promotion below is what holds the pose instead.
#[test]
fn the_ranged_fire_clips_are_the_three_attack_ids_and_not_the_loads() {
assert!(is_ranged_fire(46)); // AttackBow
assert!(is_ranged_fire(49)); // AttackRifle
assert!(is_ranged_fire(107)); // AttackThrown
// The Load clips are the base idle, not one-shots — they must never take the hold-out.
assert!(!is_ranged_fire(105));
assert!(!is_ranged_fire(106));
assert!(!is_ranged_fire(112));
// Nor any melee swing: those return to the gait the moment they finish, as they always have.
assert!(!is_ranged_fire(0));
assert!(!is_ranged_fire(15)); // AttackUnarmed
fn each_ranged_load_promotes_to_its_weapon_familys_hold() {
assert_eq!(ranged_hold_anim(105), 109); // LoadBow → HoldBow
assert_eq!(ranged_hold_anim(106), 110); // LoadRifle → HoldRifle
assert_eq!(ranged_hold_anim(112), 111); // LoadThrown → HoldThrown
assert_eq!(ranged_hold_anim(111), 111); // the wand's idle IS the hold: it re-arms itself
assert_eq!(ranged_hold_anim(25), 25); // no ranged weapon holds nothing: ReadyUnarmed stays
// The promotion is keyed on the LOAD clip, so a fire id is not a valid input and must not
// silently map onto a hold — it recomputes the base instead, and the gait re-picks the Load.
assert_eq!(ranged_hold_anim(46), 46);
}
#[test]
@@ -8,6 +8,8 @@
#[cfg(test)]
mod tests;
use std::cell::Cell;
use bevy::ecs::entity::{EntityHashMap, EntityHashSet};
use bevy::prelude::*;
@@ -349,6 +351,67 @@ fn resolve_kit(
.flatten()
}
/// [`resolve_kit`] plus this lane's **one trace line** (`WOW_MOVE_TRACE`, tag `fx`) — the
/// instrument the cast-edge router went without until bug B307, where "does the shooter's body
/// kit resolve at all?" cost a day and three agents to answer and this line answers in one live
/// run.
///
/// It earns its place because of *how* this chain fails. A basic ranged shot — Auto Shot 75, wand
/// Shoot 5019, Throw 2764 — carries `SpellVisual = 0`, so its **entire** body animation comes from
/// the equipped ranged weapon's `ItemDisplayInfo` col-10 substitute visual ([`resolve_stages`]'s
/// merge). Every link in that chain degrades **silently** to "no kit": an empty ranged slot, an
/// item template the ask-once layer has not answered yet, a display id naming no visual. The
/// observable of each is identical, and identical to a shooter that simply never animates — so
/// the symptom names nothing and the code must. The line separates them:
/// `weapon=not-consulted` (a non-ranged spell — the fallback is not supposed to run),
/// `weapon=none` (it ran and found nothing — the shot IS silent, the B307 shape), or the
/// resolved visual id, followed by the kit and the anim id actually requested of the body. Pair
/// it with the driver's `fct: anim play unit=… id=…` to see whether that request reached bone 0.
///
/// Free when the trace is off: the caller's lookup still runs exactly once (the [`Cell`] only
/// records what it returned), and the diagnosis re-derives only inside the `enabled()` guard —
/// re-using the recorded value rather than re-running the lookup.
fn resolve_kit_traced(
stage_name: &str,
entity: Entity,
spells: &crate::ui_action::Spells,
visuals: &SpellVisualCatalog,
spell_id: u32,
stage: impl Fn(&VisualStages) -> u32,
weapon_visual: impl FnOnce() -> Option<u32>,
) -> Option<VisualKit> {
// `None` = the closure never ran (`resolve_stages` short-circuits before the fallback for a
// non-ranged spell); `Some(v)` = it ran and returned `v`. The two are different diagnoses.
let consulted: Cell<Option<Option<u32>>> = Cell::new(None);
let kit = resolve_kit(spells, visuals, spell_id, &stage, || {
let visual = weapon_visual();
consulted.set(Some(visual));
visual
});
if benilla_assets::trace::enabled() {
let def = spells.catalog.get(spell_id);
let weapon = match consulted.get() {
None => "not-consulted".to_string(),
Some(None) => "none".to_string(),
Some(Some(visual)) => visual.to_string(),
};
// Re-derived from the RECORDED weapon visual — never a second lookup.
let kit_id = resolve_stages(spells, visuals, spell_id, || consulted.get().flatten())
.map_or(0, |s| stage(&s));
benilla_assets::trace::line(
"fx",
&format!(
"kit {stage_name} unit={entity} spell={spell_id} own_visual={} ranged_slot={} \
weapon={weapon} kit={kit_id} anim={:?}",
def.map_or(0, |d| d.visual),
def.is_some_and(|d| d.ranged_slot()),
kit.and_then(|k| k.anim_id),
),
);
}
kit
}
/// The in-flight cast's **strike sound** for the `$TRD` anim event (`0x62faa0`): the handler
/// resolves the spell's visual to its 16-dword `SpellVisual.dbc` row (`0x60d450` →
/// `DAT_00c0d738[visualId]`) and plays that row's **dword 14** (`[row+0x38]`) positioned at the
@@ -654,7 +717,9 @@ pub(super) fn route_cast_visuals(
ceremony: false,
});
}
if let Some(kit) = resolve_kit(
if let Some(kit) = resolve_kit_traced(
"precast",
ev.entity,
spells,
&visuals.0,
ev.spell_id,
@@ -751,7 +816,9 @@ pub(super) fn route_cast_visuals(
// after their model's own clip span (the client's stage-0/1 completion callback).
// For a basic shot this is the fire clip itself (the ranged fallback: Throw's
// AttackThrown, Auto Shot's AttackBow — wow-re `throw-ranged-attack-anim.md`).
if let Some(kit) = resolve_kit(
if let Some(kit) = resolve_kit_traced(
"cast",
ev.entity,
spells,
&visuals.0,
ev.spell_id,
@@ -1094,3 +1094,298 @@ fn a_kit_with_a_chain_char_proc_asks_for_a_beam_from_both_dispatcher_sites() {
"the channel kit's type-0 proc asks for a persistent beam"
);
}
// ── The real-chain shooter pin (bug B307) ────────────────────────────────────────────────────
//
// Reported 2026-08-22: "characters don't use reload anim when using autoshot with any ranged
// weapons (bows/crossbows/guns)" — the shooter fires from a still pose. Every link above this
// point is tested on SYNTHETIC tables, and every router test spawns a bare unit with no
// `NetEntity`/`ObjectStore` — so [`super::WeaponVisualSrc::caster`], the one lookup that turns a
// PLAYER's equipped ranged weapon into the substitute visual the whole merge hangs on, returns
// `None` in all of them and nothing notices (their ranged spell carries its own cast kit).
// These tests close that hole: the real 5875 DBCs, a real self-player, a real `item_template`
// row, one `CastEvent` in, the body's clip out.
/// Auto Shot — the one spell every ranged auto-attack fires (one `SMSG_SPELL_GO` per shot).
/// Its real 5875 row: `Attributes = 0x50012` (so `& 0x2`, USES_RANGED_SLOT, IS set),
/// `AttributesEx2 = 0x20` (auto-repeat), Speed 40 — and **`SpellVisual1 = 0`**. It authors no
/// visual whatsoever, so every clip it plays comes from the equipped weapon's substitute visual
/// through [`super::resolve_stages`]'s merge. If that lookup fails, the shooter is silent.
const AUTO_SHOT: u32 = 75;
/// One real `item_template` row — what the wire hands the client for an equipped ranged weapon.
/// Entry/display/class/subclass read from the live vmangos `mangos.item_template`; the
/// display → `ItemDisplayInfo` col 10 → `SpellVisual` → kit → `AnimationData` tail is the real
/// 5875 DBCs' and is re-derived by the test, never assumed.
struct RealRanged {
name: &'static str,
entry: u32,
display_id: u32,
/// `ItemClass` 2 (weapon) and its subclass: 2 bow, 3 gun, 18 crossbow.
class: u32,
subclass: u32,
/// The pull, from the weapon visual's PRECAST kit: LoadBow 105 / LoadRifle 106.
load_anim: u16,
/// The release, from its CAST kit: AttackBow 46 / AttackRifle 49.
fire_anim: u16,
}
/// A bow (`item_template` 2504, display 8106 → visual 5 → kits 7/164 → 105/46).
const WORN_SHORTBOW: RealRanged = RealRanged {
name: "Worn Shortbow",
entry: 2504,
display_id: 8106,
class: 2,
subclass: 2,
load_anim: 105,
fire_anim: 46,
};
/// A gun (`item_template` 2508, display 6606 → visual 224 → kits 161/167 → 106/49).
const OLD_BLUNDERBUSS: RealRanged = RealRanged {
name: "Old Blunderbuss",
entry: 2508,
display_id: 6606,
class: 2,
subclass: 3,
load_anim: 106,
fire_anim: 49,
};
/// A crossbow (`item_template` 12651, display 22929 → visual 743 → kits 803/804 → 106/49 —
/// crossbows share the rifle clips, they do not have their own).
const BLACKCROW: RealRanged = RealRanged {
name: "Blackcrow",
entry: 12651,
display_id: 22929,
class: 2,
subclass: 18,
load_anim: 106,
fire_anim: 49,
};
/// `PLAYER_VISIBLE_ITEM_18_0` — the public item ENTRY worn in equipment slot 17 (vmangos
/// `EQUIPMENT_SLOT_RANGED`), i.e. `PLAYER_VISIBLE_ITEM_1_CREATOR (258) + 2 + 12 × 17`. Spelled
/// out because that base index is private to `benilla-protocol`; `player_visible_item_entry(17)`
/// is the accessor it feeds, and this test would fail loudly if the two ever disagreed.
const VISIBLE_RANGED_ENTRY_FIELD: u16 = 258 + 2 + 12 * 17;
/// Keeps the item layer's ask-once channel ALIVE for the app's life, so an unexpected
/// `ItemQuery` send (the "template not landed" path — the failure mode that would silently
/// starve the weapon lookup) is observable rather than swallowed by a dropped receiver.
#[derive(Resource)]
struct AskLog(crossbeam_channel::Receiver<crate::net::ClientCommand>);
/// The cast router wired over the **real 5875 tables**, with a self-player wearing `weapon` in
/// the ranged slot and its template already landed in the item cache — which is the live state
/// by the time anything shoots (the equipped weapon rendered through the same ask-once layer
/// long before). `None`, with the skip note printed, when there is no WoW install to read.
fn real_shooter(weapon: &RealRanged) -> Option<(App, Entity)> {
let data = benilla_formats::wow_data_or_skip!(None);
let mut chain = benilla_formats::open_chain(&data).expect("open the install's MPQ chain");
let visuals =
benilla_formats::load_spell_visual_catalog(&mut chain).expect("SpellVisual/SpellVisualKit");
let spells = benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc");
let displays =
benilla_formats::load_item_display_catalog(&mut chain).expect("ItemDisplayInfo.dbc");
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_message::<CastEvent>()
.add_message::<SpellGoTargets>()
.add_message::<KitPush>()
.add_message::<EmoteAnim>()
.add_message::<WoundAnim>()
.add_message::<SpellKitSound>()
.add_message::<SpellKitFx>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
.add_message::<super::ChainProcPlay>()
.add_message::<SheathRequest>();
app.insert_resource(SpellVisuals(visuals));
app.insert_resource(crate::ui_action::Spells {
catalog: spells,
..crate::ui_action::Spells::empty_for_tests()
});
app.insert_resource(crate::entities::ItemDisplays::icons_for_tests(displays));
let mut items = crate::items::Items::default();
let mut template = crate::items::test_template(weapon.name);
template.class = weapon.class;
template.subclass = weapon.subclass;
template.display_info_id = weapon.display_id;
template.inventory_type = 15; // INVTYPE_RANGED — the real row's; unread by this chain
items.insert_template(weapon.entry, Some(template));
app.insert_resource(items);
let (tx, rx) = crossbeam_channel::unbounded();
app.insert_resource(crate::net::NetCommands(tx));
app.insert_resource(AskLog(rx));
// The shooter: our own body, a PLAYER on the wire, with the weapon's ENTRY in the public
// visible-item field — exactly what `SMSG_UPDATE_OBJECT` carries for equipment.
let unit = app
.world_mut()
.spawn((
crate::net::SelfPlayer,
crate::net::NetEntity {
kind: benilla_protocol::EntityKind::Player,
display_id: Some(49), // HumanMale — authors 46/49/105/106 internally
scale: 1.0,
},
crate::net::ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(
VISIBLE_RANGED_ENTRY_FIELD,
weapon.entry,
)])),
))
.id();
app.add_systems(Update, route_cast_visuals);
Some((app, unit))
}
/// The one-shot clips this frame asked of `unit`'s body.
fn emote_anims(app: &mut App, unit: Entity) -> Vec<u16> {
app.world_mut()
.resource_mut::<Messages<EmoteAnim>>()
.drain()
.filter(|e| e.entity == unit)
.map(|e| e.anim_id)
.collect()
}
/// The entries the item layer had to ask the server for — empty is the expected reading (the
/// template is pre-landed); anything here means the weapon lookup starved on a cold cache.
fn asked_entries(app: &App) -> Vec<u32> {
app.world()
.resource::<AskLog>()
.0
.try_iter()
.filter_map(|c| match c {
crate::net::ClientCommand::ItemQuery { entry, .. } => Some(entry),
_ => None,
})
.collect()
}
/// **B307's pin, bow half.** One `SMSG_SPELL_GO` for Auto Shot on a self-player with a real bow
/// equipped must reach the body as `EmoteAnim { anim_id: 46 }` (AttackBow) — through the live
/// chain end to end: `Spell.dbc` 75 (`Attributes & 0x2`, no visual of its own) →
/// [`super::WeaponVisualSrc::caster`] (`PLAYER_VISIBLE_ITEM` slot 17 → the item template →
/// display 8106) → `ItemDisplayInfo` col 10 (visual 5) → the merge → `SpellVisual` 5's cast kit
/// 164 → its anim 46.
#[test]
fn a_real_bow_shooters_auto_shot_go_plays_attackbow() {
let Some((mut app, unit)) = real_shooter(&WORN_SHORTBOW) else {
return;
};
app.world_mut()
.write_message(cast_event(unit, AUTO_SHOT, CastEventKind::Go));
app.update();
assert_eq!(
emote_anims(&mut app, unit),
vec![WORN_SHORTBOW.fire_anim],
"Auto Shot's GO plays the bow's release clip (asked the server for {:?})",
asked_entries(&app)
);
assert!(
asked_entries(&app).is_empty(),
"the weapon template was already landed — no ask-once miss starved the lookup"
);
}
/// **B307's pin, "any ranged weapon" half.** A gun and a crossbow take the SAME road to a
/// different pair of clips (visual 224 / 743 → AttackRifle 49) — so a fix that only ever saw a
/// bow, or a schema that only decodes one display row, is caught here.
#[test]
fn a_real_gun_or_crossbow_shooters_auto_shot_go_plays_attackrifle() {
for weapon in [&OLD_BLUNDERBUSS, &BLACKCROW] {
let Some((mut app, unit)) = real_shooter(weapon) else {
return;
};
app.world_mut()
.write_message(cast_event(unit, AUTO_SHOT, CastEventKind::Go));
app.update();
assert_eq!(
emote_anims(&mut app, unit),
vec![weapon.fire_anim],
"{}'s Auto Shot GO plays its release clip",
weapon.name
);
}
}
/// The START arm of the same chain — the **pull**, which is what the report calls the "reload
/// anim": `SMSG_SPELL_START` for Auto Shot arms `CastHold { anim_id: 105 }` (LoadBow) on a bow
/// shooter, plus the ranged-slot marks the driver reads ([`RangedHold`], the sheath snap). One
/// START per auto-repeat activation, so this is the clip that opens a volley.
#[test]
fn a_real_bow_shooters_auto_shot_start_arms_the_loadbow_hold() {
let Some((mut app, unit)) = real_shooter(&WORN_SHORTBOW) else {
return;
};
app.world_mut()
.write_message(cast_event(unit, AUTO_SHOT, CastEventKind::Start));
app.update();
let held = app.world().entity(unit).get::<CastHold>();
assert_eq!(
held.map(|h| (h.anim_id, h.spell_id, h.ranged)),
Some((WORN_SHORTBOW.load_anim, AUTO_SHOT, true)),
"START arms the bow's pull as the cast hold"
);
assert!(
app.world().entity(unit).get::<RangedHold>().is_some(),
"…and the any-caster `0x400` weapon-visual hold"
);
let sheaths: Vec<_> = app
.world_mut()
.resource_mut::<Messages<SheathRequest>>()
.drain()
.filter(|s| s.entity == unit)
.map(|s| s.state)
.collect();
assert_eq!(sheaths, vec![2], "…and the ranged stance snaps drawn");
// The GO releases it and fires — the shot's own clip, on the same body.
app.world_mut()
.write_message(cast_event(unit, AUTO_SHOT, CastEventKind::Go));
app.update();
assert!(
app.world().entity(unit).get::<CastHold>().is_none(),
"the GO releases the pull"
);
assert_eq!(
emote_anims(&mut app, unit),
vec![WORN_SHORTBOW.fire_anim],
"…and plays the release"
);
}
/// The control that proves the tests above are actually exercising
/// [`super::WeaponVisualSrc::caster`] and not passing for some other reason: the SAME shooter
/// with an EMPTY ranged slot resolves nothing at all — Auto Shot has no visual of its own to
/// fall back on.
#[test]
fn a_shooter_with_no_ranged_weapon_resolves_no_clip_at_all() {
let Some((mut app, unit)) = real_shooter(&WORN_SHORTBOW) else {
return;
};
// Strip the equipment field — the wire's "nothing in slot 17".
app.world_mut()
.entity_mut(unit)
.insert(crate::net::ObjectStore(
benilla_protocol::ObjectFields::from_pairs(&[(VISIBLE_RANGED_ENTRY_FIELD, 0)]),
));
app.world_mut()
.write_message(cast_event(unit, AUTO_SHOT, CastEventKind::Start));
app.world_mut()
.write_message(cast_event(unit, AUTO_SHOT, CastEventKind::Go));
app.update();
assert!(
emote_anims(&mut app, unit).is_empty(),
"no weapon, no substitute visual, no clip"
);
assert!(
app.world().entity(unit).get::<CastHold>().is_none(),
"and no pull to hold"
);
}
+141 -148
View File
@@ -40,6 +40,7 @@ use crate::target::ClickConfig;
use crate::ui_loot::LootConfig;
use crate::ui_script::UiScaleCvar;
use crate::video::VideoConfig;
use crate::vplates::VPlateMode;
use benilla_ui::script::UiScript;
use benilla_ui::widget::MINIMAP_ZOOM_LEVELS;
use benilla_world::clutter::ClutterConfig;
@@ -96,6 +97,15 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
("UnitNamePlayer", "1"),
("UnitNameNPC", "1"),
("UnitNameOwn", "1"),
// The two V-plate toggles over `VPlateMode` — the engine bitmask `[0xc4da34]`'s bit 0 and
// bit 3. 1.12 registers NO nameplate CVar (wow-re, VERIFIED — the bitmask is a plain runtime
// global, persisted FrameXML-side as the `RegisterForSave`'d `NAMEPLATES_ON` /
// `FRIENDNAMEPLATES_ON`), so these take the LATER-era engine's names: the `autoLootDefault`
// posture, where benilla's persistence IS the CVar store (0954) and a setting with no 1.12
// CVar gets the era spelling rather than an invented one. Defaults mirror
// `VPlateMode::default()` — enemy ON is the 0167 director call, friendly OFF is faithful.
(crate::vplates::CVAR_ENEMIES, "1"),
(crate::vplates::CVAR_FRIENDS, "0"),
// World detail (0992): 1.12's video-panel var (the ENVIRONMENT_DETAIL slider, 0..2) over
// the clutter-density knob — 0 is the client's bare frillDensity baseline (×1 = 16 visits),
// each step +1×; the "2" default IS ClutterConfig's shipped ×3 (the reference's High).
@@ -234,7 +244,70 @@ impl Plugin for CvarPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<CvarPersist>()
.add_systems(Startup, load_config)
.add_systems(Update, (sync_cvars, save_config).chain());
.add_systems(Update, sync_cvars);
// **The flush is on the exit edge, not beside its feed** (decision 1528). It used to be
// `(sync_cvars, save_config).chain()` in `Update`, which made the "or the app exiting"
// half of its own gate dead on the exit a player actually causes: the close button's
// `AppExit` is not written until `PostUpdate`, so the last second of slider drags went
// with the process. `Last` still runs after `sync_cvars` — schedule order does what the
// `.chain()` did — and now also after every announcement.
crate::shutdown::on_app_exit(app, save_config.into_configs());
}
}
/// The knob resources as a **SystemParam** — the one census, fetched once, shared by all three
/// entry points ([`load_config`], [`sync_cvars`], [`fold_dying_vm_cvars`]).
///
/// It exists because the census had grown past Bevy's **16-param ceiling**: with fifteen knobs,
/// `sync_cvars` (script + persist + knobs) and the fold's `SystemState` both stopped compiling the
/// moment the plate toggles landed. Re-typing the list at every call site was already the shape
/// that made a new knob a four-place edit; bundling it makes a new knob one field here, one field
/// on [`Knobs`], and one arm in [`apply_to_knobs`], and the ceiling stops being reachable.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct KnobParams<'w> {
sound: ResMut<'w, SoundConfig>,
scale: ResMut<'w, UiScaleCvar>,
view: ResMut<'w, ViewDistance>,
look: ResMut<'w, LookConfig>,
click: ResMut<'w, ClickConfig>,
loot: ResMut<'w, LootConfig>,
names: ResMut<'w, NameConfig>,
plates: ResMut<'w, VPlateMode>,
clutter: ResMut<'w, ClutterConfig>,
minimap: ResMut<'w, MinimapZoom>,
bubbles: ResMut<'w, BubbleConfig>,
zoom: ResMut<'w, ZoomLimit>,
follow: ResMut<'w, FollowConfig>,
video: ResMut<'w, VideoConfig>,
pane_rate: ResMut<'w, PaneRate>,
}
impl KnobParams<'_> {
/// Borrow the set as [`Knobs`] for a write.
///
/// **Deref-muts every resource, so call it only when a change is actually being applied**
/// (0992's change-detection trap: the clutter re-scatter watches `is_changed::<ClutterConfig>`,
/// and a set built on every frame — or before the change queue is known to be non-empty —
/// re-scattered the world on every MasterVolume drag tick). Reading a field off `self`
/// directly, as the session seed does, goes through `Deref` and flags nothing.
fn knobs(&mut self) -> Knobs<'_> {
Knobs {
sound: &mut self.sound,
scale: &mut self.scale,
view: &mut self.view,
look: &mut self.look,
click: &mut self.click,
loot: &mut self.loot,
names: &mut self.names,
plates: &mut self.plates,
clutter: &mut self.clutter,
minimap: &mut self.minimap,
bubbles: &mut self.bubbles,
zoom: &mut self.zoom,
follow: &mut self.follow,
video: &mut self.video,
pane_rate: &mut self.pane_rate,
}
}
}
@@ -248,6 +321,7 @@ struct Knobs<'a> {
click: &'a mut ClickConfig,
loot: &'a mut LootConfig,
names: &'a mut NameConfig,
plates: &'a mut VPlateMode,
clutter: &'a mut ClutterConfig,
minimap: &'a mut MinimapZoom,
bubbles: &'a mut BubbleConfig,
@@ -298,6 +372,10 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
"unitnameplayer" => knobs.names.player = v != 0.0,
"unitnamenpc" => knobs.names.npc = v != 0.0,
"unitnameown" => knobs.names.own = v != 0.0,
// The two V-plate toggles — the bitmask's two bits, flags like every other checkbox.
// Lowercased here like every arm; `VPlateMode`'s consts carry the registered spelling.
"nameplateshowenemies" => knobs.plates.enemies = v != 0.0,
"nameplateshowfriends" => knobs.plates.friends = v != 0.0,
// Two CVars with no HOST knob, because their consumers are Lua (1140, B230). Known — so
// the caller dirties the config and the value persists — with nothing to apply this side.
"statusbartext" | "ubertooltips" => {}
@@ -336,40 +414,8 @@ fn zoom_index(v: f32) -> u8 {
/// to the knob resources — except keys the environment overrides this session (their resources
/// already read the env var in their `Default`s). The VM does not exist yet; [`sync_cvars`]
/// seeds the table when it does.
#[allow(clippy::too_many_arguments)] // one knob resource per registered CVar family
fn load_config(
mut persist: ResMut<CvarPersist>,
mut sound: ResMut<SoundConfig>,
mut scale: ResMut<UiScaleCvar>,
mut view: ResMut<ViewDistance>,
mut look: ResMut<LookConfig>,
mut click: ResMut<ClickConfig>,
mut loot: ResMut<LootConfig>,
mut names: ResMut<NameConfig>,
mut clutter: ResMut<ClutterConfig>,
mut minimap: ResMut<MinimapZoom>,
mut bubbles: ResMut<BubbleConfig>,
mut zoom: ResMut<ZoomLimit>,
mut follow: ResMut<FollowConfig>,
mut video: ResMut<VideoConfig>,
mut pane_rate: ResMut<PaneRate>,
) {
let mut knobs = Knobs {
sound: &mut sound,
scale: &mut scale,
view: &mut view,
look: &mut look,
click: &mut click,
loot: &mut loot,
names: &mut names,
clutter: &mut clutter,
minimap: &mut minimap,
bubbles: &mut bubbles,
zoom: &mut zoom,
follow: &mut follow,
video: &mut video,
pane_rate: &mut pane_rate,
};
fn load_config(mut persist: ResMut<CvarPersist>, mut params: KnobParams) {
let mut knobs = params.knobs();
if std::env::var_os("WOW_UI_SCALE").is_some() {
persist.env_overridden.insert("uiscale".into());
}
@@ -430,29 +476,34 @@ fn load_config(
/// Per frame: seed the VM's table once it exists (registered set + the RESOLVED session values,
/// so `GetCVar` reflects env overrides and the loaded config alike), then drain Lua `SetCVar`
/// changes into the knob resources and mark the config dirty.
#[allow(clippy::too_many_arguments)] // one knob resource per registered CVar family
fn sync_cvars(
script: Option<NonSendMut<UiScript>>,
mut persist: ResMut<CvarPersist>,
mut sound: ResMut<SoundConfig>,
mut scale: ResMut<UiScaleCvar>,
mut view: ResMut<ViewDistance>,
mut look: ResMut<LookConfig>,
mut click: ResMut<ClickConfig>,
mut loot: ResMut<LootConfig>,
mut names: ResMut<NameConfig>,
mut clutter: ResMut<ClutterConfig>,
mut minimap: ResMut<MinimapZoom>,
mut bubbles: ResMut<BubbleConfig>,
mut zoom: ResMut<ZoomLimit>,
mut follow: ResMut<FollowConfig>,
mut video: ResMut<VideoConfig>,
mut pane_rate: ResMut<PaneRate>,
mut params: KnobParams,
) {
let Some(mut script) = script else {
return;
};
if persist.registered.claim(&script) {
// Read-only borrows for the seed: field access through `ResMut`'s `Deref` flags nothing,
// which is the half of 0992's change-detection trap this system has to keep.
let KnobParams {
sound,
scale,
view,
look,
click,
loot,
names,
plates,
clutter,
minimap,
bubbles,
zoom,
follow,
video,
pane_rate,
} = &params;
// The config file's values go in FIRST (decision 1291): registration — ours below, or an
// addon's `RegisterCVar` later — starts a key at its saved value. This is what carries a
// knobless CVar (`statusBarText`) and an addon-declared one across a VM replacement; the
@@ -467,7 +518,7 @@ fn sync_cvars(
);
script.register_cvars(REGISTERED.iter().copied());
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
let session: [(&str, String); 28] = [
let session: [(&str, String); 30] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -492,6 +543,8 @@ fn sync_cvars(
("UnitNamePlayer", flag(names.player)),
("UnitNameNPC", flag(names.npc)),
("UnitNameOwn", flag(names.own)),
(crate::vplates::CVAR_ENEMIES, flag(plates.enemies)),
(crate::vplates::CVAR_FRIENDS, flag(plates.friends)),
// The session density on the panel scale (×1..×3 → 0..2). An env-driven off-grid
// multiplier seeds off-grid honestly — the dropdown shows the raw number, checks
// nothing (the 0959 out-of-range posture, dropdown-flavored).
@@ -514,22 +567,7 @@ fn sync_cvars(
if changes.is_empty() {
return;
}
let mut knobs = Knobs {
sound: &mut sound,
scale: &mut scale,
view: &mut view,
look: &mut look,
click: &mut click,
loot: &mut loot,
names: &mut names,
clutter: &mut clutter,
minimap: &mut minimap,
bubbles: &mut bubbles,
zoom: &mut zoom,
follow: &mut follow,
video: &mut video,
pane_rate: &mut pane_rate,
};
let mut knobs = params.knobs();
for (name, value) in changes {
if apply_to_knobs(&name, &value, &mut knobs) {
persist.dirty = true;
@@ -552,95 +590,31 @@ fn sync_cvars(
///
/// `dirty` is left alone: if nothing changed, the fold is an identity; if something did, the
/// change that did it already marked the config dirty.
#[allow(clippy::type_complexity)] // one Option per knob resource, the same census sync_cvars keeps
pub(crate) fn fold_dying_vm_cvars(world: &mut World) {
// Every param is `Option` because this runs from an exclusive edge function, not the app's
// schedule: a test world or a stripped scenario may not carry the cvar plugin at all, and
// "no persist state" simply means there is no file to bridge — not a panic.
// A world with no persist state has no file to bridge — a test world or a stripped scenario
// that never added the plugin. It is checked up front because the knob set below is fetched
// NON-optionally, and the two facts are one: any world carrying `CvarPersist` carries every
// knob too (the plugin's own `load_config`/`sync_cvars` take them the same way, and would
// have panicked at startup otherwise).
if !world.contains_resource::<CvarPersist>() {
return;
}
let mut state: bevy::ecs::system::SystemState<(
Option<NonSendMut<UiScript>>,
Option<ResMut<CvarPersist>>,
Option<ResMut<SoundConfig>>,
Option<ResMut<UiScaleCvar>>,
Option<ResMut<ViewDistance>>,
Option<ResMut<LookConfig>>,
Option<ResMut<ClickConfig>>,
Option<ResMut<LootConfig>>,
Option<ResMut<NameConfig>>,
Option<ResMut<ClutterConfig>>,
Option<ResMut<MinimapZoom>>,
Option<ResMut<BubbleConfig>>,
Option<ResMut<ZoomLimit>>,
Option<ResMut<FollowConfig>>,
Option<ResMut<VideoConfig>>,
Option<ResMut<PaneRate>>,
ResMut<CvarPersist>,
KnobParams,
)> = bevy::ecs::system::SystemState::new(world);
let (
script,
persist,
sound,
scale,
view,
look,
click,
loot,
names,
clutter,
minimap,
bubbles,
zoom,
follow,
video,
pane_rate,
) = state.get_mut(world);
let (Some(mut script), Some(mut persist)) = (script, persist) else {
let (script, mut persist, mut params) = state.get_mut(world);
let Some(mut script) = script else {
return;
};
let changes = script.take_cvar_changes();
if !changes.is_empty() {
// The knobs exist whenever the app is real (each plugin inits its own); a world that
// carries CvarPersist but not the knobs is a partial test rig, where dropping the
// final-frame drain is the right degradation — the fold below still preserves the file.
if let (
Some(mut sound),
Some(mut scale),
Some(mut view),
Some(mut look),
Some(mut click),
Some(mut loot),
Some(mut names),
Some(mut clutter),
Some(mut minimap),
Some(mut bubbles),
Some(mut zoom),
Some(mut follow),
Some(mut video),
Some(mut pane_rate),
) = (
sound, scale, view, look, click, loot, names, clutter, minimap, bubbles, zoom, follow,
video, pane_rate,
) {
let mut knobs = Knobs {
sound: &mut sound,
scale: &mut scale,
view: &mut view,
look: &mut look,
click: &mut click,
loot: &mut loot,
names: &mut names,
clutter: &mut clutter,
minimap: &mut minimap,
bubbles: &mut bubbles,
zoom: &mut zoom,
follow: &mut follow,
video: &mut video,
pane_rate: &mut pane_rate,
};
for (name, value) in changes {
if apply_to_knobs(&name, &value, &mut knobs) {
persist.dirty = true;
persist.last_change = Some(Instant::now());
}
let mut knobs = params.knobs();
for (name, value) in changes {
if apply_to_knobs(&name, &value, &mut knobs) {
persist.dirty = true;
persist.last_change = Some(Instant::now());
}
}
}
@@ -799,6 +773,12 @@ mod tests {
assert_eq!(d["UnitNamePlayer"] != 0.0, names.player);
assert_eq!(d["UnitNameNPC"] != 0.0, names.npc);
assert_eq!(d["UnitNameOwn"] != 0.0, names.own);
// The V-plate pair welds to VPlateMode's defaults — the enemy "1" is the 0167 director
// divergence from the binary's both-OFF boot, the friendly "0" is faithful (0599).
let plates = VPlateMode::default();
assert_eq!(d[crate::vplates::CVAR_ENEMIES] != 0.0, plates.enemies);
assert_eq!(d[crate::vplates::CVAR_FRIENDS] != 0.0, plates.friends);
assert!(plates.enemies && !plates.friends, "the shipped boot pair");
// ClutterConfig::default() reads $WOW_CLUTTER_DENSITY; the registered default mirrors
// the env-less ×3 literal (clutter.rs: "Default ×3 = High") on the panel's 0..2 scale.
assert_eq!(d["WorldDetail"], 2.0);
@@ -829,6 +809,7 @@ mod tests {
let mut click = ClickConfig::default();
let mut loot = LootConfig::default();
let mut names = NameConfig::default();
let mut plates = VPlateMode::default();
// Literal fields, not Default: ClutterConfig::default() reads the env A/B vars.
let mut clutter = ClutterConfig {
density: 3.0,
@@ -850,6 +831,7 @@ mod tests {
click: &mut click,
loot: &mut loot,
names: &mut names,
plates: &mut plates,
clutter: &mut clutter,
minimap: &mut minimap,
bubbles: &mut bubbles,
@@ -911,6 +893,15 @@ mod tests {
assert!(!knobs.names.npc);
assert!(apply_to_knobs("unitnameown", "1", &mut knobs));
assert!(knobs.names.own);
// …and the plate pair on the two bits of the bitmask, either casing.
assert!(apply_to_knobs(
crate::vplates::CVAR_ENEMIES,
"0",
&mut knobs
));
assert!(!knobs.plates.enemies);
assert!(apply_to_knobs("nameplateshowfriends", "1", &mut knobs));
assert!(knobs.plates.friends);
// The bubble pair lands on the spawn gate's own knob (1139).
assert!(apply_to_knobs("ChatBubbles", "0", &mut knobs));
assert!(!knobs.bubbles.all);
@@ -989,6 +980,7 @@ mod tests {
.init_resource::<ClickConfig>()
.init_resource::<LootConfig>()
.init_resource::<NameConfig>()
.init_resource::<VPlateMode>()
.init_resource::<ClutterConfig>()
.init_resource::<MinimapZoom>()
.init_resource::<BubbleConfig>()
@@ -1060,6 +1052,7 @@ mod tests {
.init_resource::<ClickConfig>()
.init_resource::<LootConfig>()
.init_resource::<NameConfig>()
.init_resource::<VPlateMode>()
.init_resource::<ClutterConfig>()
.init_resource::<MinimapZoom>()
.init_resource::<BubbleConfig>()
+105 -15
View File
@@ -10,7 +10,7 @@ use bevy_egui::{egui, EguiContexts};
use super::{overlay_text, OVERLAY_FILL, OVERLAY_TEXT_DIM};
use crate::net::ObjectStore;
use crate::ui_script::{InspectMode, PointerOverUi};
use benilla_world::interact::{pick_at_cursor, PickParts, WorldObject};
use benilla_world::interact::{WorldObject, WorldPick};
use benilla_world::model_render::ModelKind;
use benilla_world::modkeys::DEV_CHORD;
use benilla_world::view::WorldCamera;
@@ -85,6 +85,16 @@ pub(super) struct InspectStores<'w, 's> {
motion: Query<'w, 's, MotionReadout>,
factions: Option<Res<'w, crate::target::ring::Factions>>,
self_store: Query<'w, 's, &'static ObjectStore, With<crate::net::SelfPlayer>>,
/// The live standings — the other half of the reaction resolve (`ring_reaction` takes the
/// reputation branch first, and a rank that came off a standing rather than a faction-template
/// comparison is the single most confusing thing about a unit's colour).
reputations: Res<'w, crate::net::Reputations>,
/// This frame's plate verdict ([`crate::vplates::VPlates`]) — the *rendered* answer, not a
/// re-derivation, so the card can never disagree with what is on screen.
plates: Res<'w, crate::vplates::VPlates>,
/// Which of the two master bits are on, so a `plate ✗` can say whether the unit lost its plate
/// on the category gate or on something else.
plate_mode: Res<'w, crate::vplates::VPlateMode>,
/// The ask-once GO template cache — the readable head a TEXT object's line reports
/// (decision 1105).
go_templates: Res<'w, crate::go_templates::GameObjectTemplates>,
@@ -111,7 +121,6 @@ pub(super) fn inspect_ui(
mut contexts: EguiContexts,
inspect: Res<InspectMode>,
mouseover: Res<MouseoverTarget>,
objects: Query<&WorldObject>,
// The pickable mesh is a child of the net entity; its descriptor store (`ObjectStore`) lives on the
// parent, so the readout hops child → parent.
parents: Query<&ChildOf>,
@@ -160,7 +169,7 @@ pub(super) fn inspect_ui(
});
// The identity card: only when hovering a picked object, pinned just off the cursor tip.
let Some(obj) = mouseover.entity.and_then(|e| objects.get(e).ok()) else {
let Some(obj) = mouseover.object.as_ref() else {
return Ok(());
};
let Some(cursor) = ctx.pointer_latest_pos() else {
@@ -174,6 +183,8 @@ pub(super) fn inspect_ui(
.and_then(|e| parents.get(e).ok())
.map(|c| c.parent());
let (factions, self_store) = (stores.factions.as_deref(), stores.self_store.single().ok());
let (reputations, plates, plate_mode) =
(&*stores.reputations, &stores.plates.0, &*stores.plate_mode);
let go_templates = &*stores.go_templates;
let (stores, kinds, collision, lit, motion, go_anims) = (
&stores.stores,
@@ -321,6 +332,70 @@ pub(super) fn inspect_ui(
s.0.unit_gender().unwrap_or(0)
)
});
// **Why this unit does or does not carry a V-plate** — the whole input set of
// `vplates::drive_vplates`'s gate on one line, plus the verdict it actually reached this frame
// (read out of [`crate::vplates::VPlates`], never re-derived, so the card cannot disagree with
// the screen). "These plates shouldn't be here" is otherwise a question no amount of looking
// can answer: the plate is drawn from a REACTION rank whose provenance (a reputation standing
// vs a faction-template comparison) is invisible, over unit flags nothing displays, and the
// two master bits are off-screen state. Reading `reaction 3 neutral(rep)` off a green-looking
// city NPC is the entire diagnosis.
let plate_line = store.filter(|_| is_unit).map(|s| {
let rank = crate::target::ring::ring_reaction(factions, reputations, Some(s), self_store);
let word = match rank {
0 => "hated",
1 => "hostile",
2 => "unfriendly",
3 => "neutral",
4 => "friendly",
5 => "honored",
6 => "revered",
_ => "exalted",
};
// Which branch produced it: a faction WITH a reputation slot colours by our standing
// (`0x605fc0` → `0x4d63a0`, before any template comparison), everything else by the
// template comparator. The two disagree constantly — a battleground emissary is `friendly`
// by template and `neutral` by standing — and only this tag says which one you are seeing.
let branch = s
.0
.unit_faction_template()
.and_then(|t| factions?.catalog().template(t))
.map(|t| {
if factions.is_some_and(|f| f.catalog().reputation_faction(t.faction).is_some()) {
"rep"
} else {
"tpl"
}
})
.unwrap_or("?");
let ctype = net_entity
.and_then(|p| guids.get(p).ok())
.and_then(|g| benilla_protocol::guid::entry(g.0))
.and_then(|e| names.creature_type(e));
let ctype = ctype.map_or_else(|| "type ?".to_string(), |t| format!("type {t}"));
let verdict = match net_entity {
Some(p) if plates.contains(&p) => "plate ✓".to_string(),
_ => {
// The category the gate would have put it in, and whether that bit is on — the
// first thing to check when a plate is missing (or present) unexpectedly.
let (category, bit) = if rank >= 4 {
("friendly", plate_mode.friends)
} else {
("enemy", plate_mode.enemies)
};
if bit {
"plate ✗".to_string()
} else {
format!("plate ✗ ({category} bit off)")
}
}
};
format!(
"{verdict} · reaction {rank} {word}({branch}) · faction {} · unitflags {:#x} · {ctype}",
s.0.unit_faction_template().unwrap_or(0),
s.0.unit_flags(),
)
});
// Player-only customization: the compositor's input, shown raw so we can
// confirm the PLAYER_BYTES decode against an in-game character.
let customization_line = store.filter(|_| is_player).map(|s| {
@@ -444,6 +519,9 @@ pub(super) fn inspect_ui(
if let Some(line) = &appearance_line {
lines.push(line.clone());
}
if let Some(line) = &plate_line {
lines.push(line.clone());
}
if let Some(line) = &customization_line {
lines.push(line.clone());
}
@@ -497,6 +575,9 @@ pub(super) fn inspect_ui(
if let Some(line) = &appearance_line {
ui.label(egui::RichText::new(line).color(OVERLAY_TEXT_DIM));
}
if let Some(line) = &plate_line {
ui.label(egui::RichText::new(line).color(OVERLAY_TEXT_DIM));
}
if let Some(line) = &customization_line {
ui.label(egui::RichText::new(line).color(OVERLAY_TEXT_DIM));
}
@@ -532,10 +613,17 @@ pub(super) fn inspect_ui(
Ok(())
}
/// The nearest [`WorldObject`] under the cursor this frame, or `None`. Consumers read `entity` and look
/// up its [`WorldObject`]; `point`/`distance` are the world-space hit.
/// The nearest identified thing under the cursor this frame, or `None`. `point`/`distance` are the
/// world-space hit.
///
/// The identity is the resource's own (`object`), not something the consumer looks up off `entity`:
/// most of the static world draws from a consolidated lane and has **no entity** to look anything
/// up on (decision 1534). `entity` is `Some` only when one owns the geometry — which is exactly
/// when the extra per-entity readouts (a unit's descriptor store, a GameObject's collision) mean
/// anything.
#[derive(Resource, Default)]
pub struct MouseoverTarget {
pub object: Option<WorldObject>,
pub entity: Option<Entity>,
pub point: Vec3,
pub distance: f32,
@@ -550,15 +638,18 @@ pub(super) fn update_mouseover(
mut target: ResMut<MouseoverTarget>,
window: Query<&Window, With<PrimaryWindow>>,
camera: Query<(&Camera, &GlobalTransform), With<WorldCamera>>,
objects: Query<Entity, With<WorldObject>>,
parts: PickParts,
objects: Query<(Entity, &WorldObject)>,
// Everything drawn, entity-owned or not — see [`WorldPick`]: most of the static world draws
// from a consolidating lane and would be invisible to a bare `PickParts` cast (1534).
pick: WorldPick,
) {
if !inspect.enabled {
if target.entity.is_some() {
if target.object.is_some() {
*target = MouseoverTarget::default();
}
return;
}
target.object = None;
target.entity = None;
// The pointer is over the dev UI (e.g. the now-overlaid debug panel), not the world — don't pick
// behind it. This replaces the old "is the cursor in the inset world viewport?" test, which no
@@ -575,12 +666,11 @@ pub(super) fn update_mouseover(
let Some(cursor) = window.cursor_position() else {
return; // cursor left the window, or we're in mouselook (hidden)
};
let identified: HashSet<Entity> = objects.iter().collect();
if let Some((entity, point, distance)) =
pick_at_cursor(cursor, camera, cam_tf, &identified, &parts)
{
target.entity = Some(entity);
target.point = point;
target.distance = distance;
let identified: HashSet<Entity> = objects.iter().map(|(e, _)| e).collect();
if let Some(hit) = pick.at_cursor(cursor, camera, cam_tf, &identified) {
target.object = hit.object;
target.entity = hit.entity;
target.point = hit.hit.point;
target.distance = hit.hit.distance;
}
}
+14
View File
@@ -114,6 +114,7 @@ impl Plugin for DevProbesPlugin {
"WOW_PROBE_KEY",
"WOW_PROBE_LUA",
"WOW_PROBE_CHEST",
"WOW_PROBE_CLAM",
"WOW_RIG",
"WOW_LIVE_FPS",
]
@@ -254,6 +255,12 @@ impl Plugin for DevProbesPlugin {
if std::env::var("WOW_PROBE_MAIL").is_ok() {
app.add_plugins(crate::capture::ProbeMailPlugin);
}
// The auction-arc live probe: `WOW_PROBE_AUCTION=1` GM-hops to a Stormwind auctioneer,
// greets it on the wire and drives browse/throttle/sell/owner-list/cancel through the
// live Lua VM — decision 1511's end-to-end instrument (see `capture::ProbeAuctionPlugin`).
if std::env::var("WOW_PROBE_AUCTION").is_ok() {
app.add_plugins(crate::capture::ProbeAuctionPlugin);
}
// The bank-arc live probe: `WOW_PROBE_BANK=1` GM-hops to a pure banker, drives the whole
// six-opcode bank wire (activate/deposit/withdraw/buy-slot/refusal) — decision 0604's
// end-to-end instrument (see `capture::ProbeBankPlugin`).
@@ -280,6 +287,13 @@ impl Plugin for DevProbesPlugin {
if std::env::var("WOW_PROBE_CHEST").is_ok() {
app.add_plugins(crate::capture::ProbeChestPlugin);
}
// The openable-item live probe: `WOW_PROBE_CLAM=1` stocks a clam, right-clicks it
// through the live VM's own `UseContainerItem` and reports whether a loot window opens
// on the item's own guid — the numeric answer to the director's "clams don't open"
// (decision 1531; see `capture::ProbeClamPlugin`).
if std::env::var("WOW_PROBE_CLAM").is_ok() {
app.add_plugins(crate::capture::ProbeClamPlugin);
}
// The cast-cancel live probe: `WOW_PROBE=castcancel` hearths and presses W mid-cast — the
// local self-cancel's end-to-end timing instrument (see `capture::ProbeCastCancelPlugin`).
if std::env::var("WOW_PROBE").as_deref() == Ok("castcancel") {
+43 -1
View File
@@ -41,7 +41,7 @@ use display::{
/// Attaching a visual to each net entity (the back half of this subsystem) — kept in its own file as it
/// carries the bulk of the per-entity spawn logic (skeleton/animation, character geoset + skin, fade).
mod attach;
use attach::{attach_entity_visuals, build_dressup_preview, build_glue_preview};
use attach::{attach_entity_visuals, build_dressup_preview, build_glue_pet, build_glue_preview};
/// The dynamic point lights an entity's own model carries into the world — the held torch above all:
/// decision 0016's law applied to the *entity* half of the scene, not just the placed half.
@@ -293,6 +293,25 @@ impl Creatures {
self.catalog.display_base_alpha(display_id)
}
/// A display's **footstep camera-shake preset** (`CameraModelData.FootstepShakeSize` → a
/// `CameraShakes.dbc` row id) — see [`crate::camera_shake`]. `None` for the 405 of 430 shipped
/// models that shake nothing.
pub(crate) fn footstep_shake(&self, display_id: u32) -> Option<u32> {
self.catalog.footstep_shake(display_id)
}
/// A display's **death-thud camera-shake preset** — same table, fired on `$DTH`.
pub(crate) fn death_thud_shake(&self, display_id: u32) -> Option<u32> {
self.catalog.death_thud_shake(display_id)
}
/// A display's **spawned-creature render scale** — see
/// [`benilla_formats::CreatureCatalog::model_scale`] for why almost nothing reads it and why
/// the glue screens' pet must. `None` for an unknown display.
pub(crate) fn model_scale(&self, display_id: u32) -> Option<f32> {
self.catalog.model_scale(display_id)
}
/// A display's resolved blood id (decision 0137 phase 3 — see [`CreatureModel::blood`]):
/// the UnitBloodLevels key the melee spurt chain starts from; `None` = unknown display.
pub(crate) fn blood(&self, display_id: u32) -> Option<i32> {
@@ -638,6 +657,10 @@ impl Plugin for EntitiesPlugin {
// the freshly-built display model, for the create booth to bake. After
// `update_display_models` (its want-list built the body) — server-less, at char select.
build_glue_preview,
// …and the select screen's PET beside it: an ordinary creature display, assembled
// off the same freshly-built cache, on its own latch so a slow pet model never
// holds the character back.
build_glue_pet,
// The dressing room's preview (decision 1060): the same tuple-driven assembly,
// for the item nobody in the world is wearing. Beside the glue one — same
// dependency (the display cache built by `update_display_models` above), same
@@ -937,6 +960,17 @@ fn setup_entities(
}
Err(e) => warn!("enchants unavailable: no enchant glow, no enchant line: {e:#}"),
}
// `ItemRandomProperties` — the random-suffix roll's name and its enchant slots 2..6 (decision
// 1547). Absent, a rolled item reads by its base name and shows no suffix lines.
match benilla_formats::load_random_property_catalog(&mut chain) {
Ok(props) => {
info!("random-property catalog: {} suffix rows", props.len());
commands.insert_resource(crate::items::RandomProperties(props));
}
Err(e) => {
warn!("random properties unavailable: no item name suffix, no suffix line: {e:#}")
}
}
match benilla_formats::load_durability_tables(&mut chain) {
Ok(tables) => commands.insert_resource(crate::ui_merchant::RepairTables(tables)),
Err(e) => warn!("durability tables unavailable, repair costs show 0: {e:#}"),
@@ -995,6 +1029,14 @@ fn update_display_models(
}
}
}
// The select screen's **pet** — the same kind of wantless want, one display further. It is an
// ordinary creature display (`Unit`, not `Player`): the enum's pet triple carries a
// `CreatureDisplayInfo` id, so it resolves down the plain creature chain, skins and all.
if let Some(preview) = glue_preview.as_deref() {
if let Some(pet) = preview.look.and_then(|l| l.pet()) {
actives.push((EntityKind::Unit, pet.display_id));
}
}
for (kind, disp) in actives {
match kind {
@@ -35,7 +35,7 @@ mod dress;
use dress::{spawn_part, PartDress};
mod preview;
pub(crate) use preview::equip_slot;
pub(super) use preview::{build_dressup_preview, build_glue_preview};
pub(super) use preview::{build_dressup_preview, build_glue_pet, build_glue_preview};
mod redress;
pub(super) use redress::redress_player_looks;
@@ -34,8 +34,8 @@ use benilla_protocol::{CharEnumItem, CHARACTER_FLAG_HIDE_CLOAK, CHARACTER_FLAG_H
use bevy::prelude::*;
use crate::portrait::{
DressUpBake, DressUpLook, DressUpPreview, GlueLook, GluePreview, GluePreviewBake,
PreviewBillboard, PreviewEffects, PreviewPart, PreviewRider,
DressUpBake, DressUpLook, DressUpPreview, GlueLook, GluePetBake, GluePreview, GluePreviewBake,
PetLook, PreviewBillboard, PreviewEffects, PreviewPart, PreviewRider,
};
use benilla_assets::materials::WowModelMaterial;
use benilla_assets::WorldAssets;
@@ -531,6 +531,10 @@ fn assemble(spec: &PreviewSpec, ctx: &mut PreviewCtx<'_, '_>) -> Option<Assemble
static_mesh: p.mesh.clone(),
skinned_mesh: p.skinned_mesh.clone(),
material: steady_material(p, &char_mats).unwrap_or_else(|| p.material.clone()),
// `None` — decision 0807's named gap, deliberately still open: a character batch with
// an authored dimming constant previews at 1.0. The select body's look is signed off
// as it stands, so closing it is its own change, not a rider on the pet's.
alpha_anim: None,
})
.collect();
@@ -799,6 +803,135 @@ fn steady_material(
Some(quint.0.clone())
}
/// Assemble the select screen's **pet** — the hunter/warlock companion the enum's pet triple names
/// (`SMSG_CHAR_ENUM`'s `petDisplayId`, the reference's secondary model `record+0x114`).
///
/// Its own system beside [`build_glue_preview`], not a limb of it, for the reason
/// [`GluePetBake`] is its own resource: a pet is an ordinary creature display with no compositor,
/// no equipment and no geoset selection between the display cache and the booth, and its model
/// lands on its own schedule. Folding it into the character's assembly would gate the *character*
/// on the pet's asset — a body that waits for a wolf to load is a worse screen than a body that is
/// joined by one a frame later.
///
/// Runs after `update_display_models`, whose want-list asked for this display; retries (leaving
/// `built` false) until its parts build.
pub(in crate::entities) fn build_glue_pet(
preview: Res<GluePreview>,
mut bake: ResMut<GluePetBake>,
creatures: Option<Res<Creatures>>,
// The `CreatureFamily` size ramp (decision 1538) — the pet's scale is its family's, read at its
// level. Absent (the DBC failed to load) ⇒ every pet falls back to its display's own scale
// product, which is the reference's own family-miss fallthrough.
families: Option<Res<crate::ui_pet_stats::PetFamilyTables>>,
mut state: Local<PetState>,
) {
let want = preview.look.and_then(|l| l.pet());
if state.last != want {
state.last = want;
state.built = false;
}
if state.built {
return;
}
// No pet (every class but a living hunter's or warlock's — the server suppresses the triple
// for the rest) → clear the bake once and stop.
let Some(want) = want else {
if bake.display_id != 0 {
*bake = GluePetBake {
revision: bake.revision + 1,
..default()
};
}
state.built = true;
return;
};
let Some(creatures) = creatures.as_deref() else {
return;
};
let Some(pet) = assemble_pet(creatures, families.as_deref(), want) else {
return; // the model is still loading — retry next frame
};
debug!(
"glue pet: display {} (family {} level {}) → {} parts, {} camera-facing, scale {:.3}",
want.display_id,
want.family,
want.level,
pet.parts.len(),
pet.billboards.len(),
pet.scale,
);
*bake = GluePetBake {
revision: bake.revision + 1,
..pet
};
state.built = true;
}
/// The pet's parts, straight off the shared display cache: every batch of the creature model, split
/// into plain meshes and the camera-facing ones (a voidwalker's eyes are the latter). `None` while
/// the display's model is still building.
///
/// No geoset filter: only the character compositor selects among a model's geosets, and a pet is
/// not a character model — the world's creature path draws every batch of a beast the same way.
fn assemble_pet(
creatures: &Creatures,
families: Option<&crate::ui_pet_stats::PetFamilyTables>,
want: PetLook,
) -> Option<GluePetBake> {
let display_id = want.display_id;
let model = creatures.models.get(&display_id)?;
let parts = model.parts.as_deref()?;
let (billboard_parts, mesh_parts): (Vec<&EntityPart>, Vec<&EntityPart>) =
parts.iter().partition(|p| p.billboard.is_some());
Some(GluePetBake {
display_id,
parts: mesh_parts
.iter()
.map(|p| PreviewPart {
static_mesh: p.mesh.clone(),
skinned_mesh: p.skinned_mesh.clone(),
material: p.material.clone(),
alpha_anim: p.alpha_anim.clone(),
})
.collect(),
billboards: billboard_parts
.iter()
.filter_map(|p| {
let info = p.billboard.as_ref()?;
Some(PreviewBillboard {
mesh: p.mesh.clone(),
material: p.material.clone(),
bone: info.bone,
// `ZERO`: the pet is rigged, so its billboard bone's booth joint already bakes
// the pivot (the 0130 rig identity) — the same seat the body's eye-glow takes.
offset: Vec3::ZERO,
kind: info.kind,
})
})
.collect(),
// The model's own emitters, carried whole: an imp without its flames is the same kind of
// wrong as an imp without its wings. `parts` and `emitters` are captured together when the
// asset lands, so the `parts` gate above already answers for both.
emitters: model.emitters.clone(),
// The size: the family's level ramp, and the display's scale product only where the family
// table can't answer — which is the reference's own order (it computes the product, then
// overwrites it with the ramp, `0x472e87`). A display neither can size renders at 1.0.
scale: families
.and_then(|f| f.families.pet_scale(want.family, want.level))
.or_else(|| creatures.model_scale(display_id))
.unwrap_or(1.0),
revision: 0, // the caller stamps it
})
}
/// [`build_glue_pet`]'s per-run memory — the pet display it last emitted a bake for, and whether
/// that bake succeeded (a display whose model wasn't ready keeps retrying).
#[derive(Default)]
pub(in crate::entities) struct PetState {
last: Option<PetLook>,
built: bool,
}
/// [`build_glue_preview`]'s per-run memory.
#[derive(Default)]
pub(in crate::entities) struct PreviewState {
@@ -817,6 +950,93 @@ pub(in crate::entities) struct DressUpState {
mod tests {
use super::*;
/// **The bug the imp filed** (decision 1539): a pet is a *creature* display, and creature models
/// routinely author their own particle emitters — the Imp's three flame jets, the Voidwalker's
/// four smoke plumes. `assemble_pet` read `parts` and nothing else, so they never reached the
/// booth and the select screen stood a grey imp beside the warlock.
///
/// Asserted as "everything the display cache holds arrives", not "three emitters arrive": the
/// failure mode was a *dropped field*, and a count pinned to one model would pass just as
/// silently the next time one is added. The billboard-chain emitter is in the fixture because
/// it is the branch that survives a booth's `without_camera_billboards` only through the frame
/// [`crate::portrait::spawn_booth_own_emitters`] spawns for it.
#[test]
fn a_pet_carries_every_emitter_its_display_authored() {
let emitter = |bone: u16, billboard: Option<u16>| benilla_assets::ModelEmitter {
def: benilla_formats::ParticleEmitterDef {
bone,
..benilla_world::testing::plain_particle_def()
},
texture: None,
bone_pivot: [0.0; 3],
billboard: billboard.map(|b| benilla_assets::EmitterBillboard {
kind: benilla_formats::BillboardKind::Spherical,
pivot: [0.0, 0.0, 1.0],
bone: b,
}),
recursion: None,
geometry: None,
owner_reach: 0.0,
water_bound: (Vec3::ZERO, 0.0),
idle_seq: usize::from(bone),
};
let mut dm = crate::entities::display::empty_shell();
// Built, and it drew nothing of its own — the emitters are the whole model here. An empty
// `parts` list is still `Some`, which is what "the asset landed" means to the gate.
dm.parts = Some(Vec::new());
dm.emitters = vec![emitter(17, None), emitter(45, None), emitter(51, Some(42))];
let mut creatures = Creatures {
catalog: Default::default(),
models: std::collections::HashMap::new(),
};
creatures.models.insert(4449, dm);
let bake = assemble_pet(
&creatures,
None,
PetLook {
display_id: 4449,
level: 60,
family: 0,
},
)
.expect("a display whose parts have landed assembles");
assert_eq!(
bake.emitters.len(),
3,
"every emitter the display holds must reach the bake, not a subset"
);
assert_eq!(
bake.emitters.iter().map(|e| e.def.bone).collect::<Vec<_>>(),
[17, 45, 51],
"and in file order, on their own bones"
);
assert_eq!(
bake.emitters[2].billboard.map(|b| b.bone),
Some(42),
"the billboard host bone rides along — a booth needs it to seat the frame"
);
// A display still loading (`parts: None`) yields nothing at all rather than a pet with
// emitters and no body: the retry latch is what stands it up a frame later.
let mut loading = crate::entities::display::empty_shell();
loading.emitters = vec![emitter(17, None)];
creatures.models.insert(4450, loading);
assert!(
assemble_pet(
&creatures,
None,
PetLook {
display_id: 4450,
level: 60,
family: 0
}
)
.is_none(),
"emitters alone are not a pet"
);
}
/// The InventoryType → equipment-slot map lands each worn kind where the Select pipeline reads
/// it, and drops the non-rendered kinds. Pinned against the real Human Warrior recruit set
/// (byte-verified in decision 0527): shirt→body(3), pants→legs(6), boots→feet(7), the mainhand
@@ -613,7 +613,7 @@ fn spawn_slot(
// (`BillboardCard::frame_following`). Nothing else in the chain is live: of the
// 95 item models whose emitters ride a billboard bone, none animates its chain.
let owner = match em.billboard {
Some((kind, pivot)) => {
Some(benilla_assets::EmitterBillboard { kind, pivot, .. }) => {
let frame = commands
.spawn(BillboardCard::frame_following(
kind,
@@ -1020,7 +1020,11 @@ mod tests {
def: benilla_world::testing::plain_particle_def(),
texture: None,
bone_pivot: [0.0; 3],
billboard: Some((benilla_formats::BillboardKind::Spherical, [0.0; 3])),
billboard: Some(benilla_assets::EmitterBillboard {
kind: benilla_formats::BillboardKind::Spherical,
pivot: [0.0; 3],
bone: 1,
}),
recursion: None,
geometry: None,
owner_reach: 0.0,
+14 -1
View File
@@ -260,6 +260,19 @@ pub(super) fn spawn_wmo_gameobject_props(
}
slot
});
// This path never diverts (below), so the identity only names the kind and what the
// parts are — the GameObject's own `WorldObject` is what a pick on the ship answers
// with, inserted by `entities::attach` on the parts it owns.
let object = std::sync::Arc::new(benilla_world::interact::WorldObject {
kind: benilla_world::model_render::ModelKind::Doodad,
label: prop
.handle
.path()
.map(|p| p.path().to_string_lossy().into_owned())
.unwrap_or_default(),
id: 0,
detail: "WMO gameobject prop".into(),
});
let SpawnedModel {
entities: ents,
host,
@@ -272,7 +285,7 @@ pub(super) fn spawn_wmo_gameobject_props(
&m.submeshes,
forms.slices(&prop.handle),
prop.local, // doodad-LOCAL — the parent composes the world pose
false,
&object,
// Deck props: plain matte, like a terrain exterior prop on lit ground (a boat is
// never MCSH-shadowed). Interior props: the selector is unread — the probe lane.
ShadeSel::Matte,
+243 -6
View File
@@ -10,6 +10,7 @@ pub(crate) mod art;
pub(crate) mod backdrop;
pub(crate) mod widgets;
use bevy::ecs::entity::EntityHashSet;
use bevy::prelude::*;
use art::{tc_rect, GlueArt, BTN_BG, BTN_HOVER, BUTTON_TC, GOLD};
@@ -30,6 +31,8 @@ impl Plugin for GluePlugin {
Startup,
crate::glue_strings::load_glue_strings.after(benilla_assets::AssetSet::Open),
)
.init_resource::<GlueClicks>()
.add_systems(PreUpdate, glue_clicks.after(bevy::ui::UiSystems::Focus))
.add_systems(
Update,
(backdrop::fit_backdrop_borders, seat_outline_copies),
@@ -37,6 +40,65 @@ impl Plugin for GluePlugin {
}
}
/// The glue widgets whose click landed **this frame** — the reference's `OnClick`.
///
/// Ask this, never `Interaction::Pressed`, to run a button's action: pressing is not clicking (see
/// [`glue_clicks`]). Rebuilt every frame in `PreUpdate`, so an `Update` reader sees exactly the
/// current frame's clicks and a screen that did not run misses nothing it should have had.
#[derive(Resource, Default)]
pub(crate) struct GlueClicks(EntityHashSet);
impl GlueClicks {
/// Did this widget's click land this frame?
pub(crate) fn hit(&self, widget: Entity) -> bool {
self.0.contains(&widget)
}
}
/// The glue layer's click law: a button fires on the **release**, and only when the release lands
/// back on the button that took the press.
///
/// VERIFIED against the real 1.12.1 client (wow-re `ui.md`, the `OnDoubleClick`/`RegisterForClicks`
/// byte law): `CSimpleButton`'s ctor default click mask `[+0x330] = 0x100` is **`LeftButtonUp`
/// alone**, so a stock `<Button>` — every glue button is one — is dispatched from the mouse-**up**
/// dispatcher `0x7792d0`; the mouse-**down** dispatcher `0x779210` fires nothing a glue screen
/// registers. Two more predicates ride the release: the button must be in state `[+0x328] == 2`
/// (PUSHED — it took the press), and the release must **hit-test inside the frame** (`0x76b020`).
/// So press-and-slide-off cancels, and press-off-slide-on does nothing — which is what a player
/// expects of every button they have ever used. Our own in-game FrameXML path already implements
/// exactly this (`benilla_ui::script::pointer`'s `same_frame` gate); the glue screens were the half
/// that dispatched on the press edge, and every one of them moved here (1533).
///
/// Bevy's [`Interaction`] carries the same three states under other names, so the up edge is a
/// state transition and needs no cursor maths: `ui_focus_system` sets `Pressed` only on the frame
/// the press lands on a hovered node, holds it through a drag that leaves the node, and on release
/// clears every `Pressed` back to `None` — after which the same run re-raises `Hovered` on whatever
/// still contains the cursor. **`Pressed → Hovered` is therefore exactly "released inside"**, and
/// `Pressed → None` exactly "released outside, or hidden mid-press". The `pushed` set is the ref's
/// state byte.
pub(crate) fn glue_clicks(
interactions: Query<(Entity, &Interaction)>,
mut pushed: Local<EntityHashSet>,
mut clicks: ResMut<GlueClicks>,
) {
clicks.0.clear();
pushed.retain(|&e| match interactions.get(e) {
Ok((_, Interaction::Pressed)) => true, // still held
Ok((_, Interaction::Hovered)) => {
clicks.0.insert(e); // released inside — the click
false
}
// Released outside, hidden mid-press, or despawned: the press is simply dropped, as the
// ref drops one whose release fails the hit test.
_ => false,
});
for (e, interaction) in &interactions {
if *interaction == Interaction::Pressed {
pushed.insert(e);
}
}
}
/// Seat every outline copy at exactly ONE device pixel from its real string (`dir / scale_factor`
/// logical) — the reference's baked 1-px dilation ring, reproduced as string geometry (see
/// [`widgets::OutlineCopy`]). Runs every frame but writes only when the wanted value differs
@@ -55,6 +117,36 @@ pub(crate) fn seat_outline_copies(
}
}
/// The character-preview **drag rate**: the ref's `CHARACTER_ROTATION_CONSTANT = 0.6` degrees per
/// **UI unit** (declared once in `CharacterSelect.lua` and read by both screens' `OnUpdate`; the
/// facing setter takes degrees, deg→rad at the C boundary). Dragging right increases the facing.
///
/// It lives here for the reason this module exists: it is one number the two screens share, and it
/// had already forked — the create screen carried a bare `0.01` rad/px, 4.5 % off its neighbour and
/// off its own comment (1533). Apply it through [`drag_yaw`], never to a raw pixel delta.
pub(crate) const ROTATION_PER_UI_UNIT: f32 = 0.6 * std::f32::consts::PI / 180.0;
/// The yaw a horizontal cursor move of `delta_px` **logical** pixels turns the preview by.
///
/// `CHARACTER_ROTATION_CONSTANT` is per UI unit, not per pixel: `GetCursorPosition` (`0x46dad0`)
/// answers on the glue engine's `aspect·768 × 768` virtual canvas, so the same physical drag turns
/// the character *less* on a taller display — 0.6°/px only at a 768-line window, 0.427°/px at 1080p
/// (wow-re `glue/scratch/glue-preview-facing-law.md`, 1533). Feeding it the raw pixel delta ran the
/// drag ~40 % fast at 1080p and made it scale with the window instead of with the canvas.
///
/// The divisor is the true `height / 768`, not [`screen_scale`]: that one is clamped at 2.2 so the
/// *layout* stops growing on a very tall window, and the cursor's canvas has no such clamp.
pub(crate) fn drag_yaw(delta_px: f32, window: Option<&Window>) -> f32 {
let ui_per_px = 768.0 / window.map_or(768.0, |w| w.height().max(1.0));
delta_px * ui_per_px * ROTATION_PER_UI_UNIT
}
/// The rotate buttons' **hold rate** — the ref's `CHARACTER_FACING_INCREMENT = 2`, applied per
/// `OnUpdate` tick by both screens' `RotateLeft/Right_OnUpdate`. Ours is per *second* at the ref's
/// 60 fps, so the arrow turns at one speed instead of at the frame rate (`method.md` step 3: the
/// mechanism, not the quirk).
pub(crate) const ROTATE_RATE: f32 = 120.0 * std::f32::consts::PI / 180.0;
/// The glue engine's virtual-screen scale: the ref lays every glue screen out on a 1024×768
/// virtual screen stretched to the window, so each authored coordinate draws at `value · this`.
/// The ONE copy of the formula — every glue spawn site and every rescale check reads it here, and
@@ -120,9 +212,9 @@ pub(crate) fn glue_button_visuals(
art: Res<GlueArt>,
mut glue_btns: Query<
(
Entity,
&GlueDisabled,
&Interaction,
&Children,
&mut ImageNode,
&mut BackgroundColor,
Has<widgets::FallbackFace>,
@@ -130,10 +222,11 @@ pub(crate) fn glue_button_visuals(
),
(With<GlueBtn>, Without<ArtSwap>),
>,
children: Query<&Children>,
mut captions: Query<&mut TextColor, With<GlueCaption>>,
mut hilights: Query<&mut Visibility, With<widgets::Hilight>>,
) {
for (disabled, interaction, children, mut node, mut bg, fallback, tc) in &mut glue_btns {
for (btn, disabled, interaction, mut node, mut bg, fallback, tc) in &mut glue_btns {
let disabled = disabled.0;
let pressed = !disabled && *interaction == Interaction::Pressed;
let hovered = !disabled && *interaction != Interaction::None;
@@ -160,8 +253,14 @@ pub(crate) fn glue_button_visuals(
} else if fallback {
bg.0 = if hovered { BTN_HOVER } else { BTN_BG };
}
for child in children {
if let Ok(mut caption) = captions.get_mut(*child) {
// DESCENDANTS, not children. The caption is a **grandchild's** child: `outlined_text`
// wraps every glue string in a layout wrapper + the −1px trim node before the real string,
// so a direct-children scan finds the `Hilight` overlay and the wrapper and never the
// `GlueCaption` — which is why the sheen lit on hover for a year while the caption stayed
// gold, and why a disabled button's caption never grayed either (1533). The walk is over a
// ~11-entity subtree; depth is the primitive's business, not this pass's.
for child in children.iter_descendants(btn) {
if let Ok(mut caption) = captions.get_mut(child) {
// The disabled caption grays (`GlueFontDisable`), hover whitens, rest is gold.
caption.0 = if disabled {
Color::srgb(0.5, 0.5, 0.5)
@@ -171,7 +270,7 @@ pub(crate) fn glue_button_visuals(
GOLD
};
}
if let Ok(mut vis) = hilights.get_mut(*child) {
if let Ok(mut vis) = hilights.get_mut(child) {
*vis = if hovered {
Visibility::Inherited
} else {
@@ -184,10 +283,148 @@ pub(crate) fn glue_button_visuals(
#[cfg(test)]
mod tests {
use super::screen_scale;
use super::widgets::{FallbackFace, GlueBtn, GlueCaption, GlueDisabled};
use super::{glue_button_visuals, glue_clicks, screen_scale, GlueArt, GlueClicks, GOLD};
use bevy::prelude::*;
use bevy::window::WindowResolution;
/// Drive one widget's [`Interaction`] through a press/release and read back whether the click
/// landed — the whole of the law in [`glue_clicks`], exercised the way `ui_focus_system` writes
/// it (see that function's doc for the three transitions).
fn click_run(app: &mut App, widget: Entity, interaction: Interaction) -> bool {
*app.world_mut().get_mut::<Interaction>(widget).unwrap() = interaction;
app.update();
app.world().resource::<GlueClicks>().hit(widget)
}
fn click_app() -> (App, Entity) {
let mut app = App::new();
app.init_resource::<GlueClicks>()
.add_systems(Update, glue_clicks);
let widget = app.world_mut().spawn(Interaction::None).id();
(app, widget)
}
/// **A press is not a click.** The reference dispatches a stock `<Button>` from the mouse-UP
/// handler alone (ctor click mask `0x100` = `LeftButtonUp`), so the action must not fire while
/// the button is held — the whole glue layer used to fire here (1533).
#[test]
fn a_held_button_has_not_clicked_yet() {
let (mut app, widget) = click_app();
assert!(
!click_run(&mut app, widget, Interaction::Hovered),
"hover alone is not a click"
);
assert!(
!click_run(&mut app, widget, Interaction::Pressed),
"the press is not the click"
);
assert!(
!click_run(&mut app, widget, Interaction::Pressed),
"and holding it does not become one"
);
}
/// The release **over the button that took the press** is the click, and it fires exactly once.
#[test]
fn the_release_over_the_button_is_the_click() {
let (mut app, widget) = click_app();
click_run(&mut app, widget, Interaction::Pressed);
assert!(
click_run(&mut app, widget, Interaction::Hovered),
"released inside → the click"
);
assert!(
!click_run(&mut app, widget, Interaction::Hovered),
"and it does not repeat while the cursor rests there"
);
}
/// Press, slide off, release: **no click**. The ref hit-tests the release inside the frame
/// (`0x76b020`) before firing, which is the escape hatch every player expects of a button they
/// pressed by mistake. Bevy leaves the node `Pressed` through the slide and drops it to `None`
/// on a release elsewhere, so the law reads it off the transition.
#[test]
fn sliding_off_before_the_release_cancels() {
let (mut app, widget) = click_app();
click_run(&mut app, widget, Interaction::Pressed);
click_run(&mut app, widget, Interaction::Pressed); // dragged off, still held
assert!(
!click_run(&mut app, widget, Interaction::None),
"released outside → nothing"
);
assert!(
!click_run(&mut app, widget, Interaction::Hovered),
"and coming back afterwards is not a click either"
);
}
/// A press that begins somewhere else and releases here is not this button's click.
#[test]
fn a_release_without_a_press_is_not_a_click() {
let (mut app, widget) = click_app();
assert!(
!click_run(&mut app, widget, Interaction::Hovered),
"no press ever landed"
);
}
/// The caption whitens on hover and grays when disabled — the ref's `<HighlightFont>` /
/// `<DisabledFont>` on `GlueButtonTemplate`.
///
/// The regression this pins is a *depth* bug, so the test builds the real depth:
/// [`super::widgets::outlined_text`] wraps every glue string in a layout wrapper and a −1px
/// trim node before the string itself, which put `GlueCaption` three levels under the button
/// while `glue_button_visuals` scanned the button's direct children. The sheen (a direct child)
/// lit on hover and the caption never moved off gold (1533).
#[test]
fn the_caption_whitens_on_hover_however_deep_the_outline_nests_it() {
let mut app = App::new();
app.init_resource::<GlueArt>()
.add_systems(Update, glue_button_visuals);
let mut caption = Entity::PLACEHOLDER;
let button = app
.world_mut()
.spawn((
GlueBtn,
GlueDisabled(false),
FallbackFace,
Interaction::None,
ImageNode::default(),
BackgroundColor::DEFAULT,
))
.with_children(|btn| {
// wrapper → trim → the real string, exactly as `outlined_text` builds it.
btn.spawn(Node::default()).with_children(|wrapper| {
wrapper.spawn(Node::default()).with_children(|trim| {
caption = trim.spawn((GlueCaption, TextColor(GOLD))).id();
});
});
})
.id();
let colour = |app: &App| app.world().get::<TextColor>(caption).unwrap().0;
app.update();
assert_eq!(colour(&app), GOLD, "at rest the caption is gold");
*app.world_mut().get_mut::<Interaction>(button).unwrap() = Interaction::Hovered;
app.update();
assert_eq!(
colour(&app),
Color::WHITE,
"hover whitens it (the ref's HighlightFont)"
);
app.world_mut().get_mut::<GlueDisabled>(button).unwrap().0 = true;
app.update();
assert_ne!(
colour(&app),
Color::WHITE,
"a disabled button does not highlight"
);
assert_ne!(colour(&app), GOLD, "it grays (the ref's GlueFontDisable)");
}
/// The authored layout must FIT the window at every height — the B120 regression.
/// [`screen_scale`] is the only thing standing between a 768-unit-tall tree and a shorter
/// window, so a floor there silently amputates the bottom of every glue screen (the create
+145 -11
View File
@@ -74,6 +74,30 @@ pub(crate) type EnchantSlot = (u8, i32, u32, Option<u64>);
pub(crate) fn enchant_lines(
slots: impl IntoIterator<Item = EnchantSlot>,
enchants: Option<&Enchants>,
) -> Vec<benilla_ui::script::EnchantView> {
let lines = enchant_lines_quiet(slots, enchants);
// One breadcrumb per session the first time any surface resolves an enchant — the
// machine-readable "this lane is live" signal for a feature whose whole symptom is ABSENCE
// (`entities::item_glow`'s idiom, and the reason this one exists: the enchant ids reaching the
// tooltip travel a DIFFERENT wire field from the ones the weapon glow reads, so a silent miss
// here would look exactly like an unenchanted item).
if !lines.is_empty() {
static FIRST: std::sync::Once = std::sync::Once::new();
// The whole view, not just the name: the countdown and the charges each ride a different
// wire lane from the id, so "which of the three arrived" is exactly what this must answer.
FIRST.call_once(|| info!("item enchant: {lines:?} (the first resolved this session)"));
}
lines
}
/// [`enchant_lines`] without the breadcrumb — the same gate and the same naming, for the one
/// caller that is not a live item: the startup resolve of the whole `ItemRandomProperties` table
/// ([`random_property_views`]). Routing that through the loud one would fire "the first enchant
/// resolved this session" at load, every session, which is exactly the signal the breadcrumb
/// exists to distinguish from silence.
fn enchant_lines_quiet(
slots: impl IntoIterator<Item = EnchantSlot>,
enchants: Option<&Enchants>,
) -> Vec<benilla_ui::script::EnchantView> {
let Some(enchants) = enchants else {
return Vec::new();
@@ -100,20 +124,130 @@ pub(crate) fn enchant_lines(
})
})
.collect();
// One breadcrumb per session the first time any surface resolves an enchant — the
// machine-readable "this lane is live" signal for a feature whose whole symptom is ABSENCE
// (`entities::item_glow`'s idiom, and the reason this one exists: the enchant ids reaching the
// tooltip travel a DIFFERENT wire field from the ones the weapon glow reads, so a silent miss
// here would look exactly like an unenchanted item).
if !lines.is_empty() {
static FIRST: std::sync::Once = std::sync::Once::new();
// The whole view, not just the name: the countdown and the charges each ride a different
// wire lane from the id, so "which of the three arrived" is exactly what this must answer.
FIRST.call_once(|| info!("item enchant: {lines:?} (the first resolved this session)"));
}
lines
}
/// `ItemRandomProperties.dbc` — the **random-suffix roll**: the "of the Monkey" a drop rolled, and
/// the enchants that roll grants (decision 1547). One table, two consumers, exactly as in the
/// reference: the display NAME ([`item_display_name`], its `0x5d8b00`) and the tooltip's enchant
/// slots 2..6 ([`random_property_lines`], its §E5 suffix-row copy).
///
/// Optional like every DBC-backed resource: absent, names stay unsuffixed and a rolled item shows
/// no suffix lines — the behaviour benilla had before this arc.
#[derive(Resource)]
pub(crate) struct RandomProperties(pub(crate) benilla_formats::RandomPropertyCatalog);
/// The item's display NAME — the reference's one name formatter `0x5d8b00(entry, randomPropertyId)`,
/// whose whole law is its two exits: `ITEM_SUFFIX_TEMPLATE` (`"%s %s"`) joined with the roll's
/// suffix, or the plain template name when the id is 0, negative, past the table, or names a row
/// with no suffix string.
///
/// **Every** display of an item's name goes through here, because in the reference every one of
/// them goes through that function: the tooltip plate, the `|Hitem:…|h[Name]|h` link (the link is
/// built FROM this string), the loot row, the chat "You receive loot" line, the auction and mail
/// rows. A surface that composed the name itself would print "Chipped Claw" where the client prints
/// "Chipped Claw of the Bear" — which is exactly the drift decision 0888 recorded and this closes.
pub(crate) fn item_display_name(
base: &str,
random_property_id: i32,
props: Option<&RandomProperties>,
) -> String {
match props.and_then(|p| p.0.get(random_property_id)) {
Some(row) => format!("{base} {}", row.suffix),
None => base.to_string(),
}
}
/// The tooltip lines a random-property **roll** contributes, for a source that has no item object
/// to read `ITEM_FIELD_ENCHANTMENT` from — a loot slot, a chat link, an auction or mail row.
///
/// This is the reference's §E5 mechanism, one for one: the tooltip resolves its `+0x424`
/// randomPropertyId against `ItemRandomProperties.dbc` and copies the row's five enchant ids into
/// session slots **2..6**, which the enchant family then prints exactly like an object's own slots
/// (white, since only slots 0/1 ever colour). So the same [`enchant_lines`] gate runs over them —
/// one law for both id sources, which is the point of routing them through it.
///
/// An item OBJECT needs none of this: the server writes the rolled ids into its own enchant slots,
/// and the object path already reads them.
pub(crate) fn random_property_lines(
row: &benilla_formats::RandomProperty,
enchants: Option<&Enchants>,
) -> Vec<benilla_ui::script::EnchantView> {
let slots = row.enchants.iter().enumerate().map(|(i, &id)| {
(
benilla_formats::RANDOM_PROPERTY_FIRST_SLOT + i as u8,
id as i32,
0,
None,
)
});
enchant_lines_quiet(slots, enchants)
}
/// The whole roll table, resolved for the engine — every `ItemRandomProperties` row as the
/// suffix plus its named enchant lines, ready to push once ([`benilla_ui::script::UiScript::
/// set_random_properties`]).
///
/// Pushed whole rather than asked for per id, because it is a static table the app already holds
/// and its consumers are click-driven: a chat-link tooltip has no hover re-enter loop, so a late
/// answer would leave the first click showing an item with no lines. The reference reads its own
/// loaded DBC store the same way, at draw time, from the id the source supplied.
pub(crate) fn random_property_views(
props: &RandomProperties,
enchants: Option<&Enchants>,
) -> std::collections::HashMap<u32, benilla_ui::script::RandomPropertyView> {
props
.0
.iter()
.map(|(id, row)| {
(
id,
benilla_ui::script::RandomPropertyView {
suffix: row.suffix.clone(),
enchants: random_property_lines(row, enchants),
},
)
})
.collect()
}
/// The two catalogs a random-suffix **roll** resolves through, as one value: the roll's own table
/// and the enchant names its five ids land on. They are never useful apart — a roll is a name plus
/// a set of enchant ids, and both halves come from a DBC the app owns — and every surface that
/// shows a rolled item needs the pair, so it travels as a pair rather than as two `Option` params
/// threaded through every loot/link/auction/mail feed.
#[derive(Clone, Copy)]
pub(crate) struct RollCatalogs<'a> {
pub(crate) props: Option<&'a RandomProperties>,
pub(crate) enchants: Option<&'a Enchants>,
}
impl RollCatalogs<'_> {
/// No DBC catalogs — what the unit tests run with (no install), and what a shipping session
/// falls back to when the tables are missing: the plain name, and no suffix lines.
#[cfg(test)]
pub(crate) const NONE: RollCatalogs<'static> = RollCatalogs {
props: None,
enchants: None,
};
/// [`item_display_name`] — the item's name with this roll's suffix joined on.
pub(crate) fn name(&self, base: &str, random_property_id: u32) -> String {
item_display_name(base, random_property_id as i32, self.props)
}
/// [`random_property_lines`] for one id — the roll's enchant slots 2..6, resolved and named.
/// The live surfaces do NOT call this (the engine resolves the lines itself, off the pushed
/// table); it is the app-side check that the id → row → slot mapping is what the law says.
#[cfg(test)]
pub(crate) fn lines(&self, random_property_id: u32) -> Vec<benilla_ui::script::EnchantView> {
match self.props.and_then(|p| p.0.get(random_property_id as i32)) {
Some(row) => random_property_lines(row, self.enchants),
None => Vec::new(),
}
}
}
/// The item stores: instances by guid, templates by entry (+ the in-flight ask-once set).
/// Filled by the net bridge; read by the container APIs (`GetContainerItemInfo` and kin).
#[derive(Resource, Default)]
+17
View File
@@ -41,6 +41,7 @@ mod autocast_shine;
mod bindings;
mod blob_shadow;
mod bowstring;
mod camera_shake;
#[cfg(feature = "dev")]
mod capture;
mod char_create;
@@ -91,12 +92,17 @@ mod raid_marks;
mod run_mode;
mod screenshot;
mod shaders;
/// Where "the client is going down" may be observed, and why that is `Last` and not `Update`
/// (decision 1528). Every system that persists state on the way out registers through it.
mod shutdown;
mod smart_rect;
mod sound;
mod target;
mod textinput;
mod transport;
mod ui_action;
mod ui_auction;
mod ui_aura;
mod ui_bank;
mod ui_binder;
@@ -154,6 +160,7 @@ mod world_state;
use bevy::prelude::*;
use blob_shadow::BlobShadowPlugin;
use bowstring::BowstringPlugin;
use camera_shake::CameraShakePlugin;
use creature_anim::CreatureAnimPlugin;
use cursor::CursorPlugin;
use entities::EntitiesPlugin;
@@ -169,6 +176,7 @@ use target::TargetPlugin;
use textinput::TextInputPlugin;
use transport::TransportPlugin;
use ui_action::UiActionPlugin;
use ui_auction::UiAuctionPlugin;
use ui_aura::UiAuraPlugin;
use ui_bank::UiBankPlugin;
use ui_binder::UiBinderPlugin;
@@ -442,6 +450,11 @@ pub fn run(build: BuildId) -> AppExit {
// activation so a probe/capture launch never yanks focus off the director's screen. The
// window-side half (unfocused + always-on-bottom) is in the `Window` above. Decision 0703.
.add_plugins(benilla_world::bgwin::BgWinPlugin)
// The other winit-default the client has to undo (decision 1528): macOS's `Cmd+Q` is wired
// straight to `terminate:`, which never runs another frame — so the gesture a Mac player
// reaches for first exited without writing one line of their session. Re-pointed at the
// window close, which the shutdown tail in [`shutdown`] already sees.
.add_plugins(benilla_world::mac_quit::MacQuitPlugin)
// **The engine, as one name** (decision 1164). Everything `benilla-world` will own,
// in the order both binaries used before this group existed — see `world_plugins.rs`
// for the two ordering edges inside it that are load-bearing, and for what is
@@ -472,6 +485,7 @@ pub fn run(build: BuildId) -> AppExit {
.add_plugins(BlobShadowPlugin)
// Footprint decals (B212, decision 1006): the prints a walking unit leaves on snow/sand,
// spawn-once projections on the same decal projector, fading off the effect stream.
.add_plugins(CameraShakePlugin)
.add_plugins(FootprintsPlugin)
// GameObject animation (decision 0242): net-streamed GObjects (doors/chests) play an M2 sequence
// on GAMEOBJECT_STATE change — the state-machine sibling of the doodad idle loop above.
@@ -661,6 +675,9 @@ pub fn run(build: BuildId) -> AppExit {
.add_plugins(UiMerchantPlugin)
// The bank window (decision 0604): the SHOW_BANK session (BankOpen) + the purchase row;
// the vault's slots ride the container feed as bags −1/5..=10.
// The auction house (decision 1511) — an NPC-session window like the bank beside it, but the
// only `doublewide` panel in the UI, so it displaces both the left and center seats.
.add_plugins(UiAuctionPlugin)
.add_plugins(UiBankPlugin)
// The mail window (decision 0544 P1/P2): the client-side mailbox session (MailOpen), the
// NPC-session range guard, and MailFrame.xml over the Era mail API (inbox, open-letter,
+38 -16
View File
@@ -444,7 +444,8 @@ fn enter_login(mut form: ResMut<LoginForm>, mut preview: ResMut<GluePreview>) {
/// boxes / press the buttons / toggle the checkbox.
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
fn login_input(
presses: Query<(&LoginAction, &Interaction), Changed<Interaction>>,
presses: Query<(Entity, &LoginAction, Ref<Interaction>)>,
clicks: Res<crate::glue::GlueClicks>,
mut keyboard: MessageReader<KeyboardInput>,
keys: Res<ButtonInput<KeyCode>>,
// The host pasteboard + the window handle the Wayland backend needs (decision 0702).
@@ -470,21 +471,41 @@ fn login_input(
// While a dialog is up it owns the input; the box/button surface underneath is inert.
let dialog_open = dialog.kind.is_some();
for (action, interaction) in &presses {
if *interaction != Interaction::Pressed || dialog_open {
// **The edit boxes focus on the PRESS**, and only they. The reference's `CEditBox` takes focus
// from its own OnMouseDown handler (`0x77b800`), unconditionally and autoFocus-independent
// (wow-re `ui.md`) — an edit box is not a Button and does not wait for the release. Every
// *button* on this screen fires from the release loop below (1533).
for (entity, action, interaction) in &presses {
if dialog_open {
continue;
}
// **The edit boxes focus on the PRESS**, and only they: the reference's `CEditBox` takes
// focus from its own OnMouseDown handler (`0x77b800`), unconditionally and
// autoFocus-independent (wow-re `ui.md`) — an edit box is not a Button and does not wait
// for the release. `Ref` supplies the press *edge* the old `Changed<Interaction>` filter
// gave, without costing this system a second query.
if interaction.is_changed() && *interaction == Interaction::Pressed {
match action {
// A click takes the focus the same way TAB does — including the solid caret the
// fresh focus starts on, so the box you just clicked never answers with a blank
// half-period.
LoginAction::FocusAccount => {
form.focus = Field::Account;
form.focused().reset_blink();
}
LoginAction::FocusPassword => {
form.focus = Field::Password;
form.focused().reset_blink();
}
_ => {}
}
}
// Everything else on this screen is a Button, and a Button fires on the RELEASE (1533).
if !clicks.hit(entity) {
continue;
}
match action {
// A click takes the focus the same way TAB does — including the solid caret the fresh
// focus starts on, so the box you just clicked never answers with a blank half-period.
LoginAction::FocusAccount => {
form.focus = Field::Account;
form.focused().reset_blink();
}
LoginAction::FocusPassword => {
form.focus = Field::Password;
form.focused().reset_blink();
}
LoginAction::FocusAccount | LoginAction::FocusPassword => {} // focused on the press
LoginAction::Login => do_login = true,
LoginAction::Quit => do_quit = true,
LoginAction::ToggleSave => {
@@ -670,7 +691,8 @@ fn drive_dialog(
assets: Res<AssetServer>,
strings: Option<Res<GlueStrings>>,
keys: Res<ButtonInput<KeyCode>>,
presses: Query<(&LoginAction, &Interaction), Changed<Interaction>>,
buttons: Query<(Entity, &LoginAction)>,
clicks: Res<crate::glue::GlueClicks>,
mut texts: Query<&mut Text, With<screen::DialogText>>,
window: Query<&Window, With<bevy::window::PrimaryWindow>>,
) {
@@ -714,9 +736,9 @@ fn drive_dialog(
}
// The one button (or its keys).
let mut pressed = presses
let mut pressed = buttons
.iter()
.any(|(a, i)| matches!(a, LoginAction::Dialog) && *i == Interaction::Pressed);
.any(|(e, a)| matches!(a, LoginAction::Dialog) && clicks.hit(e));
if keys.just_pressed(KeyCode::Escape) {
pressed = true;
}
+265 -69
View File
@@ -11,9 +11,10 @@
//! data that is exactly the 32 world-PvP tower rows, e.g. the EPL towers). An ordinary
//! in-range landmark (town/city, `Flags & 2` clear) draws nothing.
//! - **Out-of-range**, `d ≤ 694.444`: the nearest **3**, ranked by (`Importance` **signed**
//! asc, dist asc) — the rank key is Importance, NOT AreaID (the fold-back's C2 correction —
//! draw as `Rotating-MinimapArrow` instances ON the 0.8 rim, each **rotated to point at its
//! POI** (the client bakes direction + `0.8·radius` into the arrow frame's matrix).
//! asc, dist asc) — the rank key is Importance, NOT AreaID (the fold-back's C2 correction) —
//! drawn ON the 0.8 rim, each **rotated to point at its POI** (the client bakes direction +
//! `0.8·radius` into the arrow frame's matrix), in the art its **source** calls for: see
//! [`RimArrow`], the four-way table 1519 pinned.
//!
//! **Quest dots** — the ObjectIcons classifier `0x4eaa90`: reads the SAME per-guid
//! DIALOG_STATUS cache the overhead `!`/`?` markers mirror (one `SMSG_QUESTGIVER_STATUS`, two
@@ -40,19 +41,35 @@
//! dot 8 px, POI icon 16 px, orbit 0.8 × the 70.4-px half-disc; the arrows render their M2
//! models at 768 px/unit (rim, modelScale 0.6) and 1280 px/unit (player). Both arrow models
//! are plain textured quads (measured from the real M2s), so the flat-sprite stand-in is
//! geometry-exact at the model's own quad size — no uv crop, the padding is authored.
//! geometry-exact at the model's own quad size — no uv crop, the padding is authored. The rim
//! model stacks six of them, but only ever shows one at a time ([`RimArrow`]).
//!
//! **The guard's directions marker** ([`crate::poi_marker`], decisions 1514/1516) is a landmark
//! candidate like any other, appended after the DBC filter the way the reference appends its
//! static blip slot — everything above applies to it unchanged.
//! static blip slot — everything above applies to it unchanged, except the one thing that is
//! *about* being a different kind of thing: its rim arrow is the gold guide arrow ([`RimArrow`]).
//!
//! Remaining residue: two gates the landmark selection here does NOT yet implement, both found by
//! the 1516 §5 and both belonging to the DBC rows rather than the marker — a `WorldStateID` gate
//! at `0x6d9b27`, and a different rim-arrow model id per source (`0xa6` landmark, `0xa7` corpse,
//! `0xa8` the guard marker, `0x4ed349`-`0x4ed37b`) where we draw one arrow for all three. Also:
//! the rim arrow model stacks SIX copies of its quad (an alpha-layering
//! trick that solidifies the soft texture edges) — we draw one, a brightness nuance for the
//! director's eye; dots re-project every frame where the reference draws ~1 Hz-stale
//! Remaining residue: one gate the landmark selection here does NOT implement, found by the 1516
//! §5 and belonging to the DBC rows rather than the marker — a `WorldStateID` gate at `0x6d9b27`.
//!
//! Three more from the 1525 §5, all recorded rather than built (each is a visible change to a
//! feature that is not the arrow art, and one is contested):
//! - **The corpse is in the wrong array and the pet blip is missing** — see [`emit_party_arrows`].
//! - **Rim arrows have a per-source z-order we flatten.** `+0x48` doubles as a frame-level offset
//! (`[minimap+0xc4]+3+kind`, POI frames re-levelled every draw at `0x4ed344`; party frames once
//! at creation), giving bottom→top: landmark, party/pet, quest, gossip, corpse. We paint every
//! arrow at one `z_key` in emission order, which happens to agree on landmark-under-party and
//! disagrees above that. **NOT built because wow-re's two findings conflict**: `0x4ed7b7` has the
//! object dots drawing LAST, above every arrow, while this table puts gossip and corpse above the
//! quest dot. Frame level vs. the parent's own draw order is a compositing question neither note
//! settles, and guessing would move a look on a coin flip. Needs its own scoped pin.
//! - **Static blip slot 0 is `SelectQuestLogEntry()`'s marker** (`0x4def70`, cleared by
//! `0x4df0e0`): selecting a quest in the log drops a gold guide arrow at that quest's POI —
//! `flags = 0`, so it is **arrow-only**, showing nothing once you are inside the 0.8 rim. Live
//! code, not a dead hook, and a feature we do not have. (Its `PointX/PointY` source is INFERRED
//! from use, not byte-derived.)
//!
//! Also: dots re-project every frame where the reference draws ~1 Hz-stale
//! snapshot coords verbatim (a throttle quirk, deliberately not aped — positions agree for
//! standing NPCs); the subzone grey tint (`0xffb0b0b0`) on dots and their tooltip is drawn
//! from the indoor-containment MISMATCH — the exact `0x670540` compare is INTERIM pending its
@@ -102,12 +119,116 @@ pub(super) const BLIP_BASIS_PX: f32 = 140.8;
pub(super) const PLAYER_ARROW_QUAD_PX: f32 = 33.6;
pub(super) const PLAYER_ARROW_OFFSET_PX: bevy::math::Vec2 = bevy::math::Vec2::new(0.51, -1.73);
/// The rim arrow's on-screen quad: the `Rotating-MinimapArrow.m2` geometry is a stack of six
/// identical full-texture 0.0500-unit quads (measured from the real M2's 24 vertices — the
/// stack is an alpha-layering trick, z 0/0.0145/0.0291), and the frame renders at 768 px per
/// model unit (modelScale 0.6 × 1280 px/unit): 0.0500 · 768 = 38.4 px. The full 32² texture
/// maps onto it (uv 0..1 on every layer), so the flat-sprite stand-in needs NO uv crop — the
/// art's transparent padding is authored into the model's own quad.
/// full-texture 0.0500-unit quads (measured from the real M2's 24 vertices, z 0/0.0145/0.0291),
/// and the frame renders at 768 px per model unit (modelScale 0.6 × 1280 px/unit):
/// 0.0500 · 768 = 38.4 px. The full 32² texture maps onto it (uv 0..1 on every layer), so the
/// flat-sprite stand-in needs NO uv crop — the art's transparent padding is authored into the
/// model's own quad.
///
/// The six quads are **not** an alpha-layering trick on one texture, as this note used to say
/// (1519): they carry six *different* textures, four of which are the four arrow arts, and the
/// sequence played picks which one is opaque. See [`RimArrow`] — that is why only one of the six
/// ever draws, and why the stand-in is a single sprite rather than a stack.
const ARROW_QUAD_PX: f32 = 38.4;
/// **Which rim-arrow art a blip source draws** — the reference's four, and why they look like one.
///
/// `Rotating-MinimapArrow.mdx` is the *only* arrow model the minimap owns (`0x4ee2b0` binds it to
/// `minimapArrowModel` for both the party frames and the POI frames), so a reader naturally
/// concludes there is one arrow art. There are four, and the model picks between them **by
/// animation**: `0x4ed349`–`0x4ed37b` hands `0x76cf50` — the SetSequence arm, `0x76cf50(this, seq)`
/// → `0x7121a0(model, -1, seq, -1, 0, 1.0, 0, 1)` (wow-re `modelframe-animation-clock.md`) — an
/// `AnimationData.dbc` id chosen off the output record's `+0x48`, and `0x4ee170` arms the 5 party
/// frames with `0xa5`. Both wow-re notes that carry those ids call the argument a "model id" and
/// gloss it as four `.mdx` variants; it is a sequence id, and there is one model.
///
/// The model is what proves the mapping, and it is measured, not inferred: the install's own
/// `Rotating-MinimapArrow.m2` authors **six** textured quads — the four arrow arts below, plus a
/// glow and `Spells\Star4` — and exactly eight sequences, `0xa5`/`0xa6`/`0xa7`/`0xa8` (1 s, looping)
/// and `0xcc`–`0xcf` (2 s, one-shot arrival flashes). Every layer's M2Color **alpha** track is
/// `interp = 0` (step), and in each looping band exactly one arrow layer steps to 1.0 while the
/// other three sit at 0.0 — so "play sequence `0xa8`" *is* "draw the guide arrow". The four arrow
/// layers' RGB tracks are a single `[1,1,1]` key (untinted: the art's own colour), and the glow and
/// star layers hold alpha 0 through all four loops — they light only in the one-shot flashes, which
/// this call site never plays. So one flat sprite per source is colour- and geometry-exact.
/// [`tests::each_rim_arrow_sequence_lights_exactly_its_own_layer`] re-measures all of that against
/// the real file.
///
/// `AnimationData.dbc` names the four rows outright — 165 `GroupArrow`, 166 `Arrow`, 167
/// `CorpseArrow`, 168 `GuideArrow` (1525's §5, which derived the whole table independently and
/// agreed). Four arbitrary-looking ids landing on four names that describe their callers exactly is
/// why this is settled rather than merely likely.
///
/// **The selector is a default, not an enumeration** (1525): `0xa7` if `+0x48 == 2`, else
/// `0xa6 + 2·(v != -2)`. Only a DBC landmark and the corpse are special-cased; **everything else
/// draws the gold guide arrow**, including static slot 0. So [`Self::Guide`] is the right art for
/// any future non-DBC candidate we append, and the two named rows are the exceptions to it.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum RimArrow {
/// An `AreaPOI.dbc` landmark — sequence `0xa6`, output record `+0x48 == -2`. The white one.
Landmark,
/// A party/raid member **or your pet** — sequence `0xa5`, armed once at `0x4ee1e3` and never
/// re-armed: neither draw loop touches it, and a dead/offline/zoned-out member is hidden
/// upstream rather than re-skinned (1525). Permanently the group arrow.
Group,
/// Your own corpse — sequence `0xa7`, output record `+0x48 == 2`.
Corpse,
/// The guard's directions ([`crate::poi_marker`]) — sequence `0xa8`, the default arm. The gold
/// one: the guard is your *guide*, and the art is named for it.
Guide,
}
impl RimArrow {
/// The `AnimationData.dbc` sequence the reference plays to select this art.
///
/// Nothing in our draw path needs it — we resolve to a flat sprite and never play a sequence —
/// so it exists for the one job only the id can do: asking the real model what that sequence
/// shows, which is what pins [`Self::texture`] to something better than a guess. Test-only for
/// the same reason `poi_marker`'s `ICON_POI_REDFLAG` is.
#[cfg(test)]
pub(crate) const fn anim_id(self) -> u16 {
match self {
Self::Group => 0xa5,
Self::Landmark => 0xa6,
Self::Corpse => 0xa7,
Self::Guide => 0xa8,
}
}
/// The `.blp` that sequence lights — the layer whose M2Color alpha steps to 1.0 in its band.
pub(crate) const fn texture(self) -> &'static str {
match self {
Self::Landmark => "Interface\\Minimap\\Rotating-MinimapArrow",
Self::Group => "Interface\\Minimap\\Rotating-MinimapGroupArrow",
Self::Corpse => "Interface\\Minimap\\Rotating-MinimapCorpseArrow",
Self::Guide => "Interface\\Minimap\\Rotating-MinimapGuideArrow",
}
}
/// The four, in [`RimArrowArt`]'s slot order.
pub(crate) const ALL: [Self; 4] = [Self::Landmark, Self::Group, Self::Corpse, Self::Guide];
}
/// The four rim-arrow textures, loaded once — the flat-sprite stand-in for the one model the
/// reference re-animates. A missing slot draws no arrow for that source rather than the wrong one.
#[derive(Default)]
pub(crate) struct RimArrowArt([Option<Handle<Image>>; 4]);
impl RimArrowArt {
pub(crate) fn set(&mut self, kind: RimArrow, tex: Option<Handle<Image>>) {
self.0[kind as usize] = tex;
}
pub(super) fn get(&self, kind: RimArrow) -> Option<&Handle<Image>> {
self.0[kind as usize].as_ref()
}
/// Any art at all — the landmark pass runs on the arrow art alone (it draws the guard marker
/// even with no `AreaPOI.dbc`), so it needs to know whether the load found anything.
pub(super) fn any(&self) -> bool {
self.0.iter().any(Option::is_some)
}
}
/// An in-range POI icon's quad: 16 × 16 px (`bc7658 = base·0.0125` × 1280, ctor-frozen).
const POI_ICON_PX: f32 = 16.0;
// The dot-layer constants (cell coords, dot sizes, the tracking-predicate constants) live
@@ -204,8 +325,10 @@ impl BlipCtx {
pub(super) struct LandmarkSelection<'a> {
/// In-range rows with [`FLAG_IN_RANGE_ICON`]: the `Icon` POIIcons cell at true position.
pub(super) icons: Vec<&'a AreaPoi>,
/// The out-of-range nearest-3 `(dist, poi)` in rank order: rim arrows.
pub(super) arrows: Vec<(f32, &'a AreaPoi)>,
/// The out-of-range nearest-3 `(dist, poi, art)` in rank order: rim arrows. The art is the
/// reference's `+0x48` read ([`RimArrow`]) — a DBC row draws the white landmark arrow, the
/// appended guard marker the gold guide arrow.
pub(super) arrows: Vec<(f32, &'a AreaPoi, RimArrow)>,
}
/// The byte-verified landmark selection: candidacy (`ContinentID` + `Flags&1`), the 0.8
@@ -226,18 +349,19 @@ pub(super) fn select_landmarks<'a>(
radius_yd: f32,
) -> LandmarkSelection<'a> {
let mut icons = Vec::new();
let mut arrows: Vec<(f32, &AreaPoi)> = Vec::new();
let mut arrows: Vec<(f32, &AreaPoi, RimArrow)> = Vec::new();
let candidates = pois
.filter(|p| p.continent_id == map_id && p.flags & FLAG_CANDIDATE != 0)
.chain(marker);
for p in candidates {
.map(|p| (p, RimArrow::Landmark))
.chain(marker.map(|m| (m, RimArrow::Guide)));
for (p, art) in candidates {
let d = ((p.pos[0] - wx).powi(2) + (p.pos[1] - wy).powi(2)).sqrt();
if d / radius_yd <= BLIP_EDGE_RATIO {
if p.flags & FLAG_IN_RANGE_ICON != 0 {
icons.push(p);
}
} else if d <= LANDMARK_RANK_YD {
arrows.push((d, p));
arrows.push((d, p, art));
}
}
arrows.sort_by(|a, b| {
@@ -268,7 +392,7 @@ pub(super) fn emit_landmarks(
cat: Option<&AreaPoiCatalog>,
marker: Option<&AreaPoi>,
map_id: u32,
arrow_tex: &Handle<Image>,
arrows: &RimArrowArt,
poi_icons: Option<&Handle<Image>>,
quads: &mut UiQuads,
hover: &mut MinimapBlipHover,
@@ -305,26 +429,13 @@ pub(super) fn emit_landmarks(
}
}
}
for (_d, poi) in &sel.arrows {
let dir = ctx.offset(poi.pos).normalize_or_zero();
let pos = ctx.center + dir * (ctx.side * 0.5 * BLIP_EDGE_RATIO);
// Point the arrow at its POI: quad rotation is clockwise on a y-down screen, and
// the authored art reads screen-up at zero, so up→dir is atan2(x, -y).
let rotation = dir.x.atan2(-dir.y);
let rect =
Rect::from_center_size(pos, Vec2::splat(ctx.side * (ARROW_QUAD_PX / BLIP_BASIS_PX)));
// ONE pass of the texture. The model authors a six-quad alpha stack, but replaying it
// as flat UI quads read as solid blobs (director, 2026-07-13 — the reference runs it
// through the model material pipeline, whose blend mode is unread); the single soft
// pass is the approved look.
quads.overlays.push(UiQuad {
rect,
z_key: ctx.z,
texture: Some(arrow_tex.clone()),
color: [1.0, 1.0, 1.0, ctx.alpha],
rotation,
..default()
});
for (_d, poi, art) in &sel.arrows {
// No art for this source ⇒ no arrow. Drawing another source's would be a lie about
// which kind of thing is out there, which is the whole job of the four arts.
let Some(tex) = arrows.get(*art) else {
continue;
};
let rect = push_rim_arrow(ctx, poi.pos, tex, quads);
if let (Some(c), Some(ui)) = (ctx.cursor, ctx.cursor_ui) {
if rect.contains(c) {
*hover = MinimapBlipHover::Landmark(poi.name.clone(), ui);
@@ -333,6 +444,36 @@ pub(super) fn emit_landmarks(
}
}
/// One rim arrow: parked on the 0.8 rim in the target's direction and spun to point at it.
/// Returns its screen rect, for the hover hit-test.
///
/// ONE pass of the texture. The model authors six quads, but replaying a stack as flat UI quads
/// read as solid blobs (director, 2026-07-13 — the reference runs it through the model material
/// pipeline, whose blend mode is unread); the single soft pass is the approved look, and 1519's
/// measurement says it is also the exact one — only a single layer is ever opaque.
fn push_rim_arrow(
ctx: &BlipCtx,
target: [f32; 3],
tex: &Handle<Image>,
quads: &mut UiQuads,
) -> Rect {
let dir = ctx.offset(target).normalize_or_zero();
let pos = ctx.center + dir * (ctx.side * 0.5 * BLIP_EDGE_RATIO);
// Point the arrow at its target: quad rotation is clockwise on a y-down screen, and the
// authored art reads screen-up at zero, so up→dir is atan2(x, -y).
let rotation = dir.x.atan2(-dir.y);
let rect = Rect::from_center_size(pos, Vec2::splat(ctx.side * (ARROW_QUAD_PX / BLIP_BASIS_PX)));
quads.overlays.push(UiQuad {
rect,
z_key: ctx.z,
texture: Some(tex.clone()),
color: [1.0, 1.0, 1.0, ctx.alpha],
rotation,
..default()
});
rect
}
/// A party member's blip position: the live streamed transform wins; out of visibility range the
/// `PARTY_MEMBER_STATS` snapshot position covers (the wire truncates to i16 — yard precision,
/// invisible at minimap scale). No source → no blip (offline members carry no position).
@@ -354,42 +495,52 @@ fn party_member_pos(
}
/// The party/corpse **rim arrows** — the out-of-range half of `place_party_raid_blips`
/// (`0x6dad10`, VERIFIED): `d = √(dx²+dy²)`; over `0.8·radius` the member renders as the same
/// `Rotating-MinimapArrow` the POI edge arrows use (the 5-slot sibling array `this+0x320`,
/// 4 members + own corpse), on the 0.8 rim, rotated to the atan2 bearing. Drawn with the POI
/// arrows — before the player arrow; the in-range members become dots in [`emit_party_dots`],
/// drawn last with the object dots.
/// (`0x6dad10`, VERIFIED): `d = √(dx²+dy²)`; over `0.8·radius` the member rides the 0.8 rim,
/// rotated to the atan2 bearing (the 5-slot sibling array `this+0x320`). Drawn with the POI arrows
/// — before the player arrow; the in-range members become dots in [`emit_party_dots`], drawn last
/// with the object dots.
///
/// **The two sources draw different art** (1519), which this pass used to get wrong twice over: it
/// drew both with `MinimapArrow` — the *player* arrow, not a rim arrow at all. A member is the
/// party frames' `0xa5` ⇒ [`RimArrow::Group`]; your corpse is the corpse blip's `0xa7` ⇒
/// [`RimArrow::Corpse`], the art named for it. Both confirmed at the bytes by 1525's §5.
///
/// **But the corpse does not belong in this array, and the slot it occupies belongs to your PET**
/// (1525, VERIFIED — recorded here, not yet built, because it is a visible change to the corpse
/// blip 0308 rather than to this session's arrow art). `0x6dad10`'s loop splits at
/// `0x6dad60 cmp edi,4`: slots 0–3 are the party GUIDs via `0x4e81a0`, and slot **4** reads
/// `UNIT_FIELD_CHARM` else `UNIT_FIELD_SUMMON` off the descriptor base — the pet. Both legs fetch
/// with typemask UNIT (`mov ecx,8`), and a `CGCorpse` is typemask `0x80`, so a corpse could never
/// resolve there; the older wow-re note's "4 members + own corpse" is wrong the same way its "3
/// static slots" account was. The corpse reaches the minimap **only** as static blip slot 2 on the
/// POI path, which is a materially different producer: `Importance = -1` (so it outranks every
/// landmark and *takes* one of the three rim slots rather than adding a fourth), and the sole
/// exemption from the 694.444-yd cut at `0x6d9cc2`. Two consequences we currently get wrong — we
/// can draw four rim arrows where the reference draws three — and one feature we are missing
/// outright: the **pet blip**.
pub(super) fn emit_party_arrows(
ctx: &BlipCtx,
group: &crate::ui_party::GroupState,
guids: &GuidIndex,
unit_pos: &Query<&GlobalTransform, With<NetEntity>>,
corpse: Option<[f32; 3]>,
arrow_tex: &Handle<Image>,
arrows: &RimArrowArt,
quads: &mut UiQuads,
) {
let members = group
.party_slots()
.filter_map(|m| party_member_pos(m, group, guids, unit_pos));
for (x, y) in members.chain(corpse.map(|c| (c[0], c[1]))) {
.filter_map(|m| party_member_pos(m, group, guids, unit_pos))
.map(|p| (p, RimArrow::Group));
let corpse = corpse.map(|c| ((c[0], c[1]), RimArrow::Corpse));
for ((x, y), art) in members.chain(corpse) {
let d = ((x - ctx.wx).powi(2) + (y - ctx.wy).powi(2)).sqrt();
if d / ctx.radius_yd <= BLIP_EDGE_RATIO {
continue; // in range — the dot pass draws it
}
let dir = ctx.offset([x, y, 0.0]).normalize_or_zero();
let pos = ctx.center + dir * (ctx.side * 0.5 * BLIP_EDGE_RATIO);
let rotation = dir.x.atan2(-dir.y);
quads.overlays.push(UiQuad {
rect: Rect::from_center_size(
pos,
Vec2::splat(ctx.side * (ARROW_QUAD_PX / BLIP_BASIS_PX)),
),
z_key: ctx.z,
texture: Some(arrow_tex.clone()),
color: [1.0, 1.0, 1.0, ctx.alpha],
rotation,
..default()
});
let Some(tex) = arrows.get(art) else {
continue;
};
push_rim_arrow(ctx, [x, y, 0.0], tex, quads);
}
}
@@ -444,6 +595,43 @@ pub(super) fn drive_blip_tooltip(
mod tests {
use super::*;
/// **The whole [`RimArrow`] table, re-measured against the install's own model.**
///
/// This is the fact the feature rests on, and it is not one you can read off the reference's
/// call site: `0x4ed349`–`0x4ed37b` only proves *which id* each blip source plays. What that id
/// draws lives in `Rotating-MinimapArrow.m2`, so the model is the authority, and asserting
/// against it means a wrong row here fails rather than shipping the wrong arrow.
///
/// It checks both directions on purpose: each sequence lights **exactly one** layer (a table
/// that merely named a texture the sequence happens to show would pass a one-sided check while
/// three other arrows drew on top of it), and that layer is the one this table claims.
#[test]
fn each_rim_arrow_sequence_lights_exactly_its_own_layer() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::Chain::open(&data).expect("the patch chain");
let bytes = chain
.read_file("Interface\\Minimap\\Rotating-MinimapArrow.m2")
.expect("the one rim-arrow model");
for kind in RimArrow::ALL {
let shown = benilla_formats::m2_sequence_visible_textures(&bytes, kind.anim_id())
.unwrap_or_else(|| {
panic!(
"{kind:?}: the model authors no sequence {:#x} — the id \
is wrong, or this is not the model the reference animates",
kind.anim_id()
)
});
let want = format!("{}.BLP", kind.texture().to_uppercase());
assert_eq!(
shown,
vec![want],
"{kind:?} (sequence {:#x}) must light its layer and ONLY its layer",
kind.anim_id()
);
}
}
fn poi(importance: u32, flags: u32, x: f32, name: &str) -> AreaPoi {
AreaPoi {
importance,
@@ -474,7 +662,7 @@ mod tests {
];
let sel = select_landmarks(rows.iter(), None, 0, 0.0, 0.0, 133.0);
assert!(sel.icons.is_empty(), "no Flags&2 row is in range");
let names: Vec<&str> = sel.arrows.iter().map(|(_, p)| p.name.as_str()).collect();
let names: Vec<&str> = sel.arrows.iter().map(|(_, p, _)| p.name.as_str()).collect();
assert_eq!(names, ["Stormwind", "Goldshire"]);
}
@@ -491,7 +679,7 @@ mod tests {
poi(0, 1, 695.0, "beyond-rank"),
];
let sel = select_landmarks(rows.iter(), None, 0, 0.0, 0.0, 100.0);
let names: Vec<&str> = sel.arrows.iter().map(|(_, p)| p.name.as_str()).collect();
let names: Vec<&str> = sel.arrows.iter().map(|(_, p, _)| p.name.as_str()).collect();
assert_eq!(names, ["minor-near", "minor-far", "city-near"]);
}
@@ -508,12 +696,20 @@ mod tests {
];
let marker = poi(0, 0x63, 500.0, "Stormwind Warrior Trainer");
let sel = select_landmarks(cities.iter(), Some(&marker), 0, 0.0, 0.0, 133.0);
let names: Vec<&str> = sel.arrows.iter().map(|(_, p)| p.name.as_str()).collect();
let names: Vec<&str> = sel.arrows.iter().map(|(_, p, _)| p.name.as_str()).collect();
assert_eq!(
names,
["Stormwind Warrior Trainer", "Stormwind", "Goldshire"],
"Importance 0 ranks the marker first; the third city is crowded out"
);
// …and it is drawn in gold, not in the cities' white: the reference's `+0x48` read
// (1519). A rim slot won with the wrong art would look like just another town.
let arts: Vec<RimArrow> = sel.arrows.iter().map(|&(_, _, a)| a).collect();
assert_eq!(
arts,
[RimArrow::Guide, RimArrow::Landmark, RimArrow::Landmark],
"the guard's directions draw the guide arrow; DBC rows draw the landmark arrow"
);
}
/// In range, the marker draws its own `POIIcons` cell at its true position — `Flags & 2` is
+27 -16
View File
@@ -243,10 +243,10 @@ struct MinimapAssets {
/// The shared POI atlas (`Interface\Minimap\POIIcons`) — the corpse blip's skull cell
/// (decision 0308 §5) and any later POI rides it.
poi: Option<Handle<Image>>,
/// The landmark-arrow art — the flat `.blp` of the reference's `minimapArrowModel`
/// (`Rotating-MinimapArrow.mdl`); a stand-in like the player arrow's (0203's flagged
/// simplification, pending the dispatched §5 draw-law verdict).
landmark: Option<Handle<Image>>,
/// The **four** rim-arrow arts — the flat `.blp` stand-ins for the one `minimapArrowModel`
/// (`Rotating-MinimapArrow.mdx`) the reference re-animates per blip source. See
/// [`blips::RimArrow`] for the sequence→layer table and why there are four of them.
rim_arrows: blips::RimArrowArt,
/// The unit-blip atlas (`Interface\Minimap\ObjectIcons`, five 32-px dot cells) — the
/// quest-giver dots.
object_icons: Option<Handle<Image>>,
@@ -289,8 +289,10 @@ fn setup_minimap(
let mask = assets.mask_texture("Textures\\MinimapMask", &mut images);
let arrow = assets.sprite_texture("Interface\\Minimap\\MinimapArrow", &mut images);
let poi = assets.sprite_texture("Interface\\Minimap\\POIIcons", &mut images);
let landmark =
assets.sprite_texture("Interface\\Minimap\\ROTATING-MINIMAPARROW", &mut images);
let mut rim_arrows = blips::RimArrowArt::default();
for kind in blips::RimArrow::ALL {
rim_arrows.set(kind, assets.sprite_texture(kind.texture(), &mut images));
}
let object_icons =
assets.sprite_texture("Interface\\Minimap\\ObjectIcons", &mut images);
let pois = {
@@ -321,7 +323,7 @@ fn setup_minimap(
mask,
arrow,
poi,
landmark,
rim_arrows,
object_icons,
pois,
forms,
@@ -710,8 +712,11 @@ fn emit_minimap(
let blip_ctx = (blip_px_per_yd > 0.0).then(|| {
if std::env::var("WOW_MM_BLIP_PROBE").is_ok() {
eprintln!(
"BLIP-PROBE: landmark_tex={} pois={} map={} wx={wx:.0} wy={wy:.0} px_per_yd={blip_px_per_yd:.3} track_c={:#x} track_r={:#x} track_s={}",
assets.landmark.is_some(),
"BLIP-PROBE: arrow_art={} pois={} map={} wx={wx:.0} wy={wy:.0} px_per_yd={blip_px_per_yd:.3} track_c={:#x} track_r={:#x} track_s={}",
blips::RimArrow::ALL
.iter()
.filter(|k| assets.rim_arrows.get(**k).is_some())
.count(),
assets.pois.as_ref().map(|c| c.len()).unwrap_or(0),
map.0,
tracking.creatures,
@@ -753,26 +758,32 @@ fn emit_minimap(
// The guard-directions marker rides this pass as a landmark candidate, the way the
// reference appends its static blip slot after the DBC scan — so it draws even when
// `AreaPOI.dbc` failed to load, and the pass runs on the arrow art alone.
if let Some(tex) = &assets.landmark {
if assets.rim_arrows.any() {
blips::emit_landmarks(
ctx,
assets.pois.as_ref(),
poi_marker.on_map(map.0),
map.0,
tex,
&assets.rim_arrows,
assets.poi.as_ref(),
&mut quads,
&mut hover,
);
}
// The party/corpse rim arrows (0434 phase 6b, `place_party_raid_blips`' out-of-range
// half) draw with the POI arrows — before the player arrow, per the client's order.
if let Some(arrow) = &assets.arrow {
// The party/corpse rim arrows (0434 phase 6b, `place_party_raid_blips`' out-of-range
// half) draw with the POI arrows — before the player arrow, per the client's order.
let corpse = death_net
.corpse
.filter(|cp| cp.display_map == map.0 as i32)
.map(|cp| cp.position);
blips::emit_party_arrows(ctx, &group, &guids, &unit_pos, corpse, arrow, &mut quads);
blips::emit_party_arrows(
ctx,
&group,
&guids,
&unit_pos,
corpse,
&assets.rim_arrows,
&mut quads,
);
}
}
+68
View File
@@ -1263,6 +1263,71 @@ pub(crate) enum ClientCommand {
/// login to seed `HasNewMail()`/the minimap letter icon (decision 0544 P3, sent by
/// `crate::ui_mail`'s world-enter one-shot).
QueryNextMailTime,
// ── The auction house arc (decision 1511; writer bodies in benilla-protocol
// `world/writer/auction.rs`). Every verb carries the auctioneer guid — the server
// re-checks the 5 yd interact distance on each one, so there is no session token, and
// several refusals (a zero bid/duration, an unaffordable bid, a cancel whose cut can't be
// paid, a list request while one is in flight) come back as NO packet at all. ──────────
//
// Each variant carries `#[allow(dead_code)]`: decision 1511 P0 is the WIRE, and the
// auction window that will construct these is a later phase. The allow is per-variant
// rather than on the enum so a genuinely dead command elsewhere still surfaces, and each
// one comes off the moment its caller lands.
/// Greet an auctioneer (`MSG_AUCTION_HELLO`, one guid) — the two-way opcode whose *reply*
/// (an `AuctionHello` event, carrying the `AuctionHouse.dbc` house id) opens the window.
#[allow(dead_code)]
AuctionHello { auctioneer: u64 },
/// Ask a Browse page (`CMSG_AUCTION_LIST_ITEMS`): the search filters exactly as the server
/// reads them, with `benilla_protocol::messages::auction_filter`'s sentinels for the unset
/// ones. **No sort rides the wire** — sorting the page is ours. `list_from` pages by 50.
/// Answered by `SMSG_AUCTION_LIST_RESULT` (an `AuctionListResult` event).
#[allow(dead_code)]
AuctionListItems {
auctioneer: u64,
list_from: u32,
searched_name: String,
level_min: u8,
level_max: u8,
slot_id: u32,
main_category: u32,
sub_category: u32,
quality: u32,
usable: u8,
},
/// Ask the Auctions tab page (`CMSG_AUCTION_LIST_OWNER_ITEMS`) — our own listings.
#[allow(dead_code)]
AuctionListOwnerItems { auctioneer: u64, list_from: u32 },
/// Ask the Bid tab page (`CMSG_AUCTION_LIST_BIDDER_ITEMS`). `auction_ids` is a **refresh
/// set**, not a filter: those rows are emitted first, then every auction we currently hold the
/// bid on. Empty for a plain page.
#[allow(dead_code)]
AuctionListBidderItems {
auctioneer: u64,
list_from: u32,
auction_ids: Vec<u32>,
},
/// List an item for auction (`CMSG_AUCTION_SELL_ITEM`) — the Create Auction pane.
/// `etime_minutes` must be 120, 480 or 1440; the deposit leaves the purse immediately.
#[allow(dead_code)]
AuctionSellItem {
auctioneer: u64,
item_guid: u64,
bid: u32,
buyout: u32,
etime_minutes: u32,
},
/// Bid on — or buy out — an auction (`CMSG_AUCTION_PLACE_BID`). One verb for both: a `price`
/// at or above a nonzero buyout *is* the buyout, inferred server-side.
#[allow(dead_code)]
AuctionPlaceBid {
auctioneer: u64,
auction_id: u32,
price: u32,
},
/// Cancel one of our own auctions (`CMSG_AUCTION_REMOVE_ITEM`). The deposit is forfeit, and a
/// cancel on an auction that already has a bid costs the 5% cut out of pocket.
#[allow(dead_code)]
AuctionRemoveItem { auctioneer: u64, auction_id: u32 },
/// Ask for the server's wall clock (`CMSG_QUERY_TIME`, empty body) — sent on every world
/// entry, answered by `SMSG_QUERY_TIME_RESPONSE` into [`ServerWallClock`]. That clock is the
/// only way to read the absolute deadlines the server writes into descriptor fields, which is
@@ -1378,6 +1443,9 @@ pub(crate) enum ClientCommand {
GroupSetLeader { guid: u64 },
/// Leave the group (`CMSG_GROUP_DISBAND` — the wire's leave verb, despite the name).
GroupLeave,
/// Convert the party into a raid (`CMSG_GROUP_RAID_CONVERT`, leader only; echoes back as a
/// `SMSG_GROUP_LIST` with `group_type` raid).
GroupRaidConvert,
/// Set the loot rules (`CMSG_LOOT_METHOD`): `method` 0..4, `master` guid (master loot only),
/// `threshold` quality 2..4. Leader only; echoes back as a fresh `SMSG_GROUP_LIST`.
LootMethod {
+41
View File
@@ -17,6 +17,7 @@ use super::{
use benilla_world::weather::WeatherMessage;
mod anim;
mod auction;
mod chat;
mod combat;
mod combat_log;
@@ -216,6 +217,9 @@ pub(super) fn apply_net_updates(
// source of another player's honor numbers, so the reply parks here and
// `crate::ui_honor` pushes it into the pane and fires `INSPECT_HONOR_UPDATE`.
ResMut<crate::ui_honor::InspectHonor>,
// The auctioneer session (decision 1511) — the hello reply opens it, the three
// list results fill it, and `crate::ui_auction` feeds it to the window.
ResMut<crate::ui_auction::AuctionOpen>,
),
),
),
@@ -373,6 +377,7 @@ pub(super) fn apply_net_updates(
mut poi_marker,
current_map,
mut inspect_honor,
mut auction_open,
),
),
) = caches;
@@ -463,6 +468,7 @@ pub(super) fn apply_net_updates(
&mut mail_open,
&mut mail_pending,
&mut trade_session,
&mut auction_open,
&mut bank_open,
&mut duel,
&mut social,
@@ -1486,6 +1492,41 @@ pub(super) fn apply_net_updates(
SessionEvent::NextMailTime { seconds } => {
mail::next_mail_time(seconds, &mut mail_pending)
}
// The auction house arc (decision 1511 P1): the hello reply OPENS the window — our
// send does not — and the three list results fill the three tabs.
SessionEvent::AuctionHello {
auctioneer,
house_id,
} => auction::auction_hello(auctioneer, house_id, &mut auction_open),
SessionEvent::AuctionCommandResult {
auction_id,
action,
error,
tail,
} => {
auction::auction_command_result(auction_id, action, error, &tail, &mut auction_open)
}
SessionEvent::AuctionListResult {
auctions,
total_count,
} => auction::auction_list_result(auctions, total_count, &mut auction_open),
SessionEvent::AuctionOwnerListResult {
auctions,
total_count,
} => auction::auction_owner_list_result(auctions, total_count, &mut auction_open),
SessionEvent::AuctionBidderListResult {
auctions,
total_count,
} => auction::auction_bidder_list_result(auctions, total_count, &mut auction_open),
SessionEvent::AuctionBidderNotification(n) => {
auction::auction_bidder_notification(&n, &mut auction_open)
}
SessionEvent::AuctionOwnerNotification(n) => {
auction::auction_owner_notification(&n, &mut auction_open)
}
SessionEvent::AuctionRemovedNotification { item_entry, .. } => {
auction::auction_removed_notification(item_entry, &mut auction_open)
}
// The player-trade arc (decision 0592 P1): the status packet drives the open/accept/close
// state machine, the extended snapshot replaces one side's item/gold — both into the
// `TradeSession` the trade feed (`crate::ui_trade`) reads.
+351
View File
@@ -0,0 +1,351 @@
//! The auction house's wire→session bridge (decision 1511 P1) — every `SMSG_AUCTION_*` lands here
//! and becomes state on [`AuctionOpen`], which [`crate::ui_auction::feed_auction`] turns into the
//! window's events on the next frame. Nothing here touches the VM: the feed owns the script.
use benilla_protocol::messages::{
auction_action, auction_error, AuctionBidderNotification, AuctionCommandTail, AuctionListEntry,
AuctionOwnerNotification,
};
use benilla_ui::script::{BIDDER, LIST, OWNER};
use crate::ui_auction::{AuctionMessage, AuctionOpen};
/// `MSG_AUCTION_HELLO`'s reply — **this**, not our send, is what opens the window (wow-re: the
/// window's opener runs inside the hello handler). The house id keys the deposit rate.
pub(super) fn auction_hello(auctioneer: u64, house_id: u32, auction: &mut AuctionOpen) {
auction.open(auctioneer, house_id);
}
/// One of the three list results. They share a frame and a record; only the tab differs.
pub(super) fn auction_list(
which: usize,
auctions: Vec<AuctionListEntry>,
total_count: u32,
auction: &mut AuctionOpen,
) {
// Tallied before the drop below, because "it arrived and there was nowhere to put it" and "it
// never arrived" are different answers (`AuctionWireLog`).
auction.wire.list_results[which] += 1;
// A list can arrive for a window that just closed (we walked away while the page was in
// flight); dropping it is the same thing the reference does by having nowhere to put it.
if auction.auctioneer.is_none() {
return;
}
auction.set_list(which, auctions, total_count);
}
pub(super) fn auction_list_result(
auctions: Vec<AuctionListEntry>,
total_count: u32,
auction: &mut AuctionOpen,
) {
auction_list(LIST, auctions, total_count, auction);
}
pub(super) fn auction_owner_list_result(
auctions: Vec<AuctionListEntry>,
total_count: u32,
auction: &mut AuctionOpen,
) {
auction_list(OWNER, auctions, total_count, auction);
}
/// The bidder page. The server emits the explicitly-refreshed ids first and then every auction we
/// currently hold the bid on, so **one auction can appear twice in a page** — deduped here by
/// auction id, keeping the first occurrence, because a duplicated row would be two rows the player
/// can click that address the same auction.
pub(super) fn auction_bidder_list_result(
auctions: Vec<AuctionListEntry>,
total_count: u32,
auction: &mut AuctionOpen,
) {
let mut seen = std::collections::HashSet::with_capacity(auctions.len());
let deduped: Vec<AuctionListEntry> = auctions
.into_iter()
.filter(|e| seen.insert(e.auction_id))
.collect();
auction_list(BIDDER, deduped, total_count, auction);
}
/// `SMSG_AUCTION_COMMAND_RESULT` — the verdict on a sell, a cancel or a bid.
///
/// Two shapes of failure the UI has to survive: `auction_id` is **`0`** on most failure paths (the
/// server writes `auc ? auc->Id : 0`), so it is not a correlation handle on an error; and several
/// refusals send **no packet at all** (a bid the player cannot afford, a cancel whose cut they
/// cannot pay), which is why nothing in this arc blocks its UI waiting for an ack.
pub(super) fn auction_command_result(
auction_id: u32,
action: u32,
error: u32,
tail: &AuctionCommandTail,
auction: &mut AuctionOpen,
) {
let _ = tail;
// The verdict itself, kept for the live probe only (`AuctionWireLog`): a SUCCESSFUL result
// turns into a re-query below and otherwise leaves no trace, so without this a probe cannot
// tell "the server said STARTED/OK" from "the server said nothing at all".
auction.wire.last_command = Some((auction_id, action, error));
if error == auction_error::OK {
// A successful sell/cancel/bid changes a list we are showing. The reference re-queries
// rather than patching its local copy, and so do we — the server is the only thing that
// knows what the page looks like now. Each also says so, in **chat** — the success arm
// shows `0x178`/`0x179`/`0x17f` keyed on the action field, with zero varargs
// (wow-re §11.2/§11.3).
match action {
auction_action::STARTED => {
// The item is gone from the bag; the sell slot must stop claiming to hold it.
auction.sell_slot_taken();
auction.refresh_owner();
auction
.messages
.push(AuctionMessage::chat("ERR_AUCTION_STARTED"));
}
auction_action::REMOVED => {
auction.refresh_owner();
auction
.messages
.push(AuctionMessage::chat("ERR_AUCTION_REMOVED"));
}
auction_action::BID_PLACED => {
auction.refresh_bidder();
auction
.messages
.push(AuctionMessage::chat("ERR_AUCTION_BID_PLACED"));
}
_ => {}
}
return;
}
// **HIGHER_BID says nothing here.** Its arm (`0x4cc672`) reads two extra fields and takes the
// live *outbid update* path — it patches the row rather than raising a message, and the line
// the player actually sees is `ERR_AUCTION_OUTBID_S`, off the bidder notification. Printing a
// refusal here would double it (wow-re §11.2).
if error == auction_error::HIGHER_BID {
auction.refresh_bidder();
return;
}
if let Some(key) = command_error_key(error) {
auction.messages.push(AuctionMessage::error(key));
}
}
/// The failed command's GlobalStrings key — resolved to text in the feed against the player's own
/// table, never carried as English here (decisions 0669 / 1190).
///
/// **INTERIM on three arms.** wow-re §11.2 reads the dispatch as: code 1 computes its id from the
/// packet's own second field through the *inventory*-result formatter (`0x622630`) — a different
/// message family, whose keys are not carved; code 4 raises `0x17`; code 13 raises `0x1be`. Those
/// three ids are outside the `ERR_AUCTION_*` block and their GlobalStrings names are not recorded
/// yet, so they fall to the catch-all here rather than being invented. Everything else is the
/// dispatch table verbatim, including that `2, 6, 8, 9, 11, 12` and anything above 13 are the
/// reference's own `ja` default.
fn command_error_key(error: u32) -> Option<&'static str> {
Some(match error {
auction_error::NOT_ENOUGH_MONEY => "ERR_NOT_ENOUGH_MONEY", // `0x25`, the shared id
auction_error::BID_INCREMENT => "ERR_AUCTION_BID_INCREMENT", // `0x174`
auction_error::BID_OWN => "ERR_AUCTION_BID_OWN", // `0x173`
// `0x172` — the server's catch-all, and the reference's default arm for every code
// without one of its own.
_ => "ERR_AUCTION_DATABASE_ERROR",
})
}
/// `SMSG_AUCTION_BIDDER_NOTIFICATION` — we won, or we were outbid.
///
/// **`bid_or_zero == 0` means WON**, not "no bid" — the server overloads the field, and reading it
/// the obvious way turns every win into an outbid notice.
pub(super) fn auction_bidder_notification(
notice: &AuctionBidderNotification,
auction: &mut AuctionOpen,
) {
let won = notice.bid_or_zero == 0;
auction.messages.push(AuctionMessage::chat_item(
if won {
"ERR_AUCTION_WON_S"
} else {
"ERR_AUCTION_OUTBID_S"
},
notice.item_entry,
));
auction.refresh_bidder();
}
/// `SMSG_AUCTION_OWNER_NOTIFICATION` — one of ours sold, or took a bid. An all-zero bidder guid is
/// the "sold" signal (the server zeroes it on a sale).
pub(super) fn auction_owner_notification(
notice: &AuctionOwnerNotification,
auction: &mut AuctionOpen,
) {
// **Two stages, and the first one decides whether anything is said at all** (wow-re §11.4).
// A NON-zero bidder guid is "somebody bid on your auction": the row updates and the client says
// nothing — `[0x4cd25f, 0x4cd3bd)` holds no display call. Only a zeroed guid reaches the message
// path, and there the *bid* picks the line: non-zero sold, zero expired.
if notice.bidder_guid == 0 {
auction.messages.push(AuctionMessage::chat_item(
if notice.bid != 0 {
"ERR_AUCTION_SOLD_S"
} else {
"ERR_AUCTION_EXPIRED_S"
},
notice.item_entry,
));
}
auction.refresh_owner();
}
/// `SMSG_AUCTION_REMOVED_NOTIFICATION` — an auction we had bid on was cancelled by its seller.
///
/// One id, unconditionally (wow-re §11.4: `0x4cd480` has exactly one `call 0x496720` and no branch
/// selecting an id).
pub(super) fn auction_removed_notification(item_entry: u32, auction: &mut AuctionOpen) {
auction.messages.push(AuctionMessage::chat_item(
"ERR_AUCTION_REMOVED_S",
item_entry,
));
auction.refresh_bidder();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui_action::MsgSurface;
fn keys(auction: &AuctionOpen) -> Vec<(&'static str, MsgSurface, Option<u32>)> {
auction
.messages
.iter()
.map(|m| (m.key, m.surface, m.item))
.collect()
}
fn owner(bidder_guid: u64, bid: u32) -> AuctionOwnerNotification {
AuctionOwnerNotification {
auction_id: 7,
bid,
out_bid: 5,
bidder_guid,
item_entry: 2589,
random_property_id: 0,
}
}
fn bidder(bid_or_zero: u32) -> AuctionBidderNotification {
AuctionBidderNotification {
house_id: 1,
auction_id: 7,
bidder_guid: 0x6C,
bid_or_zero,
out_bid: 5,
item_entry: 2589,
random_property_id: 0,
}
}
/// The owner notification's **two-stage** discrimination (wow-re §11.4), and the first stage is
/// the one a re-implementation gets wrong: a NON-zero bidder guid means "somebody bid on your
/// auction", and the client says **nothing at all** — the row updates and that is the whole
/// response. Only a zeroed guid reaches the message path, and there the *bid* picks the line.
#[test]
fn a_bid_on_your_auction_updates_the_row_and_says_nothing() {
let mut a = AuctionOpen::default();
auction_owner_notification(&owner(0x6C, 500), &mut a);
assert!(
a.messages.is_empty(),
"a live bid raises no message; `[0x4cd25f, 0x4cd3bd)` holds no display call"
);
// Sold: guid zeroed, and a price paid.
let mut a = AuctionOpen::default();
auction_owner_notification(&owner(0, 10_000), &mut a);
assert_eq!(
keys(&a),
vec![("ERR_AUCTION_SOLD_S", MsgSurface::Chat, Some(2589))]
);
// Expired: guid zeroed, and nobody paid anything.
let mut a = AuctionOpen::default();
auction_owner_notification(&owner(0, 0), &mut a);
assert_eq!(
keys(&a),
vec![("ERR_AUCTION_EXPIRED_S", MsgSurface::Chat, Some(2589))],
"a zero bid on a closed auction is an expiry, not a sale"
);
}
/// The bidder side's discriminator is the zero bid field and nothing else.
#[test]
fn a_zero_bid_field_means_you_won() {
let mut a = AuctionOpen::default();
auction_bidder_notification(&bidder(0), &mut a);
assert_eq!(
keys(&a),
vec![("ERR_AUCTION_WON_S", MsgSurface::Chat, Some(2589))]
);
let mut a = AuctionOpen::default();
auction_bidder_notification(&bidder(9_000), &mut a);
assert_eq!(
keys(&a),
vec![("ERR_AUCTION_OUTBID_S", MsgSurface::Chat, Some(2589))]
);
}
/// Every one of the eight OUTCOMES goes to CHAT, which is the director's report of 2026-08-22
/// ("I just saw a red center screen message that one of my auc sold, pretty sure that the wrong
/// place"). Catalog rows `0x178`-`0x17f` are kind 0; the twelve refusals below are kind 2.
#[test]
fn the_outcomes_are_chat_and_the_refusals_are_the_error_frame() {
let tail = AuctionCommandTail::Empty;
for (action, key) in [
(auction_action::STARTED, "ERR_AUCTION_STARTED"),
(auction_action::REMOVED, "ERR_AUCTION_REMOVED"),
(auction_action::BID_PLACED, "ERR_AUCTION_BID_PLACED"),
] {
let mut a = AuctionOpen::default();
auction_command_result(7, action, auction_error::OK, &tail, &mut a);
assert_eq!(
keys(&a),
vec![(key, MsgSurface::Chat, None)],
"a successful {action} says so in chat, with no item fill"
);
}
let mut a = AuctionOpen::default();
auction_removed_notification(2589, &mut a);
assert_eq!(
keys(&a),
vec![("ERR_AUCTION_REMOVED_S", MsgSurface::Chat, Some(2589))]
);
// A refusal, on the red frame.
let mut a = AuctionOpen::default();
auction_command_result(0, auction_action::BID_PLACED, 10, &tail, &mut a);
assert_eq!(
keys(&a),
vec![("ERR_AUCTION_BID_OWN", MsgSurface::Error, None)]
);
// HIGHER_BID is the live outbid UPDATE path, not a message — the line the player sees is
// ERR_AUCTION_OUTBID_S off the bidder notification, and printing here would double it.
let mut a = AuctionOpen::default();
auction_command_result(7, auction_action::BID_PLACED, 5, &tail, &mut a);
assert!(
a.messages.is_empty(),
"code 5 patches the row, it does not talk"
);
}
/// A notice outlives the window. "Your auction sold" arrives wherever the player is standing,
/// and closing an auctioneer's window between the packet and the next feed must not swallow it.
#[test]
fn closing_the_window_does_not_swallow_a_pending_notice() {
let mut a = AuctionOpen::default();
a.open(0x42, 1);
auction_owner_notification(&owner(0, 10_000), &mut a);
a.clear();
assert_eq!(
keys(&a),
vec![("ERR_AUCTION_SOLD_S", MsgSurface::Chat, Some(2589))]
);
}
}
@@ -382,7 +382,6 @@ pub(super) fn unit_move(
relay: Default::default(),
last_apply_ms: now_ms,
last_apply_pos: mv.position,
settled_at: None,
};
let fire_ms = rm.relay.schedule(mv.wire_ms, now_ms, 0, true);
debug_assert_eq!(fire_ms, now_ms, "a seeding packet fires at arrival");
+2 -2
View File
@@ -8,9 +8,9 @@ use benilla_protocol::messages::{
};
use bevy::prelude::*;
use crate::ui_action::UiError;
use crate::ui_action::{MsgSurface, UiError};
use crate::ui_chat::ChatLog;
use crate::ui_quest::{MsgSurface, QuestGiver};
use crate::ui_quest::QuestGiver;
use crate::ui_quest_log::QuestLog;
use super::super::NetCommands;
@@ -157,6 +157,7 @@ pub(super) fn disconnected(
mail: &mut MailOpen,
mail_pending: &mut crate::ui_mail::MailPending,
trade: &mut crate::ui_trade::TradeSession,
auction: &mut crate::ui_auction::AuctionOpen,
bank: &mut crate::ui_bank::BankOpen,
duel: &mut crate::ui_duel::DuelState,
social: &mut crate::ui_social::SocialState,
@@ -221,6 +222,10 @@ pub(super) fn disconnected(
// The arrival countdown is login-scoped (decision 0544 P3): a fresh login re-queries
// `MSG_QUERY_NEXT_MAIL_TIME` at world-enter, so nothing carries over across a reconnect.
*mail_pending = crate::ui_mail::MailPending::default();
// An open auction house dies with the socket (decision 1511): every auction command
// re-validates the auctioneer server-side, so a session that survived a reconnect would be a
// window whose every button silently failed.
auction.clear_session();
// An open trade dies with the socket too (decision 0592) — the reconnect starts with no trade.
trade.clear_session();
// The bank window dies with the socket (decision 0604) — a reconnect re-opens via the banker.
+52
View File
@@ -870,6 +870,57 @@ fn writer_loop(
w.item_text_query(text_id, mail_id)
}
ClientCommand::QueryNextMailTime => w.query_next_mail_time(),
// The auction house arc (decision 1511 P0) — the CMSG verbs onto the
// P0 writers; the auctioneer guid rides on every one.
ClientCommand::AuctionHello { auctioneer } => w.auction_hello(auctioneer),
ClientCommand::AuctionListItems {
auctioneer,
list_from,
searched_name,
level_min,
level_max,
slot_id,
main_category,
sub_category,
quality,
usable,
} => w.auction_list_items(
auctioneer,
list_from,
&searched_name,
level_min,
level_max,
slot_id,
main_category,
sub_category,
quality,
usable,
),
ClientCommand::AuctionListOwnerItems {
auctioneer,
list_from,
} => w.auction_list_owner_items(auctioneer, list_from),
ClientCommand::AuctionListBidderItems {
auctioneer,
list_from,
auction_ids,
} => w.auction_list_bidder_items(auctioneer, list_from, &auction_ids),
ClientCommand::AuctionSellItem {
auctioneer,
item_guid,
bid,
buyout,
etime_minutes,
} => w.auction_sell_item(auctioneer, item_guid, bid, buyout, etime_minutes),
ClientCommand::AuctionPlaceBid {
auctioneer,
auction_id,
price,
} => w.auction_place_bid(auctioneer, auction_id, price),
ClientCommand::AuctionRemoveItem {
auctioneer,
auction_id,
} => w.auction_remove_item(auctioneer, auction_id),
ClientCommand::QueryTime => w.query_time(),
// The inspect request (decision 0631) — no reply is awaited; see the writer.
ClientCommand::Inspect { target } => w.inspect(target),
@@ -914,6 +965,7 @@ fn writer_loop(
ClientCommand::GroupUninvite { name } => w.group_uninvite(&name),
ClientCommand::GroupSetLeader { guid } => w.group_set_leader(guid),
ClientCommand::GroupLeave => w.group_disband(),
ClientCommand::GroupRaidConvert => w.group_raid_convert(),
ClientCommand::LootMethod {
method,
master,
+239 -35
View File
@@ -73,18 +73,6 @@ pub(crate) struct RemoteMotion {
/// not anything the server said. Read by the runaway watch in [`extrapolate_remote_units`].
pub(crate) last_apply_ms: f64,
pub(crate) last_apply_pos: [f32; 3],
/// **The settled memo** — the position at which this mover's last world resolve was a
/// fixed point: flag-still, grounded on a real support, and returned bitwise where it
/// started. While the frame's pre-resolve position still equals it, the resolve is skipped
/// whole — a standing NPC was paying a depenetration contact enumeration (heap Vec + broad
/// phase) plus a capsule down-cast every frame for a result proven identical (1473 §3's
/// idle-remote row; the coordinate round-trip is sign/permutation-lossless and
/// `grounded_step` is pure in its inputs, so bitwise equality is the honest test, the
/// `facing.rs` dead-band's shape). Anything that moves the mover — a packet apply, a
/// direction flag, the reconcile lerp, a jump arc — changes the position or flags and
/// misses the memo; a mover standing where ground has not streamed in never arms it
/// (`ground` was `None`) and keeps resolving until the world exists under it.
pub(crate) settled_at: Option<[f32; 3]>,
}
/// What [`crate::net::apply`]'s `unit_move` did with an inbound relayed move — the `out=` field of
@@ -209,16 +197,25 @@ const REMOTE_TRACE_MS: f64 = 500.0;
/// whose own client is stopped dead is the dead-reckon marching into the geometry (wrong), and it
/// is the "sinks in and pops out again and again" report.
///
/// - **`drop`** — how far **below the Z its last packet carried** we are currently drawing this
/// mover (`last_apply_pos[2] − wow_pos[2]`; negative means we are drawing it *above*). This is
/// the number that falsifies the whole under-the-floor class, and `rem` shipped without it:
/// `dz` is a per-frame delta, so a 1/36-yd-a-frame creep through a WMO floor reads as an
/// ordinary healthy ground-follow sample after sample while it accumulates into decision 1545's
/// measured 2.14 yd. A sustained nonzero `drop` on a mover whose `age` keeps climbing is us
/// inventing a height the server never described.
///
/// `age` (ms since a packet last applied) rides along so a sample is never read as server truth.
fn trace_remote(e: Entity, rm: &RemoteMotion, held: f32, dz: f32, now_ms: f64) {
benilla_assets::trace::line(
"rem",
&format!(
"{e} flags={:#x} pos=[{:.2},{:.2},{:.2}] dz={dz:+.3} held={held:.3} age={:.0}ms",
"{e} flags={:#x} pos=[{:.2},{:.2},{:.2}] dz={dz:+.3} drop={:+.3} held={held:.3} age={:.0}ms",
rm.flags,
rm.wow_pos[0],
rm.wow_pos[1],
rm.wow_pos[2],
rm.last_apply_pos[2] - rm.wow_pos[2],
now_ms - rm.last_apply_ms,
),
);
@@ -243,6 +240,20 @@ fn flat_extrapolation() -> bool {
*FLAT.get_or_init(|| std::env::var_os("WOW_REMOTE_FLAT").is_some())
}
/// The A/B switch behind decision 1545: `WOW_REMOTE_IDLE_GATE=off` resolves **every** remote mover
/// against the world, flag-still ones included — the pre-1545 leg, where a watched player standing
/// inside a building whose floor collider has not attached yet is walked down through the floor at
/// `STEP_SNAP_SLACK` (1/36 yd) a frame — 1.67 yd/s at 60 fps — and left on the terrain under
/// it for the session. Kept as the lever that reproduces B197's player site on the *fixed* binary,
/// the twin of [`flat_extrapolation`] (0626) and `WOW_CLAMP_SEAT=off` (1384), and for the same
/// reason: the fix's evidence never has to depend on two different builds.
fn idle_gate_disabled() -> bool {
static OFF: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*OFF.get_or_init(|| {
std::env::var("WOW_REMOTE_IDLE_GATE").is_ok_and(|v| matches!(v.as_str(), "off" | "0"))
})
}
/// The reconcile-arm tolerance (squared yards): predicted-vs-event disagreement below this needs
/// no correction. The reference's `0x80c744` = 7.716e-4 ≈ (0.0278 yd)², compared in 2D — Z joins
/// only while SWIMMING (`0x619090`, wow-re `spec-driver-A.md`).
@@ -397,6 +408,14 @@ pub(in crate::net) fn drain_pending_moves(
/// comes from the ground under the mover **every frame**, and the dead-reckon cannot walk a watched
/// player into geometry.
///
/// **…for a mover the reference integrates at all** (decision 1545). One controller, every mover —
/// but a mover with none of [`move_flags::INTEGRATED`] (`0x20ff`) set does not reach that controller
/// in the first place: `CMovement::Update`'s substep loop and the manager's per-mover tick both bail
/// on the same mask, and a flag-less unit is not even in the mover list. So a *standing* watched
/// player keeps its last packet's pose verbatim — which is also the only safe answer, because the
/// resolve has anything to correct only when our world is missing a floor the server has, and a
/// building's late collider is exactly that.
///
/// Without it the extrapolation ran in a vacuum, and the two symptoms that follow are the ones the
/// director reported. **Height:** [`RemoteMotion::advance`] never moves a grounded mover's Z, so Z
/// changed only where a *packet* put it — at the apply, or dragged there by the pre-fire reconcile
@@ -492,10 +511,31 @@ pub(in crate::net) fn extrapolate_remote_units(
// fight it).
let airborne = rm.flags & move_flags::FALLING != 0;
let afloat = rm.flags & move_flags::SWIMMING != 0;
// The settled memo ([`RemoteMotion::settled_at`]): an idle mover standing exactly where
// its last resolve left it re-derives nothing — the resolve is skipped, `held` honestly
// stays 0.
if !afloat && !riding && rm.settled_at != Some(pos) && !flat_extrapolation() {
// …and **a flag-still mover is not integrated at all** (decision 1545) — the reference's
// own gate, [`move_flags::INTEGRATED`] = `0x20ff`: `CMovement::Update`'s substep loop
// (`0x616e20`) and the manager's per-mover tick (`0x6166f5`) both bail on it, and wow-re
// records that such a unit "is not even in the mover list". Its pose is the last packet's,
// verbatim.
//
// That is not merely faithful, it is the only safe answer, because **our world can be
// missing a floor the server has**: the WMO's collider attaches structurally later than the
// terrain under it (1384 §1 — the ADT decode *starts* the WMO load, `finish_colliders`
// heads the chain, and the paced form furnisher gates the bake; 1303 measured the gap in
// seconds at a city pin). Standing still the election's reach collapses to
// `STEP_SNAP_SLACK` (1/36 yd), and `grounded_step`'s no-hit branch spends that whole
// reach *descending* — right for the local mover, whose next frame elects a real fall, and
// open-loop for a remote, which has no fall election at all. So the resolve walked a
// watched player through the floor at 1/36 yd a frame — 1.67 yd/s at 60 fps, 3.3 at 120 —
// onto the terrain below, where the (now deleted) settled memo froze them for the session.
// A standing player sends no packets, so nothing ever re-seated them: B197's fourth site,
// and nazriel_0's "until I move around, then it pops up back to normal".
//
// Deleting the memo with the gate is the point, not a side effect: it existed only to skip
// this resolve for a flag-still mover whose answer was proven identical (1473 §3), and the
// gate skips it strictly earlier and for a stated reason — taking the last un-dated
// collision cache (1384's part 3, never fixed on this lane) with it.
let integrating = rm.flags & move_flags::INTEGRATED != 0 || idle_gate_disabled();
if integrating && !afloat && !riding && !flat_extrapolation() {
let half_h = Vec3::Y * (crate::player::CAPSULE_HEIGHT * 0.5);
let from = wow_to_bevy(rm.wow_pos) + half_h;
// The frame's velocity by construction. Grounded, [`RemoteMotion::advance`] never moves
@@ -506,17 +546,8 @@ pub(in crate::net) fn extrapolate_remote_units(
} else {
Vec3::ZERO
};
let (resolved_center, supported) = if airborne {
(
crate::player::mover::airborne_step(
&world,
&capsule.0,
from,
vel,
time.delta(),
),
false,
)
let resolved_center = if airborne {
crate::player::mover::airborne_step(&world, &capsule.0, from, vel, time.delta())
} else {
let g = crate::player::mover::grounded_step(
&world,
@@ -534,15 +565,9 @@ pub(in crate::net) fn extrapolate_remote_units(
// next packet's wire Z corrects whatever that misses.
crate::player::mover::Support::default(),
);
(g.center, g.ground.is_some())
g.center
};
let resolved = bevy_to_wow(resolved_center - half_h);
// Arm the memo only at the fixed point: flag-still, on real ground, and the resolve
// handed back exactly its input (one snap frame after stopping, the second resolve
// does — deterministically, the wow↔bevy round-trip being lossless). A miss on any
// term keeps resolving every frame.
rm.settled_at = (supported && rm.flags & move_flags::ANY_MOVE == 0 && resolved == pos)
.then_some(resolved);
held = (pos[0] - resolved[0]).hypot(pos[1] - resolved[1]);
pos = resolved;
}
@@ -820,3 +845,182 @@ impl RemoteMotion {
(pos, orientation, 0.0, speed)
}
}
/// **B197's fourth site — the *player* one** (decision 1545), in a world small enough to assert on:
/// a watched mover standing inside a building whose floor collider has not attached yet.
///
/// The same three facts as `spline::under_floor`, which is that record's creature twin: the terrain
/// is under the mover, the floor is not there yet, and it arrives later. What differs is the lane —
/// a player owns its Z through [`extrapolate_remote_units`], never through the creature clamp — and
/// therefore what the fix is: not a seat and an epoch, but **not running at all**. A flag-still
/// mover is not integrated by the reference (`0x20ff`, [`move_flags::INTEGRATED`]), so it is not
/// integrated here, and the incomplete world under it never gets to say anything.
///
/// Run under `WOW_REMOTE_IDLE_GATE=off` the standing test fails with **7.00 against 9.08** — the
/// mover buried on the terrain under the building — which is the bug, on this same binary. So the
/// test is known to test something (1384's own standard for its creature twin).
#[cfg(test)]
mod under_floor {
use avian3d::prelude::*;
use bevy::ecs::system::RunSystemOnce;
use bevy::prelude::*;
use bevy::time::Real;
use std::time::Duration;
use super::{extrapolate_remote_units, RelayChain, RemoteMotion};
use crate::creature_anim::move_flags;
use crate::player::{PlayerCapsule, CAPSULE_HEIGHT, CAPSULE_RADIUS};
use benilla_world::collision::ColliderEpoch;
/// Auberdine's geometry, to the yard (the pin `spline::under_floor` is built on): terrain at
/// 6.98, the building's floor 2.08 above it, and the wire Z for a body standing on that floor
/// a hair above it — for a *player* that hair is their own client's resting clearance against
/// the very geometry we baked our collider from, so it is small by construction.
const TERRAIN_Y: f32 = 6.98;
const FLOOR_Y: f32 = 9.06;
const WIRE_Y: f32 = 9.08;
/// 60 fps, the rate the pre-1545 creep was measured at (it is frame-rate dependent — the
/// descent is per *frame*, not per second, which is half of why it had to go).
const FRAME: Duration = Duration::from_nanos(16_666_667);
/// A 10×10 up-wound quad at `y` — a floor the one-sided down-cast will stand on.
fn floor_at(app: &mut App, y: f32) -> Entity {
let verts = vec![
Vec3::new(-5.0, y, -5.0),
Vec3::new(5.0, y, -5.0),
Vec3::new(5.0, y, 5.0),
Vec3::new(-5.0, y, 5.0),
];
app.world_mut()
.spawn((
RigidBody::Static,
Collider::trimesh(verts, vec![[0u32, 2, 1], [0, 3, 2]]),
Transform::default(),
))
.id()
}
fn mover(flags: u32, wow_z: f32) -> RemoteMotion {
RemoteMotion {
wow_pos: [0.0, 0.0, wow_z],
orientation: 0.0,
flags,
pitch: 0.0,
speed: 0.0,
vertical_velocity: 0.0,
jump_xy_vel: [0.0; 2],
fall_start_z: None,
pending: std::collections::VecDeque::new(),
relay: RelayChain::default(),
last_apply_ms: 0.0,
last_apply_pos: [0.0, 0.0, wow_z],
}
}
/// The terrain in, the building's floor still building, and one mover standing at the Z the
/// wire gave it.
fn half_arrived_world(flags: u32) -> (App, Entity) {
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
bevy::transform::TransformPlugin,
bevy::asset::AssetPlugin::default(),
bevy::scene::ScenePlugin,
PhysicsPlugins::new(bevy::app::PostUpdate),
));
app.init_asset::<Mesh>().init_resource::<ColliderEpoch>();
app.insert_resource(PlayerCapsule(Collider::capsule(
CAPSULE_RADIUS,
CAPSULE_HEIGHT - 2.0 * CAPSULE_RADIUS,
)));
// `update()` never runs plugin `finish()`, where avian seats its diagnostics resources —
// and the update that lands the late floor below does step physics.
app.finish();
app.cleanup();
floor_at(&mut app, TERRAIN_Y);
let e = app
.world_mut()
.spawn((mover(flags, WIRE_Y), Transform::from_xyz(0.0, WIRE_Y, 0.0)))
.id();
app.update(); // seats Position/Rotation and the collider trees
(app, e)
}
fn frames(app: &mut App, n: usize) {
for _ in 0..n {
app.world_mut()
.resource_mut::<Time<Real>>()
.advance_by(FRAME);
app.world_mut()
.run_system_once(extrapolate_remote_units)
.expect("extrapolate");
}
}
fn z_of(app: &App, e: Entity) -> f32 {
app.world().get::<RemoteMotion>(e).unwrap().wow_pos[2]
}
#[test]
fn a_standing_mover_never_leaves_the_z_the_wire_gave_it() {
let (mut app, e) = half_arrived_world(0);
// Four seconds inside a building we have not finished building. Pre-1545 this was a
// 1/36-yd-a-frame descent: z=9.05 after one frame, on the terrain (6.98 + the capsule's
// SKIN_WIDTH = 7.00) inside 1.3 s, and stuck there — the reported screenshot.
frames(&mut app, 240);
assert_eq!(
z_of(&app, e),
WIRE_Y,
"a flag-still mover is not integrated: no floor under us is OUR gap, not its business"
);
// The building's floor collider attaches, and stamps the world.
floor_at(&mut app, FLOOR_Y);
app.world_mut().resource_mut::<ColliderEpoch>().bump();
app.update();
// Still nothing to do — it was already standing on that floor, which is what its own
// client resolved before the packet was ever sent. (Pre-1545 this is where it stayed
// buried: the settled memo had armed on the terrain hit and carried no epoch.)
frames(&mut app, 60);
assert_eq!(
z_of(&app, e),
WIRE_Y,
"the floor arrived under a mover that was already standing on it"
);
}
/// The control: 0626's resolve is untouched for a mover the reference *does* integrate.
#[test]
fn a_moving_mover_still_meets_the_world() {
let (mut app, e) = half_arrived_world(move_flags::FORWARD);
frames(&mut app, 240);
assert!(
z_of(&app, e) < WIRE_Y,
"a mover carrying a direction bit is integrated and resolved, as it was before 1545"
);
}
/// …and the A/B lever still reproduces the bug on this binary, which is what makes the row
/// above evidence rather than an assertion (0626's `WOW_REMOTE_FLAT`, 1384's `WOW_CLAMP_SEAT`).
/// Serialised against the other two by running in-process only when the env var is set, since
/// the lever is a process-wide `OnceLock`; `cargo test` sees it unset and skips.
#[test]
fn the_lever_reproduces_the_sink() {
if !super::idle_gate_disabled() {
return; // WOW_REMOTE_IDLE_GATE=off cargo test -p benilla-app the_lever_reproduces
}
let (mut app, e) = half_arrived_world(0);
frames(&mut app, 240);
// `-- --nocapture` prints the number decision 1545's table quotes.
println!(
"pre-1545 leg, 240 frames: z={:.4} (wire {WIRE_Y})",
z_of(&app, e)
);
assert!(
z_of(&app, e) < TERRAIN_Y + 0.1,
"the pre-1545 leg walks a standing mover down onto the terrain under the building"
);
}
}
+33 -26
View File
@@ -62,7 +62,6 @@ fn motion(flags: u32, orientation: f32) -> RemoteMotion {
relay: Default::default(),
last_apply_ms: 0.0,
last_apply_pos: [0.0, 0.0, 0.0],
settled_at: None,
}
}
@@ -884,12 +883,15 @@ fn a_gameobject_without_a_usable_quaternion_falls_back_to_its_facing() {
}
}
/// **The settled memo** ([`RemoteMotion::settled_at`], 1490 item 2): an idle grounded remote
/// arms it once its resolve reaches the fixed point, holds bitwise still while armed, keeps
/// resolving where no ground exists, and disarms the moment a direction flag moves it — so the
/// per-frame depenetration + down-cast run only for movers that can actually change.
/// **A flag-still remote is not integrated at all** (decision 1545) — the reference's own gate,
/// `0x20ff` ([`move_flags::INTEGRATED`]): `CMovement::Update`'s substep loop (`0x616e20`) and the
/// manager's per-mover tick (`0x6166f5`) both bail on a mover with no move/jump/fall bit, and
/// wow-re records that such a unit "is not even in the mover list". So its pose is the last
/// packet's, verbatim — and the per-frame depenetration + down-cast the settled memo used to claw
/// back (1490 item 2 / 1473 §3) does not run at all, for a stated reason rather than a proof that
/// its answer was identical.
#[test]
fn an_idle_remote_settles_and_stops_sweeping() {
fn a_flag_still_remote_is_left_where_the_wire_put_it() {
use avian3d::prelude::{Collider, RigidBody};
use bevy::prelude::*;
use bevy::transform::TransformPlugin;
@@ -928,43 +930,45 @@ fn an_idle_remote_settles_and_stops_sweeping() {
.spawn((
Transform::default(),
motion(0, 0.0),
// Real speeds, so the FORWARD leg below actually translates — a flag with no speed
// moves nothing and would (correctly) stay settled.
// Real speeds, so the FORWARD leg below actually translates.
crate::net::UnitSpeeds(speeds()),
))
.id();
// The one that used to sink: standing where no collider exists at all — a mover inside a
// building whose floor has not attached (B197's player site), or over a tile still streaming.
let mut over_nothing = motion(0, 0.0);
over_nothing.wow_pos = [50.0, 50.0, 10.0];
let stranded = app
.world_mut()
.spawn((Transform::default(), over_nothing))
.id();
// Seed the idle mover a hair above the floor — inside the standing snap reach (at zero
// speed the whole reach IS `STEP_SNAP_SLACK` ≈ 0.028), like a wire Z, which always arrives
// on the ground. The first resolve snaps it on, the second arms the memo.
// Seed the idle mover a hair above the floor, the way a wire Z arrives: its own client's
// resting clearance against the same geometry. The reference leaves that hair alone.
let seated = [0.0, 0.0, 0.01];
app.world_mut()
.entity_mut(idle)
.get_mut::<RemoteMotion>()
.unwrap()
.wow_pos = [0.0, 0.0, 0.01];
.wow_pos = seated;
for _ in 0..4 {
for _ in 0..8 {
app.update();
}
let rm = app.world().entity(idle).get::<RemoteMotion>().unwrap();
let armed_at = rm.settled_at.expect("an idle grounded mover arms the memo");
assert_eq!(armed_at, rm.wow_pos, "the memo is the pose it stands at");
let parked = rm.wow_pos;
for _ in 0..3 {
app.update();
}
let rm = app.world().entity(idle).get::<RemoteMotion>().unwrap();
assert_eq!(rm.wow_pos, parked, "armed ⇒ bitwise still");
assert_eq!(rm.settled_at, Some(parked), "…and still armed");
// No ground in reach ⇒ never arms; it keeps asking until the world exists under it.
assert_eq!(
rm.wow_pos, seated,
"flag-still ⇒ not integrated: the wire's Z stands, hair and all"
);
// Before 1545 this one was descending STEP_SNAP_SLACK (1/36 yd) EVERY FRAME with nothing to
// end it — the whole defect, in one assertion.
let rm = app.world().entity(stranded).get::<RemoteMotion>().unwrap();
assert_eq!(rm.settled_at, None, "no support ⇒ no memo");
// A direction flag moves the mover and the memo falls with it.
assert_eq!(
rm.wow_pos,
[50.0, 50.0, 10.0],
"no ground under a standing mover is OUR world being incomplete, not a drop to take"
);
// A direction flag puts it back in the mover list, and 0626's resolve runs: the floor is 0.01
// below, well inside the standing reach, so the resolve settles it onto the surface.
app.world_mut()
.entity_mut(idle)
.get_mut::<RemoteMotion>()
@@ -972,5 +976,8 @@ fn an_idle_remote_settles_and_stops_sweeping() {
.flags = move_flags::FORWARD;
app.update();
let rm = app.world().entity(idle).get::<RemoteMotion>().unwrap();
assert_eq!(rm.settled_at, None, "a moving mover resolves every frame");
assert!(
rm.wow_pos[2] < seated[2],
"a mover carrying a direction bit is integrated and resolved against the world"
);
}
+107 -51
View File
@@ -73,8 +73,8 @@ pub(crate) use camera::apply_self_model_fade;
// `/follow` (decision 0890): chat asks with the message, `crate::target` resolves the subject into
// the state, and this module owns the motion.
use camera::{
apply_zoom_scroll, run_look_session, seat_camera, CameraProbe, FlyCam, LookButton,
CAM_COLLISION_RADIUS, CAM_DIST_DEFAULT, CAM_PIVOT_FALLBACK,
apply_zoom_scroll, model_pivot_height, run_look_session, seat_camera, CameraProbe, FlyCam,
LookButton, CAM_COLLISION_RADIUS, CAM_DIST_DEFAULT,
};
pub(crate) use camera::{head_height, CameraControl, CameraPivot};
pub(crate) use follow::{FollowRequest, FollowState};
@@ -285,6 +285,21 @@ impl Plugin for PlayerPlugin {
.in_set(WorldStage::Input)
.before(control),
)
// The camera shake (B298, decision 1540) lands on the camera AFTER `control` has
// seated it: the applier adds its offset to the pose `seat_camera` just wrote, so the
// eye it measures the distance falloff against is the un-shaken one. `control` is at
// Bevy's 16-param ceiling, so the offset cannot be threaded into it as a resource —
// and running after is the better shape anyway.
.add_systems(
Update,
crate::camera_shake::apply_camera_shake
.in_set(WorldStage::Input)
.after(control)
// Gated off in capture mode alongside `control`, and for the same reason a
// capture keeps the doodad rail static: a pinned camera that a passing kodo
// could nudge is not a regression baseline any more.
.run_if(not(resource_exists::<crate::run_mode::CaptureMode>)),
)
// `/follow` (decision 0890): steer the facing and decide this tick's synthesized forward
// input immediately BEFORE the controller, which folds the flag into its forward axis.
// The player's own turn input therefore runs after us and wins, which is exactly what
@@ -332,6 +347,23 @@ fn mirror_mover_collision_height(
}
}
/// The camera pivot's **target** height for a driven body: its model-local [`CameraPivot`] × the
/// body's raw `OBJECT_FIELD_SCALE_X`, clamped — or `None` before its model has attached.
///
/// `None` is the load-bearing half. The reference recomputes the pivot preset only on a model event
/// and *skips the camera update entirely* while the model is unresolved (`0x50e907`), so a
/// display swap reads as a brief hold and then one glide. Aiming the channel at a placeholder height
/// during those frames instead would send the camera on a round trip nobody asked for.
///
/// The **raw** scale (not the transform's eased one) is the reference's own input — see
/// [`camera::head_height`] for the byte citation and why the distinction is visible.
fn body_pivot_target(
pivot: Option<&CameraPivot>,
net: Option<&crate::net::NetEntity>,
) -> Option<f32> {
pivot.map(|p| model_pivot_height(p, net.map_or(1.0, |n| n.scale)))
}
/// Camera + avatar controller. Free-flies until the server reports our position; then takes
/// third-person control (WASD walks the avatar; right-drag turns it, left-drag orbits the camera,
/// wheel zooms) and streams our movement to the server as the confirmed mover. The dev chord + `F`
@@ -435,6 +467,9 @@ fn control(
// What is worn in each hand and in the ranged slot — the Z toggle's cycle reads it
// (the ref's three `GetWeapon(0/1/2)` calls before the state walk, below).
Option<&crate::creature_anim::Wielded>,
// The **raw** `OBJECT_FIELD_SCALE_X`, for the camera pivot's target height — not the
// transform's, which is the 2 s-eased render scale (see [`camera::head_height`]).
Option<&crate::net::NetEntity>,
),
(With<Embodied>, Without<FlyCam>),
>,
@@ -604,7 +639,7 @@ fn control(
let stunned = body
.single()
.ok()
.and_then(|(.., store, _, _, _, _)| {
.and_then(|(.., store, _, _, _, _, _)| {
store.map(|s| s.0.unit_flags() & UNIT_FLAG_STUNNED != 0)
})
.unwrap_or(false);
@@ -616,7 +651,7 @@ fn control(
let f = body
.single()
.ok()
.and_then(|(.., store, _, _, _, _)| store.and_then(|s| s.0.player_drunk_byte()))
.and_then(|(.., store, _, _, _, _, _)| store.and_then(|s| s.0.player_drunk_byte()))
.map_or(0.0, drunk::fraction);
if stunned {
0.0
@@ -805,19 +840,23 @@ fn control(
player.model_yaw = yaw;
}
}
let pivot_h = body
.single()
.map(|(_, t, _, pivot, ..)| head_height(pivot, t.scale.x))
.unwrap_or(CAM_PIVOT_FALLBACK);
let head = player.pos + Vec3::Y * (CAPSULE_HEIGHT - CAPSULE_RADIUS);
// Far sight outlives all three, so it has to be honoured here too — Sentry Totem
// carries no interrupt flags at all, which means you can board a taxi with your view
// still on the totem. Same substitution as the main path below; skipping it would read
// as far sight mysteriously dropping the moment a spline takes the body.
let (orbit_pos, sweep_from, orbit_pivot) = match view_subject.remote {
Some(v) => (v.feet, v.sweep_origin(), v.pivot_height),
None => (player.pos, head, pivot_h),
let (orbit_pos, sweep_from) = match view_subject.remote {
Some(v) => (v.feet, v.sweep_origin()),
None => (player.pos, head),
};
// The pivot **height** takes the same substitution and then the channel: it is smoothed
// toward its target, never taken raw ([`camera::PivotGlide`]).
let pivot_target = view_subject.remote.map(|v| v.pivot_height).or_else(|| {
body.single()
.ok()
.and_then(|(_, _, _, pivot, .., net)| body_pivot_target(pivot, net))
});
let orbit_pivot = rig.pivot.advance(pivot_target, dt);
seat_camera(
dt,
0.0,
@@ -1037,7 +1076,7 @@ fn control(
let stand_byte = body
.single()
.ok()
.and_then(|(.., store, _, _, _, _)| store.map(|s| s.0.unit_stand_state()))
.and_then(|(.., store, _, _, _, _, _)| store.map(|s| s.0.unit_stand_state()))
.unwrap_or(0);
if player.stand_pending == Some(stand_byte) {
player.stand_pending = None; // the echo landed
@@ -1076,7 +1115,7 @@ fn control(
// wow-re `sheath-policy.md` §4): entering any stand-state ∉ {0 STAND, 2 SIT_CHAIR}
// force-stows drawn weapons, through the anim layer's one setter.
if s != 0 && s != 2 {
if let Ok((e, _, _, _, drv, _, _, _, _, _)) = body.single() {
if let Ok((e, _, _, _, drv, _, _, _, _, _, _)) = body.single() {
if drv.and_then(|d| d.sheath_state()).unwrap_or(0) != 0 {
net.3.write(crate::creature_anim::SheathRequest {
entity: e,
@@ -1097,7 +1136,7 @@ fn control(
// `sheath-policy.md`). No body model yet (no driver) drops the toggle, the client's own
// refusal.
if binds.fired(crate::bindings::cmd::TOGGLE_SHEATH) {
if let Ok((e, _, _, _, Some(drv), store, engaged, _, _, wielded)) = body.single() {
if let Ok((e, _, _, _, Some(drv), store, engaged, _, _, wielded, _)) = body.single() {
// The manual toggle's guard chain (decision 0080d) — the guards of the client's
// 12-deep silent-refusal chain (`ToggleSheath` `0x5eb480`) whose states exist
// today: dead · engaged in combat · not standing (`GetStandState() != 0` —
@@ -1239,7 +1278,7 @@ fn control(
// (TU-F: Space is the Jump command; it is NOT a pitch or ascend input) — and never
// reaches this walk-side gate.
if want_jump && !moving && !swimming && player.airborne_since.is_none() {
if let Ok((e, .., store, _, _, _, _)) = body.single() {
if let Ok((e, .., store, _, _, _, _, _)) = body.single() {
if store.is_some_and(|s| s.0.unit_mount_display_id() != 0) {
want_jump = false;
if !turning {
@@ -1664,11 +1703,12 @@ fn control(
// animation selector reads. Scale is left untouched (the renderer baked the display scale on).
// `horiz_vel` is already the directional speed (runBack when backpedaling), so the backpedal
// clip scales by it and no longer drags.
// World camera-pivot height: `H = 0.9·bbox_z_extent·scale`, floored (see [`CameraPivot`] /
// `CAM_PIVOT_FLOOR`). Read the avatar's model-local pivot + its live scale here (where we hold the
// self entity); until the body attaches (no `CameraPivot` yet) fall back to a human neck height.
let mut cam_pivot_height = CAM_PIVOT_FALLBACK;
if let Ok((entity, mut t, motion, pivot, .., twist, _)) = body.single_mut() {
// This frame's camera-pivot **target** — the model-local [`CameraPivot`] × the body's RAW
// scale, clamped (see [`camera::head_height`] for why raw and not the rendered scale).
// `None` while the body has no model yet: the channel holds rather than aiming at a
// placeholder, which is what makes a display swap one glide instead of two.
let mut cam_pivot_target = None;
if let Ok((entity, mut t, motion, pivot, .., twist, _, net_entity)) = body.single_mut() {
t.translation = player.pos;
// The swim body pitch (TU-A, `0x60a110`→`0x710620`): while swimming AND moving fwd/back
// the model root renders `Rz(yaw)·Ry(−pitch)` — in Bevy axes, the yaw then a nose-up
@@ -1695,7 +1735,7 @@ fn control(
descent: player.fall_start_y - player.pos.y,
});
}
cam_pivot_height = head_height(pivot, t.scale.x);
cam_pivot_target = body_pivot_target(pivot, net_entity);
if let Some(mut motion) = motion {
// A swimmer's stroke rate takes the flag-scalar directional speed (full rate at
// any pitch — a vertical climb must not freeze the stroke); the ground gaits
@@ -1716,33 +1756,6 @@ fn control(
}
}
// `WOW_CAM_DUMP`: the per-frame INPUT signal beside `seat_camera`'s realized-pose `[cam]`
// line — wall clock, frame dt, this frame's accumulated mouse delta, the active look mode,
// and the yaw/pos the frame produced. A turn-feel question ("keyboard turn smooth, mouse
// turn jittery") needs the input cadence and the output cadence on the same timeline: a
// bursty `dx` under a steady `dt` convicts event delivery; a steady `dx` with an uneven
// realized pose convicts everything downstream.
if std::env::var_os("WOW_CAM_DUMP").is_some() {
eprintln!(
"[turn] t={:.6} dt={:.6} dx={:.3} dy={:.3} look={} face={:.6} model={:.6} \
pos [{:.4},{:.4},{:.4}]",
time.elapsed_secs_f64(),
dt,
mouse_motion.delta.x,
mouse_motion.delta.y,
match rig.look {
Some(LookButton::Right) => "R",
Some(LookButton::Left) => "L",
None => "-",
},
player.face_yaw,
player.model_yaw,
player.pos.x,
player.pos.y,
player.pos.z,
);
}
// The camera-collision sweep is rooted at the *head* (capsule top hemisphere centre), not the
// framing pivot — see `seat_camera`'s doc for why. Computed here (not in `camera`) because it
// depends on the avatar's own capsule constants, which are a movement concern.
@@ -1760,10 +1773,53 @@ fn control(
// hearing and sending movement as yourself while only the picture moves. The sweep origin
// moves with the subject too; rooting it at our own head would cast the boom across the
// world and jam it on the first wall in between ([`RemoteView::sweep_origin`]).
let (orbit_pos, sweep_from, orbit_pivot) = match view_subject.remote {
Some(v) => (v.feet, v.sweep_origin(), v.pivot_height),
None => (player.pos, head, cam_pivot_height),
let (orbit_pos, sweep_from) = match view_subject.remote {
Some(v) => (v.feet, v.sweep_origin()),
None => (player.pos, head),
};
// The framing height is the **channel's**, not this frame's target: it eases there over
// `|Δh| / 1.2` s with a cosine profile, so a shapeshift, a mount, a growth aura or a
// far-sight switch move the camera smoothly instead of teleporting it
// ([`camera::PivotGlide`]; wow-re `pivot-height-glide.md`). A far-sight subject supplies
// the target the same way the body does — one channel, whatever it is looking at.
let orbit_pivot = rig.pivot.advance(
view_subject
.remote
.map(|v| v.pivot_height)
.or(cam_pivot_target),
dt,
);
// `WOW_CAM_DUMP`: the per-frame INPUT signal beside `seat_camera`'s realized-pose `[cam]`
// line — wall clock, frame dt, this frame's accumulated mouse delta, the active look mode,
// and the yaw/pos the frame produced. A turn-feel question ("keyboard turn smooth, mouse
// turn jittery") needs the input cadence and the output cadence on the same timeline: a
// bursty `dx` under a steady `dt` convicts event delivery; a steady `dx` with an uneven
// realized pose convicts everything downstream.
if std::env::var_os("WOW_CAM_DUMP").is_some() {
eprintln!(
"[turn] t={:.6} dt={:.6} dx={:.3} dy={:.3} look={} face={:.6} model={:.6} \
pos [{:.4},{:.4},{:.4}] pivot={:.4}->{:.4}",
time.elapsed_secs_f64(),
dt,
mouse_motion.delta.x,
mouse_motion.delta.y,
match rig.look {
Some(LookButton::Right) => "R",
Some(LookButton::Left) => "L",
None => "-",
},
player.face_yaw,
player.model_yaw,
player.pos.x,
player.pos.y,
player.pos.z,
// The pivot channel's live height and where it is heading — the two columns that
// answer "does the camera snap?" numerically (decision 0404: timing is measured).
rig.pivot.probe().0,
rig.pivot.probe().1,
);
}
// `turn_delta` is the character's own turn this frame (keyboard turn, or the drunk veer)
// — the deck's yaw delta was already applied to `cam.yaw` at the ride block (frame motion
// carries the camera unconditionally; only input turns respect `seat_camera`'s
+246 -10
View File
@@ -573,22 +573,155 @@ const CAM_RETURN_RATE: f32 = 6.0;
/// floors. The collision sweep still starts from the *head* (not the pivot), so a jump in a low room
/// stops the camera under the ceiling — see `control`.
///
/// Floor (yd) on the world pivot height — VERIFIED `5/6` (`0x50e570`'s `max(hi, target)` lower bound).
/// Floor (yd) on the world pivot height — VERIFIED `5/6` (`0x50ca90`'s per-preset clamp, and
/// `0x50e570`'s corridor lower bound).
pub(super) const CAM_PIVOT_FLOOR: f32 = 5.0 / 6.0;
/// Ceiling (yd) on the world pivot height — VERIFIED `15.0` (`[0x8089c8]`, the upper arm of the same
/// per-preset clamp in `0x50ca90`). A giant's scale cannot walk the framing pivot off into the sky.
pub(super) const CAM_PIVOT_CEIL: f32 = 15.0;
/// Pivot height used before the avatar model has attached (so `CameraPivot` isn't on the entity yet):
/// a human's ~neck height, so the first frames of third-person don't ride high. Replaced by the exact
/// model-derived value the moment the body attaches.
/// model-derived value the moment the body attaches — as a **snap**, not a glide ([`PivotGlide`]).
pub(super) const CAM_PIVOT_FALLBACK: f32 = 1.8;
/// World head height above a modeled unit's feet — its model-local [`CameraPivot`] (`attach17`-derived
/// head/eye for a character, else `0.9 × bbox-z`) × the live scale, floored at [`CAM_PIVOT_FLOOR`], or
/// the neck-height [`CAM_PIVOT_FALLBACK`] before the body attaches. The single definition shared by the
/// two things that sit at the character's head: the third-person framing pivot and the 3D-audio
/// listener (the client's `SoundListenerAtCharacter=1` default, wow-re benilla-pins B14).
/// One modeled unit's world head height: its model-local [`CameraPivot`] × the given scale, clamped
/// to `[CAM_PIVOT_FLOOR, CAM_PIVOT_CEIL]` — the reference's per-preset clamp in `0x50ca90`.
pub(super) fn model_pivot_height(pivot: &CameraPivot, scale: f32) -> f32 {
(pivot.height_local * scale).clamp(CAM_PIVOT_FLOOR, CAM_PIVOT_CEIL)
}
/// World head height above a modeled unit's feet — [`model_pivot_height`], or the neck-height
/// [`CAM_PIVOT_FALLBACK`] when the body has no model yet. The single definition shared by the things
/// that sit at the character's head: the framing-pivot *target*, the far-sight subject's pivot, and
/// the 3D-audio listener (the client's `SoundListenerAtCharacter=1` default, wow-re benilla-pins B14).
///
/// **Which `scale` to pass is a fidelity question with a verified answer** (wow-re
/// `pivot-height-glide.md`, C3): the reference's pivot preset multiplies the **raw**
/// `OBJECT_FIELD_SCALE_X` descriptor (vtable slot 7 = `0x469f10`, `fld [descriptors+0x10]`), *not*
/// the 2 s-eased render scale — the two are deliberately split in the binary (`0x4833d3` folds the
/// eased one in for a selection-ring consumer, and only there). So the camera passes
/// [`crate::net::NetEntity::scale`] and the pivot moves in **one** step per model event, which
/// [`PivotGlide`] then walks; multiplying by the eased scale instead would stack a second, slower
/// ease on top of the first and is what made a shapeshift snap *and* drift. The audio listener still
/// passes the rendered scale — it tracks the drawn body, and nothing verified says otherwise.
pub(crate) fn head_height(pivot: Option<&CameraPivot>, scale: f32) -> f32 {
pivot.map_or(CAM_PIVOT_FALLBACK, |p| {
(p.height_local * scale).max(CAM_PIVOT_FLOOR)
})
pivot.map_or(CAM_PIVOT_FALLBACK, |p| model_pivot_height(p, scale))
}
/// `cameraHeightSmoothSpeed` (yd/s) — the pivot channel's rate, VERIFIED registrar default `"1.2"`.
/// The duration of a move is `|Δh| / this` (`0x51276c`/`0x512777`), so it is an *average* rate: the
/// cosine profile peaks at `π/2 ×` it in the middle and is zero at both ends. There is no duration
/// clamp on this channel (unlike the yaw channel's `[0.1 s, 2.0 s]`).
const CAM_PIVOT_SMOOTH_SPEED: f32 = 1.2;
/// The pivot setter's "already there / already arming this" epsilon — VERIFIED `0.001` (`[0x801360]`,
/// `0x5126b0`). It is what makes the per-frame re-arm a no-op in steady state.
const CAM_PIVOT_EPS: f32 = 0.001;
/// **The camera's pivot-height channel** — the height the framing pivot actually rides, chasing the
/// model-derived target with a cosine smoothstep instead of taking it raw.
///
/// The reference's live `cam+0xfc` chasing target `cam+0x1c8`: armed by `0x5126b0` → `0x512790`,
/// stepped by `0x50f160`'s `[0x50f36a, 0x50f417)` block (wow-re `pivot-height-glide.md`, §5 round).
/// **This is why a druid shapeshift does not snap the reference's camera**, and it glides in *both*
/// directions: the solver's `max(target, live)` (`0x50e5a9`) is only the collision-corridor seed, and
/// the far chain clamps the result back down to the live value (`0x50e767`), so an unobstructed pivot
/// simply *is* `cam+0xfc` — rising or falling.
///
/// Two structural facts, both load-bearing:
/// - **The first arm of a camera's life snaps** (`0x5127d4`; the latch bit `0x80` is never cleared —
/// image-wide census). So logging in establishes the height instantly, and everything after it
/// glides. Nothing else re-snaps: a target-GUID change, a mount, a morph, `SetView` — all glide.
/// - **A model that has not resolved yet holds the channel**, it does not re-aim it (the reference
/// skips the whole camera update while the preset is stale, `0x50e907`). Ours is the `None` target:
/// during the frames a swapped-in model is loading, the pivot stays where it is and one glide runs
/// when the new height lands.
pub(super) struct PivotGlide {
/// The live height — what the camera uses (`cam+0xfc`).
live: f32,
/// Where the move started (`cam+0x1cc`) and where it is going (`cam+0x1c8`).
from: f32,
to: f32,
/// Seconds since arming, and the move's total (`cam+0x1c0`/`+0x1c4`); `None` = nothing in
/// flight (the reference's armed bit `[cam+0x90] & 0x20000000`).
flight: Option<(f32, f32)>,
/// Has the channel ever been armed? The reference's latch bit `0x80` — false only until the
/// first model-derived height arrives, which is therefore a snap.
seeded: bool,
}
impl Default for PivotGlide {
fn default() -> Self {
Self {
live: CAM_PIVOT_FALLBACK,
from: CAM_PIVOT_FALLBACK,
to: CAM_PIVOT_FALLBACK,
flight: None,
seeded: false,
}
}
}
impl PivotGlide {
/// Arm the channel with this frame's model-derived target (`None` while the subject has no
/// model — hold), step whatever is in flight, and return the height to frame at.
///
/// Called every frame, which is the reference's own cadence (`0x50f880` from the driver tail
/// `0x50f011`): the epsilon tests below turn a steady target into a no-op, so "arm per frame"
/// and "arm on change" are the same thing except at the instant the target actually moves.
pub(super) fn advance(&mut self, target: Option<f32>, dt: f32) -> f32 {
if let Some(target) = target {
self.arm(target);
}
if let Some((elapsed, dur)) = self.flight.as_mut() {
*elapsed += dt;
let s = *elapsed / *dur;
if s >= 1.0 {
self.live = self.to;
self.flight = None;
} else {
// The reference's kernel `0x5b7bb0` — the same cosine smoothstep the yaw channel
// and the render-scale ease use: `a + (b − a)·(1 − cos(πs))/2`.
let e = (1.0 - (std::f32::consts::PI * s).cos()) * 0.5;
self.live = self.from + (self.to - self.from) * e;
}
}
self.live
}
/// The arming half (`0x5126b0` → `0x512790`), in its own order: the re-arm memo, the
/// already-there test, then the duration — and the once-per-camera snap.
fn arm(&mut self, target: f32) {
if !self.seeded {
// The latch: the first height a camera ever sees is established, not travelled to.
self.seeded = true;
self.live = target;
self.to = target;
self.from = target;
self.flight = None;
return;
}
// Already arming exactly this — a no-op, so a per-frame re-arm cannot restart the move
// from its own midpoint (which would stretch it forever, asymptotically never arriving).
if self.flight.is_some() && (self.to - target).abs() < CAM_PIVOT_EPS {
return;
}
if (self.live - target).abs() < CAM_PIVOT_EPS {
// Already there: park the target and disarm. The steady-state path, every frame.
self.to = target;
self.flight = None;
return;
}
self.from = self.live;
self.to = target;
self.flight = Some((0.0, (target - self.live).abs() / CAM_PIVOT_SMOOTH_SPEED));
}
/// What the channel is doing, for `WOW_CAM_DUMP`: `(live, target)`. A pivot question is a
/// *timing* question — "does it snap?" is answered by these two columns on a trace, never by
/// watching a capture (method: timing is measured, never eyeballed).
pub(super) fn probe(&self) -> (f32, f32) {
(self.live, self.to)
}
}
/// A small sphere swept from the camera pivot toward the desired camera seat each frame to keep walls
@@ -626,6 +759,11 @@ pub(crate) struct CameraControl {
/// return in flight. It lives on the rig rather than beside the knob because it is *pose*, not
/// setting — a transition survives the frame, not the session.
pub(super) follow: FollowRig,
/// The framing pivot's height channel — smoothed, not taken raw ([`PivotGlide`]). On the rig
/// for the same reason `follow` is, and for one more: it belongs to the **camera**, not to the
/// body, which is why it glides *through* a change of subject (a shapeshift, a far-sight
/// switch) instead of being reset by one.
pub(super) pivot: PivotGlide,
}
impl CameraControl {
@@ -1352,6 +1490,104 @@ mod tests {
use benilla_world::billboard::BillboardCard;
use benilla_world::mesh_tag::alpha_bits;
/// Step a [`PivotGlide`] at 60 Hz for `secs`, holding the target, and return the heights it
/// passed through (one per frame).
fn glide_run(g: &mut PivotGlide, target: Option<f32>, secs: f32) -> Vec<f32> {
let dt = 1.0 / 60.0;
(0..(secs / dt).round() as usize)
.map(|_| g.advance(target, dt))
.collect()
}
/// **The report** (the director, on the reference vs ours): shifting form on the real client
/// *glides* the camera to the new body's height; ours snapped there and then drifted. The
/// channel's whole job is that this is one smooth move, in **both** directions — the solver's
/// `max(target, live)` is only a collision seed, and the far chain clamps back to the live
/// value (`0x50e767`), so nothing about a *rising* target arrives early (wow-re
/// `pivot-height-glide.md`, C2).
#[test]
fn a_shapeshift_glides_the_pivot_both_ways_and_never_snaps() {
// Tauren → cat: the heights measured off a live probe run.
let (tauren, cat) = (2.4659_f32, 1.0552_f32);
let mut g = PivotGlide::default();
// The first height a camera ever sees is established, not travelled to (`0x5127d4`).
assert_eq!(
g.advance(Some(tauren), 1.0 / 60.0),
tauren,
"the first arm snaps"
);
for (from, to) in [(tauren, cat), (cat, tauren)] {
let expected = (to - from).abs() / CAM_PIVOT_SMOOTH_SPEED;
let frames = glide_run(&mut g, Some(to), expected * 2.0);
// It arrives, and only at the end.
assert!((frames.last().copied().unwrap() - to).abs() < CAM_PIVOT_EPS);
let arrived = frames
.iter()
.position(|h| (h - to).abs() < CAM_PIVOT_EPS)
.unwrap();
let took = arrived as f32 / 60.0;
assert!(
(took - expected).abs() < 0.05,
"|Δh| / 1.2 yd/s = {expected:.3} s, took {took:.3} s ({from} → {to})"
);
// No frame jumps: the biggest single step is the cosine's own midpoint rate, which
// for these Δ is far under half the total. A snap would put the whole Δ in one frame.
let biggest = frames
.windows(2)
.map(|w| (w[1] - w[0]).abs())
.fold(0.0, f32::max);
assert!(
biggest < (to - from).abs() * 0.5,
"the pivot must never teleport: biggest step {biggest} of Δ {}",
(to - from).abs()
);
}
}
/// A model that has not resolved yet **holds** the channel — the reference skips the camera
/// update outright while the preset is stale (`0x50e907`), which is what makes a display swap
/// read as a pause and then one glide. Aiming at a placeholder in the meantime would send the
/// camera on a round trip.
#[test]
fn a_body_with_no_model_holds_the_pivot_instead_of_re_aiming_it() {
let mut g = PivotGlide::default();
g.advance(Some(2.4659), 1.0 / 60.0);
let held = glide_run(&mut g, None, 0.5);
assert!(
held.iter().all(|h| *h == 2.4659),
"no target ⇒ no motion (the model is still loading)"
);
// …and the glide that follows starts from where it held, not from a placeholder.
let frames = glide_run(&mut g, Some(1.0552), 2.0);
assert!(frames[0] < 2.4659 && frames[0] > 2.4);
}
/// The per-frame re-arm has to be a **no-op** in steady state (the setter's 0.001 epsilon,
/// `0x5126b0`). Restarting the move from its own midpoint every frame would stretch it
/// asymptotically and it would never arrive — the classic re-arm bug this epsilon prevents.
#[test]
fn re_arming_the_same_target_every_frame_does_not_stretch_the_glide() {
let mut g = PivotGlide::default();
g.advance(Some(1.0), 1.0 / 60.0);
let frames = glide_run(&mut g, Some(2.2), 2.0);
assert!(
(frames.last().copied().unwrap() - 2.2).abs() < CAM_PIVOT_EPS,
"a per-frame re-arm must still arrive"
);
}
/// The pivot target is the model height × the **raw** scale, clamped to the reference's own
/// `[5/6, 15]` band (`0x50ca90`) — a giant's aura cannot walk the framing pivot into the sky,
/// and a shrink cannot bury it in the floor.
#[test]
fn the_pivot_target_is_clamped_to_the_references_band() {
let p = CameraPivot { height_local: 2.0 };
assert_eq!(model_pivot_height(&p, 1.0), 2.0);
assert_eq!(model_pivot_height(&p, 0.01), CAM_PIVOT_FLOOR);
assert_eq!(model_pivot_height(&p, 100.0), CAM_PIVOT_CEIL);
}
/// A press that has travelled `yaw`/`pitch` **degrees** of camera rotation.
fn press(yaw_deg: f32, pitch_deg: f32) -> PressGesture {
PressGesture {
@@ -228,8 +228,11 @@ pub(super) fn plugin(app: &mut App) {
.before(super::control)
.run_if(in_state(ClientState::InWorld)),
)
.add_systems(OnExit(ClientState::InWorld), save_on_session_end)
.add_systems(Update, save_on_exit);
.add_systems(OnExit(ClientState::InWorld), save_on_session_end);
// The quit root goes on the exit edge, never `Update` (decision 1528): the close button's
// `AppExit` is not written until `PostUpdate`, so an `Update` reader watched the pose die with
// the process — a `/logout` saved it and quitting from in-world did not.
crate::shutdown::on_app_exit(app, save_on_exit.into_configs());
}
#[cfg(test)]
-1
View File
@@ -180,7 +180,6 @@ mod tests {
relay: Default::default(),
last_apply_ms: 0.0,
last_apply_pos: [0.0; 3],
settled_at: None,
}
}
+44 -1
View File
@@ -456,7 +456,11 @@ pub(crate) fn incapacitated_flags(flags: u32, rooted: bool, stunned: bool) -> u3
/// chord + `F` (decision 1043).
/// `active`/`pos`/`detached` are `pub(crate)` so terrain streaming can center the loaded block on the
/// avatar in third-person and on the free-flying camera while detached.
#[derive(Resource, Default)]
/// `PartialEq` is here for one assertion, and it is the assertion that keeps this type honest:
/// the session boundary returns the whole resource to `Player::default()`
/// ([`super::wire_in::release_on_session_end`], decision 1542), and its test says exactly that
/// rather than re-listing the fields a hand-picked reset would have to remember.
#[derive(Resource, Default, PartialEq)]
pub(crate) struct Player {
pub(crate) active: bool,
/// **The granted mover modes** — what the server has switched on for our mover, as typed state
@@ -731,6 +735,7 @@ pub(crate) struct Player {
}
/// The player's attachment to a transport's platform frame — see [`Player::ride`].
#[derive(PartialEq)]
pub(super) struct PlayerRide {
/// The transport's ECS entity (its collider is the ground support that attached us).
pub(super) entity: Entity,
@@ -782,6 +787,44 @@ impl Player {
self.move_flags
}
/// **What is holding this body still** — every state on the resource that kills WASD, named,
/// or `"none"`. An *instrument*, not a gate (decision 1542): the controller keeps its own
/// tests, because each of these suppresses a different amount (`control_lost`/`server_riding`
/// take the whole controller at [`super::control`]'s early-out; `rooted` zeroes the direction
/// vector and the swim amounts but deliberately leaves turning live). What this exists for is
/// the question a *log line* has to answer after a session boundary — "can the character that
/// just entered the world be driven?" — which B306 proved nothing was asking: `scripts/smoke.sh`
/// has crossed `/logout` → re-enter on every run since 1291 while counting UI rebuilds, errors
/// and shutdown writes, none of which a frozen character disturbs.
///
/// It does **not** make the smoke a B306 regression: that run logs in as a GM probe, so vmangos
/// takes the instant-logout branch and never roots us at all (`smoke.sh` reports that gap on
/// every run rather than banking a vacuous pass). What this covers live is the rest of the
/// family — a possession or a lost session that leaves the reins where they were.
///
/// It reads only this resource. A stun (`UNIT_FIELD_FLAGS`, [`crate::player::UNIT_FLAG_STUNNED`])
/// lives on the unit's descriptor block and is not visible from here.
pub(crate) fn movement_suppressors(&self) -> String {
let named = [
(!self.active, "inactive"),
(self.detached, "free-fly"),
(self.modes.rooted, "root"),
(self.control_lost, "control-lost"),
(self.server_riding, "server-ride"),
(self.foreign_mover.is_some(), "possession"),
(self.reseat, "reseat-pending"),
];
let list: Vec<&str> = named
.into_iter()
.filter_map(|(set, name)| set.then_some(name))
.collect();
if list.is_empty() {
"none".to_string()
} else {
list.join(",")
}
}
/// The **commanded planar speed** in yd/s — the reference's `[[player+0x118]+0x84]`, the
/// CMovement speed scalar its producer `0x7c4c90` returns:
///
@@ -81,7 +81,13 @@ pub(super) fn publish_view_subject(
mut subject: ResMut<ViewSubject>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
index: Res<GuidIndex>,
poses: Query<(&Transform, Option<&CameraPivot>)>,
// The subject's pose, its model-derived pivot, and its **raw** scale — the pivot height takes
// the raw `OBJECT_FIELD_SCALE_X`, not the eased render scale ([`head_height`]).
poses: Query<(
&Transform,
Option<&CameraPivot>,
Option<&crate::net::NetEntity>,
)>,
net: Res<NetCommands>,
mut engaged: Local<Option<u64>>,
) {
@@ -92,9 +98,9 @@ pub(super) fn publish_view_subject(
subject.remote = anchor
.and_then(|guid| index.0.get(&guid).copied())
.and_then(|entity| poses.get(entity).ok())
.map(|(t, pivot)| RemoteView {
.map(|(t, pivot, net)| RemoteView {
feet: t.translation,
pivot_height: head_height(pivot, t.scale.x),
pivot_height: head_height(pivot, net.map_or(1.0, |n| n.scale)),
});
// The toggle vote, on the edge — the reference sends it from exactly two sites, `1` as the view
+152 -14
View File
@@ -393,6 +393,13 @@ pub(super) fn apply_server_moves(
// the outgoing mover — resetting movement flags and cancelling click-to-move — and
// carrying any of it across would have the new body sprinting, falling or standing on a
// boat it is nowhere near.
//
// `first` scopes this to a mover change **inside one session**, and that gate is
// right: on a login there is no outgoing mover here to tear down, because the session
// that owned it ended at `release_on_session_end`, which since decision 1542 takes the
// whole resource. Until it did, the two lists were the same law kept in two places and
// this one was the longer — the reason B306's root outlived a `/logout` was that the
// only teardown naming `modes` was the one a login skips.
if !first {
player.vel_y = 0.0;
player.horiz_vel = Vec3::ZERO;
@@ -586,15 +593,33 @@ fn apply_self_move(
player.wedge_still = 0;
}
/// **The avatar went away with the session** — drop control, so the next login re-takes it from
/// its own streamed `SelfPlayer` (possibly a different character on a different map — the boot
/// path). The entity itself is despawned by the net drain the same frame these messages are
/// written.
/// **The avatar went away with the session** — the whole resource goes back to the state the
/// process booted in (`player::setup` inserts `Player::default()`), so the next login re-takes
/// control from its own streamed `SelfPlayer` (possibly a different character on a different map
/// — the boot path) owing nothing to the session that ended. The entity itself is despawned by the
/// net drain the same frame these messages are written.
///
/// Two edges, one answer (decision 1262): a confirmed `/logout` (decision 0193), and a **lost**
/// session, which since 1262 takes the avatar too — there is no reconnect left for it to be the
/// puppet of. Missing the second edge would leave `Player.active` true over a despawned entity: a
/// controller driving nothing, which is the shape of the free camera this arc is about.
///
/// **Everything on this resource belonged to the mover that just ended, so all of it dies here**
/// (decision 1542, B306 — reported and diagnosed by Liho). It used to clear a hand-picked six
/// fields, and the field it did not name was `modes`: `/logout` has vmangos root us for the
/// countdown (`MiscHandler.cpp` `SetRooted(true)`), we ack the grant, and the next session never
/// hears an unroot — the fresh server-side `Player` was never rooted, so there is nothing for it
/// to revoke. `modes.rooted` therefore survived into the new world and killed WASD for the rest of
/// the run. The list was the bug, not the missing line: the take-control edge above keeps its own
/// list for the same law (a mover *change* tears the old mover down — `SetActiveMover 0x6006e0`),
/// the two drifted apart, and only one of them ran here. So this end takes the resource whole —
/// a field added tomorrow is mover state by default, which is the safe direction.
///
/// It is also what the reference does, by construction rather than by list: the server's logout
/// confirm runs `ShutdownGame 0x491180` (wow-re `ui/scratch/lua-state-lifecycle.md` §3.3 —
/// `0x5aaeb0` → `0x401ee0` → `0x402039`, ~35 subsystem shutdowns, the Lua VM replaced twice), so
/// the real client has no per-mover state left to carry across a character-select round trip. Ours
/// is a long-lived resource; this is where it pays that back.
pub(super) fn release_on_session_end(
mut logouts: MessageReader<crate::net::LoggedOutMessage>,
mut lost: MessageReader<crate::net::DisconnectedMessage>,
@@ -603,16 +628,129 @@ pub(super) fn release_on_session_end(
// Both readers drain unconditionally — `|`, not `||`: a short-circuit would leave the other
// message unread, and its cursor would carry it into the next frame.
if logouts.read().next().is_some() | lost.read().any(|m| m.session_over) {
player.active = false;
player.move_flags = 0;
player.airborne_since = None;
player.wedged = false;
player.wedge_still = 0;
// The arrival debt dies with the session (decision 1340): a logout mid-settle must not
// carry a worldport ack into the NEXT login's settle release — the server would reject
// an ack from a player who is in world (vmangos ProcessPackets, STATUS_TRANSFER), and
// the old session's transfer was already force-acked server-side at logout.
player.owes_worldport_ack = false;
// Nothing is carried across. Among what this clears that a named list kept missing: the
// granted modes (B306's root, and water-walk/feather-fall/hover with it — vmangos re-sends
// every one the new session really holds, `Player::SendInitialPacketsAfterAddToMap`
// re-applies the aura family), the reins (`control_lost`, `foreign_mover`) which would
// otherwise leave the next login driving a guid that no longer exists, the autorun latch,
// and — as before — `active`, the movement flags, the fall/wedge state and the worldport
// ack debt (decision 1340: an ack from the old transfer is rejected by a player who is
// already in world, and the server force-acked it at logout anyway).
*player = Player::default();
}
}
#[cfg(test)]
mod session_end_tests {
use super::*;
use crate::net::{DisconnectedMessage, LoggedOutMessage};
use benilla_protocol::SessionEnd;
fn harness() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<Player>()
.add_message::<LoggedOutMessage>()
.add_message::<DisconnectedMessage>()
.add_systems(Update, release_on_session_end);
app
}
/// A session that actually ran: in world, moving, with every mode the server can grant on our
/// mover — and the reins in somebody else's hands. Every one of these was granted TO THE MOVER
/// this session owned, which is the whole of why none of it may outlive it.
fn a_session_that_ran(app: &mut App) {
let mut p = app.world_mut().resource_mut::<Player>();
p.active = true;
p.modes = super::super::state::MoveModes {
rooted: true,
water_walking: true,
feather_fall: true,
hover: true,
levitating: true,
};
p.autorun = true;
p.control_lost = true;
p.foreign_mover = Some(0xBB);
p.server_riding = true;
p.swimming = true;
p.move_flags = crate::creature_anim::move_flags::FORWARD;
p.pos = Vec3::new(10.0, 20.0, 30.0);
p.owes_worldport_ack = true;
p.airborne_since = Some(4.0);
p.wedged = true;
}
/// **B306, at its own edge** (decision 1542; reported and diagnosed by Liho). `/logout` has
/// vmangos root the player for the countdown (`MiscHandler.cpp:329 SetRooted(true)`), which
/// reaches us as the ack'd `SMSG_FORCE_MOVE_ROOT` above; the next login gets no unroot, because
/// server-side the fresh `Player` was never rooted (`SendInitialPacketsBeforeAddToMap` re-sends
/// the water-walk/feather-fall/hover aura family and roots only for a stun aura). So anything
/// the ended session left on this resource is permanent, and `modes.rooted` is WASD.
///
/// The assertion is deliberately against `Player::default()` whole rather than a list of
/// fields: a list is what failed — this reset named six fields, the take-control edge's own
/// teardown named nine, and only the second list named `modes`. A field added to `Player`
/// tomorrow is covered by this test on the day it is added.
#[test]
fn a_logout_takes_every_grant_the_ended_session_made() {
let mut app = harness();
a_session_that_ran(&mut app);
app.world_mut().write_message(LoggedOutMessage);
app.update();
let p = app.world().resource::<Player>();
assert!(
p.modes == Default::default(),
"the granted modes belonged to the mover that just ended — a root that survives \
`/logout` is B306: the character re-enters the world and WASD is dead"
);
assert!(
*p == Player::default(),
"and nothing else survives either: the session boundary returns the resource to the \
state `player::setup` inserts at boot (1542)"
);
}
/// The second edge (decision 1262) is the same answer: a lost session takes the avatar too, so
/// it takes everything granted to it. Not a variant of the above — it is a different message
/// on a different reader, and the `|` that drains both is load-bearing.
#[test]
fn a_lost_session_takes_them_too() {
let mut app = harness();
a_session_that_ran(&mut app);
app.world_mut().write_message(DisconnectedMessage {
reason: "world stream closed".into(),
end: SessionEnd::Lost,
session_over: true,
});
app.update();
assert!(*app.world().resource::<Player>() == Player::default());
}
/// **A teardown that is not the end must not wipe a live avatar.** Both cases that reach this
/// system with `session_over: false` are ones where the body stays ours: the `/logout`'s own
/// teardown disconnect (the roster relist *inside* one session — its `LoggedOutMessage` is the
/// edge, above) and an unattended run's seamless reconnect (0065). Resetting on the mere
/// arrival of a `DisconnectedMessage` would drop control from under a probe mid-run.
#[test]
fn a_teardown_that_is_not_the_end_keeps_the_avatar() {
let mut app = harness();
a_session_that_ran(&mut app);
app.world_mut().write_message(DisconnectedMessage {
reason: "logged out".into(),
end: SessionEnd::LoggedOut,
session_over: false,
});
app.update();
let p = app.world().resource::<Player>();
assert!(
p.active,
"the session is not over — the avatar is still ours"
);
assert!(p.modes.rooted, "and so is everything granted to its mover");
}
}
+190 -13
View File
@@ -30,7 +30,7 @@ pub(super) struct BoothPart {
pub(super) alpha_anim: Option<std::sync::Arc<benilla_formats::AlphaAnim>>,
}
/// Marks a booth part whose render-alpha `MeshTag` is driven by its own
/// Marks a booth part whose render-alpha `MeshTag` **and `Visibility`** are driven by its own
/// [`MatAnim`](benilla_world::doodad_anim::MatAnim) sample — the booth twin of the world lane's writer.
///
/// The world path's writer is the *visibility authority* (`debug_panel::apply_model_visibility`),
@@ -39,6 +39,12 @@ pub(super) struct BoothPart {
/// nothing to do with — so the booth owns this one small writer instead
/// ([`push_booth_mat_alpha`]). Marker-scoped rather than inferred from "has no `ModelPart`", so the
/// two lanes cannot silently overlap.
///
/// Being out of that query means being out of **both** halves of what it does, and the booth owes
/// its parts both: the alpha field *and* the `mat_factor > 0.0` term in its `desired` verdict — the
/// reference's `A <= 0` cull, which fires before the blend mode is ever read (wow-re
/// `m2-alpha-combine-cull.md`). Marking the marker is therefore also a claim of sole `Visibility`
/// authority over the parts that carry it; nothing else in the booth writes theirs.
#[derive(Component)]
pub(super) struct BoothMatAlpha;
@@ -210,17 +216,19 @@ pub(super) fn spawn_booth_effects(
// seated at the pivot under the host. No billboard in the chain (every shipped glow
// model, most items) → the host itself owns the emitter and the rest pose stands.
let owner = match em.billboard {
Some((kind, pivot)) => {
Some(bb) => {
let frame = commands
.spawn((
Transform::from_translation(benilla_assets::coords::wow_to_bevy(pivot)),
Transform::from_translation(benilla_assets::coords::wow_to_bevy(
bb.pivot,
)),
layer.clone(),
ChildOf(host),
BoothBillboard::frame(kind),
BoothBillboard::frame(bb.kind),
))
.id();
frames += 1;
(frame, pivot)
(frame, bb.pivot)
}
None => (host, [0.0; 3]),
};
@@ -259,6 +267,93 @@ pub(super) fn spawn_booth_effects(
(spawned, frames)
}
/// Spawn a booth model's **OWN** particle emitters — the ones authored on its own skeleton, riding
/// its own joints. The twin of [`spawn_booth_effects`], and the distinction is which model the
/// emitters belong to: that one seats a *separate* model's emitters (an equipped item's) on a body
/// bone through a host; this one is the model itself burning — the backdrop scene's braziers, the
/// select pet's flames. Returns `(emitters, frames)` like its twin.
///
/// `root` is the bake root: the fallback owner when a bone has no anchor (boneless bake, bad index),
/// the cloud's anchor, and the parent every emitter is hung under so a re-bake's
/// `despawn_related::<Children>` reaps them.
///
/// **The billboard arm is why this is a shared function and not two loops.** A booth pose drops the
/// camera arms ([`benilla_world::rig_anim::RigPose::without_camera_billboards`] — the world pass
/// would face them at the *world* camera), so unlike a rigged model in the world, a booth's emitter
/// whose chain reaches a billboard bone gets no replacement from its palette and would sit at its
/// rest-pose origin. It needs the same mesh-less [`BoothBillboard`] frame the no-rig lanes use —
/// seated here on the **billboard bone's own joint** at `ZERO`, because that joint already bakes the
/// pivot with the chain above it folded in (the 0130 rig identity, `BoothBillboardSpec::offset`'s
/// rule). Only when that bone has no anchor does it fall back to the rest-pose placement at the
/// pivot under `root`, which is all the no-rig lanes can ever do.
pub(super) fn spawn_booth_own_emitters(
commands: &mut Commands,
rig: &mut BoothRig,
root: Entity,
layer: &RenderLayers,
light: Option<&bevy::render::render_resource::Buffer>,
emitters: &[benilla_assets::ModelEmitter],
) -> (usize, usize) {
let mut spawned = 0usize;
let mut frames = 0usize;
for em in emitters {
let owner = match em.billboard {
Some(bb) => {
// The joint bakes the pivot → the frame sits at ZERO and only its rotation counts.
// No joint (boneless bake) → the rest-pose placement, at the pivot under the root.
let (seat, at) = match rig.anchor(commands, bb.bone) {
Some(joint) => (joint, Vec3::ZERO),
None => (root, benilla_assets::coords::wow_to_bevy(bb.pivot)),
};
let frame = commands
.spawn((
Transform::from_translation(at),
layer.clone(),
ChildOf(seat),
BoothBillboard::frame(bb.kind),
))
.id();
frames += 1;
(frame, bb.pivot)
}
// The ordinary case: the emitter rides its own bone's joint, in that bone's frame.
None => rig
.anchor(commands, em.def.bone)
.map_or((root, [0.0; 3]), |joint| (joint, em.bone_pivot)),
};
let Some(e) = benilla_world::particles::spawn_emitter(
commands,
em,
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,
// A booth bake has no appear/despawn ramp and no self-avatar feather (0827).
alpha: None,
// Scene-graph-carried: this model's motion arrives on the device stack (0986).
world_composed: false,
},
// A booth loops its one authored clip forever — the doodad law.
benilla_world::particles::EmitClock::Pinned,
) else {
continue;
};
commands.entity(e).insert((layer.clone(), ChildOf(root)));
if let Some(buf) = light {
commands
.entity(e)
.insert(benilla_world::particles::buffer::EffectLightOverride(
buf.clone(),
));
}
spawned += 1;
}
(spawned, frames)
}
/// How the booth bake's `AnimationPlayer` runs. Portraits are a **still** ([`Self::Frozen`] — Stand
/// paused at t = 0, the ref bake); the char-create preview is a **live scene** ([`Self::Loop`] —
/// Stand looping), the one case where the ref screen itself animates (decision 0423).
@@ -546,25 +641,53 @@ pub(super) fn face_booth_billboards(
}
}
/// Push each booth part's sampled material alpha onto its render-alpha `MeshTag` field — the booth's
/// own tiny twin of the world visibility authority's write (see [`BoothMatAlpha`] for why the world
/// one can't serve here).
/// Resolve each booth part's sampled material alpha — **cull it at `A <= 0`, dim it otherwise** —
/// the booth's own tiny twin of the world visibility authority (see [`BoothMatAlpha`] for why the
/// world one can't serve here).
///
/// `doodad_anim::sample_mat_anim` already ticks **every** `MatAnim` in the world, booth parts
/// included, so this only moves the sampled value onto the channel: `with_alpha` writes the alpha
/// field alone, so the rig slot (and the probe slot the rig lane reads from the same tag) ride
/// through untouched. Write-on-change, so the overwhelmingly common case — an authored *constant*
/// like UI_Tauren's 0.55 vignette — costs one compare per part per frame and never re-batches.
/// included, so this only spends the sampled value. Two ways, exactly as
/// `model_render::visibility` spends it on a world part:
///
/// - **`A <= 0` → `Hidden`.** The reference culls the batch before it looks at the blend mode, so a
/// zero is a *disappearance*, not a fade to nothing — and only a `Visibility` write can say that.
/// The tag alpha reaches the shader's blend source, which an Opaque draw ignores outright
/// (`wow_model.wgsl`: "For steady cutout/opaque draws blend is off so this is ignored"), so a
/// batch the artist keyed off in this sequence would otherwise draw **solid**. That is not an
/// edge case: `alphascan` counts 56 of 420 creature models authoring per-sequence batch
/// visibility, and the shipped one that stands in this booth is the Voidwalker, whose two
/// shoulder props are keyed to zero for the whole of Stand and hung in the air without this.
/// `Inherited` rather than `Visible`, so a hidden bake root still takes its parts with it.
/// - **Otherwise → the alpha field.** `with_alpha` writes that field alone, so the rig slot (and the
/// probe slot the rig lane reads from the same tag) ride through untouched.
///
/// Both are write-on-change, so the overwhelmingly common case — an authored *constant* like
/// UI_Tauren's 0.55 vignette — costs two compares per part per frame and never re-batches.
///
/// `Visibility` is required, not optional, and that is safe by construction: every site that adds
/// [`BoothMatAlpha`] spawns the part with a `Mesh3d`, which requires `Visibility` — and a part
/// without one is not a booth draw at all. Required rather than `Option`, because an optional match
/// would quietly ship the dimming half alone again, which is the whole defect this system had.
pub(super) fn push_booth_mat_alpha(
mut parts: Query<
(
&benilla_world::doodad_anim::MatAnim,
&mut bevy::mesh::MeshTag,
&mut Visibility,
),
With<BoothMatAlpha>,
>,
) {
for (anim, mut tag) in &mut parts {
for (anim, mut tag, mut vis) in &mut parts {
// The cull, the same term the world authority ANDs into its verdict as `mat_factor > 0.0`.
let want = if anim.current > 0.0 {
Visibility::Inherited
} else {
Visibility::Hidden
};
if *vis != want {
*vis = want;
}
let bits = benilla_world::mesh_tag::with_alpha(tag.0, anim.current);
if tag.0 != bits {
tag.0 = bits;
@@ -610,6 +733,10 @@ mod tests {
bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag(rig_slot, 1.0)),
anim,
BoothMatAlpha,
// Mirrors production: both marker sites spawn a `Mesh3d`, which *requires*
// `Visibility` — the writer's query takes it unconditionally, so a fixture without
// one is not a booth part and would silently fall out.
Visibility::Inherited,
))
.id();
app.update();
@@ -631,6 +758,56 @@ mod tests {
);
}
/// The `A <= 0` cull — the half the booth twin was missing, and the reason the Voidwalker
/// stood in the char-select booth with two shoulder props hung in the air. The reference culls
/// the batch before it reads the blend mode (wow-re `m2-alpha-combine-cull.md`), and the world
/// lane says so as `mat_factor > 0.0` in its `desired` verdict
/// (`model_render::visibility`); a tag write alone cannot express it, because an Opaque draw
/// never looks at the blend source the tag alpha feeds. Both directions, in one run: a zeroed
/// batch goes `Hidden`, a live one stays drawable, and the writer keeps up when the sample moves.
#[test]
fn a_batch_the_artist_zeroed_in_this_sequence_is_culled_not_merely_dimmed() {
let mut app = App::new();
app.add_systems(Update, push_booth_mat_alpha);
let spawn = |app: &mut App, v: f32| {
app.world_mut()
.spawn((
bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag(0, 1.0)),
MatAnim::driving_tag(constant_alpha(v), 0.0, None),
BoothMatAlpha,
Visibility::Inherited,
))
.id()
};
// The Voidwalker's Stand: the shoulder shackles keyed to 0, the wrist pair left at 1.
let shoulder = spawn(&mut app, 0.0);
let wrist = spawn(&mut app, 1.0);
app.update();
let vis = |app: &App, e: Entity| *app.world().entity(e).get::<Visibility>().unwrap();
assert_eq!(
vis(&app, shoulder),
Visibility::Hidden,
"A <= 0 is a disappearance, not a fade to nothing — an Opaque draw ignores the tag"
);
assert_eq!(
vis(&app, wrist),
Visibility::Inherited,
"and a live batch stays drawable — Inherited, so a hidden bake root still wins"
);
// Not a one-shot verdict: the sample is per frame, so a batch keyed back on comes back.
app.world_mut()
.entity_mut(shoulder)
.insert(MatAnim::driving_tag(constant_alpha(1.0), 0.0, None));
app.update();
assert_eq!(
vis(&app, shoulder),
Visibility::Inherited,
"the cull tracks the sample; it does not latch"
);
}
/// **The booth twin of the equipped-item emitter's billboard frame** (decision 0813, carried to
/// the select screen for `#bugs` B118). Same real numbers as
/// `billboard::tests::an_item_emitters_billboard_frame_puts_it_behind_the_pivot`
+130 -69
View File
@@ -19,83 +19,88 @@ use bevy::prelude::*;
/// camera brings its own FOV (see [`frame`]).
pub(super) const PORTRAIT_FOV: f32 = 0.5;
/// The aspect the **portrait bake's** own diagonal→vertical crop is computed at: the client feeds
/// its portrait projection a fixed `4/3` (wow-re portrait-render §4), which makes the crop factor
/// `1/√((4/3)²+1)` = `3/5` exactly — the community's "fov × 0.6" legend, emergent, not prescaled.
/// The aspect the client's portrait bake feeds `0x5c3cc0` — **exactly `1.0`, on every screen**
/// (decision 1543).
///
/// It is a *separate number* from the projection's `aspect` (see [`WowPortraitProjection::aspect`]):
/// this one is the client's own, fixed by its bake; that one is our destination pane's, and exists
/// purely to cancel the UI's stretch (1069). They coincide on the **model-pane** path, where the
/// client renders straight into the pane and its crop aspect IS the pane's.
pub(super) const PORTRAIT_CROP_ASPECT: f32 = 4.0 / 3.0;
/// `0x524f60` builds it as `(G44·64/W)/(G48·64/H)` = `(G44/G48)·(H/W)`, and `G44`/`G48`
/// (`[0x832a44]`/`[0x832a48]`) are the *live* normalized screen-aspect direction cosines
/// (`s/√(1+s²)` and `1/√(1+s²)`), so their ratio IS the screen aspect `s` and the `H/W` cancels it
/// exactly. The same pair divides back out of the viewport locals (`0x41ade0`), which is why the
/// bake's viewport is a square `64×64` px box on any target — both observed in the real client's
/// own GL stream at two different screen aspects (1543).
///
/// So the crop is `1/√(1²+1)` and **`fovy = fov/√2 = 0.7071·fov`**.
///
/// **The "fov × 0.6" legend is `1/√((4/3)²+1)`** — what this quantity would be if the aspect were
/// `4/3`, which it never is. Carrying it here after 0163 had already corrected the matrix to
/// isotropic is what left benilla's portraits 18.7% too tight (director report, 2026-08-22).
pub(super) const PORTRAIT_ASPECT: f32 = 1.0;
/// A camera record's *diagonal* fov → the **vertical** opening angle, at a given crop aspect:
/// A camera record's *diagonal* fov → the **vertical** opening angle at a given aspect:
/// `fov/√(a²+1)`, the client's own `0x5c3cc0` relation (`t = tan((fov/2)/√(a²+1))`, `m11 = 1/t`, so
/// `tan(fovy/2) = t` exactly). The one place a plain Bevy `PerspectiveProjection` needs it.
pub(super) fn diag_to_vert(fov: f32, crop_aspect: f32) -> f32 {
fov / (crop_aspect * crop_aspect + 1.0).sqrt()
///
/// `a` is `0x5c3cc0`'s single `aspect` — the same one that sets the squeeze, never a second number
/// (1543). [`PORTRAIT_ASPECT`] for the round bake; the pane's own rect for a `<PlayerModel>`.
pub(super) fn diag_to_vert(fov: f32, aspect: f32) -> f32 {
fov / (aspect * aspect + 1.0).sqrt()
}
/// The real client's portrait **projection** (wow-re portrait-render §4, corrected `aa186e79`):
/// gxumath `0x5c3cc0` is a *diagonal-FOV* perspective — half-angle `θ = (fov/2)/√(aspect²+1)`,
/// `m11 = 1/tan θ`, `m00 = m11/aspect` — and the portrait bake feeds it `aspect = 4/3`
/// ([`PORTRAIT_CROP_ASPECT`]). Net: vertical half-angle **`0.3·fov`** (the refs' tight crop; 1.72–1.75×
/// tighter than a naive `tan(fov/2)` read) plus a deliberate **3:4 anamorphic squeeze** (spheres
/// render as 4:3-tall ellipses in every real portrait).
/// 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`.
///
/// **One `aspect`, two jobs — because `0x5c3cc0` has exactly one.** The same variable sets the
/// horizontal squeeze *and* the diagonal→vertical crop, so a rig that used different numbers for
/// the two builds a matrix the client cannot produce. benilla carried precisely that split for six
/// weeks (0163 corrected the matrix to isotropic and left the crop on `4/3`), and decision 1543
/// closed it: the field below is singular on purpose, and must stay that way.
///
/// - **Round portrait:** `1.0` ([`PORTRAIT_ASPECT`]) — the client's own value, and our sampling
/// region is square by construction, so it is also the number that leaves the UI's stretch
/// nothing to cancel.
/// - **Model pane:** the pane's own width÷height — the client renders straight into the pane rect
/// (`318×224 → 0.576·fov`), and our booth's square target is stretched onto that rect by the UI,
/// so the one number does both jobs there too (decision 1069).
///
/// Carried as a custom [`CameraProjection`] rather than a `PerspectiveProjection` because the
/// camera system re-derives `aspect_ratio` from the (square) render target on every projection
/// write — it would stomp ours. `update` is a deliberate no-op: the ref mapping is
/// target-size-independent too (its 64×64 surface is forced by a hardcoded scissor).
/// write — it would stomp ours. `update` is a deliberate no-op, and that is faithful: the client's
/// bake emits the *same* matrix at 1152×648 and at 1280×800 (1543).
#[derive(Debug, Clone)]
pub(super) struct WowPortraitProjection {
/// The M2 record's fov (radians) — a *diagonal* angle in the client's convention, NOT fovy.
pub(super) fov: f32,
pub(super) near: f32,
pub(super) far: f32,
/// The **destination pane's** width ÷ height.
/// `0x5c3cc0`'s one `aspect` — it drives BOTH the horizontal squeeze (`m00 = m11/aspect`) and
/// the diagonal→vertical crop (`fovy = fov/√(aspect²+1)`). See the type docs for which value
/// each path takes and why they are the same number.
///
/// A booth renders into a *square* target that the UI then stretches to fill whatever rect the
/// sampling region resolves to (`extract`'s `UvRect::FULL`). So the on-screen proportions are
/// only true when the projection runs at the **pane's** aspect: the projection squeezes by
/// `1/aspect` horizontally, the stretch undoes it exactly, and a sphere lands round. Rendering
/// at 1.0 into a 316×351 pane is what made every dressing-room character 11% too tall
/// (director report, 2026-08-06 — decision 1069).
///
/// 1.0 for a round portrait: its region is square by construction (the circular mask is
/// inscribed in it), so there is nothing to cancel.
/// It also happens to be the term that cancels the UI's stretch: a booth renders into a
/// *square* target that the UI stretches to fill whatever rect the sampling region resolves to
/// (`extract`'s `UvRect::FULL`), so on-screen proportions are only true when the projection
/// runs at the destination's aspect. Rendering at 1.0 into a 316×351 pane is what made every
/// dressing-room character 11% too tall (director report, 2026-08-06 — decision 1069). That the
/// client's own aspect and our destination's agree on both paths is not luck: the round
/// portrait's region is square *because* the client baked it square.
pub(super) aspect: f32,
/// The aspect the **client** computes its diagonal→vertical crop at — `fovy = fov/√(a²+1)`.
///
/// Not the same question as [`Self::aspect`], and the two genuinely differ by path. The portrait
/// bake hardcodes `4/3` ([`PORTRAIT_CROP_ASPECT`]) while our destination region is square, so
/// there it is `4/3` against an `aspect` of `1.0`. A `<PlayerModel>` body pane renders *straight
/// into the pane* in the real client (`0x5c3cc0` with the frame's plain pixel width/height), so
/// there the two are the same number — and the crop genuinely moves with the pane: `318×224`
/// crops to `0.576·fov`, `233×224` to `0.693·fov`. Hardcoding `0.6` was only ever right at 4/3.
pub(super) crop_aspect: f32,
}
impl WowPortraitProjection {
/// Full vertical opening angle — `2θ` where `θ = (fov/2)/√(crop_aspect²+1)` is the client's own
/// Full vertical opening angle — `2θ` where `θ = (fov/2)/√(aspect²+1)` is the client's own
/// half-angle (`0x5c3cc0`: `t = tan θ`, `m11 = 1/t`, so `tan(fovy/2) = tan θ` exactly, no
/// small-angle step anywhere).
fn fovy(&self) -> f32 {
diag_to_vert(self.fov, self.crop_aspect)
diag_to_vert(self.fov, self.aspect)
}
}
impl CameraProjection for WowPortraitProjection {
fn get_clip_from_view(&self) -> Mat4 {
// No anamorphic squeeze — square-true proportions ON SCREEN, which is what `aspect` (the
// destination pane's, not the render target's) buys: it cancels the UI's stretch rather
// than adding a distortion of its own. The 4/3 squeeze wow-re's §4 verdict prescribed
// rendered visibly TALLER faces than the reference's on-screen portrait (the director's
// A/B, 2026-07-06) — the director's capture is ground truth, so the squeeze is out pending
// wow-re's reconciliation (their first reading WAS aspect ≈ 1; the display path may also
// compensate). The 0.6 diag→vert factor stays: the crop tightness matched refs and is
// aspect-derivation-independent on screen. Bevy-native reverse-z infinite depth (the
// record far — 27.8 — never clips a model-local portrait).
// The client's matrix, with Bevy-native reverse-z infinite depth (the record far — 27.8 on
// HumanMale — never clips a model-local portrait). The round portrait's `aspect` is 1.0, so
// there is no anamorphic squeeze: the director's 2026-07-06 A/B said so before the bytes
// did, and 1543 measured `m00 == m11` in the client's own GL stream.
Mat4::perspective_infinite_reverse_rh(self.fovy(), self.aspect, self.near)
}
@@ -216,8 +221,8 @@ const WINDOW_MAX: f32 = 1.1;
/// **Authored path** (the mechanism, module docs): the model's own portrait camera, verbatim —
/// `lookAt(eye, target, up)` with up = +Y rolled about the view axis (roll is `0.0` on every
/// portrait camera audited), and the client's exact projection of the record's fov/near/far
/// ([`WowPortraitProjection`] — diagonal-FOV at the fixed 4/3 aspect). No yaw, no window math —
/// the artist already framed it.
/// ([`WowPortraitProjection`] at [`PORTRAIT_ASPECT`] — diagonal-FOV, isotropic, `fovy = fov/√2`).
/// No yaw, no window math — the artist already framed it.
///
/// **Fallback path** (camera-less models): a heuristic face closeup aimed at the head anchor (else
/// just above the neck pivot) from a slight three-quarter angle, sized by the model's own height
@@ -232,12 +237,11 @@ pub(super) fn frame(a: &PortraitAnchors) -> (Transform, Projection) {
fov: cam.fov,
near: cam.near,
far: cam.far,
// A round portrait's sampling region is square by construction (the mask is the
// inscribed circle) — nothing to cancel.
aspect: 1.0,
// …but the CROP is the client's own, and its portrait bake computes it at a fixed
// 4/3. Two different aspects, two different jobs.
crop_aspect: PORTRAIT_CROP_ASPECT,
// The client's own bake aspect, which is also ours: its `(G44/G48)·(H/W)` cancels
// to 1.0 on any screen, and our sampling region is square by construction (the
// circular mask is inscribed in it), so there is nothing to cancel either. One
// number, both jobs — `fovy = fov/√2`.
aspect: PORTRAIT_ASPECT,
}),
);
}
@@ -370,9 +374,25 @@ pub(super) fn pane_camera(a: &PortraitAnchors) -> benilla_assets::PortraitCamera
})
}
/// A pane camera's projection: the record's scalars, with the pane's aspect in BOTH roles (the
/// client renders straight into the pane rect, so its matrix aspect and its diagonal→vertical crop
/// aspect are the same number). See [`WowPortraitProjection::crop_aspect`].
/// A pane camera's projection: the record's scalars at the pane's aspect — which does both the
/// squeeze and the diagonal→vertical crop, because the client renders straight into the pane rect.
/// See [`WowPortraitProjection::aspect`].
///
/// **1543's cancellation does NOT reach here, and this was checked, not assumed** (wow-re
/// `bcd1f2c2`, a §5 round run blind to decision 1089). Both paths build through the same
/// `0x7ada40`/`0x7ac640`, so the question is only what each caller puts in the aspect rect: the
/// *bake* constructs `{0, 0, 1.0, D}` whose only size terms are its fixed 64/64, which is why its
/// screen terms cancel to `1.0`; the *widget* (`0x76d42d`) hands over the frame's **raw cached
/// layout rect** verbatim (`0x768320`, a 4-dword copy of `[layoutFrame+0x40]`), so the same
/// cancellation removes only the screen terms and the pane's own shape survives —
/// `aspect = (W/H)·(a/a_screen)` = `W/H` whenever the configured and actual display aspects agree,
/// which 1543's `widescreen` census showed is every state the client can reach. The `0x41ade0`
/// unscales on this path (`0x76d45b`/`0x76d46e`) run *after* the camera build and serve the
/// viewport only.
///
/// The falsifier, if anyone is tempted to propagate the portrait's `1.0` here anyway: the viewport
/// comes from that same rect, so projection and viewport aspects agree by construction, and a
/// transplanted `1.0` would render a sphere **1.42× wider than tall** in the 318×224 pet pane.
pub(super) fn pane_projection(
cam: &benilla_assets::PortraitCamera,
aspect: f32,
@@ -382,7 +402,6 @@ pub(super) fn pane_projection(
near: cam.near,
far: cam.far,
aspect,
crop_aspect: aspect,
}
}
@@ -466,7 +485,7 @@ mod tests {
(want.fov, want.near, want.far),
(human.fov, human.near, human.far)
);
assert_eq!((want.aspect, want.crop_aspect), (aspect, aspect));
assert_eq!(want.aspect, aspect);
assert_eq!(
projection.get_clip_from_view(),
want.get_clip_from_view(),
@@ -500,7 +519,6 @@ mod tests {
near: c.near,
far: c.far,
aspect,
crop_aspect: aspect,
};
// One yard of height at the subject's own standing point.
let base = Vec3::new(0.0, c.target.y, 0.0);
@@ -596,21 +614,65 @@ mod tests {
}
}
/// The client's diagonal→vertical crop is **aspect-dependent**, and hardcoding the 4/3 value
/// (0.6) was only right at 4/3. `fovy = fov/√(a²+1)` — so the pet pane's 318×224 crops to
/// 0.576·fov and the character pane's 233×224 to 0.693·fov.
/// The client's diagonal→vertical crop is **aspect-dependent**: `fovy = fov/√(a²+1)`. The pet
/// pane's 318×224 crops to 0.576·fov and the character pane's 233×224 to 0.693·fov — and the
/// round portrait, whose aspect is 1, to `1/√2`. The famous `0.6` is the value at 4/3 and
/// nothing in the portrait path is ever at 4/3 (1543).
#[test]
fn the_diagonal_crop_follows_the_pane_aspect() {
fn the_diagonal_crop_follows_the_aspect() {
let close = |a: f32, b: f32| (a - b).abs() < 5e-4;
assert!(close(diag_to_vert(1.0, 4.0 / 3.0), 0.6), "the 4/3 legend");
assert!(close(diag_to_vert(1.0, 318.0 / 224.0), 0.575_86));
assert!(close(diag_to_vert(1.0, 233.0 / 224.0), 0.693_05));
assert!(close(
diag_to_vert(1.0, 1.0),
diag_to_vert(1.0, PORTRAIT_ASPECT),
std::f32::consts::FRAC_1_SQRT_2
));
}
/// **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
/// upload the SAME bake matrix for HumanMale's `cameraLookup[0]` (`fov = π/4`, `far = 27.78`):
///
/// ```text
/// m00 = 3.508226 m11 = 3.508225 (m00 == m11 → isotropic, aspect = 1)
/// ```
///
/// This is the ground truth that refutes BOTH recorded readings of the bake aspect (4/3, and
/// `a²`), and the number benilla was missing: `1/3.508226 = tan((fov/2)/√2)`. If a future
/// "correction" moves the portrait crop off `1/√2`, this test is what should stop it.
#[test]
fn the_portrait_bake_matches_the_clients_own_matrix() {
const OBSERVED_M11: f32 = 3.508_226;
let proj = WowPortraitProjection {
fov: std::f32::consts::FRAC_PI_4,
near: 0.111_57,
far: 27.778,
aspect: PORTRAIT_ASPECT,
};
let m = proj.get_clip_from_view();
assert!(
(m.x_axis.x - OBSERVED_M11).abs() < 1e-4,
"m00 {} is not the client's {OBSERVED_M11}",
m.x_axis.x
);
assert!(
(m.y_axis.y - OBSERVED_M11).abs() < 1e-4,
"m11 {} is not the client's {OBSERVED_M11}",
m.y_axis.y
);
// The old 4/3 crop — what made the portraits 18.7% too tight — must not come back.
let tight = WowPortraitProjection {
aspect: 4.0 / 3.0,
..proj
};
assert!(
tight.get_clip_from_view().y_axis.y > OBSERVED_M11 * 1.15,
"the 4/3 crop should be visibly tighter than the client's"
);
}
/// **The 1069 defect, pinned.** A booth bakes into a *square* target that the UI stretches into
/// the pane's rect, so a figure only keeps its proportions when the projection runs at the
/// pane's aspect. Measured the way the eye does: screen pixels per model yard, sideways vs
@@ -627,7 +689,6 @@ mod tests {
near: c.near,
far: c.far,
aspect,
crop_aspect: aspect,
};
// A small cross at the framing centre — the on-screen size of each arm, in pane pixels.
let mid = Vec3::new(0.0, transform.translation.y, 0.0);
+496 -62
View File
@@ -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_CROP_ASPECT};
use super::framing::{attachment_point, diag_to_vert, PORTRAIT_ASPECT};
use super::{
aim, body_frame, new_target_image, spawn_booth_effects, spawn_booth_model, Booth,
BoothBillboardSpec, BoothCam, BoothEffects, BoothLight, BoothMotion, BoothPart, BoothRider,
@@ -71,6 +71,26 @@ pub(crate) struct SelectLook {
/// `CHARACTER_FLAG_*` bits (hide-helm / hide-cloak honored by the builder).
pub(crate) flags: u32,
pub(crate) equipment: [benilla_protocol::CharEnumItem; 19],
/// The roster row's **pet** triple — the hunter/warlock companion that stands beside the
/// selected character. It rides the look, not a `GluePreview` field of its own, because it is
/// part of the enum record and wants the same change detection every other select field gets:
/// swapping rows re-bakes the pet with the body.
///
/// All three words matter. The display names the model; the **level and family are its size**
/// (`CreatureFamily`'s ramp — decision 1538), so a freshly tamed wolf stands visibly smaller
/// than a level-60 one.
pub(crate) pet_display_id: u32,
pub(crate) pet_level: u32,
pub(crate) pet_family: u32,
}
/// The select pet, as the booth needs it: which creature, and the two words that size it.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) struct PetLook {
/// `CreatureDisplayInfo` id — never `0` here ([`GlueLook::pet`] is what filters that out).
pub(crate) display_id: u32,
pub(crate) level: u32,
pub(crate) family: u32,
}
impl From<&benilla_protocol::Character> for SelectLook {
@@ -85,6 +105,9 @@ impl From<&benilla_protocol::Character> for SelectLook {
facial_hair: c.facial_hair,
flags: c.flags,
equipment: c.equipment,
pet_display_id: c.pet_display_id,
pet_level: c.pet_level,
pet_family: c.pet_family,
}
}
}
@@ -104,6 +127,20 @@ impl GlueLook {
GlueLook::Select(l) => (l.race, l.sex),
}
}
/// The **pet** standing beside the character, or `None`. Only a select look can carry one: the
/// create screen builds a character that has no pet yet, and the wire field it would come from
/// doesn't exist until the character does.
pub(crate) fn pet(&self) -> Option<PetLook> {
match self {
GlueLook::Create(_) => None,
GlueLook::Select(l) => (l.pet_display_id != 0).then_some(PetLook {
display_id: l.pet_display_id,
level: l.pet_level,
family: l.pet_family,
}),
}
}
}
/// Which glue background scene shows (decision 0539 §5): the login screen's main-menu gate, or a
@@ -158,9 +195,32 @@ pub(crate) struct CreateScene {
cam: Option<benilla_assets::PortraitCamera>,
/// The character's stage spot — scene attachment 0 (Bevy model space), `ZERO` with no scene.
/// (Verified for select too: the body seats on attachment **0**, `0x473039` — attachment 1 is
/// the rare enum secondary model, wow-re `glue-select-model.md` TU-B; 0429's attachment-1
/// the enum's secondary model, wow-re `glue-select-model.md` TU-B; 0429's attachment-1
/// presumption is dead.)
char_spot: Vec3,
/// The stage's **authored frame** — attachment 0's bone matrix ([`stage_frame`]): the rotation
/// and uniform scale the scene author parked on that bone. Identity/1.0 with no scene;
/// UI_Human's holds the −16.5° yaw that aims the character at that scene's off-axis camera
/// (and a 0.97 scale), UI_Tauren's a 1.04 scale (1533).
char_facing: Quat,
char_scale: f32,
/// The **pet's** spot — scene attachment 1 (`0x47306b` chains the secondary model there), a few
/// yards off the stage. Every `UI_*` scene authors exactly these two attachments and no more,
/// and both their bones are parentless roots — so the seat is that bone's own frame, with
/// nothing above it to compose, which is why the pet is placed the same way the body is.
pet_root: Entity,
pet_spot: Vec3,
/// The pet's share of the stage's frame — attachment **1**'s bone, read the same way
/// [`Self::char_facing`] is. 1536 placed the pet with a pure translation on the reading that
/// both attachment bones are unkeyed; that holds on five scenes and **not** on two of them.
/// UI_Human's attachment-1 bone (bone 5) carries the identical constant −16.5° yaw and 0.97
/// scale its attachment-0 bone does — without the yaw the pet stands square beside an owner
/// turned 16.5° away — and UI_Tauren's parks a **0.7324** scale on the pet's bone alone, three
/// quarters the size we were drawing it (1533).
pet_facing: Quat,
pet_stage_scale: f32,
/// The pet bake standing at [`Self::pet_spot`] — its revision, so a re-bake happens once.
pet_baked: Option<u64>,
/// Whether the current spawn wrote the fog rows enabled (the create law) — a screen hop with
/// the same scene race still rebuilds when this flips.
fog: bool,
@@ -274,6 +334,12 @@ pub(crate) struct PreviewPart {
pub(crate) static_mesh: Handle<Mesh>,
pub(crate) skinned_mesh: Option<Handle<Mesh>>,
pub(crate) material: Handle<WowModelMaterial>,
/// The batch's **animated material alpha**, carried to the booth's [`BoothPart`]. The CHARACTER
/// assembly still supplies `None` here — threading it through the appearance layer is decision
/// 0807's named gap, and the select body's look is signed off as it stands. The **pet** builder
/// has the field in hand (it reads the display's `EntityPart`s straight, with no compositor in
/// between), so it fills it.
pub(crate) alpha_anim: Option<std::sync::Arc<benilla_formats::AlphaAnim>>,
}
/// One equipment rider on the assembled preview (helm / shoulder / sheathed weapon — decision
@@ -362,6 +428,41 @@ pub(crate) struct GluePreviewBake {
pub(crate) revision: u64,
}
/// The select screen's **pet** — the hunter/warlock companion standing beside the selected
/// character (`SMSG_CHAR_ENUM`'s pet triple, the reference's "secondary model" `record+0x114`).
///
/// Its own resource, and its own revision, deliberately: the pet's model can land frames after the
/// body's, and folding it into [`GluePreviewBake`] would make that late arrival re-bake the
/// character too — restarting an already-correct Stand loop for nothing. Empty (`display_id == 0`)
/// whenever the look carries no pet, which is every character but a living hunter's or warlock's
/// (the server suppresses the triple for the rest — see [`benilla_protocol::Character`]).
#[derive(Resource, Default)]
pub(crate) struct GluePetBake {
/// The pet's `CreatureDisplayInfo` id — `0` = no pet, and the booth stands empty.
pub(crate) display_id: u32,
pub(crate) parts: Vec<PreviewPart>,
/// The pet's camera-facing batches — the voidwalker's eyes are exactly this.
pub(crate) billboards: Vec<PreviewBillboard>,
/// The pet model's **own** particle emitters, straight off the display cache — the imp's three
/// green flame jets (bones 17/45/46, `FlameLickSmall`, rate keyed alive in every sequence), the
/// felhunter's, the succubus's. Unlike the body's [`GluePreviewBake::effects`] — which are an
/// *equipped item's* emitters, seated on a character bone through a host — these are authored on
/// the pet's own skeleton, so they ride its own joints: the scene-backdrop recipe, not the
/// worn-item one ([`sync_glue_pet`]).
pub(crate) emitters: Vec<benilla_assets::ModelEmitter>,
/// The uniform render scale — the **`CreatureFamily` level ramp**, `minScale` → `maxScale`
/// across `minScaleLevel` → `maxScaleLevel`
/// ([`benilla_formats::CreatureFamily::scale_at`], decision 1538).
///
/// Not the `CreatureModelData.modelScale × CreatureDisplayInfo.scale` product this field
/// briefly held: the reference computes that product one instruction before the ramp and then
/// overwrites it (`0x472e87 fstp`), so the product is only ever the fallback for a family the
/// table doesn't know. Either way the glue pet has no wire object, so — unlike every unit in
/// the world — nothing has folded a scale in for it and the client must size it itself.
pub(crate) scale: f32,
pub(crate) revision: u64,
}
/// Stand up the create booth beside the portrait/paper-doll ones (called from [`super::setup_booths`],
/// which owns the shared image/booth resources). Same off-screen pipeline as the paper doll — HDR +
/// FfxGlow, negative order — but a [`GLUE_SIZE`] target cleared *transparent*, its own layer,
@@ -428,6 +529,12 @@ pub(super) fn spawn_glue_booth(
);
// The background scene's own root (the character root above yaws; the scene never does).
let scene_root = commands
.spawn((Transform::IDENTITY, Visibility::Visible, layer.clone()))
.id();
// …and the pet's, a third sibling: it stands on its own scene attachment and, like the scene,
// never yaws — the reference's facing call touches only the character component's model
// (`0x4730e0` → `cc+0x38`), so dragging the character spins the character alone.
let pet_root = commands
.spawn((Transform::IDENTITY, Visibility::Visible, layer))
.id();
commands.insert_resource(CreateScene {
@@ -437,6 +544,13 @@ pub(super) fn spawn_glue_booth(
spawned: false,
cam: None,
char_spot: Vec3::ZERO,
char_facing: Quat::IDENTITY,
char_scale: 1.0,
pet_root,
pet_spot: Vec3::ZERO,
pet_facing: Quat::IDENTITY,
pet_stage_scale: 1.0,
pet_baked: None,
fog: true,
light: None,
variants: default(),
@@ -491,6 +605,9 @@ pub(super) fn sync_glue_scene(
scene.spawned = false;
scene.cam = None;
scene.char_spot = Vec3::ZERO;
scene.char_facing = Quat::IDENTITY;
scene.char_scale = 1.0;
clear_pet(&mut commands, &mut scene);
resize_target(&mut images, &booth.target, GLUE_SIZE, GLUE_SIZE);
}
return;
@@ -526,6 +643,7 @@ pub(super) fn sync_glue_scene(
scene.spawned = false;
scene.cam = None;
scene.char_spot = Vec3::ZERO;
clear_pet(&mut commands, &mut scene);
commands.entity(scene.root).despawn_related::<Children>();
// The model may still be loading — strip the old rig state off the outliving root now,
// not only at the next spawn (which may be many frames away).
@@ -547,6 +665,12 @@ pub(super) fn sync_glue_scene(
// portraits' shared studio light stays untouched. Rewritten in place on every scene swap
// (the cached scene + character materials keep binding it).
let stage = attachment_point(&model.skeleton, &model.attachments, 0).unwrap_or(Vec3::ZERO);
// The stage's frame, not just its point — see [`stage_frame`].
let (stage_facing, stage_scale) = model
.attachments
.iter()
.find(|a| a.id == 0)
.map_or((Quat::IDENTITY, 1.0), |a| stage_frame(model, a.bone));
let rig = scene_rig(&model.lights);
let (fog_rgb, fog_far) = scene_fog(token);
// Rig materials read the probe + point table; the core rows still carry the plain ambient
@@ -635,62 +759,65 @@ pub(super) fn sync_glue_scene(
&[], // …nor a character's eye-glow
);
// The scene's authored particle emitters (decision 0539 §5) — the braziers/embers every
// UI_* scene carries (MainMenu 28, Orc 11, NightElf 12…), dead until now. The world spawn
// path, on the booth's layer, lit/fogged by the SCENE's own light buffer (the ModelFFX fog
// covers the whole model, emitters included — carried by `EffectLightOverride` into the
// shared lane's per-draw bind group); owner = the emitter bone's anchor, so fire flicker
// rides the animated bones; anchored at the static scene root. No `EmitterFade`: a glue
// scene always ticks. Parented under the scene root so teardown cascades.
let mut emitters = 0usize;
for em in &model.emitters {
let owner = scene_rig
.anchor(&mut commands, em.def.bone)
.map_or((scene.root, [0.0; 3]), |j| (j, em.bone_pivot));
if let Some(e) = benilla_world::particles::spawn_emitter(
&mut commands,
em,
Transform::IDENTITY,
benilla_world::particles::EmitterFrames {
owner: Some(owner),
attach: None, // a backdrop scene is never an attached model
anchor: Some(scene.root),
// The scene root is torn down and rebuilt as a whole.
on_owner_loss: benilla_world::particles::OwnerLoss::Free,
// A booth bake has no appear/despawn ramp and no self-avatar feather — its
// riders are always opaque (0827).
alpha: None,
// Scene-graph-carried: this model's world motion arrives on the reference's
// device stack, so its cloud RIDES (0986's baseline).
world_composed: false,
},
// A glue scene loops its one authored clip forever — the doodad law.
benilla_world::particles::EmitClock::Pinned,
) {
commands.entity(e).insert((
booth.layer.clone(),
ChildOf(scene.root),
benilla_world::particles::buffer::EffectLightOverride(light.clone()),
));
emitters += 1;
}
}
// UI_* scene carries (MainMenu 28, Orc 11, NightElf 12…). Lit/fogged by the SCENE's own
// light buffer: the ModelFFX fog covers the whole model, emitters included, carried by
// `EffectLightOverride` into the shared lane's per-draw bind group. The seating, the
// billboard arm and the teardown parenting are the shared recipe's (1539).
let (emitters, fx_frames) = super::spawn_booth_own_emitters(
&mut commands,
&mut scene_rig,
scene.root,
&booth.layer,
Some(&light),
&model.emitters,
);
if emitters > 0 {
info!("create scene: UI_{token} — {emitters} particle emitter(s) up");
info!(
"create scene: UI_{token} — {emitters} particle emitter(s) up \
({fx_frames} on a billboard frame)"
);
}
scene_rig.finish(&mut commands);
scene.spawned = true;
scene.cam = model.camera0;
scene.char_spot = stage;
scene.char_facing = stage_facing;
scene.char_scale = stage_scale;
// The pet's seat comes off the same skeleton, one attachment along — frame and all. A scene
// that somehow authors none leaves the pet at the origin rather than on the stage — visibly
// wrong, and it never happens: all six `UI_*` scenes author 0 and 1.
scene.pet_spot =
attachment_point(&model.skeleton, &model.attachments, 1).unwrap_or(Vec3::ZERO);
let (pet_facing, pet_stage_scale) = model
.attachments
.iter()
.find(|a| a.id == 1)
.map_or((Quat::IDENTITY, 1.0), |a| stage_frame(model, a.bone));
scene.pet_facing = pet_facing;
scene.pet_stage_scale = pet_stage_scale;
// A rebuilt scene moved the seat, so the standing pet is re-seated (its bake is still good;
// `sync_glue_pet` re-runs the placement every frame the spot changes).
scene.pet_baked = None;
scene.rev += 1; // the character re-lights onto the scene rig (sync_glue_booth's key)
info!(
"create scene: UI_{token} up — {} submeshes, camera {} stage {:?}",
"create scene: UI_{token} up — {} submeshes, camera {} stage {:?} facing {:.2}° \
scale {:.3}",
model.submeshes.len(),
scene.cam.is_some(),
scene.char_spot,
scene.char_facing.to_euler(EulerRot::YXZ).0.to_degrees(),
scene.char_scale,
);
// Seat the character on the stage at the current facing (the yaw-only path below only
// runs when the yaw *changes*).
apply_yaw(&mut commands, booth.root, scene.char_spot, preview.yaw);
apply_yaw(
&mut commands,
booth.root,
scene.char_spot,
scene.char_facing,
scene.char_scale,
preview.yaw,
);
}
// The authored camera owns the booth camera while the scene shows (re-asserted every frame —
// cheap, and it also tracks live window-aspect changes through the diagonal-FOV conversion).
@@ -770,13 +897,16 @@ pub(super) fn sync_glue_booth(
};
// Where the character stands (the scene's stage spot while a scene is up, else the origin) —
// and the scene revision, so the bake re-lights the moment a scene rig lands.
let (spot, scene_rev) = scene.as_deref().map_or((Vec3::ZERO, 0), |s| {
if s.spawned {
(s.char_spot, s.rev)
} else {
(Vec3::ZERO, s.rev)
}
});
let (spot, stage, stage_scale, scene_rev) =
scene
.as_deref()
.map_or((Vec3::ZERO, Quat::IDENTITY, 1.0, 0), |s| {
if s.spawned {
(s.char_spot, s.char_facing, s.char_scale, s.rev)
} else {
(Vec3::ZERO, Quat::IDENTITY, 1.0, s.rev)
}
});
let (last_rev, last_yaw, last_scene) = last.unwrap_or((u64::MAX, f32::NAN, u64::MAX));
let rebake = last_rev != bake.revision || last_scene != scene_rev;
let reyaw = last_yaw != preview.yaw;
@@ -794,7 +924,14 @@ pub(super) fn sync_glue_booth(
booth.rigged = false;
booth.parked = false;
*last = Some((bake.revision, preview.yaw, scene_rev));
apply_yaw(&mut commands, booth.root, spot, preview.yaw);
apply_yaw(
&mut commands,
booth.root,
spot,
stage,
stage_scale,
preview.yaw,
);
return;
}
// The rig + framing come from the display cache — the same readiness gate the builder used
@@ -926,9 +1063,10 @@ pub(super) fn sync_glue_booth(
// Since 1089 that transform is the model's own `<PlayerModel>` pane camera (raw index 1),
// which is the client's full-body character framing — the right stand-in, and the fov below
// now comes off the same record rather than from the retired body-fit constant. The crop
// aspect is the portrait bake's 4/3 rather than this window's: exact framing here does not
// matter, because `sync_glue_scene` re-aims onto the scene's authored camera the frame the
// art lands, and this rig only ever shows for those few loading frames.
// aspect is the portrait bake's ([`PORTRAIT_ASPECT`]) rather than this window's: exact
// framing here does not matter, because `sync_glue_scene` re-aims onto the scene's authored
// camera the frame the art lands, and this rig only ever shows for those few loading
// frames.
let (t, _) = body_frame(&anchors, 1.0);
let record_fov = anchors
.pane_camera
@@ -939,7 +1077,7 @@ pub(super) fn sync_glue_booth(
&(
t,
Projection::from(PerspectiveProjection {
fov: diag_to_vert(record_fov, PORTRAIT_CROP_ASPECT),
fov: diag_to_vert(record_fov, PORTRAIT_ASPECT),
near: 0.02,
far: 100.0,
..default()
@@ -947,16 +1085,247 @@ pub(super) fn sync_glue_booth(
),
);
}
apply_yaw(&mut commands, booth.root, spot, preview.yaw);
apply_yaw(
&mut commands,
booth.root,
spot,
stage,
stage_scale,
preview.yaw,
);
*last = Some((bake.revision, preview.yaw, scene_rev));
}
/// Seat the model root on the stage `spot` facing `yaw` (the ref's `Model:SetRotation` — rotation
/// about the root's own origin, never about the scene's).
fn apply_yaw(commands: &mut Commands, root: Entity, spot: Vec3, yaw: f32) {
/// Stand the select screen's **pet** on scene attachment 1 — the hunter/warlock companion beside
/// the selected character (the reference's secondary model `record+0x114`, wow-re
/// `glue-select-model.md` §A4/§B1).
///
/// The booth twin of [`sync_glue_booth`], and deliberately the simpler one: a pet is an ordinary
/// creature display, so its parts come straight off the shared display cache with no compositor,
/// no equipment, no geoset selection and no riders. What it does share is the law that matters —
/// a fresh instance looping its own **Stand**, relit onto the scene's authored rig, on the booth's
/// layer — plus the model's own particle emitters riding its own joints, which is what makes an
/// imp an imp.
///
/// It runs only while a scene is up: both the seat and the light come from the background model,
/// and a pet standing at the origin next to a body standing at the origin is worse than no pet at
/// all for the frame or two the art takes to land.
///
/// **Not yaw-driven.** Dragging the character spins the character: the reference's facing call
/// (`0x4730e0`) writes the character component's model transform and nothing else, and both scene
/// attachments are parentless unkeyed pivots, so the pet keeps the seat's own orientation.
#[allow(clippy::too_many_arguments)]
pub(super) fn sync_glue_pet(
mut commands: Commands,
pet: Res<GluePetBake>,
booths: Res<Booths>,
mut scene: Option<ResMut<CreateScene>>,
mut materials: ResMut<Assets<WowModelMaterial>>,
creatures: Option<Res<Creatures>>,
anim_data: Option<Res<crate::creature_anim::AnimData>>,
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
) {
let Some(scene) = scene.as_deref_mut() else {
return;
};
let Some(booth) = booths.0.get(GLUE_SLOT) else {
return;
};
// No pet on this character (every class but a living hunter/warlock) — clear once and stop.
if pet.display_id == 0 || pet.parts.is_empty() {
if scene.pet_baked.is_some() {
clear_pet(&mut commands, scene);
}
return;
}
if !scene.spawned {
return; // no stage yet — nothing to stand on, nothing to be lit by
}
if scene.pet_baked == Some(pet.revision) {
return; // already standing, and nothing about it changed
}
// The rig comes from the same display cache that produced the parts, so both are ready
// together; if somehow not, leave `pet_baked` alone and retry next frame.
let (Some(creatures), Some(light)) = (creatures.as_deref(), scene.light.clone()) else {
return;
};
let Some(rig) = creatures.display_rig(pet.display_id) else {
return;
};
let mut relight = |material: &Handle<WowModelMaterial>,
variants: &mut std::collections::HashMap<
AssetId<WowModelMaterial>,
Handle<WowModelMaterial>,
>| {
super::material_variant(variants, &light, material, &mut materials, true)
.unwrap_or_else(|| material.clone())
};
let booth_parts: Vec<BoothPart> = pet
.parts
.iter()
.map(|p| BoothPart {
skinned: p.skinned_mesh.clone(),
static_mesh: p.static_mesh.clone(),
material: relight(&p.material, &mut scene.variants),
alpha_anim: p.alpha_anim.clone(),
})
.collect();
let booth_billboards: Vec<BoothBillboardSpec> = pet
.billboards
.iter()
.map(|b| BoothBillboardSpec {
mesh: b.mesh.clone(),
material: relight(&b.material, &mut scene.variants),
bone: b.bone,
offset: b.offset,
kind: b.kind,
})
.collect();
commands
.entity(root)
.insert(Transform::from_translation(spot).with_rotation(Quat::from_rotation_y(yaw)));
.entity(scene.pet_root)
.despawn_related::<Children>();
super::clear_booth_rig(&mut commands, scene.pet_root);
let mut pet_rig = spawn_booth_model(
&mut commands,
&mut palettes,
scene.pet_root,
booth.layer.clone(),
&booth_parts,
&[], // a pet wears nothing
rig.inverse_bindposes
.as_ref()
.map(|ibp| (rig.skeleton, ibp, rig.animations)),
anim_data.as_deref().map(|a| &a.0),
BoothMotion::Loop, // Stand, looping — the same live scene the character is
[false, false], // …and no hands to grip with
&booth_billboards,
);
// The pet's OWN emitters — the imp's flames (decision 1539). Authored on the pet's own bones,
// so this is the model's-own recipe, NOT the worn-item one (`spawn_booth_effects`, which seats
// a *separate* model's emitters on a body bone through a host): the flame at the hand rides the
// hand through the Stand loop.
//
// Anchored at `pet_root`, whose scale is the pet's — a half-size imp gets half-size flames,
// positions and quads alike (`particles::quads`, the `sizeByInstanceScale` flag all three of
// the imp's set). Relit like every other booth draw, onto the SCENE's light buffer, so the
// flames take the backdrop's fog instead of the world's time of day.
let (emitters, fx_frames) = super::spawn_booth_own_emitters(
&mut commands,
&mut pet_rig,
scene.pet_root,
&booth.layer,
Some(&light),
&pet.emitters,
);
// After the emitter spawn: `anchor` writes into the pose buffer, `finish` is what commits it.
pet_rig.finish(&mut commands);
// The seat: attachment 1's whole frame — its translation, the constant yaw its bone carries on
// the Human scene and the constant scale it carries on Human and Tauren (1533) — times the DBC
// scale product the wire never carries here (see [`GluePetBake::scale`]).
commands.entity(scene.pet_root).insert(
Transform::from_translation(scene.pet_spot)
.with_rotation(scene.pet_facing)
.with_scale(Vec3::splat(pet.scale.max(0.01) * scene.pet_stage_scale)),
);
scene.pet_baked = Some(pet.revision);
info!(
"glue pet: display {} up — {} part(s), {} camera-facing, {} emitter(s) ({} on a billboard \
frame), scale {:.3} at {:?}",
pet.display_id,
booth_parts.len(),
booth_billboards.len(),
emitters,
fx_frames,
pet.scale,
scene.pet_spot,
);
}
/// Tear the standing pet down (no pet on this character, or the stage went away).
fn clear_pet(commands: &mut Commands, scene: &mut CreateScene) {
commands
.entity(scene.pet_root)
.despawn_related::<Children>();
// The rig state lives on the root, which outlives its bakes — the despawn doesn't reach it.
super::clear_booth_rig(commands, scene.pet_root);
scene.pet_baked = None;
}
/// Seat the model root on the stage `spot`, in the stage's own frame (`stage` + `stage_scale`),
/// facing `yaw` (the ref's `Model:SetRotation` — rotation about the root's own origin, never about
/// the scene's).
fn apply_yaw(
commands: &mut Commands,
root: Entity,
spot: Vec3,
stage: Quat,
stage_scale: f32,
yaw: f32,
) {
commands.entity(root).insert(
Transform::from_translation(spot)
.with_rotation(stage * Quat::from_rotation_y(yaw))
.with_scale(Vec3::splat(stage_scale)),
);
}
/// The stage's **authored frame**: attachment `bone`'s chain, composed from the constant rotation
/// and scale the scene author parked on it.
///
/// A stage is an M2 *attachment*, and an attachment carries a frame, not just a point. The
/// reference composes the whole thing: `child+0xfc = child+0xbc × (T(att.pos) · parentBone[att.bone])`
/// (`MatrixMultiply` at `0x71439b`, inside `0x714260`) — **rotation and scale go in, not only the
/// translation** (wow-re `glue/scratch/glue-preview-facing-law.md`, §the attachment leg).
/// Five of the six shipped `UI_*` scenes leave that bone unrotated,
/// because their camera is authored looking straight down the stage's +X and a WoW model's default
/// facing already looks that way (measured on the files: Orc +0.02°, Dwarf +0.62°, NightElf
/// −0.24°, Scourge 0.00°, Tauren +0.02° between the camera's eye→target and +X).
///
/// **`UI_Human`'s is the exception, and taking only the attachment *point* was why every Human
/// stood angled away on the login and select screens (1533).** That scene is a lifted chunk of
/// Elwynn at world (−225, −81), and its camera sits at azimuth −18.2° from the stage rather than
/// on the axis; the author aimed the character at it by keying the stage bone (bone 4) with a
/// constant **−16.5°** yaw — two identical quaternion keys `(0, 0, −0.14349, 0.98965)` on global
/// sequence 1, byte-read from the shipped `UI_Human.m2`.
///
/// Read off the model's baked global-sequence channels because that is where a *constant* M2
/// rotation lives: vanilla's rest pose is identity rotations with the pivot carrying bind position,
/// so an author who wants a bone held at an angle keys it and parks the key. The channels are
/// already conjugated into Bevy space by the loader, so the composed quaternion drops straight into
/// the character root's transform.
///
/// The **scale** rides the same channels and is uniform on every shipped scene — UI_Human parks
/// 0.97 on both its attachment bones, UI_Tauren 1.04 on the character's and 0.7324 on the pet's,
/// the other four none — so it is carried as one factor rather than a `Vec3`, and `x` is read
/// because a non-uniform stage would be an authoring surprise, not a case to silently average.
fn stage_frame(model: &M2Model, bone: u16) -> (Quat, f32) {
let Some(anims) = model.animations.as_ref() else {
return (Quat::IDENTITY, 1.0);
};
let mut rot = Quat::IDENTITY;
let mut scale = 1.0;
let mut idx = bone;
// Cycle-guarded by the joint count, exactly as the pivot walk beside it is.
for _ in 0..=model.skeleton.joints.len() {
if let Some(g) = anims.global_bones.iter().find(|g| g.bone == idx) {
// Parent-first composition up the chain; `t = 0` because these are parked keys — a
// stage bone that genuinely animated would need the live joint, and none does.
if let Some(c) = g.rotation.as_ref() {
rot = c.sample(0.0) * rot;
}
if let Some(c) = g.scale.as_ref() {
scale *= c.sample(0.0).x;
}
}
let Some(joint) = model.skeleton.joints.get(usize::from(idx)) else {
break;
};
match u16::try_from(joint.parent) {
Ok(parent) => idx = parent,
Err(_) => break, // -1 = root reached
}
}
(rot, scale)
}
/// The phase-3 preview instrument (`WOW_CREATE_TEST="race,sex[,class,skin,face,hairStyle,hairColor,facialHair]"`,
@@ -1023,6 +1392,71 @@ mod tests {
use benilla_assets::ModelLight;
use benilla_formats::M2Light;
/// **Only a select look stands a pet, and only when the wire named one.**
///
/// `petDisplayId == 0` is the entire client-side gate: the server suppresses the triple for
/// everything but a living hunter or warlock (vmangos `Player::BuildEnumData`), so a class or
/// ghost test here would be a second, drifting copy of a rule we do not own. The create screen
/// builds a character that has no pet yet and must never show one.
#[test]
fn a_pet_stands_only_for_a_select_look_whose_wire_named_one() {
let mut c = benilla_protocol::Character {
guid: 1,
name: "Hunter".into(),
race: 1,
class: 3,
gender: 0,
skin: 0,
face: 0,
hair_style: 0,
hair_color: 0,
facial_hair: 0,
level: 60,
zone: 0,
map: 0,
position: benilla_protocol::wire::Vector3d {
x: 0.0,
y: 0.0,
z: 0.0,
},
flags: 0,
equipment: [benilla_protocol::CharEnumItem::default(); 19],
pet_display_id: 517,
pet_level: 60,
pet_family: 1,
};
assert_eq!(
GlueLook::Select(SelectLook::from(&c)).pet(),
Some(PetLook {
display_id: 517,
level: 60,
family: 1,
}),
"the roster row's whole pet triple rides the select look — the level and family are \
its size, not decoration"
);
c.pet_display_id = 0;
assert_eq!(
GlueLook::Select(SelectLook::from(&c)).pet(),
None,
"a zeroed triple is 'no pet' — the only gate the client applies"
);
// The create screen: a character being built has no pet, whatever class is picked.
let create = GlueLook::Create(CreateLook {
race: 1,
sex: 0,
class: 3, // hunter
skin: 0,
face: 0,
hair_style: 0,
hair_color: 0,
facial_hair: 0,
});
assert_eq!(create.pet(), None);
}
/// A same-size `resize_target` must not touch the image at all. `Assets::get_mut` queues
/// `AssetEvent::Modified` whether or not the caller writes, and this target is the glue
/// scene's fullscreen render target — one needless borrow per frame re-uploaded 22 MB per
+11 -9
View File
@@ -32,9 +32,9 @@
//! The framing is the model's **authored portrait camera** — the MD20 camera `cameraLookup[0]`
//! selects (VERIFIED, wow-re `system/ui/scratch/portrait-render.md` §4 + corrected verdict
//! `aa186e79`): the real bake builds `lookAt(eye, target, up-from-roll)` + the gxumath
//! *diagonal-FOV* perspective at the portrait path's fixed **4/3 aspect** — net vertical
//! half-angle `0.3·fov`, with a 3:4 anamorphic squeeze (`framing::WowPortraitProjection`) —
//! and **no** engine-side yaw or normalization on top. Every artist calibrated camera 0 to their
//! *diagonal-FOV* perspective at the portrait path's aspect, which is **1.0 on every screen** —
//! net vertical half-angle `fov/(2√2)`, isotropic (`framing::WowPortraitProjection`, decision
//! 1543) — and **no** engine-side yaw or normalization on top. Every artist calibrated camera 0 to their
//! own model — that is the whole mechanism behind the ref's uniformly tight, consistently-angled
//! face crops across humans, wolves, and rabbits. It supersedes the first RE verdict's C4 ("framing
//! is not model data"), corrected on the wow-re record. A camera-less model (a few creatures,
@@ -82,15 +82,15 @@ pub(crate) use framing::{attachment_point, head_anchor, PortraitAnchors};
use framing::{body_frame, frame, PORTRAIT_FOV};
mod booth;
use booth::{
clear_booth_rig, spawn_booth_effects, spawn_booth_model, BoothBillboardSpec, BoothEffects,
BoothMotion, BoothPart, BoothRider,
clear_booth_rig, spawn_booth_effects, spawn_booth_model, spawn_booth_own_emitters,
BoothBillboardSpec, BoothEffects, BoothMotion, BoothPart, BoothRider,
};
mod dressup;
pub(crate) use dressup::{DressUpBake, DressUpLook, DressUpPreview};
mod glue_booth;
pub(crate) use glue_booth::{
CreateLook, GlueLook, GluePreview, GluePreviewBake, GlueScene, PreviewBillboard,
PreviewEffects, PreviewPart, PreviewRider, SelectLook, GLUE_SLOT,
CreateLook, GlueLook, GluePetBake, GluePreview, GluePreviewBake, GlueScene, PetLook,
PreviewBillboard, PreviewEffects, PreviewPart, PreviewRider, SelectLook, GLUE_SLOT,
};
mod light;
use light::{material_variant, model_pane_light, studio_light, BoothLight};
@@ -712,6 +712,7 @@ impl Plugin for PortraitPlugin {
.init_resource::<PetDollBooth>()
.init_resource::<glue_booth::GluePreview>()
.init_resource::<glue_booth::GluePreviewBake>()
.init_resource::<glue_booth::GluePetBake>()
.init_resource::<dressup::DressUpPreview>()
.init_resource::<dressup::DressUpBake>()
.init_resource::<Booths>()
@@ -736,6 +737,8 @@ impl Plugin for PortraitPlugin {
sync_petdoll_booth,
glue_booth::sync_glue_booth,
glue_booth::sync_glue_scene,
// After the scene: the pet's seat and its light are the scene's to publish.
glue_booth::sync_glue_pet,
dressup::sync_dressup_booth,
// Last: it reads the wake/pending state every sync above may have armed.
gate_booth_cameras,
@@ -843,9 +846,8 @@ pub(crate) fn spawn_warm_booth(
fov: framing::PANE_FIXED_FOV,
near: 0.02,
far: 100.0,
// The warm pass compiles PIPELINES, which neither aspect keys.
// The warm pass compiles PIPELINES, which the aspect does not key.
aspect: 1.0,
crop_aspect: 1.0,
}),
layer.clone(),
))
+48 -6
View File
@@ -16,8 +16,42 @@
//! yet; add that branch with the mount arc). No slot ⇒ the marker is created but never parented
//! — invisible; we render nothing.
//! - **Scale** (`0x607570`): `1 / |attach-bone basis|`, computed once at attach and baked into the
//! marker's base matrix — the marker renders at a constant world size regardless of the NPC's
//! model scale (a gnome and an ogre get the same-sized `!`). Not distance-based, not unit scale.
//! marker's base matrix (`marker+0xbc`). Not distance-based, not unit scale, no clamp or floor.
//!
//! **The `1/L` is a SNAPSHOT, not an invariant** (wow-re AMENDED §Q3/§Q3c, 2026-08-22, four-worker
//! §5 + oracle execution of `0x607570` under the difftest harness). The arithmetic is confirmed —
//! `L = ‖row0‖` of the attach point's WORLD matrix, so the compensation cancels the whole attach
//! basis (`s` and any bone-chain scale alike). What was wrong is the *published consequence*: the
//! old sentence here — "a constant world size regardless of the NPC's model scale, a gnome and an
//! ogre get the same-sized `!`" — was unconditional and **is not**. `0x607570` reads the parent's
//! `model+0xbc` **as it stands at attach**, and the reference has legs where it has not been
//! stamped yet, each leaving the ctor identity ⇒ **no counter-scale at all**:
//!
//! | ordering at attach | `L` | `marker+0xbc` | final world scale at `s = 3` |
//! |---|---|---|---|
//! | parent already placed (**case B**) | `3.0` | `diag(1/3)` | **1.0 — constant** |
//! | parent still ctor identity (**case A**) | `≈1.0` | identity (skip) | **3.0 — proportional** |
//!
//! Case A fires when `SMSG_QUESTGIVER_STATUS` lands while the body model is still streaming (the
//! query goes out at object-create, in the same breath as the load starts, so it is a race), and
//! *structurally* for a mounted questgiver whose mount M2 is still resident-pending (`0x6075ac`
//! tests the MOUNT model too, which `0x6074c0` does not). Nothing re-runs the bake afterwards, so
//! case A is permanent for that unit. Both are invisible at `s = 1`, which is why this went
//! unnoticed — it is visible only on a scaled creature, which is exactly where B301 came from.
//!
//! **benilla is case B by construction**: [`bake_seat_scale`] deliberately waits for propagation
//! to produce a settled joint matrix. **That is the right leg, and it is settled by observation,
//! not by argument** — the director ran the A/B against the reference and 1.12's `?` looks the
//! same as ours (decision 1548, closing 1541's open question). B301 is `not-a-bug`: a
//! gnome-sized `?` over a building-sized NPC is what the real client shows too.
//!
//! So do **not** "fix" this by dropping the counter-scale. The report is real, the case-A leg is
//! real, and the change is one line — which is exactly why this paragraph exists.
//!
//! One correction worth carrying: the post-multiply operand is `CM2Scene+0xdc` = **inverse(view)**,
//! not "the model's world transform" (`CM2Shared+0xdc`) as the note used to say. The bone matrices
//! live in VIEW space and already carry `model+0xbc`; the final compose is
//! `marker+0xfc = marker+0xbc × parentAttachMatrix` at `0x71439b`.
//! - **Animation** (`0x6076c0`): the marker's own M2 animation is armed looping — anim **0**
//! normally, anim **190** while the unit has a live overhead-name object (`unit+0xc7c` via
//! `0x6c7950`). The two bands hold the same 3-key bob SHAPE at different heights: anim 0 at the
@@ -508,10 +542,18 @@ fn pose_markers(
}
/// Bake each new seat's one-time counter-scale — the client's `0x607570` computes
/// `L = |attach-bone basis|` once at attach and writes `diag(1/L)` into the marker's base matrix,
/// so the marker's world size is constant across NPC model scales (the exact-`1.0` skip is the
/// client's own identity guard). One-time per seat (latched), so the joint global it reads being
/// a frame old is immaterial — bone basis length doesn't animate.
/// `L = |attach-bone basis|` once at attach and writes `diag(1/L)` into the marker's base matrix
/// (the exact-`1.0` skip is the client's own identity guard). One-time per seat (latched).
///
/// **This is the module doc's "case B" leg, and choosing it is a decision, not a transcription.**
/// We retry until propagation yields a real joint matrix, so our `L` is always the *settled* world
/// basis and the marker is always constant-size. The reference reads whatever `model+0xbc` happens
/// to hold at attach, and on a still-streaming parent that is the ctor identity — no counter-scale,
/// a marker proportional to the NPC, permanently. So the old note here ("the joint global being a
/// frame old is immaterial — bone basis length doesn't animate") was true about the *bone* and
/// wrong about the *risk*: what varies between the two legs is not the bone, it is whether the
/// parent's world matrix exists yet. We keep the settled read — it is the reference's intent, the
/// only leg that is deterministic, and (decision 1548) the leg the reference was observed on.
fn bake_seat_scale(
mut seats: Query<(&mut MarkerSeat, &ChildOf, &mut Transform)>,
joints: Query<&GlobalTransform, Without<MarkerSeat>>,
+257
View File
@@ -0,0 +1,257 @@
//! **The application-exit edge** — the one place that says *where* "the client is going down" may
//! be observed, because getting that wrong is silent and costs the player everything they changed
//! (decision 1528).
//!
//! ## The frame the app decides to quit is the last frame there is — but it is not always the only one
//!
//! Bevy's runner checks `App::should_exit()` **after** `app.update()` returns and, if an `AppExit`
//! is there, leaves the event loop (`bevy_winit::state`, `run_app_update`'s tail). So a system that
//! wants to run "on the way out" gets its chance in the same `Main` pass in which the exit was
//! announced — and only if it runs *after* the announcement.
//!
//! What it does **not** get is a guarantee that the pass is the last one (decision 1537). On macOS
//! `event_loop.exit()` is a request, not a return: events already queued still dispatch, and one
//! more `about_to_wait` → `run_app_update` can land before the loop unwinds. `exit_on_all_closed`
//! has no latch of its own — it writes an `AppExit` on **every** frame its window query is empty —
//! so that extra pass announces the exit a second time and every shutdown system reading
//! `MessageReader<AppExit>` runs twice. Observed on the real client, in `scripts/smoke.sh`, roughly
//! one run in three: two `"No windows are open, exiting"` lines 50 ms apart, the second on the far
//! side of winit's own `"Closing window 0v0"`, with a complete duplicate save burst under each.
//!
//! Rewriting the same files twice is harmless; **firing `PLAYER_LEAVING_WORLD`/`PLAYER_LOGOUT`
//! into every addon's Lua twice is not**. So the set runs on the *edge*: [`the_exit_frame`] opens
//! on the first frame of a run of announcements and stays shut for the rest of it.
//!
//! Announcements do not all arrive early. The one a **player** produces is the latest of all:
//! clicking the window's close button posts `WindowCloseRequested`, `close_when_requested`
//! (`Update`) despawns the window, and `exit_on_all_closed` (**`PostUpdate`**) writes the
//! `AppExit` (`bevy_window`'s own placement, 0.18.1). Every `Update` system has already run by
//! then. So *"read `MessageReader<AppExit>` in `Update`"* — which is what three of our five
//! shutdown systems did — cannot see the exit a player actually causes, and no later frame will
//! give it a second look.
//!
//! **Measured, not reasoned**: the real client, in the world, closed by its own window button
//! wrote *nothing* — no `saved-variables.lua`, no per-addon `saved/*.lua`, no `addons/*.txt`, no
//! camera pose, no `config.toml`. Every addon's settings and every UI toggle the session changed
//! were lost, and the next login came up on whatever the last *graceful* exit had written. That is
//! the director's *"addon settings don't save between logins"* in one line.
//!
//! ## The rule
//!
//! **Register it here, never by hand.** [`on_app_exit`] puts the system in `Last`, which runs after
//! `PostUpdate` in the same `Main` pass and therefore after every announcement Bevy can make. Two
//! systems already lived there and had quietly been right the whole time
//! (`hover_log::report_on_exit`, `perf::stall::beat` — whose own comment says *"no later frame will
//! run to unlatch anything"*); this makes that the rule instead of a coincidence.
//!
//! ## What this does NOT fix, said out loud
//!
//! An exit the process is never told about in ECS terms still loses the session:
//!
//! - **macOS `Cmd+Q`** goes `terminate:` → `applicationWillTerminate:` → winit's `LoopExiting`,
//! with no `app.update()` anywhere in it — so there is no schedule to run in. Handled a layer
//! down instead, by [`benilla_world::mac_quit`], which re-points that menu item at
//! `performClose:` so the gesture becomes the window close above.
//! - **A crash, a `SIGKILL`, a wedged teardown.** Nothing survives those, and the reference client
//! loses settings to them too (decision 1128 kept its no-autosave shape deliberately). If we ever
//! want to beat that, it is an autosave design and its own decision — not a fourth place to read
//! `AppExit`.
use bevy::ecs::schedule::ScheduleConfigs;
use bevy::ecs::system::ScheduleSystem;
use bevy::prelude::*;
/// Every system that persists state as the client goes down. A set rather than bare `Last`
/// registrations so the ordering question ("does the CVar flush see the VM the UI teardown just
/// replaced?") has somewhere to be answered when it comes up.
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct OnAppExit;
/// Marks the one-time `configure_sets` below as done — [`on_app_exit`] is called once per shutdown
/// system, and the set's edge condition must be attached exactly once however many of them there are.
#[derive(Resource)]
struct ExitSetLatched;
/// **The rising edge of "the app is exiting"** — true on the first frame of a run of `AppExit`
/// announcements, false for every frame the run continues (decision 1537).
///
/// An *edge*, not a once-per-process latch, because that is what the tail is for and because the
/// two are not the same thing. `exit_on_all_closed` re-announces on every frame its window query is
/// empty, so the extra `Main` pass macOS can pump after `event_loop.exit()` produces a second,
/// immediately-consecutive announcement — suppressed here. A *later* announcement with a quiet
/// frame in between is a different shutdown, and is served: that is how a harness drives more than
/// one exit through one `App`, which `cvars`' persistence test does.
///
/// A run condition on the set rather than a latch inside each shutdown system, because the systems
/// are registered from eight different modules and "has this edge already been served" is a
/// property of the edge, not of any one saver.
fn the_exit_frame(mut exits: MessageReader<AppExit>, mut announced: Local<bool>) -> bool {
let now = exits.read().next().is_some();
let rising = now && !*announced;
*announced = now;
rising
}
/// Register a system that must run on the frame the app decides to exit.
///
/// The schedule is not the caller's choice — that is the whole point of the function existing (see
/// the module header). Call it with a system that reads `MessageReader<AppExit>` and does its work
/// when the read is non-empty; the set's own edge condition guarantees it is asked once per exit.
pub(crate) fn on_app_exit(app: &mut App, systems: ScheduleConfigs<ScheduleSystem>) -> &mut App {
if !app.world().contains_resource::<ExitSetLatched>() {
app.insert_resource(ExitSetLatched);
app.configure_sets(Last, OnAppExit.run_if(the_exit_frame));
}
app.add_systems(Last, systems.in_set(OnAppExit))
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::window::{PrimaryWindow, Window, WindowCloseRequested, WindowPlugin};
/// How many frames the probe ran on. A resource rather than a `Local` so the test can read it
/// back — and a count rather than a flag, because "ran twice" is one of the things under test.
#[derive(Resource, Default)]
struct Saw(u32);
fn probe(mut exits: MessageReader<AppExit>, mut saw: ResMut<Saw>) {
if exits.read().next().is_some() {
saw.0 += 1;
}
}
/// `bevy_window`'s close takes **two** frames — `close_when_requested` marks the window
/// `ClosingWindow` on the frame the request arrives and despawns it on the next, and only then
/// does `exit_on_all_closed` find no windows. Both are driven here so the test measures the
/// real sequence rather than a shortened one.
fn close_and_exit(app: &mut App) {
app.update();
app.update();
}
/// An app with `bevy_window`'s real close→exit systems and one window, plus a probe registered
/// by `place`. No winit: `close_when_requested` and `exit_on_all_closed` are plain systems and
/// they are the two that decide when the `AppExit` lands.
fn app_with_probe(place: fn(&mut App)) -> App {
let mut app = App::new();
app.add_plugins(WindowPlugin {
primary_window: None, // spawned below, so the test owns the entity id
..default()
})
.init_resource::<Saw>();
place(&mut app);
let window = app
.world_mut()
.spawn((Window::default(), PrimaryWindow))
.id();
app.world_mut()
.write_message(WindowCloseRequested { window });
app
}
/// **The director's bug, reproduced.** A player closes the window; a shutdown system in
/// `Update` never learns the client is exiting, because the `AppExit` is not written until
/// `PostUpdate` — and the runner leaves the loop before another frame starts.
#[test]
fn an_update_system_never_sees_the_exit_a_window_close_produces() {
let mut app = app_with_probe(|app| {
app.add_systems(Update, probe);
});
close_and_exit(&mut app);
assert!(
app.should_exit().is_some(),
"the close did produce an AppExit — bevy_window's exit_on_all_closed ran"
);
assert!(
app.world().resource::<Saw>().0 == 0,
"…and the Update system did not see it. This is the bug: every file the session \
would have written on the way out is simply never written (1528)"
);
}
/// …and the fix, at the same edge: [`on_app_exit`] places it in `Last`, after `PostUpdate`,
/// so the same close is observed on the frame it happens.
#[test]
fn on_app_exit_sees_the_exit_a_window_close_produces() {
let mut app = app_with_probe(|app| {
on_app_exit(app, probe.into_configs());
});
close_and_exit(&mut app);
assert!(app.should_exit().is_some(), "the close produced an AppExit");
assert!(
app.world().resource::<Saw>().0 == 1,
"and the shutdown system ran on that frame — the frame the exit was announced"
);
}
/// The other four roots announce from `Update` (a `/quit` from Lua, the logout-complete edge,
/// the capture harness). `Last` is after those too, so one placement covers every announcement
/// rather than winning a scheduler lottery — which is what the old `Update` placement was doing
/// on the paths where it happened to work.
#[test]
fn on_app_exit_also_sees_an_exit_announced_from_update() {
let mut app = App::new();
app.init_resource::<Saw>();
app.add_systems(Update, |mut exit: MessageWriter<AppExit>| {
exit.write(AppExit::Success);
});
on_app_exit(&mut app, probe.into_configs());
app.update();
assert_eq!(app.world().resource::<Saw>().0, 1);
}
/// **The exit can be announced twice, and the tail must still run once** (1537).
///
/// `exit_on_all_closed` has no latch: with the window gone it writes an `AppExit` on every
/// frame it runs. macOS pumps one more `Main` pass after `event_loop.exit()` often enough to
/// fail `scripts/smoke.sh` about one run in three — and a second pass means a second
/// `PLAYER_LOGOUT` into every addon's Lua, not merely a second identical file write. Driven
/// here by running past the exit frame, which is exactly what that extra pass is.
#[test]
fn a_second_announcement_does_not_run_the_tail_again() {
let mut app = app_with_probe(|app| {
on_app_exit(app, probe.into_configs());
});
close_and_exit(&mut app);
assert_eq!(
app.world().resource::<Saw>().0,
1,
"the exit frame ran the tail"
);
app.update(); // the pass macOS can pump after `event_loop.exit()`
app.update();
assert!(
app.should_exit().is_some(),
"…and `exit_on_all_closed` really did announce again — the control for this test"
);
assert_eq!(
app.world().resource::<Saw>().0,
1,
"the tail runs on the edge; a second run would re-fire \
PLAYER_LEAVING_WORLD/PLAYER_LOGOUT into every addon (1537)"
);
}
/// …and an edge is not a once-per-process latch. A harness that drives an exit, carries on, and
/// drives another gets both — which is how `cvars`' persistence test writes `config.toml` twice
/// through one `App`, and the reason [`the_exit_frame`] detects an edge rather than spending.
#[test]
fn a_later_exit_after_a_quiet_frame_is_a_new_edge() {
let mut app = App::new();
app.init_resource::<Saw>();
on_app_exit(&mut app, probe.into_configs());
app.world_mut().write_message(AppExit::Success);
app.update();
app.update(); // quiet — nothing announced, so the edge re-arms
app.world_mut().write_message(AppExit::Success);
app.update();
assert_eq!(app.world().resource::<Saw>().0, 2);
}
}
+26 -7
View File
@@ -663,13 +663,22 @@ fn interact_command(
match kind {
CursorKind::Pickup => Some(ClientCommand::ListInventory { guid }),
CursorKind::Taxi => Some(ClientCommand::TaxiQueryNodes { guid }),
// Buy(3) is banker OR auctioneer (the ladder's shared leg). A pure banker (bit 8 the
// lowest service bit — the only way Buy classified) opens the bank directly
// (`CMSG_BANKER_ACTIVATE`, decision 0604); the auctioneer stays on the gossip fallback
// (its window is its own arc).
// Buy(3) is banker OR auctioneer (the ladder's shared leg), so it forks twice. A pure
// banker (bit 8 — the lowest service bit, and the only way Buy classified) opens the bank
// directly (`CMSG_BANKER_ACTIVATE`, decision 0604).
CursorKind::Buy if npc_flags & cursor_mode::npc_flags::BANKER != 0 => {
Some(ClientCommand::BankerActivate { guid })
}
// An auctioneer (bit 12) greets its house (decision 1511). Note what this does NOT do:
// open anything. The greeting is a round trip, and the window opens when the *reply*
// lands — that is the real client's own law, and it is also what makes the house id
// (which only the reply carries) available before the sell pane needs a deposit rate.
// An auctioneer that also carries the gossip bit never reaches here: the service ladder
// is first-match-wins from the low bits, so bit 0 pre-empts bit 12 and the menu's own
// auctioneer option asks the server for the same greeting.
CursorKind::Buy if npc_flags & cursor_mode::npc_flags::AUCTIONEER != 0 => {
Some(ClientCommand::AuctionHello { auctioneer: guid })
}
CursorKind::Speak | CursorKind::Buy | CursorKind::Trainer | CursorKind::Interact => {
Some(ClientCommand::GossipHello { guid })
}
@@ -944,8 +953,6 @@ mod tests {
// Gossip (Speak) and the out-of-scope service kinds route through the universal hello.
for (kind, flags) in [
(CursorKind::Speak, 0x1),
// Buy with no banker bit (a pure auctioneer) stays on the gossip fallback.
(CursorKind::Buy, 0x1000),
(CursorKind::Trainer, 0x10),
(CursorKind::Interact, 0x80),
] {
@@ -960,8 +967,20 @@ mod tests {
}
}
/// The auctioneer split (decision 1511): a pure auctioneer greets its house rather than
/// falling to the gossip universal, and the greeting is all it does — the window opens when
/// the *reply* lands, which is the real client's own law.
#[test]
fn interact_routes_pure_auctioneer_to_the_hello() {
use cursor_mode::CursorKind;
assert!(matches!(
interact_command(CursorKind::Buy, 0x51, cursor_mode::npc_flags::AUCTIONEER),
Some(ClientCommand::AuctionHello { auctioneer: 0x51 })
));
}
/// The banker split (decision 0604): Buy(3) is banker OR auctioneer — the BANKER flag routes
/// the direct `CMSG_BANKER_ACTIVATE`, its absence falls to the gossip universal. A
/// the direct `CMSG_BANKER_ACTIVATE`, its absence falls to the auctioneer fork above. A
/// gossip-flagged banker never reaches this fork (the low-bit-first ladder classified Speak).
#[test]
fn interact_routes_pure_banker_direct() {
+324 -1
View File
@@ -627,6 +627,209 @@ fn ffa_reaction(
&& own.player_flags() & PLAYER_FLAGS_FFA_PVP != 0
}
/// `UNIT_FIELD_FLAGS` bits `CanAttack` (`0x606980`) tests on its TARGET, any one of which refuses
/// the attack outright — byte-verified at `0x6069b7`–`0x6069ff` (wow-re
/// `object-layer/scratch/nameplate-category-gate.md` §3b). **The bit NUMBERS are VERIFIED; the
/// vanilla names are not** (three wow-re workers hunted for a labelled consumer of bit 9 and found
/// none), so nothing here is written in terms of a name — 1 `0x2`, 7 `0x80`, 16 `0x10000`,
/// 20 `0x100000`, 25 `0x2000000`.
const CANNOT_BE_ATTACKED: u32 = 0x2 | 0x80 | 0x1_0000 | 0x10_0000 | 0x200_0000;
/// The two cross-flag immunity bits `CanAttack` reads on BOTH sides (`0x606a05`–`0x606a8a`): bit 8
/// `0x100` and bit 9 `0x200`, each paired against the other unit's `PLAYER_CONTROLLED` bit 3.
const IMMUNE_TO_PLAYER_CONTROLLED: u32 = 0x100;
const IMMUNE_TO_UNCONTROLLED: u32 = 0x200;
/// `UNIT_FIELD_FLAGS` bit 12 (`0x1000`) — the PvP-flag the both-players arm of `CanAttack` accepts
/// on its target (`0x606b5c`).
const UNIT_FLAG_PVP: u32 = 0x1000;
/// The **local player's** reaction toward a unit — `0x6061e0(this = localPlayer, arg = unit)`,
/// byte-verified (wow-re `nameplate-category-gate.md` §5 leg 3 + §8a, §5 cross-checked 2026-08-22).
///
/// **This is NOT [`ring_reaction`] with the arguments swapped, and that is the whole point.** The
/// two directions take genuinely different code inside `0x6061e0`, and the client uses both:
///
/// - **This direction** (`A` = the local player) always reaches leg 3 at `0x606372`, because
/// `0x606170(localPlayer)` resolves to the player themselves. Leg 3 answers a rep-slot faction
/// with the **AT-WAR BIT** — `at_war ? 1 : 4` — and never looks at the standing. Only a
/// faction with no reputation slot falls through to the template comparator.
/// - **[`ring_reaction`]'s direction** (`A` = the unit) never reaches leg 3, falls into `0x606530`,
/// and *there* the standing is read. That is the correct input for the plate's bar COLOUR, the
/// ring, and the overhead name.
///
/// So a not-at-war neutral-standing NPC — a Booty Bay goblin, an Argent Dawn quartermaster, a
/// battleground emissary — is **friendly** to this function and **neutral** to the other one, and
/// the client shows exactly that: a friendly-category plate with a yellow bar. Substituting the
/// standing here is what put those 36 shipped faction templates in the enemy category (1530).
///
/// Deferred, and each one only ever makes a unit *more* friendly than we say: the forced-reaction
/// table (`0x4d6490`, `SMSG_SET_FORCED_REACTIONS` — no wire support yet), the party rung of the
/// player-vs-player block, and the charmed-player case that would stop leg 3 firing at all.
pub(crate) fn reaction_from_player(
factions: Option<&Factions>,
reputations: &Reputations,
target_store: Option<&ObjectStore>,
self_store: Option<&ObjectStore>,
) -> u8 {
// The player-vs-player block (`0x606217`) sits ahead of all faction work in BOTH directions,
// and benilla implements the same two rungs of it here that [`ring_reaction`] does.
if let (Some(target), Some(own)) = (target_store, self_store) {
if let Some(rank) = duel_reaction(&target.0, &own.0) {
return rank;
}
if ffa_reaction(&target.0, &own.0) {
return Reaction::Hostile as u8;
}
}
let resolved = (|| {
let catalog = &factions?.0;
let target_tpl = catalog.template(target_store?.0.unit_faction_template()?)?;
// Leg 3: a faction that owns a reputation slot is answered by the AT-WAR bit alone.
// `faction_flags::AT_WAR` is the same wire byte the reputation pane's war checkbox reads,
// which is what makes declaring war on Booty Bay turn its goblins' plates red *and* move
// them into the enemy category — one bit, both consequences, as the reference has it.
if let Some(info) = catalog.reputation_faction(target_tpl.faction) {
let at_war = usize::try_from(info.rep_index)
.ok()
.and_then(|i| reputations.0.get(i))
.is_some_and(|&(flags, _)| flags & benilla_formats::faction_flags::AT_WAR != 0);
return Some(if at_war {
Reaction::Hostile as u8
} else {
Reaction::Friendly as u8
});
}
// Leg 4: no reputation slot → the template comparator, PLAYER → UNIT.
let self_tpl = catalog.template(self_store?.0.unit_faction_template()?)?;
Some(self_tpl.reaction_toward(target_tpl) as u8)
})();
resolved.unwrap_or(Reaction::Neutral as u8)
}
/// `CGUnit::CanAttack(this = the local player → arg = unit)` — `0x606980`, byte-verified complete
/// (wow-re `nameplate-category-gate.md` §3, §5 cross-checked 2026-08-22). Every leg, in the
/// binary's order.
///
/// This is the predicate the V-plate category actually turns on (see [`plate_is_friendly`]), and
/// it is deliberately **not** a reaction threshold: §3b lists six shipped ways it disagrees with
/// one, including an unflagged opposite-faction player on a PvE realm (hostile reaction, cannot be
/// attacked → friendly plate) and a same-faction duel opponent (friendly reaction, can be attacked
/// → enemy plate).
pub(crate) fn can_attack_from_player(
factions: Option<&Factions>,
reputations: &Reputations,
target_store: Option<&ObjectStore>,
self_store: Option<&ObjectStore>,
target_is_player: bool,
) -> bool {
let (Some(target), Some(own)) = (target_store, self_store) else {
return false; // fields not streamed yet — the reference's own null path refuses
};
let (tflags, oflags) = (target.0.unit_flags(), own.0.unit_flags());
// (a) The ghost gate (`0x606987`): a ghost PLAYER can only be attacked by an attacker holding a
// creature record whose type-flags carry `0x2` — which a player never does. So for the local
// player as attacker this leg is unconditional.
const PLAYER_FLAGS_GHOST: u32 = 0x10;
if target_is_player && target.0.player_flags() & PLAYER_FLAGS_GHOST != 0 {
return false;
}
// (b) Five target-flag disqualifiers.
if tflags & CANNOT_BE_ATTACKED != 0 {
return false;
}
// (c) The four cross-flag immunity legs, each pairing one side's immunity bit against the
// other's `PLAYER_CONTROLLED`.
let (t_controlled, o_controlled) = (
tflags & UNIT_FLAG_PVP_ATTACKABLE != 0,
oflags & UNIT_FLAG_PVP_ATTACKABLE != 0,
);
if o_controlled && tflags & IMMUNE_TO_PLAYER_CONTROLLED != 0
|| !o_controlled && tflags & IMMUNE_TO_UNCONTROLLED != 0
|| oflags & IMMUNE_TO_PLAYER_CONTROLLED != 0 && t_controlled
|| oflags & IMMUNE_TO_UNCONTROLLED != 0 && !t_controlled
{
return false;
}
// (d) The three terminal arms, selected by the two `PLAYER_CONTROLLED` bits.
let toward_target = || reaction_from_player(factions, reputations, target_store, self_store);
match (o_controlled, t_controlled) {
// Neither is player-controlled: hostile in EITHER direction is enough.
(false, false) => {
toward_target() <= Reaction::Hostile as u8
|| ring_reaction(factions, reputations, target_store, self_store)
<= Reaction::Hostile as u8
}
// Both are: the PvP arm. A friendly reaction refuses outright; past that an attack needs a
// live duel, the target's PvP flag, or mutual FFA. (The charm-owner resolve `0x606170` is
// an identity for both sides here — benilla has no charm.)
(true, true) => {
if toward_target() >= Reaction::Friendly as u8 {
return false;
}
matches!(duel_rung(&target.0, &own.0), DuelRung::Engaged { .. })
|| tflags & UNIT_FLAG_PVP != 0
|| ffa_reaction(&target.0, &own.0)
}
// The mixed arm — the local player against any ordinary NPC, i.e. the case the plate gate
// takes for almost everything on screen: attackable iff worse than friendly.
_ => toward_target() < Reaction::Friendly as u8,
}
}
/// `CanCooperate(this = the local player → arg = unit)` — `0x606ba0`, byte-verified (wow-re
/// `nameplate-category-gate.md` §2a): the two `FactionTemplate` rows' **faction-group masks**
/// (`row + 0xc`) being equal, with neither side mind-controlled and the two not being the same
/// unit. It reads no party or raid state — a committed wow-re note called it a party/raid predicate
/// and was corrected by the same round.
pub(crate) fn can_cooperate_with_player(
factions: Option<&Factions>,
target_store: Option<&ObjectStore>,
self_store: Option<&ObjectStore>,
) -> bool {
let (Some(target), Some(own)) = (target_store, self_store) else {
return false;
};
if target.0.unit_charmed_by().is_some_and(|g| g != 0)
|| own.0.unit_charmed_by().is_some_and(|g| g != 0)
{
return false;
}
let resolved = (|| {
let catalog = &factions?.0;
let target_tpl = catalog.template(target.0.unit_faction_template()?)?;
let self_tpl = catalog.template(own.0.unit_faction_template()?)?;
Some(target_tpl.group_mask == self_tpl.group_mask)
})();
resolved.unwrap_or(false)
}
/// **The V-plate category** — `0x60f6b7`–`0x60f6f1`, byte-verified (wow-re
/// `nameplate-category-gate.md` §2). `true` = the FRIENDLY bucket (Shift-V's bit `0x8`), `false` =
/// the ENEMY bucket (V's bit `0x1`). There is no reaction rank anywhere in this expression.
///
/// > A unit lands in the FRIENDLY category iff — for a non-player subject —
/// > `CanAttack(localPlayer → subject)` is FALSE; and for a player subject, additionally
/// > `CanCooperate(localPlayer → subject)` is TRUE.
pub(crate) fn plate_is_friendly(
factions: Option<&Factions>,
reputations: &Reputations,
target_store: Option<&ObjectStore>,
self_store: Option<&ObjectStore>,
target_is_player: bool,
) -> bool {
let attackable = can_attack_from_player(
factions,
reputations,
target_store,
self_store,
target_is_player,
);
if target_is_player {
can_cooperate_with_player(factions, target_store, self_store) && !attackable
} else {
!attackable
}
}
/// Why [`duel_reaction`]'s rung did or did not fire, with the values it judged on. This is the
/// diagnostic face of the same walk — `/reaction` prints it, so a duel that fails to turn the
/// opponent red names the gate that refused instead of silently reporting "neutral". Keeping one
@@ -692,7 +895,10 @@ pub(crate) fn duel_rung(
#[cfg(test)]
mod tests {
use super::{ring_variant, RingVariant};
use super::{
plate_is_friendly, ring_reaction, ring_variant, Factions, RingVariant,
UNIT_FLAG_PVP_ATTACKABLE,
};
/// The selector's player path — the `¬X∧¬Y` split the party arc added (0434 phase 6). A
/// friendly player reads soft blue solo, pale blue in our party; PvP-flagged reads green
@@ -743,4 +949,121 @@ mod tests {
RingVariant::Friendly
);
}
/// **The plate category, end to end on the REAL build-5875 DBC — the two subjects the director
/// observed on the reference client** (1530), plus the leg that actually discriminates the
/// mechanism.
///
/// This is the regression pin the old `rank >= 4` model could never have passed. It asserts the
/// *divergence itself*: the emissary's reaction rank stays 3 (neutral — which is right, and is
/// what paints its bar yellow) while its plate CATEGORY is friendly. A future refactor that
/// "simplifies" the category back into a rank threshold fails here, on real data, naming the
/// creature.
#[test]
fn the_plate_category_reproduces_the_reference_on_the_real_dbc() {
use crate::net::{ObjectStore, Reputations};
use benilla_protocol::ObjectFields;
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let factions = Factions(benilla_formats::load_faction_catalog(&mut chain).expect("dbc"));
// Field indices: 35 = UNIT_FIELD_FACTIONTEMPLATE, 46 = UNIT_FIELD_FLAGS.
let unit = |tpl: u32| ObjectStore(ObjectFields::from_pairs(&[(35, tpl)]));
// Us: faction template 1 (PLAYER, Human), carrying `PLAYER_CONTROLLED`.
let me = ObjectStore(ObjectFields::from_pairs(&[
(35, 1),
(46, UNIT_FLAG_PVP_ATTACKABLE),
]));
let quiet = Reputations::default(); // nothing at war — the out-of-box state
let category = |unit: &ObjectStore, reps: &Reputations| {
plate_is_friendly(Some(&factions), reps, Some(unit), Some(&me), false)
};
let rank = |unit: &ObjectStore, reps: &Reputations| {
ring_reaction(Some(&factions), reps, Some(unit), Some(&me))
};
// A Chicken (FT 31 → faction 28 "Prey", NO reputation slot) falls to the template
// comparator, which matches nothing → 3 → attackable → the ENEMY bucket, plain V. The
// director confirmed this on the reference: critters do plate, and nothing excludes them.
let chicken = unit(31);
assert!(!category(&chicken, &quiet), "a critter is enemy-category");
assert_eq!(rank(&chicken, &quiet), 3, "and its bar is neutral yellow");
// A League of Arathor Emissary (FT 1577 → faction 509, reputation slot 53) is answered by
// the AT-WAR bit — not at war → 4 → not attackable → the FRIENDLY bucket, Shift-V only.
// The director confirmed exactly this on the reference.
let emissary = unit(1577);
assert!(category(&emissary, &quiet), "friendly-category at neutral");
assert_eq!(
rank(&emissary, &quiet),
3,
"…while the BAR stays yellow: the two halves run the reaction in opposite \
directions, and this disagreement is the reference's own"
);
// **The discriminating population** (wow-re §7: 36 shipped templates where the at-war leg
// and the mask comparison disagree). Booty Bay's mask answer is 3 — so if this read as
// enemy-category we would be back on the comparator, and the at-war leg would be fiction.
let goblin = unit(120);
assert!(category(&goblin, &quiet), "Booty Bay: friendly, not at war");
// …and declaring war flips it, which is the same bit the reputation pane's checkbox writes.
let mut slots = vec![(0u8, 0i32); 64];
slots[1] = (benilla_formats::faction_flags::AT_WAR, 0); // Booty Bay = slot 1
let at_war = Reputations(slots);
assert!(!category(&goblin, &at_war), "at war → enemy category");
assert!(
category(&emissary, &at_war),
"…and only that faction's own bit moves"
);
// A Stormwind guard (FT 12 → faction 72, slot 19) is friendly by BOTH routes — the case
// that agreed all along, kept so the fix cannot be read as having moved it.
assert!(category(&unit(12), &quiet));
assert_eq!(rank(&unit(12), &quiet), 4);
}
/// `CanAttack`'s flag disqualifiers put a unit in the FRIENDLY bucket **at any reaction** — the
/// class of case a rank threshold gets wrong in the other direction (wow-re §3b). Asserted on
/// the mask path (a Monster-faction template, hostile by reaction) so the reaction is
/// unambiguously hostile and only the flag can be doing the work.
#[test]
fn an_unattackable_flag_beats_a_hostile_reaction() {
use crate::net::{ObjectStore, Reputations};
use benilla_protocol::ObjectFields;
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let factions = Factions(benilla_formats::load_faction_catalog(&mut chain).expect("dbc"));
let me = ObjectStore(ObjectFields::from_pairs(&[
(35, 1),
(46, UNIT_FLAG_PVP_ATTACKABLE),
]));
let reps = Reputations::default();
let mob = |flags: u32| ObjectStore(ObjectFields::from_pairs(&[(35, 14), (46, flags)]));
// FT 14 "Monster" — hostile to the player by the mask comparison.
assert_eq!(
ring_reaction(Some(&factions), &reps, Some(&mob(0)), Some(&me)),
1
);
assert!(
!plate_is_friendly(Some(&factions), &reps, Some(&mob(0)), Some(&me), false),
"a plain hostile mob is enemy-category"
);
// Each of the five refusal bits alone flips the category, hostile reaction and all.
for bit in [0x2u32, 0x80, 0x1_0000, 0x10_0000] {
assert!(
plate_is_friendly(Some(&factions), &reps, Some(&mob(bit)), Some(&me), false),
"flag {bit:#x} refuses the attack → friendly category"
);
}
// Bit 8 (0x100) refuses only because WE are player-controlled — the cross-flag leg.
assert!(plate_is_friendly(
Some(&factions),
&reps,
Some(&mob(0x100)),
Some(&me),
false
));
}
}
@@ -67,6 +67,25 @@ impl CastErrors {
#[derive(Resource, Default)]
pub(crate) struct MountErrors(pub Vec<(bool, u32)>);
/// Where a client message is SHOWN — the `kind` field (`+0x04`) of the reference's message record
/// (`0xb4b498 + 20*msgId`), which `CGGameUI::DisplayError` (`0x496720`) dispatches on through the
/// four-way jump at `0x496888`: **0 → the chat window** (`0x49a870`), 1 → `AddErrorMessage(text, 0)`
/// (the yellow info line), **2 → `AddErrorMessage(text, 1)`** (the red error line), 3 → the console.
/// Only the two our messages use are modeled (decision 0669); the info line has its own established
/// route (`UI_INFO_MESSAGE`, decision 0340) and rides [`UiError::info`] instead.
///
/// This lives beside [`UiError`] rather than in any one window because the surface is a property of
/// the MESSAGE, not of the window that raised it — and it has more than one tenant: the quest
/// refusals (0669) and, since 1523, the auction house, where the twenty `ERR_AUCTION_*` ids split
/// clean down the middle of this enum.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MsgSurface {
/// kind 0 — a chat-window system line.
Chat,
/// kind 2 — the red `UIErrorsFrame` line.
Error,
}
/// One `DisplayError` message: a GlobalStrings key plus the argText fills. The 1.12 error
/// formats use at most one `%s` and one `%d` ("Requires %s", "Requires %s %d" — wow-re
/// cursor-system.md §8.8's lock-refusal toasts, decision 0545); a key whose string carries no
+1 -1
View File
@@ -66,7 +66,7 @@ pub(crate) struct CooldownEvents;
pub(crate) use cast_send::{CastCommit, CastLadder};
pub(crate) use errors::{
attack_actor_refusal, reagent_totem_refusal, ui_error_text, CastErrors, CastFail, MountErrors,
UiError, UiErrorKeys,
MsgSurface, UiError, UiErrorKeys,
};
// `pub(crate)`: the target chain registers the cursor pre-empt + the click commit, and the
// spellbook/stance/craft drains thread the mode through the one cast-send path (decision 0792).
+992
View File
@@ -0,0 +1,992 @@
//! The app-side **auction feed** (decision 1511 P1) — the inward half of the auction seam around
//! [`benilla_ui::script`]'s `auction` module, the auctioneer twin of [`crate::ui_merchant`]'s
//! vendor seam and [`crate::ui_mail`]'s mailbox one.
//!
//! **The window is opened by the server, not by the click.** A right-click on an auctioneer
//! ([`crate::target`]) sends `MSG_AUCTION_HELLO` and nothing else happens; the *reply* — the same
//! opcode coming back with the auctioneer guid and an `AuctionHouse.dbc` house id — is what opens
//! the session here (wow-re: the window's opener calls `SetInteractNPC` and fires
//! `AUCTION_HOUSE_SHOW` from inside the hello handler). That house id is load-bearing rather than
//! decorative: it keys the deposit rate the sell pane displays, and the six faction houses charge
//! 5% where the neutral goblin house charges 25%.
//!
//! **Three lists, one session.** Browse (`"list"`), Bids (`"bidder"`) and Auctions (`"owner"`) are
//! three independent server queries into one window. Each holds at most one 50-row page — the
//! server's own cap — plus the pre-cap match count the pager needs, plus its own sort stack.
//!
//! **Sorting is ours and paging is the server's**, which is the split that shapes this module: a
//! header click re-orders rows we already hold and sends nothing ([`sort`]), while a page turn
//! re-sends the whole query. Nothing about the sort ever reaches the wire.
//!
//! **The browse throttle is real and it is silent.** The client refuses a browse query inside 5 s
//! of the last one and *drops it with no failure event*, which is why the Search button polls
//! [`benilla_ui::script::UiScript::set_auction_can_query`] every frame instead of reacting to a
//! refusal. The window opening clears the gate, so the first search is always allowed. (INTERIM,
//! decision 1511 — pinned to the in-flight §5's TU-2.)
//!
//! The net bridge ([`crate::net::apply::auction`]) fills [`AuctionOpen`] from the wire. Each frame
//! [`feed_auction`] resolves each [`AuctionListEntry`] to a Lua-facing row (name/quality/icon via
//! the ask-once item-template cache + `ItemDisplayInfo.dbc`, seller via the ask-once name cache,
//! the time-left bucket from the wire's milliseconds), applies the sort, pushes the snapshot, and
//! fires the events the reference Lua drives. [`drain_auction`] pulls the Lua intents back out
//! into the auction `CMSG`s. The standardized NPC-session range guard ([`crate::ui_session`])
//! client-side-closes the window when the player walks away from the auctioneer. That radius is
//! **VERIFIED** rather than borrowed (wow-re §5 TU-6): the auction window's own interaction cell
//! holds `30.864194869995117` — a radius of `5.5555553` yd — which is exactly the service gate
//! the cursor already greys at, so the guard's existing constant is the right one. The close
//! sends no packet, also byte-confirmed.
use bevy::prelude::*;
use benilla_protocol::messages::{auction_filter, AuctionListEntry};
use benilla_ui::script::{
AuctionCategory, AuctionItemRow, AuctionListState, AuctionState, AuctionSubCategory,
ScriptValue, UiScript, BIDDER, LIST, OWNER,
};
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
use crate::ui_action::{ui_error_text, MsgSurface, UiError};
use crate::ui_chat::{ChatEvent, ChatEventKind};
use crate::ui_script::UiInput;
use crate::ui_session::{close_npc_session_out_of_range, NpcSession};
mod sort;
use sort::SortStack;
/// The browse query rate limit, in seconds. **VERIFIED** (wow-re §5 TU-2): the reference arms the
/// gate with `tick + 0x1388` *after* the packet goes out, re-checks it inside the query itself,
/// and its refusal path fires **nothing at all** — no event, no error. That silence is why the
/// Search button polls the gate every frame instead of waiting to be told no.
const QUERY_THROTTLE_SECS: f64 = 5.0;
/// The time-left buckets, in milliseconds — the thresholds the reference's four
/// `AUCTION_TIME_LEFT` strings key off. **VERIFIED** (wow-re §5 TU-3: the table at `0x8072a8`).
const TIME_LEFT_SHORT_MS: u32 = 30 * 60 * 1000;
const TIME_LEFT_MEDIUM_MS: u32 = 2 * 60 * 60 * 1000;
const TIME_LEFT_LONG_MS: u32 = 8 * 60 * 60 * 1000;
/// Past this, the wire's `time_left_ms` is not a duration — it is an underflow.
///
/// The server writes `(expireTime - now) * 1000` with **no clamp** and only sweeps expiry on a
/// 60 s timer, so an auction that ran out less than a minute ago is still listed with a negative
/// remaining time that arrives as a `u32` near 4.29 billion. The longest auction anyone can create
/// is 24 h, so anything beyond a few days is that wrap and not a long auction — it reads as
/// **expired**, never as "Very Long" (decision 1511's verified section).
const TIME_LEFT_IMPLAUSIBLE_MS: u32 = 7 * 24 * 60 * 60 * 1000;
/// `ItemSubClass.dbc` `DisplayFlags` bit 1 — this subclass is **not offered** in the auction
/// house's category filter.
///
/// Read directly off the shipped table when the polarity was still ambiguous, and since
/// **independently confirmed at the bytes** (wow-re §5 TU-4). Guessing it backwards would have
/// emptied the filter instead of trimming it: every row carrying the bit is an obsolete or unused subclass —
/// Spear, Buckler(OBSOLETE), the OBSOLETE quivers, bolts and wands, Engineering Bag — and every
/// subclass a player can actually buy (Cloth, Leather, Mail, Plate, Shield, Arrow, Bullet, the
/// weapon families, the nine Recipe professions) has it clear.
const SUBCLASS_HIDDEN_FROM_AUCTIONS: u32 = 0x2;
/// The ten item classes the auction house offers, in the reference's own menu order.
///
/// The set and the order are a structural fact of the window, not text: the four `(OBSOLETE)`
/// classes plus Quest and Key are simply not auctionable, and every id here was checked to be a
/// real `ItemClass.dbc` row. **Every string the filter displays comes from the player's own DBC**,
/// which is what keeps this feature clear of decisions 1234/1260 — we ship the structure, the
/// install supplies the words.
const AUCTION_CLASSES: [u32; 10] = [
2, // Weapon
4, // Armor
1, // Container
0, // Consumable
7, // Trade Goods
6, // Projectile
11, // Quiver
9, // Recipe
5, // Reagent
15, // Miscellaneous
];
/// The `AuctionHouse.dbc` catalog, loaded with the other item DBCs ([`crate::ui_items`]). Optional
/// resource — absent, the sell pane shows no deposit rather than inventing one.
#[derive(Resource)]
pub(crate) struct AuctionHouses(pub(crate) benilla_formats::AuctionHouseCatalog);
/// A wire row resolved for display. Its 1-based position in [`AuctionListSlot::rows`] after the
/// sort is the index the Lua API addresses it by, which is why the sort lives on this side: the
/// index the player clicks has to mean the same thing as the index the drain maps back to
/// [`Self::auction_id`].
#[derive(Debug, Clone, Default, PartialEq)]
pub(crate) struct AuctionRow {
pub(crate) auction_id: u32,
pub(crate) item_entry: u32,
/// The listed item's random-suffix roll — the suffix in [`Self::name`], the enchant lines the
/// row hover shows, and the link's third field (decision 1547).
pub(crate) random_property_id: u32,
pub(crate) count: u32,
pub(crate) name: Option<String>,
pub(crate) texture: Option<String>,
pub(crate) quality: Option<u32>,
/// The item's `RequiredLevel` — `0` until its template lands.
pub(crate) level: u32,
pub(crate) start_bid: u32,
pub(crate) min_increment: u32,
pub(crate) buyout: u32,
pub(crate) current_bid: u32,
/// `1..=4`, or `0` for a row whose remaining time we could not read.
pub(crate) time_left: u32,
/// Whether *the player* holds the high bid.
pub(crate) high_bidder: bool,
pub(crate) owner: Option<String>,
pub(crate) link: Option<String>,
}
impl AuctionRow {
/// What the row shows in its "current bid" column: the seller's opening price until somebody
/// has actually bid. A `0` current bid is "no bids", not "free".
pub(crate) fn displayed_bid(&self) -> u32 {
if self.current_bid == 0 {
self.start_bid
} else {
self.current_bid
}
}
}
/// One of the three lists.
#[derive(Debug, Default)]
pub(crate) struct AuctionListSlot {
/// The current page exactly as the wire delivered it.
pub(crate) entries: Vec<AuctionListEntry>,
/// `totalCount` — the pre-cap match count, which is what tells the pager there is a page 2.
pub(crate) total: u32,
sort: SortStack,
}
/// What the live end-to-end probe (`crate::capture::ProbeAuctionPlugin`) reads to tell an
/// **empty** answer apart from **no** answer.
///
/// Three of this arc's outcomes are, by design, invisible in the window's own state — and each is
/// a different failure a probe has to be able to name:
///
/// - a list result carrying **zero rows** changes nothing [`feed_auction`] can diff, so "the
/// auction house is empty" and "the query never came back" look identical from the snapshot;
/// - a *successful* `SMSG_AUCTION_COMMAND_RESULT` is consumed straight into a re-query
/// (`crate::net::apply::auction`) and leaves no record — only a *failed* one surfaces, and only
/// as an error line;
/// - a browse query the throttle refuses is dropped with **no event at all** (the §5-verified
/// silence this module's header describes), so "refused" and "sent" differ only in what went
/// out on the wire.
///
/// Nothing in the client needs any of it: the window reacts to the *effects*. It therefore lives
/// in one clearly-named block that the client never reads, rather than being smeared through the
/// session's own fields — and it is a monotonic tally, deliberately **not** reset by
/// [`AuctionOpen::clear`], so a count means the same thing across a session close.
#[derive(Default)]
pub(crate) struct AuctionWireLog {
/// How many list results have landed, per list, in [`LIST`]/[`BIDDER`]/[`OWNER`] order.
pub(crate) list_results: [u32; 3],
/// How many browse queries actually went out — the count the throttle refuses to raise.
pub(crate) browse_sent: u32,
/// The most recent `SMSG_AUCTION_COMMAND_RESULT`, as `(auction_id, action, error)`.
pub(crate) last_command: Option<(u32, u32, u32)>,
}
/// The open auctioneer session, filled by the net bridge ([`crate::net::apply::auction`]) and read
/// by [`feed_auction`]. Cleared on a client-side close, on walking away, and on disconnect.
#[derive(Resource, Default)]
pub(crate) struct AuctionOpen {
/// The auctioneer whose window is open; `None` = no session.
pub(crate) auctioneer: Option<u64>,
/// The `AuctionHouse.dbc` row this auctioneer serves (1..=7) — keys the deposit rate.
pub(crate) house_id: u32,
pub(crate) lists: [AuctionListSlot; 3],
/// Fire `AUCTION_HOUSE_SHOW` next feed — set when the hello reply lands.
show_requested: bool,
/// Fire `NEW_AUCTION_UPDATE` next feed — the sell slot changed.
sell_slot_dirty: bool,
/// A list result landed, so the list events are owed **whether or not the snapshot changed**.
///
/// Diffing the snapshot is not enough and the live probe is what proved it: an empty auction
/// house, or re-running the same search, produces a result identical to what we already hold.
/// The window's Browse pane clears its "Searching…" state only on `AUCTION_ITEM_LIST_UPDATE`,
/// so on an empty server a search animated its dots forever and never reported a result. The
/// reference has no diff here at all — one routine invalidates all three lists and fires all
/// three events.
list_result_landed: bool,
/// Empty the sell slot next feed — a listing was accepted, so the staged item is *gone*.
///
/// Also from the probe: without this the pane kept painting a phantom stack after a successful
/// `StartAuction`, `Create Auction` stayed enabled, and a second click re-resolved the
/// remembered `(bag, slot)` at send time — which by then addresses whatever else has landed in
/// that slot. That is not a cosmetic bug: it auctions an item the player never chose.
sell_slot_taken: bool,
/// `Time::elapsed_secs_f64` before which a browse query is refused; `None` = allowed now.
/// Cleared when the window opens, so the first search never waits.
query_gate: Option<f64>,
/// Client messages the net apply queued for [`feed_auction`] to resolve and show — the
/// reference's `DisplayError(msgId)` split into (surface, GlobalStrings key, fill), so the line
/// comes from the VM's own strings and lands where that message's catalog row says (decisions
/// 0669 / 1523).
///
/// **Not cleared by [`Self::clear`].** These outlive the window on purpose: "your auction sold"
/// arrives wherever the player is standing, and closing an auctioneer's window must not swallow
/// a notice that has not been printed yet.
pub(crate) messages: Vec<AuctionMessage>,
/// Set when the server tells us a list we hold is now wrong (our auction sold, we were
/// outbid, a cancel went through). The drain re-asks rather than patching the local page: the
/// server is the only thing that knows what the page looks like now, and the notification
/// carries an auction id, not a row.
pending_owner_refresh: bool,
pending_bidder_refresh: bool,
/// The live probe's observation window onto the wire. Written by the net bridge and the
/// drain, read by nothing in the client — see [`AuctionWireLog`].
pub(crate) wire: AuctionWireLog,
}
/// One queued auction message: which of the reference's twenty catalog rows, and the item name that
/// fills its `%s` when it has one.
///
/// The surface is the row's own (decision 1523): the twelve **precondition failures** (`0x16c`-`0x177`
/// — "you cannot auction a soulbound item", "your bid is too low") are kind 2 and land on the red
/// `UIErrorsFrame`; the eight **outcomes** (`0x178`-`0x17f` — created, cancelled, outbid, won, sold,
/// expired, removed, bid accepted) are kind 0 and land in the **chat window** as `CHAT_MSG_SYSTEM`.
/// That split is the whole reason this is a queue of records rather than a queue of red strings.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AuctionMessage {
pub(crate) surface: MsgSurface,
pub(crate) key: &'static str,
/// The item entry whose name fills the `%s`; `None` for the six fill-less lines.
///
/// The reference composes a **plain** name here (`0x5d8b00`), never a coloured `|Hitem:` link —
/// so "Your auction of Linen Cloth sold." is unlinked in the real client too, and matching that
/// is deliberate. It also carries the random-property roll for a suffixed name; benilla's item
/// names are unsuffixed today, so only the entry is kept.
pub(crate) item: Option<u32>,
}
impl AuctionMessage {
/// A chat line with no fill (`ERR_AUCTION_STARTED` and friends).
pub(crate) fn chat(key: &'static str) -> Self {
Self {
surface: MsgSurface::Chat,
key,
item: None,
}
}
/// A chat line about one item — the five `_S` outcomes.
pub(crate) fn chat_item(key: &'static str, item: u32) -> Self {
Self {
surface: MsgSurface::Chat,
key,
item: Some(item),
}
}
/// A red line — the twelve refusals, none of which take a fill.
pub(crate) fn error(key: &'static str) -> Self {
Self {
surface: MsgSurface::Error,
key,
item: None,
}
}
}
impl AuctionOpen {
/// The hello reply — open (or re-open) the session. A *different* auctioneer resets everything
/// including the sort stacks; the same one keeps them and just re-shows, since the reference
/// re-fires its show on every greeting.
pub(crate) fn open(&mut self, auctioneer: u64, house_id: u32) {
if self.auctioneer != Some(auctioneer) {
self.auctioneer = Some(auctioneer);
self.lists = Default::default();
}
self.house_id = house_id;
// The window opening clears the throttle: the reference zeroes its gate in the same
// handler that stores the auctioneer, so the first search after walking up is free.
self.query_gate = None;
self.show_requested = true;
}
/// Replace one list's page. Always owes the list events, even for an identical page.
pub(crate) fn set_list(&mut self, which: usize, entries: Vec<AuctionListEntry>, total: u32) {
self.list_result_landed = true;
let slot = &mut self.lists[which];
slot.entries = entries;
// A server that reports fewer total matches than it just sent us is not a case worth
// trusting over our own eyes; the pager reads the larger of the two.
slot.total = total.max(slot.entries.len() as u32);
}
/// The wire auction id at a 1-based **display** row — what the auction `CMSG`s address. Takes
/// the sorted view rather than the list index, because that is the order the player clicked in.
fn auction_id_at(index_1based: u32, rows: &[AuctionRow]) -> Option<u32> {
index_1based
.checked_sub(1)
.and_then(|i| rows.get(i as usize))
.map(|r| r.auction_id)
}
/// A listing was accepted: the staged item has left the bag, so the slot must let go of it.
pub(crate) fn sell_slot_taken(&mut self) {
self.sell_slot_taken = true;
}
/// Mark our own listings stale — the drain re-queries next frame.
pub(crate) fn refresh_owner(&mut self) {
self.pending_owner_refresh = true;
}
/// Mark the bids we hold stale.
pub(crate) fn refresh_bidder(&mut self) {
self.pending_bidder_refresh = true;
}
/// Close the window (a client-side close — vanilla sends nothing).
pub(crate) fn clear(&mut self) {
self.auctioneer = None;
self.house_id = 0;
self.lists = Default::default();
self.show_requested = false;
self.sell_slot_dirty = false;
self.query_gate = None;
self.pending_owner_refresh = false;
self.pending_bidder_refresh = false;
self.list_result_landed = false;
self.sell_slot_taken = false;
}
/// Disconnect: drop the open window (mirrors every other session clear).
pub(crate) fn clear_session(&mut self) {
self.clear();
}
}
/// The auction window is an NPC session like any other: the standardized range guard closes it
/// when the player leaves the auctioneer's service range.
impl NpcSession for AuctionOpen {
fn npc(&self) -> Option<u64> {
self.auctioneer
}
fn close(&mut self) {
self.clear();
}
}
pub(crate) struct UiAuctionPlugin;
impl Plugin for UiAuctionPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<AuctionOpen>().add_systems(
Update,
(
// The merchant/mail ordering exactly: range-close before the feed so the clear
// turns into AUCTION_HOUSE_CLOSED the same frame; feed before the input pass so an
// open/close is on screen the same frame; drain after it so a click's intent goes
// out the same frame. After the UnitFeed set so a row's tooltip reads a landed
// item-template store.
close_npc_session_out_of_range::<AuctionOpen>.before(feed_auction),
feed_auction.after(crate::ui_unit::UnitFeed).before(UiInput),
drain_auction.after(UiInput),
),
);
}
}
/// The wire's unclamped millisecond remainder → the reference's four buckets.
fn time_left_bucket(ms: u32) -> u32 {
// An expired-but-still-listed auction arrives as an underflowed u32. Caught first and on its
// own, because the alternative reading — a number larger than every threshold, so bucket 4 —
// would show an auction that ended a minute ago as the longest one on the page.
if ms >= TIME_LEFT_IMPLAUSIBLE_MS {
return 1;
}
if ms < TIME_LEFT_SHORT_MS {
1
} else if ms < TIME_LEFT_MEDIUM_MS {
2
} else if ms < TIME_LEFT_LONG_MS {
3
} else {
4
}
}
/// Resolve one wire entry into a display row: name/quality/icon/required-level through the
/// ask-once item-template cache, seller through the ask-once name cache, the link built with the
/// row's full enchant/property/suffix tail (an auction row carries all three, so it gets the
/// complete link rather than the zeroed one a bag slot settles for). `None`s stay `None` while a
/// query is in flight — the row shows a placeholder and fills in when the answer lands.
#[allow(clippy::too_many_arguments)] // one resolve per ask-once cache the row reads
fn resolve_row(
entry: &AuctionListEntry,
self_guid: Option<u64>,
items: &mut Items,
icons: Option<&ItemDisplays>,
names: &mut NameCache,
commands: &NetCommands,
rolls: crate::items::RollCatalogs,
) -> AuctionRow {
let template = items.template(entry.item_entry, 0, commands);
let roll = entry.random_property_id as u32;
// The rolled name — one formatter for every display of an item's name (1547), so the row, the
// link's bracket text and the hover's plate agree.
let name = template.map(|t| rolls.name(&t.name, roll));
let quality = template.map(|t| t.quality);
let level = template.map_or(0, |t| t.required_level);
let texture = crate::ui_items::item_icon(icons, template.map_or(0, |t| t.display_info_id));
let link = name.as_ref().map(|n| {
crate::ui_items::item_link_full(
entry.item_entry,
entry.perm_enchant,
roll,
entry.suffix_factor,
n,
quality.unwrap_or(0),
)
});
AuctionRow {
auction_id: entry.auction_id,
item_entry: entry.item_entry,
random_property_id: roll,
count: entry.count,
name,
texture,
quality,
level,
start_bid: entry.start_bid,
min_increment: entry.min_increment,
buyout: entry.buyout,
current_bid: entry.current_bid,
time_left: time_left_bucket(entry.time_left_ms),
// "High bidder" is about US, not about whether anyone has bid at all.
high_bidder: self_guid.is_some_and(|g| g == entry.bidder_guid),
owner: names
.resolve(entry.owner_guid, commands)
.map(str::to_string),
link,
}
}
/// Build the Browse tab's category tree from the player's own DBCs (decision 1511 §5).
fn categories(
classes: Option<&crate::ui_items::ItemClasses>,
subclasses: Option<&crate::ui_items::ItemSubClasses>,
) -> Vec<AuctionCategory> {
let (Some(classes), Some(subclasses)) = (classes, subclasses) else {
return Vec::new();
};
AUCTION_CLASSES
.iter()
.filter_map(|&class_id| {
let name = classes.0.name(class_id)?.to_string();
let subs = subclasses
.0
.subclasses_of(class_id)
.into_iter()
.filter(|&sub| {
subclasses.0.display_flags(class_id, sub) & SUBCLASS_HIDDEN_FROM_AUCTIONS == 0
})
.filter_map(|sub| {
Some(AuctionSubCategory {
sub_id: sub,
name: subclasses.0.name(class_id, sub)?.to_string(),
// INTERIM (decision 1511, §5 TU-4): which class/subclass pairs offer the
// fourteen inventory-slot rows is not yet derived from the binary. Armor
// is the one the reference visibly offers them under, and it is the only
// class where an equip slot narrows anything. Data, not logic — a
// correction is this line.
has_inv_types: class_id == 4,
})
})
.collect();
Some(AuctionCategory {
class_id,
name,
subclasses: subs,
})
})
.collect()
}
/// Resolve + sort one list into its display rows.
#[allow(clippy::too_many_arguments)] // one resolve per ask-once cache the row reads
fn rows_for(
slot: &AuctionListSlot,
self_guid: Option<u64>,
items: &mut Items,
icons: Option<&ItemDisplays>,
names: &mut NameCache,
commands: &NetCommands,
rolls: crate::items::RollCatalogs,
) -> Vec<AuctionRow> {
let mut rows: Vec<AuctionRow> = slot
.entries
.iter()
.map(|e| resolve_row(e, self_guid, items, icons, names, commands, rolls))
.collect();
slot.sort.apply(&mut rows);
rows
}
/// One display row → the Lua-facing row.
fn to_script_row(r: &AuctionRow) -> AuctionItemRow {
AuctionItemRow {
auction_id: r.auction_id,
item_id: r.item_entry,
random_property_id: r.random_property_id,
name: r.name.clone(),
texture: r.texture.clone(),
count: r.count,
quality: r.quality,
level: r.level,
min_bid: r.start_bid,
min_increment: r.min_increment,
buyout_price: r.buyout,
bid_amount: r.current_bid,
high_bidder: r.high_bidder,
owner: r.owner.clone(),
time_left: r.time_left,
link: r.link.clone(),
}
}
/// The DBC catalogs a row reads, as ONE system param — [`feed_auction`] is at the
/// 16-SystemParam ceiling, and these four belong together anyway: the two class tables the
/// category tree is built from, and the random-suffix pair a rolled listing needs
/// (`ItemRandomProperties` for the name, `SpellItemEnchantment` for its lines — decision 1547).
type AuctionCatalogs<'w> = (
Option<Res<'w, crate::ui_items::ItemClasses>>,
Option<Res<'w, crate::ui_items::ItemSubClasses>>,
Option<Res<'w, crate::items::RandomProperties>>,
Option<Res<'w, crate::items::Enchants>>,
);
/// Push the current auction house into the VM and fire the show/close/list-update events on a
/// transition or content change. Diffed against a `VmMemo`, exactly like the merchant/mail feeds.
#[allow(clippy::too_many_arguments)] // one parameter per ask-once cache a row reads
fn feed_auction(
script: Option<NonSendMut<UiScript>>,
mut auction: ResMut<AuctionOpen>,
mut items: ResMut<Items>,
icons: Option<Res<ItemDisplays>>,
mut names: ResMut<NameCache>,
commands: Res<NetCommands>,
time: Res<Time>,
catalogs: AuctionCatalogs,
houses: Option<Res<AuctionHouses>>,
mut chat: ResMut<crate::ui_chat::ChatLog>,
self_q: Query<&crate::net::Guid, With<SelfPlayer>>,
mut last: Local<crate::ui_script::VmMemo<Option<AuctionState>>>,
mut last_open: Local<crate::ui_script::VmMemo<Option<u64>>>,
mut last_can_query: Local<crate::ui_script::VmMemo<bool>>,
mut last_sell: Local<crate::ui_script::VmMemo<Option<(i64, u32)>>>,
) {
let Some(mut script) = script else {
return;
};
let last = last.get(&script);
let last_open = last_open.get(&script);
let last_can_query = last_can_query.get(&script);
let last_sell = last_sell.get(&script);
// The queued client messages (decision 1523). Resolved against the VM's own GlobalStrings and
// shown on the surface the message's catalog row names — the `ui_quest` shape (0669).
//
// A `_S` line whose item template has not landed is **kept queued, not dropped**: the reference
// defers exactly this case through `0x4cd190` and prints the line when the item arrives. Ours
// falls out of the same ask-once cache the rows use, so the deferral is just "leave it in the
// queue and try again next frame" — and the `items.template` call below is what asks.
{
let mut deferred = Vec::new();
let mut lines = Vec::new();
for msg in std::mem::take(&mut auction.messages) {
let fill = match msg.item {
None => None,
Some(entry) => match items.template(entry, 0, &commands) {
Some(t) => Some(t.name.clone()),
// Not named yet — hold it and ask again next frame.
None => {
deferred.push(msg);
continue;
}
},
};
let get = |key: &str| script.lua().globals().get::<String>(key).ok();
let err = UiError {
key: msg.key,
fill_s: fill,
fill_d: None,
info: false,
};
if let Some(text) = ui_error_text(&err, &get) {
lines.push((msg.surface, text));
}
}
auction.messages = deferred;
for (surface, text) in lines {
// Greppable, because the chat path has no log of its own — without this a live probe
// can count lines but never read one (0669's in-app leg).
debug!("ui_auction: message ({surface:?}) {text:?}");
match surface {
MsgSurface::Chat => {
chat.push_event(ChatEvent::text_only(ChatEventKind::System, text));
}
MsgSurface::Error => {
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
}
}
}
}
let self_guid = self_q.iter().next().map(|g| g.0);
let (classes, subclasses) = (&catalogs.0, &catalogs.1);
let rolls = crate::items::RollCatalogs {
props: catalogs.2.as_deref(),
enchants: catalogs.3.as_deref(),
};
let fresh = auction.auctioneer.map(|_| {
let lists = [LIST, BIDDER, OWNER].map(|i| {
let slot = &auction.lists[i];
let rows = rows_for(
slot,
self_guid,
&mut items,
icons.as_deref(),
&mut names,
&commands,
rolls,
);
AuctionListState {
rows: rows.iter().map(to_script_row).collect(),
total: slot.total.max(rows.len() as u32),
sort: slot.sort.pairs(),
}
});
AuctionState {
lists,
categories: categories(classes.as_deref(), subclasses.as_deref()),
// The rate this house charges, keyed by the id the hello reply carried.
deposit_percent: houses
.as_deref()
.and_then(|h| h.0.deposit_percent(auction.house_id))
.unwrap_or(0),
}
});
let opened = last_open.is_none() && auction.auctioneer.is_some();
let closed = last_open.is_some() && auction.auctioneer.is_none();
let show_requested = std::mem::take(&mut auction.show_requested);
let result_landed = std::mem::take(&mut auction.list_result_landed);
let changed = fresh != *last;
if changed {
script.set_auction(fresh.clone());
}
if opened {
script.fire_event("AUCTION_HOUSE_SHOW", vec![]);
} else if closed {
script.fire_event("AUCTION_HOUSE_CLOSED", vec![]);
script.clear_auction_sell_item();
} else if auction.auctioneer.is_some() {
if show_requested {
script.fire_event("AUCTION_HOUSE_SHOW", vec![]);
}
// A landed result owes the events outright; a change owes them because an async name or
// template just filled a row in. Diffing ALONE was the bug: an empty house and a repeated
// search both produce a result identical to what we hold, and the Browse pane waits on
// this event to stop saying "Searching…".
if changed || result_landed {
// One routine invalidates all three lists in the reference too — the three events fire
// together rather than being diffed apart, and each tab's handler repaints only if it
// is the visible one.
for event in [
"AUCTION_ITEM_LIST_UPDATE",
"AUCTION_BIDDER_LIST_UPDATE",
"AUCTION_OWNED_LIST_UPDATE",
] {
script.fire_event(event, vec![]);
}
}
}
*last = fresh;
*last_open = auction.auctioneer;
// A listing was accepted: drop the staged item before anything reads the slot again.
if std::mem::take(&mut auction.sell_slot_taken) {
script.clear_auction_sell_item();
}
// The sell slot's own event: the create pane re-reads the item, re-suggests a price and
// re-computes the deposit off this.
let sell = script.auction_sell_item();
if sell != *last_sell || std::mem::take(&mut auction.sell_slot_dirty) {
*last_sell = sell;
script.fire_event("NEW_AUCTION_UPDATE", vec![]);
}
// The browse throttle, pushed every frame it changes — the Search button polls it.
let now = time.elapsed_secs_f64();
let can_query = auction.query_gate.is_none_or(|gate| now >= gate);
if can_query != *last_can_query {
script.set_auction_can_query(can_query);
*last_can_query = can_query;
}
}
/// Drain the Lua intents into the auction `CMSG`s.
#[allow(clippy::too_many_arguments)]
fn drain_auction(
script: Option<NonSendMut<UiScript>>,
mut auction: ResMut<AuctionOpen>,
commands: Res<NetCommands>,
time: Res<Time>,
mut items: ResMut<Items>,
icons: Option<Res<ItemDisplays>>,
mut names: ResMut<NameCache>,
self_q: Query<(&ObjectStore, &crate::net::Guid), With<SelfPlayer>>,
// The click→auction-id map re-derives the same sorted rows the feed pushed, so it resolves
// them the same way, roll included (1547).
props: Option<Res<crate::items::RandomProperties>>,
enchants: Option<Res<crate::items::Enchants>>,
) {
let rolls = crate::items::RollCatalogs {
props: props.as_deref(),
enchants: enchants.as_deref(),
};
let Some(mut script) = script else {
return;
};
let Some(auctioneer) = auction.auctioneer else {
// No session: honor a stray close and drop everything else on the floor.
if script.take_auction_close() {
auction.clear();
}
let _ = script.take_auction_query();
let _ = script.take_auction_owner_query();
let _ = script.take_auction_bidder_query();
let _ = script.take_auction_bids();
let _ = script.take_auction_cancels();
let _ = script.take_auction_start();
let _ = script.take_auction_sorts();
return;
};
// The browse query, behind the same gate the Search button is reading.
if let Some(q) = script.take_auction_query() {
let now = time.elapsed_secs_f64();
if auction.query_gate.is_none_or(|gate| now >= gate) {
auction.query_gate = Some(now + QUERY_THROTTLE_SECS);
auction.wire.browse_sent += 1;
let _ = commands.0.send(ClientCommand::AuctionListItems {
auctioneer,
list_from: q.page.saturating_mul(50),
searched_name: q.name,
level_min: u8::try_from(q.min_level).unwrap_or(u8::MAX),
level_max: u8::try_from(q.max_level).unwrap_or(u8::MAX),
slot_id: q.inv_type.unwrap_or(auction_filter::ANY),
main_category: q.class.unwrap_or(auction_filter::ANY),
sub_category: q.sub_class.unwrap_or(auction_filter::ANY),
quality: q.quality.unwrap_or(auction_filter::ANY),
usable: u8::from(q.usable_only),
});
}
}
// Server-driven refreshes: something we hold went stale (a sale, an outbid, a cancel). These
// are page-0 re-asks, deliberately separate from the Lua's own paging.
if std::mem::take(&mut auction.pending_owner_refresh) {
let _ = commands.0.send(ClientCommand::AuctionListOwnerItems {
auctioneer,
list_from: 0,
});
}
if std::mem::take(&mut auction.pending_bidder_refresh) {
let _ = commands.0.send(ClientCommand::AuctionListBidderItems {
auctioneer,
list_from: 0,
auction_ids: Vec::new(),
});
}
if let Some(page) = script.take_auction_owner_query() {
let _ = commands.0.send(ClientCommand::AuctionListOwnerItems {
auctioneer,
list_from: page.saturating_mul(50),
});
}
if let Some(page) = script.take_auction_bidder_query() {
// The refresh set is empty: it exists so a client can re-ask about auctions it was outbid
// on, and we do not track that memory yet (the reference keeps eight). A plain page still
// returns every auction we currently hold the bid on, which is the whole tab.
let _ = commands.0.send(ClientCommand::AuctionListBidderItems {
auctioneer,
list_from: page.saturating_mul(50),
auction_ids: Vec::new(),
});
}
// Bids and cancels address the wire auction id behind the row the player clicked, which means
// re-deriving the same sorted view the feed pushed.
let self_guid = self_q.iter().next().map(|(_, g)| g.0);
let bids = script.take_auction_bids();
let cancels = script.take_auction_cancels();
if !bids.is_empty() || !cancels.is_empty() {
for bid in bids {
let rows = rows_for(
&auction.lists[bid.list],
self_guid,
&mut items,
icons.as_deref(),
&mut names,
&commands,
rolls,
);
if let Some(auction_id) = AuctionOpen::auction_id_at(bid.index, &rows) {
let _ = commands.0.send(ClientCommand::AuctionPlaceBid {
auctioneer,
auction_id,
price: bid.amount,
});
}
}
for index in cancels {
let rows = rows_for(
&auction.lists[OWNER],
self_guid,
&mut items,
icons.as_deref(),
&mut names,
&commands,
rolls,
);
if let Some(auction_id) = AuctionOpen::auction_id_at(index, &rows) {
let _ = commands.0.send(ClientCommand::AuctionRemoveItem {
auctioneer,
auction_id,
});
}
}
}
// Creating an auction: the sell slot's (bag, slot) resolves to the wire item guid HERE rather
// than at attach time, because the reference re-reads the slot when the create fires.
if let Some(req) = script.take_auction_start() {
let item_guid = script
.auction_sell_item()
.and_then(|(bag, slot)| {
self_q.iter().next().and_then(|(store, _)| {
crate::ui_items::slot_guid(&store.0, bag, (slot.max(1) - 1) as u8, &items)
})
})
.unwrap_or(0);
if item_guid != 0 {
let _ = commands.0.send(ClientCommand::AuctionSellItem {
auctioneer,
item_guid,
bid: req.min_bid,
buyout: req.buyout,
etime_minutes: req.duration,
});
}
}
// Header clicks land LAST, and that ordering is load-bearing: a row pick above was made
// against the order the feed pushed last frame, so re-sorting before resolving it would point
// the same index at a different auction. Nothing here goes on the wire.
for (which, key) in script.take_auction_sorts() {
if let Some(slot) = auction.lists.get_mut(which) {
slot.sort.click(&key);
}
}
if script.take_auction_close() {
auction.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The four buckets, and the one that matters: an expired auction the server has not swept yet
/// arrives as an underflowed u32 and must read as Short, never as "Very Long".
#[test]
fn the_time_left_buckets_read_an_underflow_as_expired() {
assert_eq!(time_left_bucket(0), 1, "already gone");
assert_eq!(time_left_bucket(29 * 60 * 1000), 1);
assert_eq!(time_left_bucket(31 * 60 * 1000), 2);
assert_eq!(time_left_bucket(3 * 60 * 60 * 1000), 3);
assert_eq!(time_left_bucket(20 * 60 * 60 * 1000), 4, "a 24h auction");
// `(expireTime - now) * 1000` one second past the deadline, unclamped.
assert_eq!(
time_left_bucket(u32::MAX - 1000),
1,
"an underflow is expired, not eternal"
);
}
/// An identical list result still owes its events. Diffing the snapshot was the bug: an empty
/// auction house and a repeated search both produce a page identical to the one we hold, and
/// the Browse pane clears its "Searching…" state only when the event arrives — so on an empty
/// server a search animated forever and never reported a result. Found by the live probe,
/// which is the only place an empty house is the normal case.
#[test]
fn an_identical_list_result_still_owes_its_events() {
let mut open = AuctionOpen::default();
open.open(0x1234, 1);
open.set_list(LIST, Vec::new(), 0);
assert!(open.list_result_landed, "the first empty page");
// The feed consumes the flag when it fires.
open.list_result_landed = false;
// The very same empty page again — nothing to diff, and still owed.
open.set_list(LIST, Vec::new(), 0);
assert!(
open.list_result_landed,
"an identical page owes the event too — this is the whole bug"
);
}
/// An accepted listing releases the sell slot. Without it the pane kept painting a phantom
/// stack, Create Auction stayed enabled, and a second click re-resolved the remembered
/// (bag, slot) at send time — auctioning whatever had since landed in that slot.
#[test]
fn an_accepted_listing_releases_the_sell_slot() {
let mut open = AuctionOpen::default();
open.open(0x1234, 1);
assert!(!open.sell_slot_taken, "nothing listed yet");
open.sell_slot_taken();
assert!(open.sell_slot_taken, "the feed owes the slot a clear");
// And a closed session forgets it rather than clearing a slot it no longer owns.
open.clear();
assert!(!open.sell_slot_taken);
}
/// The displayed bid falls back to the opening price until somebody has bid — a zero current
/// bid means "no bids", never "free".
#[test]
fn an_unbid_row_shows_its_opening_price() {
let unbid = AuctionRow {
start_bid: 5000,
current_bid: 0,
..Default::default()
};
assert_eq!(unbid.displayed_bid(), 5000);
let bid = AuctionRow {
start_bid: 5000,
current_bid: 7500,
..Default::default()
};
assert_eq!(bid.displayed_bid(), 7500);
}
}
+258
View File
@@ -0,0 +1,258 @@
//! The auction list sort (decision 1511 §6) — client-side, because the wire carries no sort field
//! at all: `CMSG_AUCTION_LIST_ITEMS` is ten fields and none of them is a column.
//!
//! **It is not an asc → desc → none cycle**, which is the thing everyone assumes and gets wrong.
//! Each list owns a *most-recently-clicked stack* of up to eight `(key, reversed)` pairs. Clicking
//! the column that is already primary toggles its direction; clicking any other column promotes it
//! to primary **keeping the direction it already remembers**. Two consequences fall out, and both
//! are visible in the window: a column that is not currently sorting anything can still draw a
//! flipped arrow, and the sort is *stable across keys* — dropping "quality" to second place still
//! breaks ties by it.
//!
//! **VERIFIED** (wow-re §5, TU-5, `system/ui/scratch/auction-house.md`): `0x4cd940` *is* the
//! auction comparator, the sort *is* an 8-key MRU cascade, and each list owns its own `{col,dir}`
//! stack. The per-key directions below are that verdict's, not our guess — three of them were
//! wrong when this file first landed, and all three were ones a shopping UI makes the "obvious"
//! way round.
use std::cmp::Ordering;
use super::AuctionRow;
// The eight keys live in `benilla_ui::script::SORT_KEYS` — the Lua binding is what refuses an
// unknown one, with the reference's own `Usage:` error, so this side never re-lists them.
/// The reference keeps eight slots per list. Past that the oldest key is simply forgotten, which
/// is invisible in a window with at most six columns — but the bound is the reference's, so it is
/// the bound here.
const DEPTH: usize = 8;
/// One list's sort state: the most-recently-clicked key first.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct SortStack {
entries: Vec<(String, bool)>,
}
impl SortStack {
/// A header click. Already primary → flip its direction. Anywhere else (or brand new) → it
/// becomes primary, **carrying the direction it had**, and everything above it shifts down.
pub(crate) fn click(&mut self, key: &str) {
if let Some(pos) = self.entries.iter().position(|(k, _)| k == key) {
if pos == 0 {
self.entries[0].1 = !self.entries[0].1;
} else {
let entry = self.entries.remove(pos);
self.entries.insert(0, entry);
}
} else {
self.entries.insert(0, (key.to_string(), false));
self.entries.truncate(DEPTH);
}
}
/// The stack as the engine pushes it to Lua (`IsAuctionSortReversed` reads it back).
pub(crate) fn pairs(&self) -> Vec<(String, bool)> {
self.entries.clone()
}
/// Order `rows` by the whole stack: primary key first, each remaining key breaking the ties
/// above it. An empty stack leaves the server's own order alone — which for a browse page is
/// buyout ascending, and is a perfectly good default.
pub(crate) fn apply(&self, rows: &mut [AuctionRow]) {
if self.entries.is_empty() {
return;
}
// `sort_by` is stable, so equal-under-every-key rows keep the order the server sent —
// which keeps a repaint from shuffling rows the player is looking at.
rows.sort_by(|a, b| {
for (key, reversed) in &self.entries {
let ord = compare_by(key, a, b);
let ord = if *reversed { ord.reverse() } else { ord };
if ord != Ordering::Equal {
return ord;
}
}
Ordering::Equal
});
}
}
/// One key's comparison, in the direction a *first* click on that column produces (wow-re §5
/// TU-5, stated there as "which row appears first").
///
/// Three of these run the opposite way to the intuition, and each is intuitive once you know what
/// the column is for: **quality** opens with the epics, **buyout** opens with the most expensive
/// item rather than the cheapest, and **status** opens with the auctions you are already in. Only
/// level, bid, duration and the two name columns run the "small/soon/A-first" way round.
fn compare_by(key: &str, a: &AuctionRow, b: &AuctionRow) -> Ordering {
match key {
"level" => a.level.cmp(&b.level),
// Highest quality first — the epics, not the greys.
"quality" => b.quality.cmp(&a.quality),
// What the row SHOWS as the current bid: the opening price until somebody has bid.
"bid" => a.displayed_bid().cmp(&b.displayed_bid()),
// The bucket, not the raw milliseconds — two rows in the same bucket read as equal to the
// player, so ordering them by a millisecond difference they cannot see would make the sort
// look unstable.
"duration" => a.time_left.cmp(&b.time_left),
// Highest buyout first — which also settles what a *missing* buyout does without a
// special case: `0` is "no buyout", and descending drops it to the bottom on its own,
// exactly where it belongs. The ascending version this file shipped with needed a
// hand-written arm to avoid opening the column with every buyout-less auction.
"buyout" => b.buyout.cmp(&a.buyout),
// The auctions you are already in, first — the Bid tab's High Bidder / Outbid column.
"status" => b.high_bidder.cmp(&a.high_bidder),
"name" => name_of(a).cmp(name_of(b)),
"seller" => owner_of(a).cmp(owner_of(b)),
_ => Ordering::Equal,
}
}
/// A row whose template has not landed yet sorts as the empty string rather than jumping position
/// when the name arrives — the answer is at least stable while the async fill happens.
fn name_of(r: &AuctionRow) -> &str {
r.name.as_deref().unwrap_or("")
}
fn owner_of(r: &AuctionRow) -> &str {
r.owner.as_deref().unwrap_or("")
}
#[cfg(test)]
mod tests {
use super::*;
fn row(level: u32, bid: u32, buyout: u32, name: &str) -> AuctionRow {
AuctionRow {
level,
start_bid: bid,
buyout,
name: Some(name.to_string()),
..Default::default()
}
}
/// The promote-and-toggle law, which is the whole reason this is a stack and not a field.
#[test]
fn a_click_promotes_but_only_the_primary_toggles() {
let mut s = SortStack::default();
s.click("bid");
assert_eq!(s.pairs(), vec![("bid".into(), false)]);
// Re-clicking the primary flips it.
s.click("bid");
assert_eq!(s.pairs(), vec![("bid".into(), true)]);
// A new key takes the front; the old primary keeps BOTH its place behind it and its
// reversed flag.
s.click("quality");
assert_eq!(
s.pairs(),
vec![("quality".into(), false), ("bid".into(), true)]
);
// Promoting a remembered key carries its direction back to the front unflipped — this is
// the case a plain asc/desc cycle gets wrong.
s.click("bid");
assert_eq!(
s.pairs(),
vec![("bid".into(), true), ("quality".into(), false)],
"promotion is not a toggle"
);
let rev = |key: &str| s.pairs().iter().any(|(k, r)| k == key && *r);
assert!(rev("bid"));
assert!(!rev("quality"), "remembered, never reversed");
assert!(!rev("seller"), "never clicked at all");
}
/// Eight slots, oldest forgotten — the reference's own bound.
#[test]
fn the_stack_is_eight_deep() {
let mut s = SortStack::default();
for k in benilla_ui::script::SORT_KEYS {
s.click(k);
}
assert_eq!(s.pairs().len(), 8);
// A ninth distinct key would push one out; every key we have IS one of the eight, so
// re-clicking only reorders.
s.click("level");
assert_eq!(s.pairs().len(), 8, "re-click reorders, never grows");
assert_eq!(s.pairs()[0].0, "level");
}
/// Keys below the primary break its ties — the reason the whole stack is applied and not just
/// its head.
#[test]
fn lower_keys_break_the_primary_s_ties() {
let mut rows = vec![
row(10, 500, 0, "Bravo"),
row(10, 100, 0, "Alpha"),
row(5, 900, 0, "Charlie"),
];
let mut s = SortStack::default();
s.click("bid"); // secondary
s.click("level"); // primary
s.apply(&mut rows);
let got: Vec<_> = rows.iter().map(|r| r.name.clone().unwrap()).collect();
assert_eq!(got, vec!["Charlie", "Alpha", "Bravo"], "level, then bid");
}
/// Buyout opens HIGHEST-first (wow-re §5 TU-5) — and a buyout of `0` means "none", not
/// "free", so descending puts it last with no special case.
#[test]
fn buyout_opens_highest_first_and_no_buyout_sinks() {
let mut rows = vec![
row(1, 0, 0, "NoBuyout"),
row(1, 0, 100, "Cheap"),
row(1, 0, 5000, "Pricey"),
];
let mut s = SortStack::default();
s.click("buyout");
s.apply(&mut rows);
let got: Vec<_> = rows.iter().map(|r| r.name.clone().unwrap()).collect();
assert_eq!(got, vec!["Pricey", "Cheap", "NoBuyout"]);
}
/// The three columns that run opposite to the intuition, pinned together so a future "fix"
/// has to argue with the §5 rather than with a hunch.
#[test]
fn quality_and_status_open_highest_and_mine_first() {
let mut rows = vec![
AuctionRow {
quality: Some(1),
high_bidder: false,
name: Some("Common".into()),
..Default::default()
},
AuctionRow {
quality: Some(4),
high_bidder: true,
name: Some("Epic".into()),
..Default::default()
},
];
let mut s = SortStack::default();
s.click("quality");
s.apply(&mut rows);
assert_eq!(rows[0].name.as_deref(), Some("Epic"), "epics first");
let mut s = SortStack::default();
s.click("status");
s.apply(&mut rows);
assert_eq!(
rows[0].name.as_deref(),
Some("Epic"),
"the auction you are already bidding in, first"
);
}
/// No sort clicked = the server's own order, untouched.
#[test]
fn an_empty_stack_leaves_the_servers_order_alone() {
let mut rows = vec![row(9, 0, 0, "Zulu"), row(1, 0, 0, "Alpha")];
SortStack::default().apply(&mut rows);
let got: Vec<_> = rows.iter().map(|r| r.name.clone().unwrap()).collect();
assert_eq!(got, vec!["Zulu", "Alpha"]);
}
}
+31 -16
View File
@@ -546,7 +546,7 @@ fn slot_view(
store: &ObjectStore,
items: &mut Items,
icons: Option<&ItemDisplays>,
enchant_rows: Option<&crate::items::Enchants>,
rolls: crate::items::RollCatalogs,
commands: &NetCommands,
pending: &PendingItemOps,
names: &mut crate::names::NameCache,
@@ -591,19 +591,27 @@ fn slot_view(
enchant_ms[usize::from(s)],
)
}),
enchant_rows,
rolls.enchants,
);
// `ITEM_FIELD_RANDOM_PROPERTIES_ID` — the roll behind the NAME's "of the Bear" (1547); the
// roll's enchants are already in the slots read just above.
let roll = obj.item_random_properties_id();
let t = items.template(entry, guid, commands);
let (name, quality, display, link, equip_slots, class, bar_placeable) = match t {
Some(t) => (
Some(t.name.clone()),
t.quality as i32,
t.display_info_id,
Some(item_link(entry, &t.name, t.quality)),
find_equip_slot(t.inventory_type),
t.class,
t.placeable_on_action_bar(),
),
Some(t) => {
let name = rolls.name(&t.name, roll);
(
Some(name.clone()),
t.quality as i32,
t.display_info_id,
Some(crate::ui_items::item_link_full(
entry, 0, roll, 0, &name, t.quality,
)),
find_equip_slot(t.inventory_type),
t.class,
t.placeable_on_action_bar(),
)
}
None => (None, 0, 0, None, Vec::new(), 0, false),
};
// The quiver's bag-bar count is what's INSIDE it: the ref's `GetInventoryItemCount("player",
@@ -679,7 +687,7 @@ fn inventory_slots(
store: &ObjectStore,
items: &mut Items,
icons: Option<&ItemDisplays>,
enchant_rows: Option<&crate::items::Enchants>,
rolls: crate::items::RollCatalogs,
commands: &NetCommands,
pending: &PendingItemOps,
names: &mut crate::names::NameCache,
@@ -727,7 +735,7 @@ fn inventory_slots(
store,
items,
icons,
enchant_rows,
rolls,
commands,
pending,
names,
@@ -742,7 +750,7 @@ fn inventory_slots(
store,
items,
icons,
enchant_rows,
rolls,
commands,
pending,
names,
@@ -864,6 +872,8 @@ fn feed_char(
icons: Option<Res<ItemDisplays>>,
// `SpellItemEnchantment`'s name column — the equipped tooltip's enchant lines (decision 0915).
enchants: Option<Res<crate::items::Enchants>>,
// `ItemRandomProperties` — the roll behind an equipped item's "of the Monkey" name (1547).
props: Option<Res<crate::items::RandomProperties>>,
commands: Res<NetCommands>,
mut feed: ResMut<CharFeedState>,
mut booth: ResMut<PaperDollBooth>,
@@ -927,7 +937,9 @@ fn feed_char(
// `is_added` for the icon catalog: the feeds read only its load-once icon column;
// its model-cache half churns every frame (the containers gate's own note).
let icons_added = icons.as_ref().is_some_and(|r| r.is_added());
let enchants_changed = enchants.as_ref().is_some_and(|r| r.is_changed());
// Both DBC catalogs load once, at startup — one input covers the pair.
let enchants_changed = enchants.as_ref().is_some_and(|r| r.is_changed())
|| props.as_ref().is_some_and(|r| r.is_changed());
let pending_held = !pending.is_empty();
gate::trace(
"feed_char",
@@ -974,7 +986,10 @@ fn feed_char(
store,
&mut items,
icons.as_deref(),
enchants.as_deref(),
crate::items::RollCatalogs {
enchants: enchants.as_deref(),
props: props.as_deref(),
},
&commands,
&pending,
&mut names,
+15 -1
View File
@@ -100,6 +100,12 @@ pub(crate) enum SlashIndex {
/// not a `DevCmd`**: the people who need it are the people running addons, and gating it on a
/// dev build would leave exactly the reporters who asked unable to type it.
ScriptErrors,
/// `/convertraid` — **benilla's own addition**: convert the party to a raid
/// (`CMSG_GROUP_RAID_CONVERT`, leader only, server-judged). 1.12's only trigger is the
/// RaidFrame tab's Convert button, which is not built yet — until it is, this is the one way
/// to form a raid, so it is player-facing by the `/errors` reasoning, not a `DevCmd`. When
/// the Raid tab lands this alias stays, the way `/reload` outlived `/console reloadUI`.
ConvertRaid,
}
impl SlashIndex {
@@ -147,13 +153,15 @@ impl SlashIndex {
// No shipped `SLASH_BENILLASCRIPTERRORS1` exists — the aliases are literals in
// `build`. The key is still ours to name so the variant round-trips like any other.
Self::ScriptErrors => "BENILLASCRIPTERRORS",
// Same shape: a benilla addition, alias a literal in `build`.
Self::ConvertRaid => "BENILLACONVERTRAID",
}
}
/// Every registered index — the registry proper. A reference command NOT in this list resolves
/// nowhere and answers `HELP_TEXT_SIMPLE`, exactly as an unknown command does in the reference
/// (better than a registered handler that silently does nothing).
const ALL: [Self; 37] = [
const ALL: [Self; 38] = [
Self::Reply,
Self::Join,
Self::Leave,
@@ -191,6 +199,7 @@ impl SlashIndex {
Self::Console,
Self::ReloadUi,
Self::ScriptErrors,
Self::ConvertRaid,
];
}
@@ -315,6 +324,11 @@ impl SlashCommands {
Command::Slash(SlashIndex::ScriptErrors),
);
}
insert(
&mut by_alias,
"convertraid",
Command::Slash(SlashIndex::ConvertRaid),
);
let added_aliases = by_alias.len() - slash_aliases - emote_aliases;
// 4 · benilla's instruments, last so they can never shadow a shipped command.
@@ -482,6 +482,12 @@ pub(super) fn drain_chat_input(
let _ = commands.0.send(ClientCommand::GroupInvite { name });
}
}
// Fire-and-forget: the server judges leader/group state and answers with either a
// raid-typed SMSG_GROUP_LIST (the "joined a raid group" line rides the apply) or a
// SMSG_PARTY_COMMAND_RESULT error, both already handled.
ParsedChat::ConvertRaid => {
let _ = commands.0.send(ClientCommand::GroupRaidConvert);
}
ParsedChat::Uninvite { name } => {
if let Some(name) =
name.or_else(|| target_player_name(&selection, &mut names, &commands))
@@ -89,6 +89,10 @@ pub(in crate::ui_chat) enum ParsedChat {
/// the subject on, the rung that decided, and the resulting `can_attack` verdict. Bare uses
/// the current target; a name resolves a streamed player (what a scripted probe can reach).
Reaction { name: Option<String> },
/// `/convertraid` — convert the party to a raid (`CMSG_GROUP_RAID_CONVERT`, leader only,
/// server-judged). A benilla addition: 1.12's only trigger is the RaidFrame tab's Convert
/// button, not built yet — see the [`SlashIndex::ConvertRaid`] doc.
ConvertRaid,
/// `/help` (aliases /h /?) — the command summary.
Help,
/// A `/name` line that resolved in `EmotesText` (`/wave` → text id 101) — sent as
@@ -400,6 +404,7 @@ fn slash_command(index: SlashIndex, args: &str) -> ParsedChat {
// The console command match is case-insensitive like the engine's console; `0x4035f0`
// reads no arguments, so anything after `reloadUI` is ignored rather than an error.
S::ReloadUi => ParsedChat::ReloadUi,
S::ConvertRaid => ParsedChat::ConvertRaid,
// `/errors` (ours, 1495) → the script error log. Routed as Lua rather than a new
// `ParsedChat` variant for the same reason `/macro` is: the window is FrameXML, the
// toggle is a FrameXML function, and a Rust arm would only forward to it.
+5 -3
View File
@@ -1395,6 +1395,7 @@ fn real_alias_table_resolves_the_shipped_commands() {
);
}
assert_eq!(parse_line("/macrohelp"), ParsedChat::MacroHelp);
assert_eq!(parse_line("/convertraid"), ParsedChat::ConvertRaid);
// The whole shipped surface, so a table that half-loaded fails loudly: **225 distinct emote
// commands** over the 169 `EmotesText` names (the strings repeat — `EMOTE87_CMD1` and `_CMD2`
// are both "/sit" — and EMOTE27 "UNUSED" has no row, so it contributes none), and **68 distinct
@@ -1404,8 +1405,9 @@ fn real_alias_table_resolves_the_shipped_commands() {
// 1291 added CONSOLE's `/console` — one distinct alias, SLASH_CONSOLE1 and 2 are both the
// same string).
//
// The third number is benilla's own player-facing additions: `/reload` (1291) and
// `/errors` `/err` (1495, the script error log) — 3 aliases over 2 commands. Present in
// The third number is benilla's own player-facing additions: `/reload` (1291), `/errors`
// `/err` (1495, the script error log) and `/convertraid` (the raid conversion trigger the
// unbuilt RaidFrame tab would otherwise carry) — 4 aliases over 3 commands. Present in
// every build, deliberately counted apart from the shipped surface so the seam stays visible.
// The error log is player-facing on purpose and NOT an instrument: gating it on
// `dev_affordances()` would leave exactly the reporters who asked for it unable to type it.
@@ -1422,7 +1424,7 @@ fn real_alias_table_resolves_the_shipped_commands() {
};
assert_eq!(
table.counts(),
(68, 225, 3, instruments),
(68, 225, 4, instruments),
"(slash, emote, benilla addition, instrument) aliases"
);
}
+25 -8
View File
@@ -49,7 +49,6 @@ use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore};
use crate::portrait::InspectBooth;
use crate::ui_items::item_link;
use crate::ui_script::UiInput;
/// The unit tokens a UnitPopup row can name for another player — the popup menus' own set
@@ -125,18 +124,29 @@ fn inspect_slot_view(
store: &ObjectStore,
items: &mut Items,
icons: Option<&ItemDisplays>,
enchant_rows: Option<&crate::items::Enchants>,
rolls: crate::items::RollCatalogs,
commands: &NetCommands,
slot0: u8,
) -> Option<InvSlotView> {
let entry = store.0.player_visible_item_entry(slot0)?;
// The broadcast roll (`PLAYER_VISIBLE_ITEM_<slot>_PROPERTIES`'s low half — the same `movzx
// WORD` the reference's inspect leg feeds its `+0x424` with): the NAME's suffix, and only
// that. Its *lines* would come from the visible enchant slots 2..6, which 1.12 servers leave
// empty — so an inspected roll names itself and shows no stat lines, on both clients (1547).
let roll = store.0.player_visible_item_properties(slot0);
// Template-only ask (`guid` 0 — `Items::template`'s own documented shape): there is no item
// object to name, and asking by entry is precisely what the real client's ItemCache does.
let (name, quality, display) = match items.template(entry, 0, commands) {
Some(t) => (Some(t.name.clone()), t.quality, t.display_info_id),
Some(t) => (
Some(rolls.name(&t.name, roll)),
t.quality,
t.display_info_id,
),
None => (None, 0, 0),
};
let link = name.as_ref().map(|n| item_link(entry, n, quality));
let link = name
.as_ref()
.map(|n| crate::ui_items::item_link_full(entry, 0, roll, 0, n, quality));
Some(InvSlotView {
item_id: entry,
icon: icons
@@ -155,7 +165,7 @@ fn inspect_slot_view(
let id = store.0.player_visible_item_enchant(slot0, j).unwrap_or(0);
(j, id as i32, 0, None)
}),
enchant_rows,
rolls.enchants,
),
..Default::default()
})
@@ -169,8 +179,12 @@ fn feed_inspect(
mut booth: ResMut<InspectBooth>,
mut items: ResMut<Items>,
icons: Option<Res<ItemDisplays>>,
// `SpellItemEnchantment`'s name column — the inspected item's enchant line (decision 0915).
enchants: Option<Res<crate::items::Enchants>>,
// `SpellItemEnchantment`'s name column — the inspected item's enchant line (decision 0915) —
// and `ItemRandomProperties`, the roll behind its "of the Monkey" name (1547).
catalogs: (
Option<Res<crate::items::Enchants>>,
Option<Res<crate::items::RandomProperties>>,
),
commands: Res<NetCommands>,
index: Res<GuidIndex>,
stores: Query<&ObjectStore>,
@@ -273,7 +287,10 @@ fn feed_inspect(
store,
&mut items,
icons.as_deref(),
enchants.as_deref(),
crate::items::RollCatalogs {
enchants: catalogs.0.as_deref(),
props: catalogs.1.as_deref(),
},
&commands,
slot - 1,
);
+124 -2
View File
@@ -142,6 +142,9 @@ pub(super) fn drain_container_uses(
targeting: crate::ui_action::cast_target::CastTargeting,
// The client-side pending ("gray") lock — the right-click-open arm arms it (decision 0916).
mut pending_items: ResMut<PendingItemOps>,
// The loot-target latch — the right-click-open arm is one of its five arm sites, and the one
// that lets `SMSG_LOOT_RESPONSE`'s admission gate recognise an item loot (decision 1531).
mut loot_latch: ResMut<crate::ui_loot::LootLatch>,
mut ladder: crate::ui_action::CastLadder,
) {
let Some(mut script) = script else {
@@ -386,6 +389,19 @@ pub(super) fn drain_container_uses(
"ui_items: open item {:#x} (lua bag {bag} → wire {bag_index}/{wire_slot})",
c.guid
);
// **The loot latch, armed before the send** — arm site four of five (`0x5edcc0`, in
// this same emitter `0x5edc80`, immediately ahead of the lock setter and the
// `0x5edce5 push 0xac`; wow-re `loot-anim-leg.md` §5, byte-verified). The latch is
// the **item's own guid** (`[[edi+8]+0]`) because that is what the answer names:
// vmangos' `HandleOpenItemOpcode` ends in `SendLoot(pItem->GetObjectGuid(),
// LOOT_CORPSE)`, so `SMSG_LOOT_RESPONSE` comes back on the item guid with wire type
// **1** — and 1477's admission gate refuses a type-1 answer against a *cold* latch.
// Without this arm the window never opens (decision 1531).
//
// It arms no pose: predicate B `0x612710` answers false for an ITEM, and
// [`crate::ui_loot::resolve_loot_kneel`] reaches the same false through a guid the
// object manager cannot resolve — we stream no item entities.
loot_latch.0 = Some(c.guid);
// **The gray lock, armed before the send** — the reference's emitter `0x5edc80` calls
// the lock setter `0x4953e0` at `0x5edcd9` and only then ships `CMSG_OPEN_ITEM`
// (wow-re `inventory-change-failure-display.md` §8, decision 0916). So a clam,
@@ -395,8 +411,8 @@ pub(super) fn drain_container_uses(
// `PendingItemOps` already clears on.
//
// Deliberately NOT armed on the gift-unwrap arm above, which sends the same opcode:
// its emitter `0x5edd60` contains no setter call. That asymmetry is the reference's,
// verified, and copying it is the point.
// its emitter `0x5edd60` contains neither call — no lock setter and no latch write.
// That asymmetry is the reference's, verified, and copying it is the point.
let (guid, count) = self_q
.iter()
.next()
@@ -599,3 +615,109 @@ pub(super) fn drain_container_destroys(
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_protocol::messages::{ItemInfo, ObjectFields, ITEM_FLAG_LOOTABLE};
use bevy::ecs::system::RunSystemOnce;
/// "Small Barnacled Clam", entry 7973 — the director's own case, and the `--open-item` probe's.
const CLAM_ENTRY: u32 = 7973;
/// The clam's item guid (`HIGHGUID_ITEM` 0x4000…, as the live probe read it back).
const CLAM: u64 = 0x4000_0000_0000_1939;
/// `PLAYER_FIELD_PACK_SLOT_1` — backpack slot 1's guid pair, so `slot_guid(0, 0)` resolves.
const F_PACK_SLOT_1: u16 = 532;
/// `OBJECT_FIELD_ENTRY` on the item object — what `Items::object(…).object_entry()` reads.
const F_OBJECT_ENTRY: u16 = 3;
/// Right-click backpack slot 1 (holding a LOOTABLE template) and run the click dispatcher.
fn open_the_clam() -> (App, crossbeam_channel::Receiver<ClientCommand>) {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_message::<crate::sound::AutoEquipSound>()
.add_message::<crate::creature_anim::SheathRequest>()
.init_resource::<crate::ui_merchant::MerchantOpen>()
.init_resource::<crate::ui_bank::BankOpen>()
.init_resource::<crate::ui_item_text::ItemTextOpen>()
.init_resource::<PendingItemOps>()
.init_resource::<crate::ui_loot::LootLatch>()
.init_resource::<crate::target::Selection>()
.init_resource::<crate::net::SelfGuid>()
.init_resource::<crate::ui_action::cast_target::AutoSelfCast>()
.init_resource::<crate::net::Reputations>()
.init_resource::<crate::player::Player>()
.init_resource::<crate::ui_cast::PendingCast>()
.init_resource::<crate::ui_cast::QueuedMeleeSpell>()
.init_resource::<crate::cooldowns::Cooldowns>()
.init_resource::<crate::ui_action::CastErrors>()
.init_resource::<crate::ui_action::AutoRepeatActive>()
.init_resource::<crate::ui_tradeskill::TradeSkillOpens>()
.init_resource::<crate::ui_action::targeting::SpellTargeting>()
.init_resource::<Items>()
.insert_resource(NetCommands(tx));
// The player, holding the clam in backpack slot 1.
app.world_mut().spawn((
SelfPlayer,
ObjectStore(ObjectFields::from_pairs(&[
(F_PACK_SLOT_1, CLAM as u32),
(F_PACK_SLOT_1 + 1, (CLAM >> 32) as u32),
])),
));
// The item object and its landed template — LOOTABLE, so the dispatcher's open arm claims
// the click (`ItemInfo::opens_loot`).
let mut items = app.world_mut().resource_mut::<Items>();
items.insert_object(
CLAM,
ObjectFields::from_pairs(&[(F_OBJECT_ENTRY, CLAM_ENTRY)]),
);
items.insert_template(
CLAM_ENTRY,
Some(ItemInfo {
flags: ITEM_FLAG_LOOTABLE,
..crate::items::test_template("Small Barnacled Clam")
}),
);
let script = UiScript::new().unwrap();
script.run("UseContainerItem(0, 1)").unwrap();
app.insert_non_send_resource(script);
app.world_mut()
.run_system_once(drain_container_uses)
.unwrap();
(app, rx)
}
/// **The clam regression (decision 1531).** Arm site four of five: the `CMSG_OPEN_ITEM` send
/// latches the ITEM's own guid (`0x5edcc0`, wow-re `loot-anim-leg.md` §5). It is not cosmetic
/// and it is not about the pose — vmangos answers this opcode with `SendLoot(item guid,
/// LOOT_CORPSE)`, i.e. `SMSG_LOOT_RESPONSE` type **1** on that same guid (live-verified by
/// `benilla-world --open-item`), and 1477's admission gate *refuses* a type-1 answer against a
/// cold latch. Without the arm the clam greys and no window ever opens, which is exactly what
/// the director saw.
#[test]
fn the_open_item_send_arms_the_loot_latch_on_the_items_own_guid() {
let (app, rx) = open_the_clam();
assert!(
matches!(
rx.try_recv(),
Ok(ClientCommand::OpenItem {
bag_index: 255,
slot: 23
})
),
"the open arm ships CMSG_OPEN_ITEM for backpack slot 1"
);
assert_eq!(
app.world().resource::<crate::ui_loot::LootLatch>().0,
Some(CLAM),
"…having first latched the item's own guid, or the type-1 answer is refused"
);
// The grey lock is the reference's other pre-send write, and still there (decision 0916).
assert!(
app.world().resource::<PendingItemOps>().contains(0, 1),
"the slot greys at the click"
);
}
}
+72 -15
View File
@@ -17,8 +17,8 @@ use crate::ui_script::gate;
use super::equip_error::equip_error_key;
use super::{
find_equip_slot, has_key, item_link, keyring_size, slot_guid_count, EquipErrors, BAGS,
BAG_SLOT_FIRST, BANK_BAGS, BANK_BAG_ID_FIRST, BANK_BAG_SLOT_FIRST, BANK_CONTAINER, BANK_SLOTS,
find_equip_slot, has_key, keyring_size, slot_guid_count, EquipErrors, BAGS, BAG_SLOT_FIRST,
BANK_BAGS, BANK_BAG_ID_FIRST, BANK_BAG_SLOT_FIRST, BANK_CONTAINER, BANK_SLOTS,
KEYRING_CONTAINER, KEYRING_SLOTS, PACK_SLOTS,
};
@@ -300,6 +300,39 @@ pub(super) fn feed_item_sets(
/// the "hover twice" flake this replaces). **Ask**: a renderer read of an id the app never
/// resolved still records a miss, which triggers the `CMSG_ITEM_QUERY` here and lands via the
/// same push when the answer arrives.
/// Push the **whole** random-suffix table into the engine, once per VM (decision 1547).
///
/// Not an ask-once feed like the templates beside it: the roll table is a static DBC the app holds
/// from load, and its consumers are click-driven — a chat-link tooltip has no hover re-enter loop
/// to repaint on a late answer. One push per VM (a `/reload` mints a new one), and it waits for
/// both catalogs, so a session that starts before the DBC load simply pushes on the next frame.
pub(super) fn feed_random_properties(
script: Option<NonSendMut<UiScript>>,
props: Option<Res<crate::items::RandomProperties>>,
enchants: Option<Res<crate::items::Enchants>>,
mut pushed: Local<crate::ui_script::VmMemo<bool>>,
) {
let Some(mut script) = script else {
return;
};
if *pushed.get(&script) {
return;
}
// The enchant catalog is what turns the roll's five ids into lines; without it every row would
// push empty and the push would never be retried. Both, or neither.
let (Some(props), Some(enchants)) = (props, enchants) else {
return;
};
let rows = crate::items::random_property_views(&props, Some(&enchants));
let with_lines = rows.values().filter(|v| !v.enchants.is_empty()).count();
info!(
"random-property table: {} rows pushed, {with_lines} with enchant lines",
rows.len()
);
script.set_random_properties(rows);
*pushed.get(&script) = true;
}
#[allow(clippy::too_many_arguments)]
pub(super) fn feed_item_stats(
script: Option<NonSendMut<UiScript>>,
@@ -500,7 +533,7 @@ fn resolve_slot(
guid: u64,
items: &mut Items,
icons: Option<&ItemDisplays>,
enchants: Option<&crate::items::Enchants>,
rolls: crate::items::RollCatalogs,
commands: &NetCommands,
cooldowns: &crate::cooldowns::Cooldowns,
spells: Option<&benilla_formats::SpellCatalog>,
@@ -515,7 +548,7 @@ fn resolve_slot(
// (both live on `Items`): one `Option<ms>` per enchant slot.
let enchant_ms: [Option<u64>; 7] =
std::array::from_fn(|s| items.enchant_remaining_display_ms(guid, s as u32));
let (entry, count, durability, readable, creator, flags, enchant_lines) =
let (entry, count, durability, readable, creator, flags, roll, enchant_lines) =
match items.object(guid) {
Some(fields) => (
fields.object_entry().unwrap_or(0),
@@ -539,6 +572,10 @@ fn resolve_slot(
.and_then(|g| names.resolve(g, commands).map(str::to_string)),
// `ITEM_FIELD_FLAGS` — the tooltip's UNLOCKED (0x4) / WRAPPED (0x8) sub-gates.
fields.item_flags().unwrap_or(0),
// `ITEM_FIELD_RANDOM_PROPERTIES_ID` — the roll behind the NAME's "of the Bear"
// (decision 1547). Only the name: the roll's own enchants are already in the
// instance's slots 2..6 below, written there by the server.
fields.item_random_properties_id(),
// The instance's own 7 enchant slots — the tooltip's enchant lines (0915/0920). An
// item we hold streams as an OBJECT, so all seven are here, with their charges and
// their `SMSG_ITEM_ENCHANT_TIME_UPDATE` countdowns; the wire's 2-slot broadcast is
@@ -552,7 +589,7 @@ fn resolve_slot(
enchant_ms[usize::from(s)],
)
}),
enchants,
rolls.enchants,
),
),
// The player descriptor references a guid whose create hasn't landed (yet) —
@@ -584,7 +621,17 @@ fn resolve_slot(
durability,
quality: Some(t.quality),
item_id: entry,
link: Some(item_link(entry, &t.name, t.quality)),
// The link carries the roll — both in its `randomPropertyId` field and in the bracketed
// NAME, which the reference builds out of the suffix-joining formatter `0x5d8b00`. The
// slot's tooltip plate reads its name back off this string.
link: Some(crate::ui_items::item_link_full(
entry,
0,
roll,
0,
&rolls.name(&t.name, roll),
t.quality,
)),
locked: false,
readable,
creator,
@@ -651,8 +698,13 @@ pub(crate) fn feed_containers(
script: Option<NonSendMut<UiScript>>,
mut items: ResMut<Items>,
icons: Option<Res<ItemDisplays>>,
// `SpellItemEnchantment`'s name column — the tooltip's enchant lines (decision 0915).
enchants: Option<Res<crate::items::Enchants>>,
// The two item-DBC catalogs, as one param (the 16-SystemParam ceiling): `SpellItemEnchantment`'s
// name column — the tooltip's enchant lines (decision 0915) — and `ItemRandomProperties`, the
// roll behind a slot's "of the Monkey" name (decision 1547).
catalogs: (
Option<Res<crate::items::Enchants>>,
Option<Res<crate::items::RandomProperties>>,
),
// The two self-descriptor legs in one param (the 16-SystemParam ceiling): the store the
// slot arrays read, and its change tick — the gate's cheapest input (1439).
self_q: (
@@ -702,7 +754,9 @@ pub(crate) fn feed_containers(
// get-or-insert every frame, so `is_changed` reads true forever — 1439's gate-trace found
// the containers gate held open by exactly this.
let icons_changed = icons.as_ref().is_some_and(|r| r.is_added());
let enchants_changed = enchants.as_ref().is_some_and(|r| r.is_changed());
// Both DBC catalogs load once, at startup — one input covers the pair.
let enchants_changed = catalogs.0.as_ref().is_some_and(|r| r.is_changed())
|| catalogs.1.as_ref().is_some_and(|r| r.is_changed());
let spells_changed = spells.as_ref().is_some_and(|r| r.is_changed());
let families_changed = bag_families.as_ref().is_some_and(|r| r.is_changed());
let errors_held = !equip_errors.0.is_empty() || !error_lines.0.is_empty();
@@ -759,7 +813,10 @@ pub(crate) fn feed_containers(
// pushed start is frame-stable (the resource's own doc).
let (now, ui_now) = (clock.anchor, clock.ui_now);
let spell_catalog = spells.as_deref().map(|s| &s.catalog);
let enchant_rows = enchants.as_deref();
let rolls = crate::items::RollCatalogs {
enchants: catalogs.0.as_deref(),
props: catalogs.1.as_deref(),
};
// Inventory refusals surface as the client's red error line (the cast path's exact shape):
// the wire code keys into the VM's own GlobalStrings ([`equip_error_key`], total), reason 1
// filling its `%d` with the packet's required level.
@@ -843,7 +900,7 @@ pub(crate) fn feed_containers(
guid,
&mut items,
icons.as_deref(),
enchant_rows,
rolls,
&commands,
&cooldowns,
spell_catalog,
@@ -896,7 +953,7 @@ pub(crate) fn feed_containers(
guid,
&mut items,
icons.as_deref(),
enchant_rows,
rolls,
&commands,
&cooldowns,
spell_catalog,
@@ -929,7 +986,7 @@ pub(crate) fn feed_containers(
guid,
&mut items,
icons.as_deref(),
enchant_rows,
rolls,
&commands,
&cooldowns,
spell_catalog,
@@ -978,7 +1035,7 @@ pub(crate) fn feed_containers(
guid,
&mut items,
icons.as_deref(),
enchant_rows,
rolls,
&commands,
&cooldowns,
spell_catalog,
@@ -1015,7 +1072,7 @@ pub(crate) fn feed_containers(
guid,
&mut items,
icons.as_deref(),
enchant_rows,
rolls,
&commands,
&cooldowns,
spell_catalog,
+13 -1
View File
@@ -55,7 +55,9 @@ use drain::{
drain_container_autoequips, drain_container_destroys, drain_container_moves,
drain_container_uses, drain_inventory_uses,
};
use feed::{feed_containers, feed_item_sets, feed_item_stats, feed_player_req};
use feed::{
feed_containers, feed_item_sets, feed_item_stats, feed_player_req, feed_random_properties,
};
/// The backpack's fixed capacity (`PLAYER_FIELD_PACK_SLOT_1..` — 16 slots on the 1.12 wire).
pub(super) const PACK_SLOTS: u8 = 16;
@@ -960,6 +962,13 @@ fn load_item_dbcs(mut commands: Commands, world_assets: Option<Res<benilla_asset
}
Err(e) => warn!("ui_items: ItemClass.dbc failed to load: {e:#}"),
}
match benilla_formats::load_auction_houses(&mut chain) {
Ok(cat) => {
info!("ui_items: AuctionHouse.dbc loaded ({} houses)", cat.len());
commands.insert_resource(crate::ui_auction::AuctionHouses(cat));
}
Err(e) => warn!("ui_items: AuctionHouse.dbc failed to load: {e:#}"),
}
match benilla_formats::load_item_bag_families(&mut chain) {
Ok(cat) => {
info!(
@@ -1005,6 +1014,9 @@ impl Plugin for UiItemsPlugin {
// re-hover the very next frame already sees them.
feed_item_stats.in_set(UnitFeed).before(UiInput),
feed_item_sets.in_set(UnitFeed).before(UiInput),
// The roll table, pushed whole once per VM (1547) — before the input pass, so
// the first hover of the session already resolves a drop's suffix lines.
feed_random_properties.in_set(UnitFeed).before(UiInput),
feed_player_req.in_set(UnitFeed).before(UiInput),
// After the input pass, so a click's UseContainerItem goes out the same frame.
drain_container_uses.after(UiInput),
+206 -71
View File
@@ -2,8 +2,9 @@
//! [`benilla_ui::script`]'s `loot` module, the twin of [`crate::ui_merchant`]'s merchant seam.
//!
//! The net bridge ([`crate::net::apply`]) fills [`LootState`] from the wire: `SMSG_LOOT_RESPONSE` →
//! the rows + coin pile ([`LootState::open`]); `SMSG_LOOT_REMOVED` → a row drops
//! ([`LootState::remove_slot`]); `SMSG_LOOT_CLEAR_MONEY` → the coin row drops
//! the rows + coin pile ([`LootState::open`]); `SMSG_LOOT_REMOVED` → that row becomes an empty gap
//! at its fixed position ([`LootState::remove_slot`] — the layout never compacts while open);
//! `SMSG_LOOT_CLEAR_MONEY` → the coin row becomes the same kind of gap
//! ([`LootState::clear_money`]); `SMSG_LOOT_RELEASE_RESPONSE` → the window closes
//! ([`LootState::clear`]); the error shape → [`LootErrors`]; `SMSG_ITEM_PUSH_RESULT` → a queued
//! "You receive loot" line ([`LootState::receives`]). A removal that empties the window arms the
@@ -27,7 +28,7 @@ use bevy::prelude::*;
use benilla_ui::script::{LootRow, LootState as LootSnapshot, ScriptValue, UiScript};
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::items::{Items, RollCatalogs};
use crate::net::{ClientCommand, NetCommands};
use crate::ui_items::KEYRING_CONTAINER;
use crate::ui_script::UiInput;
@@ -130,14 +131,30 @@ fn push_container(bag: u8, slot: u32) -> i64 {
/// each to a display row and the drain maps a clicked 1-based row to its wire loot slot. Cleared on
/// release and on disconnect. `receives` outlives the window (a vendor buy pushes one with no loot
/// open) and is cleared only on disconnect.
///
/// **The slot layout is FIXED at open** (the reference's own slot array): a looted row — item or
/// coin — becomes an empty *gap* at its position, never a compaction. The real client's
/// `LOOT_SLOT_CLEARED` hides one button in place (`LootFrame.lua:22-37`), `numLootItems` is read
/// once at OnShow (l.132), and a cleared slot answers neither `LootSlotIsItem` nor `LootSlotIsCoin`
/// (l.80) — so the rows below a looted one keep their positions until the window closes. Modelled
/// here as `coin_slot` (does position 1 belong to the coin pile, looted or not) + `taken` (which
/// wire slots are already gone); [`snapshot`] emits `None` rows for the gaps.
#[derive(Resource, Default)]
pub(crate) struct LootState {
/// The lootable unit whose window is open; `None` = no loot open.
source: Option<u64>,
/// The coin pile in copper; `0` = no coin row.
/// The coin pile in copper; `0` = no coin left to loot.
gold: u32,
/// Whether the layout's position 1 is the coin pile — fixed at open (`gold > 0` then), and it
/// stays `true` after the coin is looted: the slot becomes a gap, not a vacancy the items
/// below shift into.
coin_slot: bool,
/// The item rows (wire order); each carries its own **wire** loot slot (`LootItem::slot`).
/// Never shrinks while the window is open — a looted row's wire slot lands in `taken` instead.
items: Vec<LootItem>,
/// Wire slots already looted by anyone (`SMSG_LOOT_REMOVED`) — their rows stay in the layout
/// as empty gaps.
taken: Vec<u8>,
/// Deferred "You receive …" lines awaiting their item name.
receives: Vec<PendingReceive>,
/// A wire removal just emptied the open window (last item taken / coin line cleared with no
@@ -170,30 +187,39 @@ impl LootState {
pub(crate) fn open(&mut self, source: u64, loot_type: u8, gold: u32, items: Vec<LootItem>) {
self.source = Some(source);
self.gold = gold;
self.coin_slot = gold > 0; // the layout is fixed here, for the window's lifetime
self.items = items;
self.taken.clear();
self.auto_release = false; // empty-at-open stays open — only a removal auto-closes
self.fishing = loot_type == benilla_protocol::messages::loot_type::FISHING;
}
/// A row was taken by anyone (`SMSG_LOOT_REMOVED`, keyed by the **wire** slot): drop it. The
/// remaining rows keep their own wire slots, so autostore stays correct even as display indices
/// shift. Emptying the window arms the auto-close ([`LootState::auto_release`]).
/// A row was taken by anyone (`SMSG_LOOT_REMOVED`, keyed by the **wire** slot): its position
/// becomes an empty gap — the layout never compacts (the reference hides that one button in
/// place, `LootFrame.lua:22-37`). Emptying the window arms the auto-close
/// ([`LootState::auto_release`]).
pub(crate) fn remove_slot(&mut self, wire_slot: u8) {
self.items.retain(|it| it.slot != wire_slot);
if self.items.iter().any(|it| it.slot == wire_slot) && !self.taken.contains(&wire_slot) {
self.taken.push(wire_slot);
}
self.arm_auto_release();
}
/// The coin line disappears for everyone (`SMSG_LOOT_CLEAR_MONEY`). Emptying the window arms
/// the auto-close ([`LootState::auto_release`]).
/// The coin line disappears for everyone (`SMSG_LOOT_CLEAR_MONEY`) — its slot stays in the
/// layout as a gap (`coin_slot` holds). Emptying the window arms the auto-close
/// ([`LootState::auto_release`]).
pub(crate) fn clear_money(&mut self) {
self.gold = 0;
self.arm_auto_release();
}
/// Arm the client-authoritative auto-close when a removal just left the open window with
/// nothing in it — no item rows and no coin line (see [`LootState::auto_release`]).
/// nothing lootable — every item taken and no coin left (see [`LootState::auto_release`]).
fn arm_auto_release(&mut self) {
if self.source.is_some() && self.items.is_empty() && !self.has_coin() {
if self.source.is_some()
&& !self.has_coin()
&& self.items.iter().all(|it| self.taken.contains(&it.slot))
{
self.auto_release = true;
}
}
@@ -227,7 +253,9 @@ impl LootState {
pub(crate) fn clear(&mut self) {
self.source = None;
self.gold = 0;
self.coin_slot = false;
self.items.clear();
self.taken.clear();
self.auto_release = false;
self.fishing = false;
}
@@ -260,21 +288,23 @@ impl LootState {
self.source
}
/// Resolve a clicked 1-based display row to its action: the coin pile (row 1 when gold > 0) or the
/// item at the corresponding **wire** loot slot.
/// Resolve a clicked 1-based display row to its action: the coin pile (position 1 when the
/// layout has a coin slot) or the item at the corresponding **wire** loot slot. Positions are
/// the FIXED open-time layout; a slot already looted (the coin gone, an item in `taken`)
/// answers `None` — a click on the gap does nothing, like the reference's hidden button.
fn action_at(&self, index_1based: u32) -> Option<LootAction> {
let index = index_1based.checked_sub(1)?; // 0-based display position
let as_item = |it: &LootItem| LootAction::Item {
let mut index = index_1based.checked_sub(1)? as usize; // 0-based layout position
if self.coin_slot {
if index == 0 {
return self.has_coin().then_some(LootAction::Money);
}
index -= 1;
}
let it = self.items.get(index)?;
(!self.taken.contains(&it.slot)).then_some(LootAction::Item {
wire_slot: it.slot,
display_id: it.display_info_id,
};
if self.has_coin() {
if index == 0 {
return Some(LootAction::Money);
}
return self.items.get((index - 1) as usize).map(as_item);
}
self.items.get(index as usize).map(as_item)
})
}
}
@@ -303,19 +333,23 @@ pub(crate) struct LootConfig {
/// **Whether we kneel at it is a separate question** — [`LootKneel`], the client's predicate B
/// `0x612710`. Arming the latch is not arming the pose; conflating the two is what 1471 got wrong.
///
/// **The arms** (the real client has five; we model the three our wire can produce):
/// **The arms** — the real client has five, and we model all five (1477 shipped four; the fifth is
/// decision 1531):
/// - the `CMSG_LOOT` send (`0x5df253`/`0x5df40d`) — a **corpse** or player bones, armed at the
/// click, so the kneel is client-predicted with no round-trip;
/// - **`SMSG_SPELL_GO`** for an `OPEN_LOCK` cast that lands on a **chest**
/// (`0x6e831b → SetLootTarget 0x5ed5f0`) — this, not the loot response, is a chest's real arm,
/// and it is why the reference is already kneeling by the time the window opens;
/// - the **`CMSG_OPEN_ITEM` send** (`0x5edcc0`, in emitter `0x5edc80`) — a clam, lockbox or loot
/// bag in the bags, latched on the **item's own guid**
/// ([`crate::ui_items::drain::drain_container_uses`]'s open arm). It changes no pose — predicate
/// B refuses an ITEM — but it is exactly what makes the response's gate admit the answer:
/// vmangos replies `SendLoot(item guid, LOOT_CORPSE)`, i.e. wire type **1**, which a cold latch
/// refuses. 1477 read this arm as pose-only and left it out; the window stopped opening;
/// - `CGPlayer_C::OnLootResponse 0x5eb900`, through its **admission gate** — see
/// [`crate::net::apply::loot::loot_response`]. Not unconditional: a `loot_type == 1` response
/// against a cold latch is *refused and bounced*.
///
/// Not modelled: the `CMSG_OPEN_ITEM` arm (`0x5edcc0`, a lockbox). It would change no pose —
/// predicate B refuses an ITEM — only the cursor and the re-loot lock-out; named in 1477 as a gap.
///
/// Cleared on release/close (`0x48f2c9`/`0x5ec0d4`). Ours clears **guid-matched** (release
/// response / refusal / our own release sends) — the safe generalization of the client's clears
/// under our corpse-switch race, where the *old* window's release response lands after the *new*
@@ -494,20 +528,27 @@ fn resolve_item(
items: &mut Items,
icons: Option<&ItemDisplays>,
commands: &NetCommands,
rolls: RollCatalogs,
) -> LootRow {
let (name, quality, link) = match items.template(item.item_id, 0, commands) {
Some(t) => (
Some(t.name.clone()),
Some(t.quality),
Some(crate::ui_items::item_link_full(
item.item_id,
0,
item.random_property_id,
0,
&t.name,
t.quality,
)),
),
Some(t) => {
// The rolled name IS the name here — the reference composes every display of an item's
// name through `0x5d8b00`, link text included, so the row, the tooltip plate and the
// shift-click link all read "Chipped Claw of the Bear" off this one string.
let name = rolls.name(&t.name, item.random_property_id);
(
Some(name.clone()),
Some(t.quality),
Some(crate::ui_items::item_link_full(
item.item_id,
0,
item.random_property_id,
0,
&name,
t.quality,
)),
)
}
None => (None, None, None),
};
let texture = icons
@@ -521,21 +562,28 @@ fn resolve_item(
is_coin: false,
item_id: item.item_id,
link,
// The roll rides as the raw id, exactly as the client's own loot record keeps it: the
// tooltip resolves it against the pushed roll table (§E5). Decision 1547.
random_property_id: item.random_property_id,
}
}
/// Build the Lua-facing snapshot from [`LootState`] — `None` when no loot is open. The coin pile is
/// prepended (row 1) when the loot carries gold.
/// Build the Lua-facing snapshot from [`LootState`] — `None` when no loot is open. One entry per
/// slot of the **fixed** open-time layout: the coin pile first when the loot opened with gold, then
/// the items in wire order. A slot already looted stays in the list as `None` — the gap the
/// reference's hidden button leaves (`LOOT_SLOT_CLEARED` hides in place; the rows below never
/// shift up).
fn snapshot(
loot: &LootState,
items: &mut Items,
icons: Option<&ItemDisplays>,
commands: &NetCommands,
rolls: RollCatalogs,
) -> Option<LootSnapshot> {
loot.source?;
let mut rows = Vec::with_capacity(loot.items.len() + 1);
if loot.has_coin() {
rows.push(LootRow {
if loot.coin_slot {
rows.push(loot.has_coin().then(|| LootRow {
name: Some(format_money(loot.gold)),
texture: Some(coin_icon(loot.gold).into()),
quantity: 1,
@@ -545,10 +593,14 @@ fn snapshot(
// No link: the coin pile is a synthesized row with no item behind it, so a modified
// click on it finds nil and does nothing (decision 1059).
link: None,
});
random_property_id: 0,
}));
}
for it in &loot.items {
rows.push(resolve_item(it, items, icons, commands));
rows.push(
(!loot.taken.contains(&it.slot))
.then(|| resolve_item(it, items, icons, commands, rolls)),
);
}
Some(LootSnapshot {
rows,
@@ -611,6 +663,7 @@ fn drain_receives(
commands: &NetCommands,
chat: &mut crate::ui_chat::ChatLog,
script: &mut UiScript,
rolls: RollCatalogs,
) {
let pending = std::mem::take(&mut loot.receives);
let mut still = Vec::new();
@@ -644,7 +697,7 @@ fn drain_receives(
if r.in_chat {
chat.push_event(crate::ui_chat::ChatEvent::text_only(
crate::ui_chat::ChatEventKind::Loot,
receive_line(&r, &name, quality),
receive_line(&r, &rolls.name(&name, r.random_property_id), quality),
));
}
}
@@ -675,11 +728,20 @@ fn feed_loot(
cfg: Res<LootConfig>,
keys: Res<ButtonInput<KeyCode>>,
mut pickup: MessageWriter<crate::sound::LootPickupSound>,
// The random-suffix roll's two catalogs (decision 1547) — the drop's "of the Monkey" name and
// the enchant slots 2..6 its tooltip shows. A loot slot carries no item object, so this is the
// only source either can come from.
props: Option<Res<crate::items::RandomProperties>>,
enchants: Option<Res<crate::items::Enchants>>,
) {
let Some(mut script) = script else {
return;
};
let last = last.get(&script);
let rolls = RollCatalogs {
props: props.as_deref(),
enchants: enchants.as_deref(),
};
// Loot refusals + "You receive …" lines migrate to the chat window (decision 0084's chat arc):
// refusals as informational SYSTEM-yellow lines, receive lines as LOOT-green. The ErrorsFrame
// keeps only the cast/equip red toasts.
@@ -696,9 +758,10 @@ fn feed_loot(
&commands,
&mut chat,
&mut script,
rolls,
);
let fresh = snapshot(&loot, &mut items, icons.as_deref(), &commands);
let fresh = snapshot(&loot, &mut items, icons.as_deref(), &commands, rolls);
if fresh == *last {
return;
}
@@ -976,8 +1039,11 @@ mod tests {
assert!(loot.action_at(3).is_none());
}
/// A looted row becomes a GAP at its fixed position — the layout never compacts while the
/// window is open (the reference hides that one button in place, `LootFrame.lua:22-37`; the
/// director's report: on ref, looting the gold does NOT pull the items up).
#[test]
fn remove_slot_keeps_the_remaining_wire_slots() {
fn remove_slot_leaves_a_gap_at_the_fixed_position() {
let mut loot = LootState::default();
loot.open(
0x42,
@@ -985,30 +1051,32 @@ mod tests {
0,
vec![item(0, 117, 1), item(1, 2589, 5), item(2, 4306, 2)],
);
loot.remove_slot(1); // take the middle wire slot
assert_eq!(loot.items.len(), 2);
// The display shifts (row 2 is now wire slot 2), but the wire slots themselves are intact.
// Take the middle wire slot: row 2 is now a dead gap; rows 1 and 3 keep their positions
// AND their wire slots.
loot.remove_slot(1);
assert!(matches!(
loot.action_at(1),
Some(LootAction::Item { wire_slot: 0, .. })
));
assert!(loot.action_at(2).is_none(), "the looted row is a gap");
assert!(matches!(
loot.action_at(2),
loot.action_at(3),
Some(LootAction::Item { wire_slot: 2, .. })
));
}
#[test]
fn clear_money_drops_the_coin_row() {
fn clear_money_leaves_the_coin_slot_as_a_gap() {
let mut loot = LootState::default();
loot.open(0x42, loot_type::CORPSE, 500, vec![item(0, 117, 1)]);
assert!(loot.has_coin());
assert!(matches!(loot.action_at(1), Some(LootAction::Money)));
loot.clear_money();
assert!(!loot.has_coin());
// The coin row is gone; row 1 is now the item.
// The coin slot stays in the layout as a gap; the item does NOT shift up into it.
assert!(loot.action_at(1).is_none(), "the looted coin slot is a gap");
assert!(matches!(
loot.action_at(1),
loot.action_at(2),
Some(LootAction::Item { wire_slot: 0, .. })
));
}
@@ -1145,6 +1213,57 @@ mod tests {
);
}
/// The **rolled name** — the reference composes every display of an item's name through
/// `0x5d8b00(entry, randomPropertyId)`, which joins `ItemRandomProperties`' suffix with
/// `ITEM_SUFFIX_TEMPLATE` ("%s %s"). Byte-verified for the loot row itself (`GetLootSlotInfo`'s
/// `item` producer `0x4c2550` ends in that call, wow-re `loot-slot-record.md` §3), and the
/// tooltip's own title line makes the same call — so row text, tooltip plate and link agree by
/// construction. Decision 1547.
#[test]
fn a_rolled_drop_reads_its_suffix_in_the_row_the_link_and_the_lines() {
use benilla_formats::{RandomProperty, RandomPropertyCatalog};
// "of the Monkey" (row 584 of the shipped table) — Agility +7, Stamina +7.
let props = crate::items::RandomProperties(RandomPropertyCatalog::from_rows(
[(
584,
RandomProperty {
suffix: "of the Monkey".into(),
enchants: [74, 71, 0, 0, 0],
},
)]
.into_iter()
.collect(),
));
let enchants = crate::items::Enchants(benilla_formats::EnchantCatalog::from_rows(
std::collections::HashMap::new(),
[
(74, "Agility +7".to_string()),
(71, "Stamina +7".to_string()),
]
.into_iter()
.collect(),
[(74, 0), (71, 0)].into_iter().collect(),
));
let rolls = RollCatalogs {
props: Some(&props),
enchants: Some(&enchants),
};
assert_eq!(rolls.name("Bloodrazor", 584), "Bloodrazor of the Monkey");
// An unrolled drop keeps the plain name — the formatter's other exit.
assert_eq!(rolls.name("Bloodrazor", 0), "Bloodrazor");
// The roll's enchant lines land in slots 2..6 (the suffix band), which is what makes them
// white at the renderer.
let lines = rolls.lines(584);
assert_eq!(
lines
.iter()
.map(|l| (l.slot, l.name.as_str()))
.collect::<Vec<_>>(),
vec![(2, "Agility +7"), (3, "Stamina +7")],
);
assert!(rolls.lines(0).is_empty(), "no roll, no lines");
}
/// The client clamps a quality outside the table to index 1 (white) — `0x52ad90`'s
/// `cmpl $0x7 / jb` arm.
#[test]
@@ -1206,11 +1325,12 @@ mod tests {
let mut loot = LootState::default();
// A pure-copper drop: name reads "4 Copper", icon is the copper coin pile (_05).
loot.open(0x42, loot_type::CORPSE, 4, vec![]);
let snap = snapshot(&loot, &mut items, None, &commands).expect("open");
let snap = snapshot(&loot, &mut items, None, &commands, RollCatalogs::NONE).expect("open");
assert_eq!(snap.rows.len(), 1, "coin row only");
assert!(snap.rows[0].is_coin);
assert_eq!(snap.rows[0].name.as_deref(), Some("4 Copper"));
assert_eq!(snap.rows[0].texture.as_deref(), Some(COIN_ICON_COPPER));
let coin = snap.rows[0].as_ref().expect("coin row present");
assert!(coin.is_coin);
assert_eq!(coin.name.as_deref(), Some("4 Copper"));
assert_eq!(coin.texture.as_deref(), Some(COIN_ICON_COPPER));
}
#[test]
@@ -1220,20 +1340,35 @@ mod tests {
let commands = NetCommands(tx);
let mut loot = LootState::default();
// Closed → no snapshot.
assert!(snapshot(&loot, &mut items, None, &commands).is_none());
assert!(snapshot(&loot, &mut items, None, &commands, RollCatalogs::NONE).is_none());
loot.open(0x42, loot_type::CORPSE, 12_345, vec![item(0, 117, 3)]);
let snap = snapshot(&loot, &mut items, None, &commands).expect("open");
let snap = snapshot(&loot, &mut items, None, &commands, RollCatalogs::NONE).expect("open");
assert_eq!(snap.rows.len(), 2, "coin + one item");
assert!(snap.rows[0].is_coin);
assert_eq!(
snap.rows[0].name.as_deref(),
Some("1 Gold 23 Silver 45 Copper")
);
assert_eq!(snap.rows[0].texture.as_deref(), Some(COIN_ICON_GOLD));
let coin = snap.rows[0].as_ref().expect("coin row present");
assert!(coin.is_coin);
assert_eq!(coin.name.as_deref(), Some("1 Gold 23 Silver 45 Copper"));
assert_eq!(coin.texture.as_deref(), Some(COIN_ICON_GOLD));
// The item's name is nil while its template is in flight; its quantity is present.
assert!(!snap.rows[1].is_coin);
assert!(snap.rows[1].name.is_none());
assert_eq!(snap.rows[1].quantity, 3);
let row = snap.rows[1].as_ref().expect("item row present");
assert!(!row.is_coin);
assert!(row.name.is_none());
assert_eq!(row.quantity, 3);
// Looting the coin turns row 1 into a gap — the item KEEPS its position (the reference's
// fixed slot array; the director's report was exactly this row sliding up).
loot.clear_money();
let snap =
snapshot(&loot, &mut items, None, &commands, RollCatalogs::NONE).expect("still open");
assert_eq!(snap.rows.len(), 2, "the layout keeps both slots");
assert!(snap.rows[0].is_none(), "the looted coin slot is a gap");
assert!(snap.rows[1].is_some(), "the item stays at position 2");
// Looting the item empties the layout entirely (both gaps) — and arms the auto-close.
loot.remove_slot(0);
let snap =
snapshot(&loot, &mut items, None, &commands, RollCatalogs::NONE).expect("still open");
assert_eq!(snap.rows, vec![None, None]);
assert!(loot.take_auto_release(), "nothing lootable left");
}
#[test]
+50 -11
View File
@@ -64,7 +64,6 @@ use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands, SelfGuid};
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
use crate::ui_items::item_link;
use crate::ui_script::UiInput;
/// Give up re-checking a pending announcement's names after this many frames — the same budget and
@@ -84,6 +83,11 @@ struct ActiveRoll {
looted_target: u64,
item_slot: u32,
item_id: u32,
/// `SMSG_LOOT_START_ROLL`'s `randomPropertyId` — the drop's random-suffix roll (decision
/// 1547). The reference's own `SetLootRollItem 0x5364a0` copies exactly this into the
/// tooltip's `+0x424` and passes no item object, so — like a loot slot — the roll is the only
/// enchant source the roll window's hover can have.
random_property_id: u32,
/// Milliseconds left; ticked down by [`feed_loot_rolls`], saturating at `0`. The roll is *not*
/// dropped at zero — the server closes it with `SMSG_LOOT_ROLL_WON`/`SMSG_LOOT_ALL_PASSED` when
/// its own timer fires, and the frame stays up (bar empty) until that lands.
@@ -149,6 +153,7 @@ impl LootRolls {
looted_target: p.looted_target,
item_slot: p.item_slot,
item_id: p.item_id,
random_property_id: p.random_property_id,
remaining_ms: p.countdown_ms,
});
self.opened.push((roll_id, p.countdown_ms));
@@ -313,15 +318,25 @@ fn render(
items: &mut Items,
names: &mut NameCache,
commands: &NetCommands,
rolls: crate::items::RollCatalogs,
) -> Option<String> {
// Every line embeds the item link, so the template must be in hand before any of them render.
let (looted_item, roller) = match line {
RollLine::Announce(p) => (p.item_id, Some(p.roller)),
RollLine::Won(p) => (p.item_id, Some(p.winner)),
RollLine::AllPassed(p) => (p.item_id, None),
let (looted_item, roll, roller) = match line {
RollLine::Announce(p) => (p.item_id, p.random_property_id, Some(p.roller)),
RollLine::Won(p) => (p.item_id, p.random_property_id, Some(p.winner)),
RollLine::AllPassed(p) => (p.item_id, p.random_property_id, None),
};
let t = items.template(looted_item, 0, commands)?;
let link = item_link(looted_item, &t.name.clone(), t.quality);
// The link's name is the ROLLED one (1547) — every announcement names the same drop the roll
// window does, so "[Bloodrazor of the Monkey] won by …" has to agree with the frame.
let link = crate::ui_items::item_link_full(
looted_item,
0,
roll,
0,
&rolls.name(&t.name.clone(), roll),
t.quality,
);
let is_self = roller.is_some() && roller == self_guid;
@@ -347,6 +362,7 @@ fn render(
/// Surface the queued announcement lines in the chat window once their names resolve, colored
/// `LOOT` green (the roll lines ride `CHAT_MSG_LOOT` in the real client, like the receive lines).
/// Unresolved lines retry up to [`LINE_MAX_TRIES`] frames, then drop.
#[allow(clippy::too_many_arguments)] // the line resolve's full read set
fn drain_lines(
rolls: &mut LootRolls,
self_guid: Option<u64>,
@@ -354,11 +370,12 @@ fn drain_lines(
names: &mut NameCache,
commands: &NetCommands,
chat: &mut ChatLog,
catalogs: crate::items::RollCatalogs,
) {
let pending = std::mem::take(&mut rolls.pending);
let mut still = Vec::new();
for mut p in pending {
match render(&p.line, self_guid, items, names, commands) {
match render(&p.line, self_guid, items, names, commands, catalogs) {
Some(text) => chat.push_event(ChatEvent::text_only(ChatEventKind::Loot, text)),
None => {
p.tries += 1;
@@ -379,6 +396,7 @@ fn snapshot(
items: &mut Items,
icons: Option<&ItemDisplays>,
commands: &NetCommands,
catalogs: crate::items::RollCatalogs,
) -> LootRollsState {
let entries = rolls
.active
@@ -390,7 +408,7 @@ fn snapshot(
.and_then(|d| d.icon.clone());
LootRollEntry {
roll_id: r.roll_id,
name: t.map(|t| t.name.clone()),
name: t.map(|t| catalogs.name(&t.name, r.random_property_id)),
texture,
// The roll is always for the whole stack the drop rolled; the wire carries no
// count on SMSG_LOOT_START_ROLL, so the frame shows the single-item case (the
@@ -401,11 +419,23 @@ fn snapshot(
bind_on_pickup: t.is_some_and(|t| t.bonding == BIND_WHEN_PICKED_UP),
time_left_ms: r.remaining_ms,
item_id: r.item_id,
// The roll the hover resolves its enchant lines from — `SetLootRollItem`'s own
// `+0x424` (1547).
random_property_id: r.random_property_id,
// The icon button's ctrl/shift arms read this (`GetLootRollItemLink`, decision
// 1059). Same builder and same arguments as the announcement lines' own link a few
// functions up ([`render`]) — one `item_link` call site per resolved roll, `None`
// until the template answers, exactly like `name`/`quality` beside it.
link: t.map(|t| item_link(r.item_id, &t.name, t.quality)),
link: t.map(|t| {
crate::ui_items::item_link_full(
r.item_id,
0,
r.random_property_id,
0,
&catalogs.name(&t.name, r.random_property_id),
t.quality,
)
}),
}
})
.collect();
@@ -449,6 +479,9 @@ fn feed_loot_rolls(
mut chat: ResMut<ChatLog>,
time: Res<Time>,
mut last: Local<crate::ui_script::VmMemo<LootRollsState>>,
// The random-suffix roll's catalogs (1547): the rolled name the frame and its chat lines show.
props: Option<Res<crate::items::RandomProperties>>,
enchants: Option<Res<crate::items::Enchants>>,
) {
rolls.tick(time.delta().as_millis() as u32);
@@ -456,6 +489,10 @@ fn feed_loot_rolls(
return;
};
let last = last.get(&script);
let catalogs = crate::items::RollCatalogs {
props: props.as_deref(),
enchants: enchants.as_deref(),
};
drain_lines(
&mut rolls,
self_guid.0,
@@ -463,13 +500,14 @@ fn feed_loot_rolls(
&mut names,
&commands,
&mut chat,
catalogs,
);
// The snapshot goes in FIRST — a GroupLootFrame claimed by the START_LOOT_ROLL below reads its
// item out of the model in its OnShow, and the roll it is about was added to `active` in the
// same `start()` call that queued `opened`, so pushing after would hand every fresh roll an
// empty lookup. Same order as ui_loot's window feed, for the same reason.
let fresh = snapshot(&rolls, &mut items, icons.as_deref(), &commands);
let fresh = snapshot(&rolls, &mut items, icons.as_deref(), &commands, catalogs);
let changed = repainted(last, &fresh);
if fresh != *last {
script.set_loot_rolls(fresh.clone());
@@ -839,7 +877,8 @@ mod tests {
time_left_ms,
item_id: 17182,
// Lands with the name — one template answer fills both (decision 1059).
link: name.map(|n| item_link(17182, n, 4)),
link: name.map(|n| crate::ui_items::item_link(17182, n, 4)),
random_property_id: 0,
}
}
+193
View File
@@ -0,0 +1,193 @@
//! The auction house's mail seam — how a mail from the auction house stops being
//! `4428:0:1` / `6C:10000:10000` and becomes "Auction won: Small Blue Pouch" over a receipt.
//!
//! **The invoice is TEXT.** Nothing structured rides the wire: the auction house writes its numbers
//! into the mail's *subject* and *body* as colon-separated fields, and the client parses them back
//! out. That is not an emulator shortcut — it is what the real 1.12 client does, byte-verified
//! (wow-re `system/ui/scratch/auction-house.md` §11.1a: `0x4ace70` for the subject,
//! `GetInboxInvoiceInfo 0x4af360` for the body).
//!
//! Two halves, and they answer different questions from different places:
//!
//! * **The subject says WHAT HAPPENED.** `<itemEntry>:<randomProperty>:<resultCode>` — five outcomes
//! over six codes. The client parses it, formats the matching `AUCTION_*_MAIL_SUBJECT`
//! GlobalString with the item's name, and writes the result back over the *displayed* subject, so
//! the inbox list and the open letter both read it. The raw triplet is never shown.
//! * **The body says HOW MUCH**, and only for the two outcomes that move money: won (`1`) and sold
//! (`2`). The other four carry no body at all, which is why an outbid notice has no invoice and
//! why [`parse_body`] is only ever asked about those two.
//!
//! The identity comes from the subject and the numbers come from the body — so a mail can be
//! recognisably "Auction won: X" in the inbox list while its invoice is still unanswerable, because
//! the body is fetched lazily (`CMSG_ITEM_TEXT_QUERY`) when the letter is opened.
/// The mail's `resultCode` — the third subject field. vmangos writes these as `MailAuctionAnswers`;
/// the client bounds the field to `0..=5` and jumps a six-entry table (`0x4acf62`/`0x4acfec`).
pub(crate) mod auction_mail {
pub(crate) const OUTBID: u32 = 0;
pub(crate) const WON: u32 = 1;
pub(crate) const SOLD: u32 = 2;
pub(crate) const EXPIRED: u32 = 3;
pub(crate) const CANCELLED_TO_BIDDER: u32 = 4;
pub(crate) const CANCELLED: u32 = 5;
}
/// A parsed auction-mail subject.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct AuctionSubject {
pub(crate) entry: u32,
/// The item's random-property roll, for the suffixed name ("of the Bear"). Carried because the
/// reference composes the displayed name from **both** (`[rec+0x250]` and `[rec+0x254]`);
/// benilla's item names are unsuffixed today, so nothing reads it yet.
#[allow(dead_code)]
pub(crate) random_property: i32,
pub(crate) result: u32,
}
/// Parse `"<itemEntry>:<randomProperty>:<resultCode>"`.
///
/// Strict on purpose: a mail whose subject is a player's own free text must NOT be mistaken for an
/// auction notice just because it happens to contain colons, so every field has to parse and the
/// result code has to be in range. The type byte already gated us to `MAIL_AUCTION`, but the
/// reference applies this second test on top of it and so do we — the subject is the only thing
/// that distinguishes the five outcomes, and an unparseable one has no outcome at all.
pub(crate) fn parse_subject(subject: &str) -> Option<AuctionSubject> {
let mut parts = subject.trim().split(':');
let entry: u32 = parts.next()?.trim().parse().ok()?;
let random_property: i32 = parts.next()?.trim().parse().ok()?;
let result: u32 = parts.next()?.trim().parse().ok()?;
if parts.next().is_some() || result > auction_mail::CANCELLED {
return None;
}
Some(AuctionSubject {
entry,
random_property,
result,
})
}
/// The `AUCTION_*_MAIL_SUBJECT` GlobalStrings key one result code displays under — the reference's
/// six-entry dispatch table at `0x4acfec`, where the two cancel codes share a key. Every one of
/// these strings is `"<something>: %s"` with the item name as the fill, and all five ship in the
/// player's own GlobalStrings.lua (l.83-99), so nothing here carries Blizzard's text.
pub(crate) fn subject_key(result: u32) -> Option<&'static str> {
Some(match result {
auction_mail::OUTBID => "AUCTION_OUTBID_MAIL_SUBJECT",
auction_mail::WON => "AUCTION_WON_MAIL_SUBJECT",
auction_mail::SOLD => "AUCTION_SOLD_MAIL_SUBJECT",
auction_mail::EXPIRED => "AUCTION_EXPIRED_MAIL_SUBJECT",
auction_mail::CANCELLED_TO_BIDDER | auction_mail::CANCELLED => {
"AUCTION_REMOVED_MAIL_SUBJECT"
}
_ => return None,
})
}
/// The numbers an invoice body carries.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct InvoiceNumbers {
/// The counterparty's guid — who bought your auction, or who sold you the one you won. Written
/// as **hex**, right-aligned in a 16-wide field, so it can arrive with leading spaces.
pub(crate) player_guid: u64,
pub(crate) bid: u32,
pub(crate) buyout: u32,
/// Seller bodies only.
pub(crate) deposit: u32,
/// Seller bodies only — the auction house's cut.
pub(crate) consignment: u32,
}
/// Parse an invoice body: five fields for a `seller` invoice, three for a buyer's.
///
/// The reference picks the `sscanf` format off the invoice type rather than counting fields
/// (`"%16I64X:%d:%d:%d:%d"` vs `"%16I64X:%d:%d"`), which is why the count is checked here and not
/// inferred: a seller body that arrived three-field would leave the reference reading two
/// uninitialised numbers, and we would rather answer "no invoice" than a deposit of whatever was on
/// the stack.
pub(crate) fn parse_body(body: &str, seller: bool) -> Option<InvoiceNumbers> {
let fields: Vec<&str> = body.trim().split(':').map(str::trim).collect();
if fields.len() != if seller { 5 } else { 3 } {
return None;
}
let player_guid = u64::from_str_radix(fields[0], 16).ok()?;
let bid = fields[1].parse().ok()?;
let buyout = fields[2].parse().ok()?;
let (deposit, consignment) = if seller {
(fields[3].parse().ok()?, fields[4].parse().ok()?)
} else {
(0, 0)
};
Some(InvoiceNumbers {
player_guid,
bid,
buyout,
deposit,
consignment,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// The two subjects the director photographed, byte for byte off the live server.
#[test]
fn the_subject_triplet_names_the_outcome() {
let won = parse_subject("4428:0:1").expect("won");
assert_eq!((won.entry, won.result), (4428, auction_mail::WON));
assert_eq!(subject_key(won.result), Some("AUCTION_WON_MAIL_SUBJECT"));
let sold = parse_subject("5529:0:2").expect("sold");
assert_eq!((sold.entry, sold.result), (5529, auction_mail::SOLD));
assert_eq!(subject_key(sold.result), Some("AUCTION_SOLD_MAIL_SUBJECT"));
// Both cancel codes land on one key (the reference's own table shares the entry).
assert_eq!(
subject_key(auction_mail::CANCELLED_TO_BIDDER),
subject_key(auction_mail::CANCELLED)
);
}
/// A player's own subject line must never be mistaken for an auction notice — the type byte
/// gates us here, but a hand-typed "3:4:5" would otherwise sail straight through.
#[test]
fn a_subject_that_is_not_the_triplet_is_not_an_auction_notice() {
assert_eq!(parse_subject("Hi there"), None);
assert_eq!(parse_subject("4428:0"), None, "two fields");
assert_eq!(parse_subject("4428:0:1:9"), None, "four fields");
assert_eq!(parse_subject("4428:0:6"), None, "result code out of range");
assert_eq!(parse_subject(""), None);
}
/// The two bodies the director photographed. The seller's five fields carry the arithmetic the
/// receipt shows; the buyer's three do not, and asking for five is what keeps them apart.
#[test]
fn the_body_carries_hex_guid_then_the_money() {
let sold = parse_body("6C:10000:10000:25:500", true).expect("seller body");
assert_eq!(sold.player_guid, 0x6C, "hex, not decimal");
assert_eq!((sold.bid, sold.buyout), (10000, 10000));
assert_eq!((sold.deposit, sold.consignment), (25, 500));
// bid == buyout is how the window knows it was bought out rather than bid up.
assert_eq!(sold.bid, sold.buyout);
let won = parse_body("6C:10000:10000", false).expect("buyer body");
assert_eq!(won.player_guid, 0x6C);
assert_eq!(
(won.deposit, won.consignment),
(0, 0),
"not in a buyer body"
);
// The right shape, asked the wrong way round, answers nothing rather than guessing.
assert_eq!(parse_body("6C:10000:10000", true), None);
assert_eq!(parse_body("6C:10000:10000:25:500", false), None);
}
/// `strm.width(16) << right << hex` pads with spaces, so the guid can arrive with a run of them
/// in front. The reference's `sscanf` skips leading whitespace; so do we.
#[test]
fn a_space_padded_guid_still_parses() {
let b = parse_body(" 6C:10000:10000", false).expect("padded");
assert_eq!(b.player_guid, 0x6C);
}
}
+124 -19
View File
@@ -257,6 +257,10 @@ pub(crate) fn mail_error_text(error: u32) -> String {
}
}
mod invoice;
use invoice::auction_mail;
/// Resolve one wire [`MailListEntry`] into the Lua-facing [`MailInboxRow`]: sender through the ask-
/// once name cache (player guids only), the enclosed item's name/quality/icon through the ask-once
/// item-template cache + `ItemDisplayInfo.dbc`, the body from the cache, the stationery basename
@@ -271,7 +275,12 @@ fn resolve_row(
stationery: Option<&Stationery>,
names: &mut NameCache,
commands: &NetCommands,
rolls: crate::items::RollCatalogs,
macros: &crate::npc_text::MacroContext,
// The VM's own GlobalStrings, for the auction notice's subject template. A resolver rather than
// a table because the strings are the player's install's, and Rust must never carry a copy of
// them (decision 0669's shape).
get_text: &dyn Fn(&str) -> Option<String>,
) -> MailInboxRow {
let is_gm = entry.stationery == STATIONERY_GM;
let returned = entry.checked & CHECKED_RETURNED != 0;
@@ -286,18 +295,27 @@ fn resolve_row(
returned || entry.sender_guid.is_none() || (entry.item.is_none() && entry.money == 0);
// The enclosed item, resolved ask-once from its template.
let (item_id, item_count, item_name, item_texture, item_quality) = match &entry.item {
let (item_id, item_roll, item_count, item_name, item_texture, item_quality) = match &entry.item
{
Some(att) => {
let template = items.template(att.entry, 0, commands);
let name = template.map(|t| t.name.clone());
// The rolled name (1547) — the same join the loot window and the auction rows make.
let name = template.map(|t| rolls.name(&t.name, att.random_prop_id));
let quality = template.map(|t| t.quality);
let display_id = template.map(|t| t.display_info_id).unwrap_or(0);
let texture = icons
.and_then(|i| i.catalog.get(display_id))
.and_then(|d| d.icon.clone());
(att.entry, u32::from(att.count), name, texture, quality)
(
att.entry,
att.random_prop_id,
u32::from(att.count),
name,
texture,
quality,
)
}
None => (0, 0, None, None, None),
None => (0, 0, 0, None, None, None),
};
let sender = entry
@@ -308,6 +326,55 @@ fn resolve_row(
.map(|s| s.0.texture(entry.stationery).to_string())
.unwrap_or_else(|| benilla_formats::StationeryCatalog::DEFAULT_TEXTURE.to_string());
// The letter body, raw. Hoisted because the auction invoice parses THIS and not the
// `$`-substituted text below: the invoice is machine-written colon fields, and running a macro
// expander over them would be expanding the auction house's own bookkeeping.
let raw_body = (entry.item_text_id != 0)
.then(|| bodies.get(&entry.item_text_id))
.flatten();
// ── The auction house's mail (`ui_mail::invoice`, wow-re §11.1a) ─────────────────────────
// Two independent resolves off one subject: the DISPLAYED subject, which every auction notice
// gets, and the INVOICE, which only a won/sold notice can answer and only once its body has
// been fetched.
let auction = (entry.message_type == mail_message_type::AUCTION)
.then(|| invoice::parse_subject(&entry.subject))
.flatten();
// The item the notice is ABOUT — resolved from the subject's own entry, not from the
// attachment: a "sold" notice encloses the money, and names an item it does not carry.
let notice_item_name = auction
.and_then(|a| items.template(a.entry, 0, commands))
.map(|t| t.name.clone());
// `<key>: %s` + the name. Until the item template lands the raw triplet stands — a subject that
// reads `4428:0:1` for one frame is better than one that reads `Auction won: ` forever if the
// query never answers, and the row re-resolves (and fires MAIL_INBOX_UPDATE) when it does.
let subject = match (auction, &notice_item_name) {
(Some(a), Some(name)) => invoice::subject_key(a.result)
.and_then(get_text)
.map(|t| t.replace("%s", name))
.unwrap_or_else(|| entry.subject.clone()),
_ => entry.subject.clone(),
};
let auction_invoice = auction
.filter(|a| matches!(a.result, auction_mail::WON | auction_mail::SOLD))
.and_then(|a| {
let seller = a.result == auction_mail::SOLD;
let n = invoice::parse_body(raw_body?, seller)?;
// An unresolved counterparty takes the miss tail this pass and fills in when the name
// query lands — the reference does exactly this, and fires MAIL_INBOX_UPDATE on the
// late arrival, which our snapshot diff does for free.
let player_name = names.resolve(n.player_guid, commands)?.to_string();
Some(benilla_ui::script::MailInvoice {
seller,
item_name: notice_item_name.clone()?,
player_name,
bid: n.bid,
buyout: n.buyout,
deposit: n.deposit,
consignment: n.consignment,
})
});
MailInboxRow {
package_icon: item_texture.clone(),
stationery_icon: Some(
@@ -319,7 +386,7 @@ fn resolve_row(
.to_string(),
),
sender,
subject: entry.subject.clone(),
subject,
money: entry.money,
cod: entry.cod,
days_left: entry.expire_days,
@@ -332,14 +399,19 @@ fn resolve_row(
// The letter body is server-authored text, so it runs the `$`-macro expander — the
// reference does it from two sites in `GetInboxText` (decision 0754). Subject is the
// local player, as it is for every panel seam.
body: (entry.item_text_id != 0)
.then(|| bodies.get(&entry.item_text_id).cloned())
.flatten()
.map(|b| crate::npc_text::substitute(&b, macros)),
body: raw_body.map(|b| crate::npc_text::substitute(b, macros)),
stationery_texture,
is_invoice: entry.message_type == mail_message_type::AUCTION,
// NOT "is an auction mail": the reference's `isInvoice` reads the fields its subject
// parser persisted, and it persists them only for won(1)/sold(2) — so an outbid or expiry
// notice answers false here (decision 1527). This is also what nils the body: the two
// answers come off the same record state, which is exactly why MailFrame can lay the
// invoice pane over the letter page.
is_invoice: auction
.is_some_and(|a| matches!(a.result, auction_mail::WON | auction_mail::SOLD)),
invoice: auction_invoice,
has_body: entry.item_text_id != 0,
item_id,
item_random_property_id: item_roll,
item_name,
item_texture,
item_quality,
@@ -356,7 +428,9 @@ fn snapshot(
stationery: Option<&Stationery>,
names: &mut NameCache,
commands: &NetCommands,
rolls: crate::items::RollCatalogs,
macros: &crate::npc_text::MacroContext,
get_text: &dyn Fn(&str) -> Option<String>,
) -> Option<MailState> {
mail.mailbox?;
Some(MailState {
@@ -372,7 +446,9 @@ fn snapshot(
stationery,
names,
commands,
rolls,
macros,
get_text,
)
})
.collect(),
@@ -399,10 +475,17 @@ fn feed_mail(
// The `$`-macro subject for the letter body: the local player, as at every panel seam.
self_q: Query<(&crate::net::ObjectStore, &crate::net::Guid), With<crate::net::SelfPlayer>>,
states: Res<crate::world_state::WorldStates>,
// The random-suffix roll's catalogs (1547): an enclosed item's rolled name.
props: Option<Res<crate::items::RandomProperties>>,
enchants: Option<Res<crate::items::Enchants>>,
) {
let Some(mut script) = script else {
return;
};
let rolls = crate::items::RollCatalogs {
props: props.as_deref(),
enchants: enchants.as_deref(),
};
let last = last.get(&script);
let last_mailbox = last_mailbox.get(&script);
let last_has_new_mail = last_has_new_mail.get(&script);
@@ -431,15 +514,21 @@ fn feed_mail(
subject: subject.as_ref(),
states: &states,
};
let fresh = snapshot(
&mail,
&mut items,
icons.as_deref(),
stationery.as_deref(),
&mut names,
&commands,
&macros,
);
let fresh = {
// Scoped: the resolver borrows the VM, and `set_mail` below needs it back.
let get_text = |key: &str| script.lua().globals().get::<String>(key).ok();
snapshot(
&mail,
&mut items,
icons.as_deref(),
stationery.as_deref(),
&mut names,
&commands,
rolls,
&macros,
&get_text,
)
};
let opened = last_mailbox.is_none() && mail.mailbox.is_some();
let closed = last_mailbox.is_some() && mail.mailbox.is_none();
let show_requested = std::mem::take(&mut mail.show_requested);
@@ -720,6 +809,12 @@ mod tests {
assert!(m.bodies.is_empty());
}
/// No GlobalStrings in a unit test — the auction-subject rewrite is exercised where the
/// strings are (`tests/mail_frame.rs`), and here every key simply misses.
fn no_strings(_key: &str) -> Option<String> {
None
}
#[test]
fn resolve_row_derives_the_reply_and_delete_law() {
let states = crate::world_state::WorldStates::default();
@@ -739,7 +834,9 @@ mod tests {
None,
&mut names,
&commands,
crate::items::RollCatalogs::NONE,
&no_macros(&states),
&no_strings,
);
assert!(row.can_reply);
assert!(row.can_delete);
@@ -757,7 +854,9 @@ mod tests {
None,
&mut names,
&commands,
crate::items::RollCatalogs::NONE,
&no_macros(&states),
&no_strings,
);
assert!(row.can_reply);
assert!(!row.can_delete);
@@ -772,7 +871,9 @@ mod tests {
None,
&mut names,
&commands,
crate::items::RollCatalogs::NONE,
&no_macros(&states),
&no_strings,
);
assert!(!row.can_reply);
assert!(row.can_delete);
@@ -787,7 +888,9 @@ mod tests {
None,
&mut names,
&commands,
crate::items::RollCatalogs::NONE,
&no_macros(&states),
&no_strings,
);
assert!(row.can_delete);
}
@@ -822,7 +925,9 @@ mod tests {
None,
&mut names,
&commands,
crate::items::RollCatalogs::NONE,
&no_macros(&states),
&no_strings,
);
assert_eq!(row.item_id, 2589);
assert_eq!(row.item_count, 5);
+1 -15
View File
@@ -28,7 +28,7 @@ use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer};
use crate::ui_action::{ui_error_text, UiError};
use crate::ui_action::{ui_error_text, MsgSurface, UiError};
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
use crate::ui_script::UiInput;
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
@@ -174,20 +174,6 @@ impl QuestGiver {
}
}
/// Where a client message is shown — the `kind` field (`+0x04`) of the reference's message record
/// (`0xb4b498 + 20*msgId`), which `CGGameUI::DisplayError` (`0x496720`) dispatches on through the
/// four-way jump at `0x496888`: **0 → the chat window** (`0x49a870`), 1 → `AddErrorMessage(text, 0)`
/// (the yellow info line), **2 → `AddErrorMessage(text, 1)`** (the red error line), 3 → the console.
/// Only the two the quest messages use are modeled (decision 0669); the info line has its own
/// established route (`UI_INFO_MESSAGE`, decision 0340).
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum MsgSurface {
/// kind 0 — a chat-window system line.
Chat,
/// kind 2 — the red `UIErrorsFrame` line.
Error,
}
/// `SMSG_QUESTGIVER_QUEST_INVALID`'s `QuestFailedReason` → its GlobalStrings key, resolved to text
/// through the VM's own `GlobalStrings.lua` at the feed. The FULL build-5875 table, read off the
/// handler `0x5dbca0` (the `0x18f` arm of the quest demux `0x5e5910`): `lea eax,[reason-1]` /
@@ -1222,6 +1222,15 @@ fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() {
.unwrap(),
"all six pages are viewable at rest — every extra bar ships off (1500)"
);
// The NUMERAL is the only output of paging the player can actually see on the bar itself, and
// it went unwritten for as long as paging existed: the arrows worked, the twelve buttons
// repainted, and the "1" beside them was a declared literal nothing ever touched — so clicking
// them read as doing nothing at all (director, 2026-08-22). It seeds at the page's own value.
let page_text = |s: &UiScript| {
s.eval::<String>("return MainMenuBarPageNumber:GetText()")
.unwrap()
};
assert_eq!(page_text(&s), "1", "the load seed is the current page");
// Raise the two bottom bars the way the client does, which is what takes pages 6 and 5 out of
// the cycle from here on.
@@ -1231,6 +1240,7 @@ fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() {
// Page up walks to 2, so button 1 shows action 13.
s.run("ActionBar_PageUp()").unwrap();
assert_eq!(s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(), 2);
assert_eq!(page_text(&s), "2", "the numeral follows the page up");
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
@@ -1257,6 +1267,11 @@ fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() {
.unwrap(),
37
);
assert_eq!(
page_text(&s),
"4",
"the numeral follows a wrap, not just a step"
);
// The pages the bottom bars already display are unreachable from the main bar — which is
// exactly what MultiActionBar_Update did above. Without it, paging up lands on a duplicate of
// the twelve actions already on screen below.
@@ -1291,3 +1306,292 @@ fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() {
"and the page comes back when the form drops"
);
}
/// The form/stance/stealth swap transition (decision 1524; ref BonusActionBarFrame.lua:1-98).
/// Entering a form slides the BonusActionBarFrame replica up over 0.15s — the main bar keeps
/// painting the OLD page underneath until the landing, which is also the one moment the sound
/// (igBonusBarOpen) plays. The overlay then STAYS shown while the form holds (the ref-visible
/// state addons read), a direct form→form swap repaints without re-sliding or re-sounding, and
/// dropping the form slides it back down carrying the old form's page (lastBonusBar), silently.
/// Keys route to the overlay's buttons from the FIRST slide frame — the swap's feel half: a key
/// pressed the instant you enter stealth already drives the stealth page.
#[test]
fn bonus_bar_slides_up_with_sound_and_down_without() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in ["Cooldown.xml", "ActionBar.xml"] {
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}: {:?}", report.errors);
}
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.tick(10.0); // a nonzero clock epoch
let _ = s.take_sounds();
assert!(
!s.eval::<bool>("return BonusActionBarFrame:IsShown()")
.unwrap(),
"no form, no overlay"
);
// ── Enter cat form: offset 0→1, the app's edge (ui_action/feed.rs) fires the event ─────────
s.set_bonus_bar_offset(1);
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BonusActionBarFrame:IsShown()")
.unwrap());
assert_eq!(
s.eval::<String>("return BonusActionBarFrame.mode").unwrap(),
"show"
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
1,
"the main bar holds the OLD page under the rising overlay"
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(BonusActionButton1)")
.unwrap(),
73,
"the overlay paints the bonus page from the first slide frame"
);
assert!(s.take_sounds().is_empty(), "no sound until the bar lands");
// Keys route to the overlay immediately (ref ActionButton.lua:15-45's IsShown fork).
s.run("ActionButtonDown(1) ActionButtonUp(1)").unwrap();
assert_eq!(
s.take_action_uses(),
vec![73],
"a key pressed mid-slide already drives the bonus page"
);
// Half the slide: the replica is half-risen (top = 0.5 * 43 over the bar's bottom edge at
// y=0), the paint suppression still holds, still silent.
s.tick(0.075);
let top = s
.eval::<f64>("return BonusActionBarFrame:GetTop()")
.unwrap();
assert!(
(top - 21.5).abs() < 0.6,
"half-slide top = {top}, want ~21.5"
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
1
);
assert!(s.take_sounds().is_empty());
// The landing edge: snap to 43, THE sound, and the main bar adopts the bonus page.
s.tick(0.08);
assert_eq!(
s.take_sounds(),
vec![benilla_ui::script::SoundRequest::KitName(
"igBonusBarOpen".into()
)]
);
let top = s
.eval::<f64>("return BonusActionBarFrame:GetTop()")
.unwrap();
assert!((top - 43.0).abs() < 0.01, "landed top = {top}, want 43");
assert_eq!(
s.eval::<String>("return BonusActionBarFrame.mode").unwrap(),
"none"
);
assert!(
s.eval::<bool>("return BonusActionBarFrame:IsShown()")
.unwrap(),
"the overlay stays up while the form holds — the ref-visible state"
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
73,
"the main bar adopts the bonus page at landing"
);
s.tick(0.5);
assert!(s.take_sounds().is_empty(), "a landed bar never re-sounds");
// ── A direct form→form swap (stance dance, powershift): repaint, no slide, no sound ────────
s.set_bonus_bar_offset(3);
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
assert_eq!(
s.eval::<String>("return BonusActionBarFrame.mode").unwrap(),
"none"
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(BonusActionButton1)")
.unwrap(),
97
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
97
);
s.tick(0.2);
assert!(
s.take_sounds().is_empty(),
"form→form never re-slides or re-sounds"
);
// ── Drop the form: the overlay descends carrying the OLD form's page, silently ─────────────
s.set_bonus_bar_offset(0);
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
assert_eq!(
s.eval::<String>("return BonusActionBarFrame.mode").unwrap(),
"hide"
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
1,
"the main bar returns to the page immediately — it is being revealed"
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(BonusActionButton1)")
.unwrap(),
97,
"the descending overlay carries the OLD form's page (lastBonusBar)"
);
// A key mid-descent still drives the old form's page — ref GetPagedID's lastBonusBar
// stand-in while the frame is still shown.
s.run("ActionButtonDown(1) ActionButtonUp(1)").unwrap();
assert_eq!(s.take_action_uses(), vec![97]);
s.tick(0.2);
assert!(
!s.eval::<bool>("return BonusActionBarFrame:IsShown()")
.unwrap(),
"the descent ends hidden"
);
assert!(
s.take_sounds().is_empty(),
"the down-slide is silent — the ref plays only on open"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A form flip mid-slide turns the bar around from where it is (our progress fraction), rather
/// than the ref's timer arithmetic, which mirror-jumps the position — the mechanism, not the
/// quirk (1524).
#[test]
fn bonus_bar_turnaround_continues_from_position() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in ["Cooldown.xml", "ActionBar.xml"] {
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}: {:?}", report.errors);
}
s.tick(10.0);
let _ = s.take_sounds();
// Up to half height, then the form drops mid-slide.
s.set_bonus_bar_offset(1);
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
s.tick(0.075);
s.set_bonus_bar_offset(0);
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
assert_eq!(
s.eval::<String>("return BonusActionBarFrame.mode").unwrap(),
"hide"
);
// A third of the way back down from the turnaround point: 43 * (0.5 - 0.03/0.15).
s.tick(0.03);
let top = s
.eval::<f64>("return BonusActionBarFrame:GetTop()")
.unwrap();
assert!(
(top - 12.9).abs() < 0.6,
"turnaround descends from 21.5, top = {top}"
);
s.tick(0.2);
assert!(!s
.eval::<bool>("return BonusActionBarFrame:IsShown()")
.unwrap());
assert!(
s.take_sounds().is_empty(),
"an aborted rise never lands, so it never sounds"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The two page arrows must not sit on top of each other.**
///
/// They are 32x32 squares stacked only 20 px apart, so their raw frame rects overlap by 12 px —
/// and the hit-test walks the draw order in reverse, so the later-declared DOWN button owned that
/// band: the bottom third of the visible UP arrow paged the bar the wrong way. The reference's
/// `<HitRectInsets>` (±6 horizontal, ±7 vertical) shrink each square to the 20x18 arrow it
/// actually draws, which separates them — the insets are behaviour here, not decoration.
#[test]
fn the_page_arrows_do_not_steal_each_other_s_clicks() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in ["Cooldown.xml", "ActionBar.xml"] {
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}: {:?}", report.errors);
}
// The post-login state, which is what a player clicks into. Without it `ExhaustionTick_Update`
// never runs, and the rested marker — DIALOG strata, declared CENTER on the XP strip, which is
// exactly where the arrows are — sits unhidden over both and eats every click. That is the
// reference's own declaration (`hidden="false"`, ref-MainMenuBar.xml l.415), hidden at runtime
// when `GetXPExhaustion()` is nil, so firing the event is the harness's job, not a fix.
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
let centre = |s: &UiScript, name: &str| {
s.eval::<(f64, f64)>(&format!(
"return ({name}:GetLeft() + {name}:GetRight()) / 2, \
({name}:GetBottom() + {name}:GetTop()) / 2"
))
.unwrap()
};
for name in ["ActionBarUpButton", "ActionBarDownButton"] {
let (x, y) = centre(&s, name);
assert_eq!(
s.hit_test_name(x as f32, y as f32).as_deref(),
Some(name),
"{name} must eat the click at its own centre"
);
}
// The contested band, measured rather than assumed: the two 32x32 squares are 20 px apart, so
// raw they share the 12 px above the UP button's bottom edge. 8 px up from that edge is inside
// the up arrow's own drawn art AND inside the down button's raw square — the exact pixel the
// player aims at and the exact pixel the later-declared down button used to win.
//
// With the ref's ±7 vertical insets the two hit rects become disjoint (up keeps its top 18 px,
// down its own), so this point resolves UP, which is what the arrow under the cursor says.
let (x, up_bottom) = s
.eval::<(f64, f64)>(
"return (ActionBarUpButton:GetLeft() + ActionBarUpButton:GetRight()) / 2, \
ActionBarUpButton:GetBottom()",
)
.unwrap();
assert_eq!(
s.hit_test_name(x as f32, up_bottom as f32 + 8.0).as_deref(),
Some("ActionBarUpButton"),
"the lower third of the visible UP arrow must page up, not down"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -170,6 +170,9 @@ fn roster() -> crate::char_select::Roster {
},
flags: 0,
equipment: [benilla_protocol::CharEnumItem::default(); 19],
pet_display_id: 0,
pet_level: 0,
pet_family: 0,
};
crate::char_select::Roster::with_pending_pick(vec![row], 7)
}
@@ -70,7 +70,7 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
s.run("BenillaBagToggle_OnClick()").unwrap();
s.set_loot(Some(LootState {
fishing: false,
rows: vec![LootRow {
rows: vec![Some(LootRow {
item_id: 0,
name: Some("Wool Cloth".into()),
texture: Some("Interface\\Icons\\INV_Fabric_Wool_01".into()),
@@ -78,7 +78,8 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
quality: Some(1),
is_coin: false,
link: None,
}],
random_property_id: 0,
})],
}));
s.fire_event("LOOT_OPENED", vec![]);
let _ = s.take_sounds();
+26 -11
View File
@@ -1006,10 +1006,36 @@ fn convert_entry(
//
// `assets` missing ENTIRELY is a data-less run (the headless UI tests), not a bad
// path — those keep the old behaviour rather than blanking every textured quad.
//
// `<TexCoords>`/`SetTexCoord` slices the sampled sub-rect. The 4-edge form
// `[left,right,top,bottom]` maps to raw UV corners (`left→u0, right→u1, top→v0,
// bottom→v1`) — carried through [`UvRect`] rather than a normalized `Rect` so a
// mirrored slice (`left>right`, e.g. PlayerFrameTexture) keeps its flip to the
// vertex buffer. The 8-arg affine form is already per-corner in the `push_quad`
// winding (the route-line quads). Absent = the full texture. Resolved BEFORE the
// handle because the wrap mode follows it:
let uv = match tex_coords {
Some(TexCoords::Rect(edges)) => UvRect::from_tex_coords(edges),
Some(TexCoords::Corners(corners)) => UvRect::from_corners(corners),
None => UvRect::FULL,
};
// A slice that runs PAST the texture is the reference's tiling idiom, not a crop:
// `SetTexCoord(0, n, 0, 1)` on an n-slots-wide strip repeats the art n times — the
// stance shelf's middle carries one slot per extra form exactly that way
// (`StanceBar.xml`, `ShapeshiftBar_Update`). Clamp-sampled it smears the last column
// across the extra width instead. Clamp/repeat bake into the `Image`, so this picks
// the tiled GPU image + cache entry, as the `Backdrop` arm's `tile` does below.
let tiled = uv
.corners
.iter()
.flatten()
.any(|c| !(-0.001..=1.001).contains(c));
let handle = match (path.as_deref(), assets.as_mut()) {
(Some(p), Some(a)) => {
let resolved = if circular {
a.portrait_texture(p, images)
} else if tiled {
a.sprite_texture_tiled(p, images)
} else {
a.sprite_texture(p, images)
};
@@ -1023,17 +1049,6 @@ fn convert_entry(
// A pathless Texture region is a solid color; a textured one tints by it.
let mut color = color.unwrap_or([1.0, 1.0, 1.0, 1.0]);
color[3] *= eq.alpha;
// `<TexCoords>`/`SetTexCoord` slices the sampled sub-rect. The 4-edge form
// `[left,right,top,bottom]` maps to raw UV corners (`left→u0, right→u1, top→v0,
// bottom→v1`) — carried through [`UvRect`] rather than a normalized `Rect` so a
// mirrored slice (`left>right`, e.g. PlayerFrameTexture) keeps its flip to the
// vertex buffer. The 8-arg affine form is already per-corner in the `push_quad`
// winding (the route-line quads). Absent = the full texture.
let uv = match tex_coords {
Some(TexCoords::Rect(edges)) => UvRect::from_tex_coords(edges),
Some(TexCoords::Corners(corners)) => UvRect::from_corners(corners),
None => UvRect::FULL,
};
out.push(UiQuad {
rect,
z_key: eq.z,
@@ -239,14 +239,14 @@ pub(super) fn emit(
shadow_spec,
);
for q in &mut sq {
// Flatten rgb to the shadow color (markup tints ride the fill only), but multiply
// the layout's own alpha — it carries the shadow's base alpha AND the write-on
// gradient's per-glyph ramp (a revealing char's shadow fades with its fill).
// Flatten rgb to the shadow color (markup tints ride the fill only); the alpha is
// every scalar the fill rides plus the shadow's own — see [`shadow_alpha`], which
// names the set and the symptom a missing member wears.
q.color = [
sh.color[0],
sh.color[1],
sh.color[2],
q.color[3] * host.alpha,
shadow_alpha(q.color[3], base_color[3], host.alpha),
];
// Every glyph quad inherits the owning Text quad's ScrollFrame clip — the
// FontString's own extract-time clip, not a per-glyph concept.
@@ -394,9 +394,50 @@ fn shadow_offset_px(scaled: f32) -> f32 {
}
}
/// A shadow glyph's alpha: **every scalar the fill rides, plus its own.**
///
/// Named because the set is the whole law and a missing member is invisible until something fades
/// through the one that was dropped. There are three:
///
/// - `layout` — what the shadow pass laid out with: the font object's `<Shadow>` colour alpha,
/// times the write-on gradient's per-glyph ramp.
/// - `fill` — the FILL colour's alpha (`base_color[3]`): `SetTextColor(r,g,b,a)`, and every
/// MessageFrame line, whose fade is a colour ramp rather than a frame-alpha one.
/// - `host` — the region's effective frame alpha (`SetAlpha` and every parent's).
///
/// `fill` was the one missing. A frame that fades through its colour (UIErrorsFrame,
/// RaidWarningFrame, chat) holds `host` at 1.0 throughout, so the shadow stayed a fully opaque
/// black copy of a vanishing string: the text read as turning BLACK across the ramp and then
/// popping out when the line retired, instead of fading.
fn shadow_alpha(layout: f32, fill: f32, host: f32) -> f32 {
layout * fill * host
}
#[cfg(test)]
mod tests {
use super::{band_clip_slack, shadow_offset_px};
use super::{band_clip_slack, shadow_alpha, shadow_offset_px};
/// A drop shadow fades with the string it belongs to — through EITHER fade lane.
///
/// The regression this pins: `UIErrorsFrame`'s "Requires Mining" and every other MessageFrame
/// line ramps its COLOUR alpha and holds frame alpha at 1.0, so a shadow that only rode
/// `host` stayed opaque black under a vanishing red fill — the text turned black across the
/// ramp and then vanished in one frame (director, 2026-08-22). Anything that fades through
/// `SetTextColor`'s alpha wore the same defect.
#[test]
fn a_drop_shadow_fades_through_the_colour_lane_as_well_as_the_frame_lane() {
// Nothing fading: an opaque shadow, as before.
assert_eq!(shadow_alpha(1.0, 1.0, 1.0), 1.0);
// The colour lane alone (a message-frame line mid-ramp): the shadow follows it. This is
// the assertion that fails without the fix — it used to answer 1.0.
assert_eq!(shadow_alpha(1.0, 0.25, 1.0), 0.25);
// The frame lane alone (UIFrameFadeOut, the zone splash): unchanged behaviour.
assert_eq!(shadow_alpha(1.0, 1.0, 0.25), 0.25);
// Both lanes compose, and the font object's own semi-transparent shadow rides on top.
assert_eq!(shadow_alpha(0.8, 0.5, 0.5), 0.2);
// Fully faded fill leaves NO shadow behind — the black that used to outlive the text.
assert_eq!(shadow_alpha(1.0, 0.0, 1.0), 0.0);
}
/// The newest chat line's band bottom IS the frame's bottom edge, so every px the renderer
/// adds below the band is a px the scissor would otherwise eat — the descender tails and
@@ -244,7 +244,7 @@ fn the_open_menu_takes_the_screen_and_refuses_every_other_panel() {
s.run("BenillaBagToggle_OnClick()").unwrap();
s.set_loot(Some(LootState {
fishing: false,
rows: vec![LootRow {
rows: vec![Some(LootRow {
item_id: 0,
name: Some("Wool Cloth".into()),
texture: Some("Interface\\Icons\\INV_Fabric_Wool_01".into()),
@@ -252,7 +252,8 @@ fn the_open_menu_takes_the_screen_and_refuses_every_other_panel() {
quality: Some(1),
is_coin: false,
link: None,
}],
random_property_id: 0,
})],
}));
s.fire_event("LOOT_OPENED", vec![]);
assert!(s

Some files were not shown because too many files have changed in this diff Show More