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:
Generated
+1
@@ -749,6 +749,7 @@ dependencies = [
|
||||
"bytemuck",
|
||||
"image",
|
||||
"libc",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
} = ¶ms;
|
||||
// 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>()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -180,7 +180,6 @@ mod tests {
|
||||
relay: Default::default(),
|
||||
last_apply_ms: 0.0,
|
||||
last_apply_pos: [0.0; 3],
|
||||
settled_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
))
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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, ¬ice_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,
|
||||
¯os,
|
||||
);
|
||||
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,
|
||||
¯os,
|
||||
&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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user