a 40-man raid costs less per frame, and the money frames go stock
- a 40-man raid cost +5 to +8 ms of CPU per frame — a population, not a hot function — and three cuts landed where it was measured: the rig palette upload sends one `write_buffer` per run, bridging the gaps parked bodies leave between live rigs (calls 93 → 3); a built material becomes an asset when something visible binds it, not when a spawner builds it — 9.9k material assets were alive for 36 drawn batches, so a material is realized only once bound and visible, readers that clone before binding doing it themselves through one new function in the engine's wall (172 → 173): a 17× cut in live GPU objects; and a body's batches spawn as material groups — a geared body stood as 12–27 mesh parts, its skin composite drawn as five separate batches, and two visible batches now join when nothing downstream can tell them apart: parts per body 17 → 12, the Stormwind raid leg 17.0–21.0 → 15.65–15.82 `cpu_ms` (1929, 1940) - the instruments that priced it, all on the probe line: wgpu's own resource census, because a sampled profile put roughly half of the pass encoder in usage trackers sized to the device's live buffer and texture counts — a term that scales with residency, not with what is drawn, and nothing printed the number; the active camera count, since every view is its own pass set; the UI's live texture-identity runs, one 2D draw each, which is the number a UI change moves, not the quad count; and parts and materials per dressed body on the dress census, the ceiling on merging a body's batches read off a real body instead of guessed from a model's batch table (1940) - the coin kit retires: every remaining money display — quest progress, the mail purse and postage, the trade recipient, the tooltip, the quest log, the trainer's purse and cost — moves onto the reference's `MoneyFrameTemplate`, each seat already matching ours; the progress panel's required-money flow is ported in full — `QuestFrame.lua:168-190` colours the FRAME red/white AND the LABEL black/dark-grey when the quest costs more than you hold, where ours had neither colour; the mail postage takes the reference's name and label, trade and mail issue no explicit update because the frames repaint on their own events, and the trainer's cost label is shown or hidden with its `local COSTS_LABEL` shadow deleted; with the trainer converted the hand-built coin trios have no consumer, so their templates and the `BenillaMoney_*` helpers are deleted and `MoneyFrame.xml` is the only money kit (1937) - the crowd-control ladder's aura exemption exists, and it is a join between two `Spell.dbc` records — each arm asks whether the spell being cast is itself an immunity covering the aura that blocks it (Ice Block cast while stunned), the school arm shifting because `School` is an index and `EffectMiscValue` a mask; what the scan mostly decides is WHICH message a refusal carries — a blocking aura the cast does not counter renames the reason to "Can't do that while %s" (1941); bevy_audio leaves the build — its plugin had been disabled since 0530, but disabling a plugin does not remove a crate, and rodio, a second cpal and an Ogg/Vorbis decoder were still linked into a client that mixes through kira (1932); and cpal has four stream errors, not two — a routine ALSA underrun was treated as "the device is gone", tearing the stream down on every hiccup; now only a lost or invalidated device rebuilds (1939) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Generated
-21
@@ -4151,17 +4151,6 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "lewton"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "777b48df9aaab155475a83a7df3070395ea1ac6902f5cd062b8f2b028075c030"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"ogg",
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
@@ -5036,15 +5025,6 @@ dependencies = [
|
||||
"nonmax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ogg"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6951b4e8bf21c8193da321bcce9c9dd2e13c858fe078bf9054a288b419ae5d6e"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -5707,7 +5687,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1"
|
||||
dependencies = [
|
||||
"cpal 0.15.3",
|
||||
"lewton",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+21
-1
@@ -14,7 +14,27 @@ authors = ["sam <samuv.eth@gmail.com>"]
|
||||
# and a workspace default-features choice can't be subtracted from downstream.
|
||||
[workspace.dependencies]
|
||||
anyhow = "1.0.102"
|
||||
bevy = "0.18.1"
|
||||
# bevy's `default` is `["2d", "3d", "ui"]`, and every one of those three pulls `audio`
|
||||
# (= `bevy_audio` + `vorbis`) — so a default bevy compiles rodio, a second cpal, a second ALSA
|
||||
# binding and an ogg/vorbis decoder into a client that mixes through kira and has had bevy's
|
||||
# `AudioPlugin` disabled at boot since 0530. This spells the same `default` out with `audio`
|
||||
# taken away: `2d`/`3d`/`ui` decomposed into the parts they are made of, which is the whole
|
||||
# union of the three minus that one entry (decision 1932 has the expansion, verified against a
|
||||
# before/after feature tree). Nothing else in the graph asks for it — avian3d, bevy_egui and
|
||||
# bevy_transform_interpolation all take bevy with `default-features = false`.
|
||||
#
|
||||
# If a future bevy needs another member, add it here by name; do not reach back for `2d`/`3d`/
|
||||
# `ui`, which would quietly bring the audio stack with it.
|
||||
bevy = { version = "0.18.1", default-features = false, features = [
|
||||
"default_app",
|
||||
"default_platform",
|
||||
"2d_bevy_render",
|
||||
"3d_bevy_render",
|
||||
"ui_api",
|
||||
"ui_bevy_render",
|
||||
"scene",
|
||||
"picking",
|
||||
] }
|
||||
# The render app's raw GPU handles (the WOW_GPU_MS meter's query set): bevy 0.18 does not wrap
|
||||
# wgpu's QuerySet family, so the meter reaches for the same wgpu the workspace already builds.
|
||||
wgpu = "27"
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
--
|
||||
-- Signature note: the ref reads the firing frame from the `this` global; the corpus
|
||||
-- convention is to keep the ref NAME and take that frame as a leading parameter
|
||||
-- (GameTooltip_OnHide(self), GameTooltip_ClearMoney(frame) — same shift). Reference code
|
||||
-- (GameTooltip_OnHide(self) — same shift; GameTooltip_ClearMoney went back to the reference's no-argument form in 1937). Reference code
|
||||
-- pasted in verbatim must therefore have the owner threaded in as arg 1.
|
||||
-- **The reference's exact signature, because a signature IS api (1199).** Ours used to take
|
||||
-- the owner as arg 1; the reference takes none and reads `this`. Outfitter calls it the
|
||||
@@ -181,30 +181,28 @@
|
||||
-- engine fires OnTooltipAddMoney with the computed price (wow-re ui/scratch/tooltip-money.md
|
||||
-- 0x52b650@0x52e376: the engine computes, FrameXML renders) and this is its handler; Lua
|
||||
-- call sites (repair cost) call it directly, exactly like the ref's ContainerFrame does.
|
||||
-- ref GameTooltip.lua:84-92, verbatim, on the reference's own widget (1937). The money row
|
||||
-- is the $parentMoneyFrame SmallMoneyFrameTemplate instance below, not a coin trio.
|
||||
function SetTooltipMoney(frame, money)
|
||||
frame:AddLine(" ", 1.0, 1.0, 1.0)
|
||||
local numLines = frame:NumLines()
|
||||
local line = getglobal(frame:GetName() .. "TextLeft" .. numLines)
|
||||
local coin1 = getglobal(frame:GetName() .. "MoneyCoin1")
|
||||
coin1:ClearAllPoints()
|
||||
coin1:SetPoint("LEFT", line, "LEFT", 4, 0)
|
||||
BenillaMoney_Set(frame:GetName() .. "Money", money, true)
|
||||
local width = 4
|
||||
for i = 1, 3 do
|
||||
local c = getglobal(frame:GetName() .. "MoneyCoin" .. i)
|
||||
if c:IsShown() then width = width + c:GetWidth() + 4 end
|
||||
end
|
||||
frame:SetMinimumWidth(width)
|
||||
local moneyFrame = getglobal(frame:GetName() .. "MoneyFrame")
|
||||
moneyFrame:SetPoint("LEFT", frame:GetName() .. "TextLeft" .. numLines, "LEFT", 4, 0)
|
||||
moneyFrame:Show()
|
||||
MoneyFrame_Update(moneyFrame:GetName(), money)
|
||||
frame:SetMinimumWidth(moneyFrame:GetWidth())
|
||||
end
|
||||
|
||||
-- The money row clears whenever the content does (the ref wires GameTooltip_ClearMoney to
|
||||
-- OnTooltipCleared; the engine fires it on ClearLines/SetOwner/Hide). BenillaMoney_Clear
|
||||
-- ships in MerchantFrame.xml (the money kit's home) — guard for engine tests that load
|
||||
-- this file alone; in the app every file is loaded.
|
||||
function GameTooltip_ClearMoney(frame)
|
||||
frame = frame or this
|
||||
if BenillaMoney_Clear then
|
||||
BenillaMoney_Clear(frame:GetName() .. "Money")
|
||||
-- ref GameTooltip.lua:94-99, verbatim: reads `this`, takes no argument. The one XML
|
||||
-- caller (OnTooltipCleared) is a handler, so `this` IS the tooltip.
|
||||
function GameTooltip_ClearMoney()
|
||||
local moneyFrame = getglobal(this:GetName() .. "MoneyFrame")
|
||||
if moneyFrame then
|
||||
moneyFrame:Hide()
|
||||
end
|
||||
end
|
||||
|
||||
@@ -312,12 +310,12 @@
|
||||
$parentStatusBar, and the OnLoad/OnHide pair (ref l.611-616).
|
||||
|
||||
What is deliberately NOT here, and stays on the GameTooltip instance:
|
||||
- The money coin trio and the OnTooltipAddMoney/OnTooltipCleared pair. The reference's
|
||||
template carries a $parentMoneyFrame (l.598-608, a SmallMoneyFrameTemplate), so its
|
||||
handler is safe on every instance; ours is a bespoke three-coin row that only
|
||||
GameTooltip declares, so a templated OnTooltipAddMoney would index a MoneyCoin1 that
|
||||
does not exist. Moving the row up would give ItemRefTooltip and every addon tooltip a
|
||||
coin line they render today without — a visible change, so it waits for the director.
|
||||
- The money row and the OnTooltipAddMoney/OnTooltipCleared pair. Since 1937 the row IS the
|
||||
reference's widget (a $parentMoneyFrame SmallMoneyFrameTemplate), but it still lives on
|
||||
the GameTooltip INSTANCE, where the reference's template carries it (l.598-608) so that
|
||||
ItemRefTooltip and every addon tooltip get the coin line too. That hoist is the remaining
|
||||
divergence here. It used to wait for the director; the 2026-09-03 ruling (faithful wins)
|
||||
resolves it, and it is its own unit because 27 corpus addons inherit this template.
|
||||
- OnTooltipSetDefaultAnchor (ref l.617-619). The engine fires it by name on `GameTooltip`
|
||||
alone (script/tooltip_unit.rs looks the frame up), so on any other instance it is dead
|
||||
weight.
|
||||
@@ -471,64 +469,11 @@
|
||||
screen center when the default-anchor script wasn't wired. -->
|
||||
<Size><AbsDimension x="120" y="32"/></Size>
|
||||
<Frames>
|
||||
<!-- The money row's coin trio (the real $parentMoneyFrame — GameTooltipTemplate.xml,
|
||||
patch.MPQ — as our shared coin-slot shape; MerchantFrame.xml's BenillaMoney_Set
|
||||
fills them, left-to-right gold-first). Coin1 is re-anchored by SetTooltipMoney to
|
||||
the money line's LEFT +4 (ref SetTooltipMoney's seat). -->
|
||||
<Frame name="$parentMoneyCoin1" hidden="true">
|
||||
<Size><AbsDimension x="13" y="13"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
|
||||
<Layers>
|
||||
<Layer level="OVERLAY">
|
||||
<Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons">
|
||||
<Size><AbsDimension x="13" y="13"/></Size>
|
||||
<Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors>
|
||||
<TexCoords left="0.5" right="0.75" top="0" bottom="1"/>
|
||||
</Texture>
|
||||
<FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT">
|
||||
<Size><AbsDimension x="20" y="13"/></Size>
|
||||
<Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors>
|
||||
</FontString>
|
||||
</Layer>
|
||||
</Layers>
|
||||
</Frame>
|
||||
<Frame name="$parentMoneyCoin2" hidden="true">
|
||||
<Size><AbsDimension x="13" y="13"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="LEFT" relativeTo="$parentMoneyCoin1" relativePoint="RIGHT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Layers>
|
||||
<Layer level="OVERLAY">
|
||||
<Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons">
|
||||
<Size><AbsDimension x="13" y="13"/></Size>
|
||||
<Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors>
|
||||
<TexCoords left="0.25" right="0.5" top="0" bottom="1"/>
|
||||
</Texture>
|
||||
<FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT">
|
||||
<Size><AbsDimension x="20" y="13"/></Size>
|
||||
<Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors>
|
||||
</FontString>
|
||||
</Layer>
|
||||
</Layers>
|
||||
</Frame>
|
||||
<Frame name="$parentMoneyCoin3" hidden="true">
|
||||
<Size><AbsDimension x="13" y="13"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="LEFT" relativeTo="$parentMoneyCoin2" relativePoint="RIGHT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Layers>
|
||||
<Layer level="OVERLAY">
|
||||
<Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons">
|
||||
<Size><AbsDimension x="13" y="13"/></Size>
|
||||
<Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors>
|
||||
<TexCoords left="0" right="0.25" top="0" bottom="1"/>
|
||||
</Texture>
|
||||
<FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT">
|
||||
<Size><AbsDimension x="20" y="13"/></Size>
|
||||
<Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors>
|
||||
</FontString>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<!-- The reference's own money row (GameTooltipTemplate.xml:598-607): ONE SmallMoneyFrameTemplate
|
||||
instance, hidden until SetTooltipMoney seats and shows it. Replaces our three hand-built
|
||||
$parentMoneyCoin1..3 — the System B shape 1937 retires from this window. -->
|
||||
<Frame name="$parentMoneyFrame" inherits="SmallMoneyFrameTemplate" hidden="true">
|
||||
<Scripts><OnLoad>SmallMoneyFrame_OnLoad(); MoneyFrame_SetType("STATIC");</OnLoad></Scripts>
|
||||
</Frame>
|
||||
</Frames>
|
||||
<Scripts>
|
||||
@@ -559,7 +504,7 @@
|
||||
computes a price and fires OnTooltipAddMoney(arg1 = copper); OnTooltipCleared fires
|
||||
whenever the content clears (ClearLines/SetOwner/Hide) and drops the coin row. -->
|
||||
<OnTooltipAddMoney>SetTooltipMoney(this, arg1)</OnTooltipAddMoney>
|
||||
<OnTooltipCleared>GameTooltip_ClearMoney(this)</OnTooltipCleared>
|
||||
<OnTooltipCleared>GameTooltip_ClearMoney()</OnTooltipCleared>
|
||||
</Scripts>
|
||||
</GameTooltip>
|
||||
|
||||
|
||||
@@ -580,13 +580,13 @@
|
||||
SendMailCODButtonText:SetTextColor(0.5, 0.5, 0.5)
|
||||
end
|
||||
getglobal("SendMailPackageButtonCount"):SetText(stackCount)
|
||||
BenillaMoney_Set("SendMailMoneyFrame", GetMoney())
|
||||
-- The 30c postage (ref l.542-547); red when it exceeds the purse.
|
||||
BenillaMoney_Set("SendMailCost", GetSendMailPrice())
|
||||
-- ref MailFrame.lua:542-547, verbatim. The purse (type PLAYER) and the postage (painted
|
||||
-- in its own OnLoad) need no update here; only the postage COLOUR is decided per
|
||||
-- refresh, and with the reference's own named colours rather than literals (1937).
|
||||
if GetSendMailPrice() > GetMoney() then
|
||||
BenillaMoney_SetColor("SendMailCost", 1.0, 0.1, 0.1)
|
||||
SetMoneyFrameColor("SendMailCostMoneyFrame", RED_FONT_COLOR.r, RED_FONT_COLOR.g, RED_FONT_COLOR.b)
|
||||
else
|
||||
BenillaMoney_SetColor("SendMailCost", 1.0, 1.0, 1.0)
|
||||
SetMoneyFrameColor("SendMailCostMoneyFrame", HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b)
|
||||
end
|
||||
SendMailFrame_CanSend()
|
||||
end
|
||||
@@ -1030,33 +1030,21 @@
|
||||
<!-- ref SendMailCostMoneyFrame l.739-767 (SmallMoneyFrameTemplate at TOPRIGHT
|
||||
-46,-48; inlined as BenillaMoney coin slots, copper-first right-to-left, with
|
||||
the SEND_MAIL_COST label). -->
|
||||
<Frame name="SendMailCost">
|
||||
<Size><AbsDimension x="13" y="13"/></Size>
|
||||
<!-- ref MailFrame.xml:739-766, and the reference's own NAME — ours was "SendMailCost". A
|
||||
SmallMoneyFrameTemplate of type STATIC, painted once from GetSendMailPrice() in its OnLoad
|
||||
exactly as the reference does, with the SEND_MAIL_COST ("Postage:") label at RIGHT->LEFT
|
||||
(-3,0) — ours had a literal "Cost:" at -56. Same TOPRIGHT (-46,-48) seat (1937). -->
|
||||
<Frame name="SendMailCostMoneyFrame" inherits="SmallMoneyFrameTemplate">
|
||||
<Anchors><Anchor point="TOPRIGHT"><Offset><AbsDimension x="-46" y="-48"/></Offset></Anchor></Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
<FontString inherits="GameFontNormal" text="Cost:">
|
||||
<Anchors><Anchor point="RIGHT" relativePoint="LEFT"><Offset><AbsDimension x="-56" y="0"/></Offset></Anchor></Anchors>
|
||||
</FontString>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Frames>
|
||||
<Frame name="$parentCoin1">
|
||||
<Size><AbsDimension x="13" y="13"/></Size>
|
||||
<Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors>
|
||||
<Layers><Layer level="OVERLAY"><Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors><TexCoords left="0.5" right="0.75" top="0" bottom="1"/></Texture><FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT"><Size><AbsDimension x="20" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors></FontString></Layer></Layers>
|
||||
</Frame>
|
||||
<Frame name="$parentCoin2">
|
||||
<Size><AbsDimension x="13" y="13"/></Size>
|
||||
<Anchors><Anchor point="RIGHT" relativeTo="$parentCoin1" relativePoint="LEFT"><Offset><AbsDimension x="-4" y="0"/></Offset></Anchor></Anchors>
|
||||
<Layers><Layer level="OVERLAY"><Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors><TexCoords left="0.25" right="0.5" top="0" bottom="1"/></Texture><FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT"><Size><AbsDimension x="20" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors></FontString></Layer></Layers>
|
||||
</Frame>
|
||||
<Frame name="$parentCoin3">
|
||||
<Size><AbsDimension x="13" y="13"/></Size>
|
||||
<Anchors><Anchor point="RIGHT" relativeTo="$parentCoin2" relativePoint="LEFT"><Offset><AbsDimension x="-4" y="0"/></Offset></Anchor></Anchors>
|
||||
<Layers><Layer level="OVERLAY"><Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors><TexCoords left="0" right="0.25" top="0" bottom="1"/></Texture><FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT"><Size><AbsDimension x="20" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors></FontString></Layer></Layers>
|
||||
</Frame>
|
||||
</Frames>
|
||||
<Layers><Layer level="BACKGROUND">
|
||||
<FontString inherits="GameFontNormal" text="SEND_MAIL_COST">
|
||||
<Anchors><Anchor point="RIGHT" relativePoint="LEFT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor></Anchors>
|
||||
</FontString>
|
||||
</Layer></Layers>
|
||||
<Scripts><OnLoad>
|
||||
SmallMoneyFrame_OnLoad(); MoneyFrame_SetType("STATIC");
|
||||
MoneyFrame_Update("SendMailCostMoneyFrame", GetSendMailPrice());
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<!-- ref SendMailSubjectEditBox l.768-842: 237×20 below the name box, 64 letters. -->
|
||||
<EditBox name="SendMailSubjectEditBox" letters="64" historyLines="0" autoFocus="false">
|
||||
@@ -1165,26 +1153,11 @@
|
||||
UI-ClassTrainer-BotLeft left of the Send button (without it the recess reads
|
||||
as a broken empty input box). Inlined as the BenillaMoney coin trio; fed
|
||||
GetMoney() by SendMailFrame_Update + the PLAYER_MONEY event. -->
|
||||
<Frame name="SendMailMoneyFrame">
|
||||
<Size><AbsDimension x="13" y="13"/></Size>
|
||||
<!-- ref MailFrame.xml:993-1001: the purse, a SmallMoneyFrameTemplate with NO OnLoad of its
|
||||
own — the template's SmallMoneyFrame_OnLoad sets type PLAYER, which repaints on
|
||||
PLAYER_MONEY. Same BOTTOMRIGHT->BOTTOMLEFT (183,84) seat (1937). -->
|
||||
<Frame name="SendMailMoneyFrame" inherits="SmallMoneyFrameTemplate">
|
||||
<Anchors><Anchor point="BOTTOMRIGHT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="183" y="84"/></Offset></Anchor></Anchors>
|
||||
<Frames>
|
||||
<Frame name="$parentCoin1">
|
||||
<Size><AbsDimension x="13" y="13"/></Size>
|
||||
<Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors>
|
||||
<Layers><Layer level="OVERLAY"><Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors><TexCoords left="0.5" right="0.75" top="0" bottom="1"/></Texture><FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT"><Size><AbsDimension x="20" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors></FontString></Layer></Layers>
|
||||
</Frame>
|
||||
<Frame name="$parentCoin2">
|
||||
<Size><AbsDimension x="13" y="13"/></Size>
|
||||
<Anchors><Anchor point="RIGHT" relativeTo="$parentCoin1" relativePoint="LEFT"><Offset><AbsDimension x="-4" y="0"/></Offset></Anchor></Anchors>
|
||||
<Layers><Layer level="OVERLAY"><Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors><TexCoords left="0.25" right="0.5" top="0" bottom="1"/></Texture><FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT"><Size><AbsDimension x="20" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors></FontString></Layer></Layers>
|
||||
</Frame>
|
||||
<Frame name="$parentCoin3">
|
||||
<Size><AbsDimension x="13" y="13"/></Size>
|
||||
<Anchors><Anchor point="RIGHT" relativeTo="$parentCoin2" relativePoint="LEFT"><Offset><AbsDimension x="-4" y="0"/></Offset></Anchor></Anchors>
|
||||
<Layers><Layer level="OVERLAY"><Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors><TexCoords left="0" right="0.25" top="0" bottom="1"/></Texture><FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT"><Size><AbsDimension x="20" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors></FontString></Layer></Layers>
|
||||
</Frame>
|
||||
</Frames>
|
||||
</Frame>
|
||||
<!-- ref SendMailCancelButton l.1002-1018 / SendMailMailButton l.1019-1035
|
||||
(UIPanelButtonTemplate 80×22 at the ref offsets; inlined UI-Panel-Button art). -->
|
||||
|
||||
@@ -17,14 +17,14 @@
|
||||
the top runtime walls the frame rename left standing.
|
||||
|
||||
── 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`, which live
|
||||
below in this same file (they were in MerchantFrame.xml until 1751 took that window to the
|
||||
stock chain): 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.
|
||||
There WAS a second coin kit — `BenillaMoney_Set`/`_Clear`/`_SetColor`, a three-SLOT chain
|
||||
(`<prefix>Coin1..3`) that filled only the non-zero denominations in display order and
|
||||
re-flowed, born in MerchantFrame.xml and used by eight windows, with a layout the director had
|
||||
signed off across them. The 2026-09-03 ruling — faithful wins, do not park on the director
|
||||
what the reference already answers — superseded that sign-off: 1928 (quest reward money) and
|
||||
1937 (quest required money, mail, trade, tooltip, quest log, trainer) moved every window onto
|
||||
this file's templates, and the helpers were deleted with the last consumer. This file is the
|
||||
only money kit now.
|
||||
|
||||
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
|
||||
@@ -106,64 +106,6 @@
|
||||
{ 0.5, 0.75 }, -- [3] copper
|
||||
}
|
||||
|
||||
-- Unused slots HIDE (the real collapse — MoneyFrame.lua l.230/247 goldButton:Hide()), never
|
||||
-- SetWidth(0): a 0 width means "derive from opposing anchors" to the layout (the client's
|
||||
-- own G+0x50 convention), not "zero px". Fills are contiguous from Coin1, so a chain never
|
||||
-- anchors THROUGH a hidden slot.
|
||||
function BenillaMoney_Clear(prefix)
|
||||
for i = 1, 3 do
|
||||
getglobal(prefix .. "Coin" .. i):Hide()
|
||||
end
|
||||
end
|
||||
|
||||
function BenillaMoney_Set(prefix, copper, highestFirst)
|
||||
copper = copper or 0
|
||||
local vals = {
|
||||
math.floor(copper / 10000), -- gold
|
||||
math.floor((copper % 10000) / 100), -- silver
|
||||
copper % 100, -- copper
|
||||
}
|
||||
-- Fill slots 1.. in display order (see the module comment); a zero denomination is
|
||||
-- skipped (the real collapse), except copper always shows when the whole amount is
|
||||
-- zero so a free item / empty purse isn't blank.
|
||||
local first, last, step = 3, 1, -1
|
||||
if highestFirst then first, last, step = 1, 3, 1 end
|
||||
local slot = 0
|
||||
for d = first, last, step do
|
||||
if vals[d] > 0 or (d == 3 and vals[1] == 0 and vals[2] == 0) then
|
||||
slot = slot + 1
|
||||
-- The real shrink: this slot is exactly its number + its 13px coin, and the
|
||||
-- XML anchor chain re-flows off the new width.
|
||||
-- Text first, then measure it: the engine answers a width read inside the
|
||||
-- tick that set the text, so the number IS this number and not the last one.
|
||||
local num = getglobal(prefix .. "Coin" .. slot .. "Num")
|
||||
num:SetText(vals[d])
|
||||
local numW = num:GetStringWidth()
|
||||
local slotFrame = getglobal(prefix .. "Coin" .. slot)
|
||||
slotFrame:SetWidth(numW + 13)
|
||||
slotFrame:Show()
|
||||
local icon = getglobal(prefix .. "Coin" .. slot .. "Icon")
|
||||
icon:SetTexture("Interface\\MoneyFrame\\UI-MoneyIcons")
|
||||
icon:SetTexCoord(BENILLA_COIN_TC[d][1], BENILLA_COIN_TC[d][2], 0, 1)
|
||||
num:SetWidth(numW)
|
||||
end
|
||||
end
|
||||
for i = slot + 1, 3 do
|
||||
getglobal(prefix .. "Coin" .. i):Hide()
|
||||
end
|
||||
end
|
||||
|
||||
-- Recolor the coin *numbers* (the ref's SetMoneyFrameColor — the digits redden, the coin icons
|
||||
-- keep their own texture). The trainer detail cost calls this white when affordable / red
|
||||
-- (1.0, 0.1, 0.1) when not; a plain purse never calls it, so its coins stay default. Applies to
|
||||
-- all 3 slots (hidden ones are harmless) and must be re-called after each BenillaMoney_Set,
|
||||
-- which fills the numbers but does not reset their color.
|
||||
function BenillaMoney_SetColor(prefix, r, g, b)
|
||||
for i = 1, 3 do
|
||||
getglobal(prefix .. "Coin" .. i .. "Num"):SetTextColor(r, g, b)
|
||||
end
|
||||
end
|
||||
|
||||
-- ── What a coin is worth ─────────────────────────────────────────────────────────────────
|
||||
-- Published globals: an addon that formats a price does the same split we do below, and
|
||||
-- reads these rather than spelling 100 twice.
|
||||
|
||||
@@ -211,8 +211,7 @@
|
||||
end)(),
|
||||
["QuestProgressScrollFrame"] = (function()
|
||||
local t = { "QuestProgressText", "QuestProgressRequiredItemsText",
|
||||
"QuestProgressRequiredMoneyText", "QuestProgressMoneyCoin1",
|
||||
"QuestProgressMoneyCoin2", "QuestProgressMoneyCoin3" }
|
||||
"QuestProgressRequiredMoneyText", "QuestProgressRequiredMoneyFrame" }
|
||||
for i = 1, MAX_REQUIRED_ITEMS do table.insert(t, "QuestProgressItem" .. i) end
|
||||
return t
|
||||
end)(),
|
||||
@@ -570,21 +569,35 @@
|
||||
local reqText = getglobal("QuestProgressRequiredItemsText")
|
||||
local moneyText = getglobal("QuestProgressRequiredMoneyText")
|
||||
local item1 = getglobal("QuestProgressItem1")
|
||||
-- ref QuestFrame.lua:168-190, ported in full (1937): the money frame paints from
|
||||
-- GetQuestMoneyToGet(); BOTH colours branch on whether the purse covers it — the frame
|
||||
-- red/white and the LABEL black/dark-grey; the label and frame are Shown/Hidden rather
|
||||
-- than blanked (the label's text is the XML's REQUIRED_MONEY); and the first required
|
||||
-- item re-anchors to whichever text is showing.
|
||||
local moneyFrame = getglobal("QuestProgressRequiredMoneyFrame")
|
||||
if numItems > 0 or (money and money > 0) then
|
||||
reqText:SetText("Items Needed:")
|
||||
reqText:Show()
|
||||
if money and money > 0 then
|
||||
moneyText:SetText("Required Money:")
|
||||
BenillaMoney_Set("QuestProgressMoney", money, true)
|
||||
MoneyFrame_Update("QuestProgressRequiredMoneyFrame", money)
|
||||
if money > GetMoney() then
|
||||
moneyText:SetTextColor(0, 0, 0)
|
||||
SetMoneyFrameColor("QuestProgressRequiredMoneyFrame", 1.0, 0.1, 0.1)
|
||||
else
|
||||
moneyText:SetTextColor(0.2, 0.2, 0.2)
|
||||
SetMoneyFrameColor("QuestProgressRequiredMoneyFrame", 1.0, 1.0, 1.0)
|
||||
end
|
||||
moneyText:Show()
|
||||
moneyFrame:Show()
|
||||
item1:SetPoint("TOPLEFT", moneyText, "BOTTOMLEFT", 0, -10)
|
||||
else
|
||||
moneyText:SetText("")
|
||||
BenillaMoney_Clear("QuestProgressMoney")
|
||||
moneyText:Hide()
|
||||
moneyFrame:Hide()
|
||||
item1:SetPoint("TOPLEFT", reqText, "BOTTOMLEFT", -3, -5)
|
||||
end
|
||||
else
|
||||
reqText:SetText("")
|
||||
moneyText:SetText("")
|
||||
BenillaMoney_Clear("QuestProgressMoney")
|
||||
reqText:Hide()
|
||||
moneyText:Hide()
|
||||
moneyFrame:Hide()
|
||||
end
|
||||
for i = 1, MAX_REQUIRED_ITEMS do
|
||||
if i <= numItems then
|
||||
@@ -909,16 +922,6 @@
|
||||
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></HighlightTexture>
|
||||
</Button>
|
||||
|
||||
<!-- One money coin slot (shared money display; BenillaMoney_Set fills three, high-denom first,
|
||||
shrinking each to its number + 13px coin — the XML width is just the pre-fill state). -->
|
||||
<Frame name="BenillaQuestMoneyCoinTemplate" virtual="true">
|
||||
<Size><AbsDimension x="13" y="13"/></Size>
|
||||
<Layers><Layer level="OVERLAY">
|
||||
<Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors></Texture>
|
||||
<FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT"><Size><AbsDimension x="20" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors></FontString>
|
||||
</Layer></Layers>
|
||||
</Frame>
|
||||
|
||||
<!-- The shared sub-panel chrome (ref `QuestFramePanelTemplate`, QuestFrameTemplates.xml l.3-84):
|
||||
the four corner parchment quadrants every one of the four panels draws independently (the
|
||||
ref's own choice — each real sub-panel repeats this art rather than the window compositing it
|
||||
@@ -1219,15 +1222,19 @@
|
||||
<Size><AbsDimension x="295" y="0"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="QuestProgressText" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
|
||||
</FontString>
|
||||
<FontString name="QuestProgressRequiredMoneyText" inherits="QuestFontNormalSmall" justifyH="LEFT">
|
||||
<FontString name="QuestProgressRequiredMoneyText" inherits="QuestFontNormalSmall" text="REQUIRED_MONEY" justifyH="LEFT">
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="QuestProgressRequiredItemsText" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
|
||||
</FontString>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Frames>
|
||||
<Frame name="QuestProgressMoneyCoin1" inherits="BenillaQuestMoneyCoinTemplate"><Anchors><Anchor point="LEFT" relativeTo="QuestProgressRequiredMoneyText" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></Frame>
|
||||
<Frame name="QuestProgressMoneyCoin2" inherits="BenillaQuestMoneyCoinTemplate"><Anchors><Anchor point="LEFT" relativeTo="QuestProgressMoneyCoin1" relativePoint="RIGHT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor></Anchors></Frame>
|
||||
<Frame name="QuestProgressMoneyCoin3" inherits="BenillaQuestMoneyCoinTemplate"><Anchors><Anchor point="LEFT" relativeTo="QuestProgressMoneyCoin2" relativePoint="RIGHT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor></Anchors></Frame>
|
||||
<!-- ref QuestFrame.xml:392-406: the REQUIRED-money frame — money the player hands over, a
|
||||
different flow from the two reward seats 1928 converted, with its own colour and anchor
|
||||
rules in QuestFrameProgressItems_Update. Same LEFT->text RIGHT (10,0) seat (1937). -->
|
||||
<Frame name="QuestProgressRequiredMoneyFrame" inherits="MoneyFrameTemplate">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="QuestProgressRequiredMoneyText" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>SmallMoneyFrame_OnLoad(); MoneyFrame_SetType("STATIC");</OnLoad></Scripts>
|
||||
</Frame>
|
||||
|
||||
<!-- The 2-col×3-row grid (ref l.407-460): item1 is Lua-repositioned
|
||||
(money-line vs header-line); items 2-6 are a STATIC chain off
|
||||
|
||||
@@ -596,7 +596,7 @@
|
||||
for i = 1, MAX_NUM_ITEMS do
|
||||
getglobal("QuestLogItem" .. i):Hide()
|
||||
end
|
||||
BenillaMoney_Clear("QuestLogMoney")
|
||||
getglobal("QuestLogMoneyFrame"):Hide()
|
||||
-- Nothing selected: the header-only span (BenillaQuestLogRewards_Update's own
|
||||
-- haveAnyReward==false case, 18px) is the right resize input here too.
|
||||
BenillaQuestLogDetail_ResizeChild(0, 18)
|
||||
@@ -754,10 +754,13 @@
|
||||
-- Money: a STATIC XML anchor (LEFT of ItemReceiveText's RIGHT, +15,0 — ref
|
||||
-- QuestLogFrame.xml:896-903) tracks receiveText's runtime SetPoint above via the resolver's
|
||||
-- fixpoint re-propagation; only the coin fill/clear is Lua's job here.
|
||||
-- ref QuestFrame.lua:350-356's tail, on the reference's own widget (1937).
|
||||
local moneyFrame = getglobal("QuestLogMoneyFrame")
|
||||
if money and money > 0 then
|
||||
BenillaMoney_Set("QuestLogMoney", money, true)
|
||||
moneyFrame:Show()
|
||||
MoneyFrame_Update("QuestLogMoneyFrame", money)
|
||||
else
|
||||
BenillaMoney_Clear("QuestLogMoney")
|
||||
moneyFrame:Hide()
|
||||
end
|
||||
|
||||
-- The section's total height, mirroring the branches above term-for-term (a money-only
|
||||
@@ -1792,29 +1795,13 @@
|
||||
|
||||
<!-- Reward money (pin §3, GetQuestLogRewardMoney) — ref QuestLogMoneyFrame anchors LEFT
|
||||
to QuestLogItemReceiveText's RIGHT, offset (15,0) (QuestLogFrame.xml:896-903). -->
|
||||
<Frame name="QuestLogMoneyCoin1">
|
||||
<Size><AbsDimension x="13" y="13"/></Size>
|
||||
<!-- ref QuestLogFrame.xml:896-906, and the reference's own NAME: one MoneyFrameTemplate at the
|
||||
same LEFT->QuestLogItemReceiveText RIGHT (15,0) seat our Coin1 used. Its OnLoad is the
|
||||
reference's QuestMoneyFrame_OnLoad (QuestFrame.lua:306, in our QuestFrame.xml — manifest 960,
|
||||
above this file at 987). Retires QuestLogMoneyCoin1..3 (1937). -->
|
||||
<Frame name="QuestLogMoneyFrame" inherits="MoneyFrameTemplate">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="QuestLogItemReceiveText" relativePoint="RIGHT"><Offset><AbsDimension x="15" y="0"/></Offset></Anchor></Anchors>
|
||||
<Layers><Layer level="OVERLAY">
|
||||
<Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors></Texture>
|
||||
<FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT"><Size><AbsDimension x="20" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors></FontString>
|
||||
</Layer></Layers>
|
||||
</Frame>
|
||||
<Frame name="QuestLogMoneyCoin2">
|
||||
<Size><AbsDimension x="13" y="13"/></Size>
|
||||
<Anchors><Anchor point="LEFT" relativeTo="QuestLogMoneyCoin1" relativePoint="RIGHT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor></Anchors>
|
||||
<Layers><Layer level="OVERLAY">
|
||||
<Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors></Texture>
|
||||
<FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT"><Size><AbsDimension x="20" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors></FontString>
|
||||
</Layer></Layers>
|
||||
</Frame>
|
||||
<Frame name="QuestLogMoneyCoin3">
|
||||
<Size><AbsDimension x="13" y="13"/></Size>
|
||||
<Anchors><Anchor point="LEFT" relativeTo="QuestLogMoneyCoin2" relativePoint="RIGHT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor></Anchors>
|
||||
<Layers><Layer level="OVERLAY">
|
||||
<Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors></Texture>
|
||||
<FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT"><Size><AbsDimension x="20" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors></FontString>
|
||||
</Layer></Layers>
|
||||
<Scripts><OnLoad>QuestMoneyFrame_OnLoad()</OnLoad></Scripts>
|
||||
</Frame>
|
||||
</Frames>
|
||||
</Frame>
|
||||
|
||||
@@ -113,7 +113,9 @@
|
||||
end
|
||||
-- Our gold is the editable input, reflected via PLAYER_TRADE_MONEY (not repainted here, so
|
||||
-- the repaint never clobbers mid-typing); only the partner's read-only gold repaints.
|
||||
BenillaMoney_Set("TradeRecipientMoneyFrame", GetTargetTradeMoney())
|
||||
-- The recipient's money frame is type TARGET_TRADE and repaints itself on
|
||||
-- TRADE_MONEY_CHANGED (MoneyFrame_OnEvent); the reference issues no explicit update
|
||||
-- here and neither do we (1937).
|
||||
-- A repaint puts both glows down. TRADE_ACCEPT_UPDATE is the only thing that raises
|
||||
-- them, so whatever is standing comes straight back on the next one.
|
||||
setTradeGlow(TradeHighlightPlayer, TradeHighlightPlayerEnchant, false)
|
||||
@@ -492,26 +494,12 @@
|
||||
</Scripts>
|
||||
</Frame>
|
||||
<!-- The partner's gold (read-only — GetTargetTradeMoney; ref TradeRecipientMoneyFrame l.574). -->
|
||||
<Frame name="TradeRecipientMoneyFrame">
|
||||
<Size><AbsDimension x="13" y="13"/></Size>
|
||||
<!-- ref TradeFrame.xml:574-588: a SmallMoneyFrameTemplate of type TARGET_TRADE, which repaints
|
||||
itself on TRADE_MONEY_CHANGED through MoneyFrame_OnEvent — no explicit update anywhere in
|
||||
the reference's Lua, and none here now. Same TOPRIGHT (-40,-80) seat as before (1937). -->
|
||||
<Frame name="TradeRecipientMoneyFrame" inherits="SmallMoneyFrameTemplate">
|
||||
<Anchors><Anchor point="TOPRIGHT"><Offset><AbsDimension x="-40" y="-80"/></Offset></Anchor></Anchors>
|
||||
<Frames>
|
||||
<Frame name="$parentCoin1">
|
||||
<Size><AbsDimension x="13" y="13"/></Size>
|
||||
<Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors>
|
||||
<Layers><Layer level="OVERLAY"><Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors><TexCoords left="0.5" right="0.75" top="0" bottom="1"/></Texture><FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT"><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors></FontString></Layer></Layers>
|
||||
</Frame>
|
||||
<Frame name="$parentCoin2">
|
||||
<Size><AbsDimension x="13" y="13"/></Size>
|
||||
<Anchors><Anchor point="RIGHT" relativeTo="$parentCoin1" relativePoint="LEFT"><Offset><AbsDimension x="-4" y="0"/></Offset></Anchor></Anchors>
|
||||
<Layers><Layer level="OVERLAY"><Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors><TexCoords left="0.25" right="0.5" top="0" bottom="1"/></Texture><FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT"><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors></FontString></Layer></Layers>
|
||||
</Frame>
|
||||
<Frame name="$parentCoin3">
|
||||
<Size><AbsDimension x="13" y="13"/></Size>
|
||||
<Anchors><Anchor point="RIGHT" relativeTo="$parentCoin2" relativePoint="LEFT"><Offset><AbsDimension x="-4" y="0"/></Offset></Anchor></Anchors>
|
||||
<Layers><Layer level="OVERLAY"><Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors><TexCoords left="0" right="0.25" top="0" bottom="1"/></Texture><FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT"><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors></FontString></Layer></Layers>
|
||||
</Frame>
|
||||
</Frames>
|
||||
<Scripts><OnLoad>SmallMoneyFrame_OnLoad(); MoneyFrame_SetType("TARGET_TRADE");</OnLoad></Scripts>
|
||||
</Frame>
|
||||
</Frames>
|
||||
<Scripts>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
- MerchantFrame.xml — the 384×512 four-quadrant window art (no <Backdrop>: the art IS the
|
||||
frame), the NPC-session lifecycle (RegisterEvent trio + PLAYER_MONEY, SHOW/UPDATE/CLOSED
|
||||
dispatch, OnShow/OnHide PlaySound with CloseTrainer() on hide), the "npc" model-portrait
|
||||
binding, and the BenillaMoney_* coin rig (detail cost + player purse). The close X reuses
|
||||
binding, and the reference's own two money frames (detail cost + player purse). The close X reuses
|
||||
the minimize-button art (this engine ships no UIPanelCloseButton).
|
||||
- QuestLogFrame.xml — the list-row Button template shape ($parent-named child regions wired in
|
||||
<OnLoad>) and the persistent selection-highlight Frame (a child Frame whose ARTWORK Texture
|
||||
@@ -56,7 +56,7 @@
|
||||
- GlobalStrings the ref reads (REQUIRES_LABEL, TRAINER_REQ_*, COSTS_LABEL, the labels) are
|
||||
defined LOCALLY below as the byte-exact 1.12 enUS values (interface.MPQ GlobalStrings.lua,
|
||||
line-cited); NORMAL/GRAY/HIGHLIGHT_FONT_COLOR are Fonts.xml globals.
|
||||
- The affordability RED-TINT of the cost coins (ref SetMoneyFrameColor) is BenillaMoney_SetColor.
|
||||
- The affordability RED-TINT of the cost is the reference's own SetMoneyFrameColor (1937).
|
||||
- The profession-confirm StaticPopup is skipped for v1 (Train buys directly); a later refinement.
|
||||
|
||||
Registers at the left UIPanel slot like Gossip/Merchant/Quest/QuestLog (UiPanels.xml) — opening
|
||||
@@ -70,7 +70,6 @@
|
||||
-- number/name (met = white, unmet = red), exactly as the real requirements line reads.
|
||||
local PARENS_TEMPLATE = "(%s)" -- GlobalStrings.lua:2468
|
||||
local REQUIRES_LABEL = "Requires:" -- REQUIRES_LABEL (2768)
|
||||
local COSTS_LABEL = "Cost:" -- COSTS_LABEL (667)
|
||||
local UNKNOWN_LABEL = "Unknown" -- UNKNOWN (3860)
|
||||
local FILTER_LABEL = "Filter" -- FILTER (1297)
|
||||
local TRAINER_REQ_LEVEL = "Level |cffffffff%d|r" -- TRAINER_REQ_LEVEL (3739)
|
||||
@@ -116,9 +115,9 @@
|
||||
getglobal("BenillaTrainerSkillName"):SetText("")
|
||||
getglobal("BenillaTrainerSkillRequirements"):SetText("")
|
||||
getglobal("BenillaTrainerSkillDescription"):SetText("")
|
||||
getglobal("BenillaTrainerCostLabel"):SetText("")
|
||||
getglobal("BenillaTrainerCostLabel"):Hide()
|
||||
getglobal("BenillaTrainerSkillIcon"):Hide()
|
||||
BenillaMoney_Clear("BenillaTrainerDetailMoney")
|
||||
getglobal("ClassTrainerDetailMoneyFrame"):Hide()
|
||||
getglobal("BenillaTrainerTrainButton"):Disable()
|
||||
end
|
||||
|
||||
@@ -177,21 +176,29 @@
|
||||
getglobal("BenillaTrainerSkillRequirements"):SetText("")
|
||||
end
|
||||
|
||||
-- Cost (ref): a free service shows no coins/label; otherwise the "Cost:" label + coins,
|
||||
-- reddened when unaffordable (BenillaMoney_SetColor: white when GetMoney() >= cost, else red).
|
||||
-- ref Blizzard_TrainerUI.lua (the "Money Frame and cost" block), in full (1937): a free
|
||||
-- service hides the frame AND the label and pulls the description up to the label's TOPLEFT;
|
||||
-- a costed one shows both, seats the description under the label, and tints the frame white
|
||||
-- when the purse covers it, red when not. The frame is repainted either way.
|
||||
local moneyCost = GetTrainerServiceCost(id)
|
||||
local costFrame = getglobal("ClassTrainerDetailMoneyFrame")
|
||||
local costLabel = getglobal("BenillaTrainerCostLabel")
|
||||
local description = getglobal("BenillaTrainerSkillDescription")
|
||||
if moneyCost and moneyCost > 0 then
|
||||
getglobal("BenillaTrainerCostLabel"):SetText(COSTS_LABEL)
|
||||
BenillaMoney_Set("BenillaTrainerDetailMoney", moneyCost, true)
|
||||
costFrame:Show()
|
||||
costLabel:Show()
|
||||
description:SetPoint("TOPLEFT", costLabel, "BOTTOMLEFT", 0, -10)
|
||||
if (GetMoney() or 0) >= moneyCost then
|
||||
BenillaMoney_SetColor("BenillaTrainerDetailMoney", 1.0, 1.0, 1.0)
|
||||
SetMoneyFrameColor("ClassTrainerDetailMoneyFrame", 1.0, 1.0, 1.0)
|
||||
else
|
||||
BenillaMoney_SetColor("BenillaTrainerDetailMoney", 1.0, 0.1, 0.1)
|
||||
SetMoneyFrameColor("ClassTrainerDetailMoneyFrame", 1.0, 0.1, 0.1)
|
||||
end
|
||||
else
|
||||
getglobal("BenillaTrainerCostLabel"):SetText("")
|
||||
BenillaMoney_Clear("BenillaTrainerDetailMoney")
|
||||
costFrame:Hide()
|
||||
costLabel:Hide()
|
||||
description:SetPoint("TOPLEFT", costLabel, "TOPLEFT", 0, 0)
|
||||
end
|
||||
MoneyFrame_Update("ClassTrainerDetailMoneyFrame", moneyCost or 0)
|
||||
|
||||
getglobal("BenillaTrainerSkillDescription"):SetText(GetTrainerServiceDescription(id))
|
||||
|
||||
@@ -251,7 +258,7 @@
|
||||
|
||||
function BenillaTrainerFrame_Update()
|
||||
getglobal("BenillaTrainerGreetingText"):SetText(GetTrainerGreetingText())
|
||||
BenillaMoney_Set("BenillaTrainerMoney", GetMoney())
|
||||
getglobal("ClassTrainerMoneyFrame"):Show() -- ref ClassTrainerFrame_Show; type PLAYER paints itself
|
||||
|
||||
local numServices = GetNumTrainerServices()
|
||||
if numServices == 0 then
|
||||
@@ -552,7 +559,6 @@
|
||||
elseif event == "TRAINER_CLOSED" then
|
||||
HideUIPanel(frame)
|
||||
elseif event == "PLAYER_MONEY" then
|
||||
BenillaMoney_Set("BenillaTrainerMoney", GetMoney())
|
||||
-- A buy changes affordability without a TRAINER_UPDATE — repaint the detail's cost tint.
|
||||
if BenillaTrainerFrame:IsVisible() then
|
||||
BenillaTrainerFrame_Update()
|
||||
@@ -635,18 +641,6 @@
|
||||
</Scripts>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
A money coin slot (copied from MerchantFrame.xml's rig): BenillaMoney_Set fills three,
|
||||
highest-denomination-first (ltr) or copper-first (rtl), shrinking each to its number + 13px coin.
|
||||
-->
|
||||
<Frame name="BenillaTrainerCoinTemplate" virtual="true">
|
||||
<Size><AbsDimension x="13" y="13"/></Size>
|
||||
<Layers><Layer level="OVERLAY">
|
||||
<Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors></Texture>
|
||||
<FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT"><Size><AbsDimension x="20" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors></FontString>
|
||||
</Layer></Layers>
|
||||
</Frame>
|
||||
|
||||
<!-- A panel action button (Train/Exit): the UIPanelButtonTemplate face, label via text=. -->
|
||||
<Button name="BenillaTrainerPanelButtonTemplate" virtual="true">
|
||||
<Size><AbsDimension x="80" y="22"/></Size>
|
||||
@@ -723,7 +717,7 @@
|
||||
<Size><AbsDimension x="244" y="0"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaTrainerSkillName" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-4"/></Offset></Anchor></Anchors>
|
||||
</FontString>
|
||||
<FontString name="BenillaTrainerCostLabel" inherits="GameFontNormalSmall" justifyH="LEFT">
|
||||
<FontString name="BenillaTrainerCostLabel" inherits="GameFontNormalSmall" justifyH="LEFT" text="COSTS_LABEL">
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="30" y="-340"/></Offset></Anchor></Anchors>
|
||||
</FontString>
|
||||
<FontString name="BenillaTrainerSkillDescription" inherits="GameFontHighlightSmall" justifyH="LEFT">
|
||||
@@ -855,33 +849,20 @@
|
||||
</Scripts>
|
||||
</Button>
|
||||
|
||||
<!-- The detail cost coins (ref ClassTrainerDetailMoneyFrame), left-to-right off the "Cost:"
|
||||
label; BenillaMoney_Set("BenillaTrainerDetailMoney", cost, true) fills them. Coin1's LEFT
|
||||
pins to the label's RIGHT (not a magic absolute x): each coin auto-shrinks to numW+13 and
|
||||
its number left-aligns at this LEFT edge, growing rightward — so the number abuts the
|
||||
":" with a fixed 4px gap regardless of the amount's digit count (the ref's cost-label →
|
||||
money-frame anchoring; the old absolute x reserved a wide 1-vs-2-digit gap). -->
|
||||
<Frame name="BenillaTrainerDetailMoneyCoin1" inherits="BenillaTrainerCoinTemplate">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="BenillaTrainerCostLabel" relativePoint="RIGHT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor></Anchors>
|
||||
</Frame>
|
||||
<Frame name="BenillaTrainerDetailMoneyCoin2" inherits="BenillaTrainerCoinTemplate">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="BenillaTrainerDetailMoneyCoin1" relativePoint="RIGHT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor></Anchors>
|
||||
</Frame>
|
||||
<Frame name="BenillaTrainerDetailMoneyCoin3" inherits="BenillaTrainerCoinTemplate">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="BenillaTrainerDetailMoneyCoin2" relativePoint="RIGHT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor></Anchors>
|
||||
<!-- ref Blizzard_TrainerUI.xml:463-476, and the reference's own NAME: the cost, a
|
||||
SmallMoneyFrameTemplate of type STATIC at the same LEFT->cost-label RIGHT (5,0) seat,
|
||||
painted by MoneyFrame_Update on every detail refresh. Retires the detail coin trio (1937). -->
|
||||
<Frame name="ClassTrainerDetailMoneyFrame" inherits="SmallMoneyFrameTemplate">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="BenillaTrainerCostLabel" relativePoint="RIGHT"><Offset><AbsDimension x="5" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>SmallMoneyFrame_OnLoad(); MoneyFrame_SetType("STATIC");</OnLoad></Scripts>
|
||||
</Frame>
|
||||
|
||||
<!-- The player purse (ref ClassTrainerMoneyFrame): the rightmost coin (Coin1) pins its
|
||||
BOTTOMRIGHT to the window BOTTOMLEFT + (180, 86); Coin2/Coin3 chain left. Filled
|
||||
copper-first by BenillaMoney_Set("BenillaTrainerMoney", GetMoney()). -->
|
||||
<Frame name="BenillaTrainerMoneyCoin1" inherits="BenillaTrainerCoinTemplate">
|
||||
<Anchors><Anchor point="BOTTOMRIGHT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="180" y="86"/></Offset></Anchor></Anchors>
|
||||
</Frame>
|
||||
<Frame name="BenillaTrainerMoneyCoin2" inherits="BenillaTrainerCoinTemplate">
|
||||
<Anchors><Anchor point="RIGHT" relativeTo="BenillaTrainerMoneyCoin1" relativePoint="LEFT"><Offset><AbsDimension x="-4" y="0"/></Offset></Anchor></Anchors>
|
||||
</Frame>
|
||||
<Frame name="BenillaTrainerMoneyCoin3" inherits="BenillaTrainerCoinTemplate">
|
||||
<Anchors><Anchor point="RIGHT" relativeTo="BenillaTrainerMoneyCoin2" relativePoint="LEFT"><Offset><AbsDimension x="-4" y="0"/></Offset></Anchor></Anchors>
|
||||
<!-- ref Blizzard_TrainerUI.xml:338-345, and the reference's own NAME: the purse, a
|
||||
SmallMoneyFrameTemplate with no OnLoad of its own (type PLAYER from the template; repaints
|
||||
on PLAYER_MONEY). Same BOTTOMRIGHT->window BOTTOMLEFT (180,86) seat; the anchor targets OUR
|
||||
window name until this file migrates. Retires BenillaTrainerMoneyCoin1..3 (1937). -->
|
||||
<Frame name="ClassTrainerMoneyFrame" inherits="SmallMoneyFrameTemplate">
|
||||
<Anchors><Anchor point="BOTTOMRIGHT" relativeTo="BenillaTrainerFrame" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="180" y="86"/></Offset></Anchor></Anchors>
|
||||
</Frame>
|
||||
|
||||
<!-- The Train button (ref ClassTrainerTrainButton): UIPanelButton face + "Train", 80×22, in
|
||||
|
||||
@@ -72,6 +72,7 @@ struct DressCensus {
|
||||
/// What the census reads per entity: identity, kind, pose, the resolved worn set, and the attach
|
||||
/// roots actually standing.
|
||||
type DressQuery = (
|
||||
Entity,
|
||||
&'static Guid,
|
||||
&'static NetEntity,
|
||||
&'static Transform,
|
||||
@@ -87,6 +88,13 @@ fn fire_dress_census(
|
||||
names: Res<NameCache>,
|
||||
body: Query<&Transform, With<SelfPlayer>>,
|
||||
entities: Query<DressQuery>,
|
||||
// The body's draw population (`parts=`/`mats=`): every mesh part hanging under the unit —
|
||||
// body batches, each attach model's batches, cards — and how many DISTINCT materials
|
||||
// they bind. The gap between the two is the ceiling on merging a body's batches by
|
||||
// material (1929's first proposal): what a merge could collapse, read off a dressed body
|
||||
// instead of guessed from a model's batch table.
|
||||
children: Query<&Children>,
|
||||
parts: Query<&MeshMaterial3d<benilla_assets::materials::WowModelMaterial>>,
|
||||
) {
|
||||
let now = time.elapsed_secs();
|
||||
if probe.next <= 0.0 || now < probe.next {
|
||||
@@ -109,7 +117,7 @@ fn fire_dress_census(
|
||||
// `(is_contradiction, sort key, line)`. A contradiction sorts to the top: it is the finding.
|
||||
let mut rows: Vec<(bool, i64, String)> = Vec::new();
|
||||
let (mut hiding_helm, mut hiding_cloak, mut bad) = (0u32, 0u32, 0u32);
|
||||
for (guid, net, t, store, equipment, attached) in &entities {
|
||||
for (unit, guid, net, t, store, equipment, attached) in &entities {
|
||||
if net.kind != EntityKind::Player
|
||||
|| t.translation.distance_squared(body.translation) > radius * radius
|
||||
{
|
||||
@@ -140,12 +148,13 @@ fn fire_dress_census(
|
||||
(false, false) => "-",
|
||||
};
|
||||
let dist = t.translation.distance(body.translation);
|
||||
let (n_parts, n_mats) = draw_population(unit, &children, &parts);
|
||||
rows.push((
|
||||
contradiction,
|
||||
(dist * 100.0) as i64,
|
||||
format!(
|
||||
"DRESS {:#018x} d={dist:6.1} flags={flags:#010x} hide={hide:<10} \
|
||||
helm={:<6} cloak={:<6} settled={} spawned=[{}] {}{}",
|
||||
helm={:<6} cloak={:<6} settled={} spawned=[{}] parts={n_parts} mats={n_mats} {}{}",
|
||||
guid.0,
|
||||
eq.helm,
|
||||
eq.cloak,
|
||||
@@ -170,3 +179,20 @@ fn fire_dress_census(
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Mesh parts under `unit` (every depth) and the distinct materials they bind.
|
||||
fn draw_population(
|
||||
unit: Entity,
|
||||
children: &Query<&Children>,
|
||||
parts: &Query<&MeshMaterial3d<benilla_assets::materials::WowModelMaterial>>,
|
||||
) -> (usize, usize) {
|
||||
let mut n = 0usize;
|
||||
let mut mats = std::collections::HashSet::new();
|
||||
for e in children.iter_descendants(unit) {
|
||||
if let Ok(m) = parts.get(e) {
|
||||
n += 1;
|
||||
mats.insert(m.0.id());
|
||||
}
|
||||
}
|
||||
(n, mats.len())
|
||||
}
|
||||
|
||||
@@ -114,6 +114,14 @@ type ScreenParams<'w, 's> = (
|
||||
Query<'w, 's, &'static bevy::window::Monitor>,
|
||||
Option<Res<'w, crate::perf::GpuMsShared>>,
|
||||
Local<'s, Vec<f32>>,
|
||||
Option<Res<'w, crate::perf::WgpuCensusShared>>,
|
||||
// Every camera, for `views=`: the number of ACTIVE views the render app walked this frame —
|
||||
// each is its own pass set (a portrait booth awake, a body pane open), and a lane that
|
||||
// scales with views (cluster assignment, the view bind groups, every per-pass tracker) is
|
||||
// not a scene cost until this number is known.
|
||||
Query<'w, 's, &'static Camera>,
|
||||
// The UI's live texture-identity runs (`ui_batches=`): one `Mesh2d` draw each in the 2D pass.
|
||||
Query<'w, 's, (), With<crate::ui_pass::UiQuadBatch>>,
|
||||
);
|
||||
|
||||
#[derive(SystemParam)]
|
||||
@@ -157,7 +165,14 @@ fn drive_live_fps(
|
||||
// the leg line carries gpu percentiles beside the cpu ones (absent when the meter is off).
|
||||
mut screen: ScreenParams,
|
||||
) {
|
||||
let (monitors, gpu, gpu_samples) = (&screen.0, &screen.1, &mut screen.2);
|
||||
let (monitors, gpu, gpu_samples, wgpu_census, cameras, ui_batches) = (
|
||||
&screen.0,
|
||||
&screen.1,
|
||||
&mut screen.2,
|
||||
&screen.3,
|
||||
&screen.4,
|
||||
&screen.5,
|
||||
);
|
||||
// Drain every frame so the state is current whichever phase we're in — the window can be
|
||||
// occluded before sampling ever starts (a detached launch spawns behind whatever is open).
|
||||
for o in occlusions.read() {
|
||||
@@ -301,8 +316,24 @@ fn drive_live_fps(
|
||||
let mut g = std::mem::take(&mut **gpu_samples);
|
||||
g.sort_by(f32::total_cmp);
|
||||
let gat = |q: f32| g[(((g.len() - 1) as f32) * q).round() as usize];
|
||||
// …and the wgpu resource census beside it (`perf::gpu::WgpuCensus`): the
|
||||
// residency term of the pass-encode CPU, sampled at the window's end.
|
||||
let census = wgpu_census
|
||||
.as_ref()
|
||||
.map(|c| {
|
||||
let ld = |a: &std::sync::atomic::AtomicU64| {
|
||||
a.load(std::sync::atomic::Ordering::Relaxed)
|
||||
};
|
||||
format!(
|
||||
" wgpu_bufs={} wgpu_texs={} wgpu_bgs={}",
|
||||
ld(&c.0.buffers),
|
||||
ld(&c.0.textures),
|
||||
ld(&c.0.bind_groups)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
" gpu_p50={:.2} gpu_p99={:.2} gpu_max={:.2}",
|
||||
" gpu_p50={:.2} gpu_p99={:.2} gpu_max={:.2}{census}",
|
||||
gat(0.50),
|
||||
gat(0.99),
|
||||
g[g.len() - 1]
|
||||
@@ -400,8 +431,15 @@ fn drive_live_fps(
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let residency_line = format!(
|
||||
" mats={} meshes={} images={} uv={} tint={}",
|
||||
seen.mats, seen.meshes, seen.images, seen.uv_anims, seen.tint_anims,
|
||||
" mats={} mats_parked={} meshes={} images={} uv={} tint={} views={} ui_batches={}",
|
||||
seen.mats,
|
||||
seen.mats_parked,
|
||||
seen.meshes,
|
||||
seen.images,
|
||||
seen.uv_anims,
|
||||
seen.tint_anims,
|
||||
cameras.iter().filter(|c| c.is_active).count(),
|
||||
ui_batches.iter().count(),
|
||||
);
|
||||
println!(
|
||||
"FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={} active={} particles={} submeshes={} drawn={} streamed={} parked={} entities={}{rigs}{residency_line} px={}x{}{cpu}{sys}{present}{display}{gpu_line} occluded_frames={}{at_pin}{cam_pose}{gate}{sky}{ribbons}{culled}",
|
||||
|
||||
@@ -761,6 +761,7 @@ impl Plugin for EntitiesPlugin {
|
||||
.before(benilla_world::schedule::WorldStage::Input),
|
||||
)
|
||||
.init_resource::<SkinComposites>()
|
||||
.init_resource::<attach::MergedFormsCache>()
|
||||
// The 16 bone-pile body models (decision 1706), keyed (race, sex) rather than by a
|
||||
// display id — a skeleton has no CreatureDisplayInfo row.
|
||||
.init_resource::<corpse::BonesModels>()
|
||||
|
||||
@@ -175,10 +175,27 @@ pub(super) struct PartDress<'a> {
|
||||
/// at the bone pivot and its transform belongs to the billboard system, so as a plain child it would
|
||||
/// render at the model origin (the "glow on the ground" family, decision 0153). It spawns as a
|
||||
/// lightweight mirror anchor under the unit plus a world-root card following the anchor's live joint.
|
||||
/// [`spawn_part`] for a merged group (`attach::merge`): the synthetic group part, the first
|
||||
/// member's index, and the member list the redress reads back. A singleton passes `None` and
|
||||
/// is exactly [`spawn_part`].
|
||||
pub(super) fn spawn_group(
|
||||
commands: &mut Commands,
|
||||
part: &EntityPart,
|
||||
group: &super::merge::BodyGroup,
|
||||
dress: &PartDress,
|
||||
) -> bool {
|
||||
let members =
|
||||
(group.members.len() > 1).then(|| super::merge::DressedGroup(group.members.clone().into()));
|
||||
spawn_part(commands, part, group.first() as usize, members, dress)
|
||||
}
|
||||
|
||||
/// Spawn one mesh part (or billboard card) of a body under `dress.unit`; `group` is the merged
|
||||
/// member list for a group of several batches (`None` for a batch of its own).
|
||||
pub(super) fn spawn_part(
|
||||
commands: &mut Commands,
|
||||
part: &EntityPart,
|
||||
index: usize,
|
||||
group: Option<super::merge::DressedGroup>,
|
||||
dress: &PartDress,
|
||||
) -> bool {
|
||||
if let Some(info) = &part.billboard {
|
||||
@@ -228,6 +245,9 @@ pub(super) fn spawn_part(
|
||||
card: None,
|
||||
},
|
||||
));
|
||||
if let Some(group) = group {
|
||||
child.insert(group);
|
||||
}
|
||||
insert_pick_marker(&mut child, dress.kind);
|
||||
// Every part gets a tag: the instance slot plus the live alpha field. Unconditional because the
|
||||
// slot is a per-instance identity every part needs (the tint) rather than a skinning detail —
|
||||
@@ -530,7 +550,7 @@ mod tests {
|
||||
let armed = {
|
||||
let world = app.world();
|
||||
let mut commands = Commands::new(&mut queue, world);
|
||||
spawn_part(&mut commands, &fadeable, 0, &dress)
|
||||
spawn_part(&mut commands, &fadeable, 0, None, &dress)
|
||||
};
|
||||
queue.apply(app.world_mut());
|
||||
assert!(!armed, "steady: no ramp armed");
|
||||
@@ -594,7 +614,7 @@ mod tests {
|
||||
let armed = {
|
||||
let world = app.world();
|
||||
let mut commands = Commands::new(&mut queue, world);
|
||||
spawn_part(&mut commands, &card, 0, &dress)
|
||||
spawn_part(&mut commands, &card, 0, None, &dress)
|
||||
};
|
||||
queue.apply(app.world_mut());
|
||||
assert!(
|
||||
@@ -670,8 +690,8 @@ mod tests {
|
||||
{
|
||||
let world = app.world();
|
||||
let mut commands = Commands::new(&mut queue, world);
|
||||
spawn_part(&mut commands, &part(None, false), 0, &dress);
|
||||
spawn_part(&mut commands, &billboard, 1, &dress);
|
||||
spawn_part(&mut commands, &part(None, false), 0, None, &dress);
|
||||
spawn_part(&mut commands, &billboard, 1, None, &dress);
|
||||
}
|
||||
queue.apply(app.world_mut());
|
||||
let world = app.world_mut();
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
//! **A body's batches, merged by material** — one mesh entity per *material group* of a dressed
|
||||
//! body instead of one per authored M2 batch (decision 1940, the crowd rig's first proposal).
|
||||
//!
|
||||
//! A geared level-60 body stands as 12–27 mesh parts binding 5–16 distinct materials (the dress
|
||||
//! census's `parts=`/`mats=`, read off a 40-man raid at the Stormwind auction house): the skin
|
||||
//! composite serves the torso, arms, legs, hands and feet as separate opaque batches, the hair
|
||||
//! texture serves the hair and the facial geosets, and every one of those was its own entity —
|
||||
//! its own row in every per-entity sweep Bevy and we run each frame (bounds, specialization,
|
||||
//! visibility, extraction, the material walk), its own phase item, its own draw with its own
|
||||
//! bind-group and vertex-buffer sets in wgpu's pass encoder. The raid's cost is that population,
|
||||
//! not a hot function (1929), and per body about 40 % of it is batches that draw with a material
|
||||
//! the batch beside it already binds.
|
||||
//!
|
||||
//! **What merges.** Two visible batches of one body join when they would be *indistinguishable*
|
||||
//! to everything downstream of the spawn: the same six-handle material set (steady, the two
|
||||
//! interior lanes, the fade twins, the depth-prime twin — every component derived from them is
|
||||
//! then identical), the same blend/sidedness, both static or both skinned, and neither carrying
|
||||
//! per-batch state the merge cannot represent: an animated alpha track, a billboard, a welded
|
||||
//! billboard seam, a ground-decal quad. Transparent batches never meet — their material key
|
||||
//! carries the authored batch order (`model_render::MatKey::batch_order`), so each has its own
|
||||
//! handle by construction and painter's order is untouched. The key is *static*: which slot a
|
||||
//! batch's texture comes from and its authored flags, never the texture itself — so a gear change
|
||||
//! that swaps the composite re-points a group's material in place exactly as it re-pointed a
|
||||
//! part's, and only a change to the *visible set* (a geoset the new gear hides or reveals)
|
||||
//! rebuilds the groups it touches.
|
||||
//!
|
||||
//! **What a group is.** A synthetic [`EntityPart`] cloned from its first member, with the
|
||||
//! geometry replaced by the members' concatenation and the render forms built from it the way
|
||||
//! `model_forms` builds a batch's — the static form `RENDER_WORLD`-only, the skinned twin keeping
|
||||
//! its main-world copy for the picker (0834's contract, unchanged). The concatenated
|
||||
//! [`RenderSubmesh`] is also the group's [`PickMesh`], so the mouseover ray still tests the
|
||||
//! resident geometry it always did. Merged forms are cached per (model geometry, member set):
|
||||
//! forty humans in the same gear silhouette share them the way they shared the per-batch forms.
|
||||
//!
|
||||
//! Attach models (helm, shoulders, held items) are their own M2s with their own roots and are not
|
||||
//! in scope here; a body's billboard cards keep their own spawner (`dress::spawn_billboard_part`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use benilla_formats::{ModelBlend, RenderSubmesh};
|
||||
use bevy::asset::AssetId;
|
||||
use bevy::camera::primitives::{Aabb, MeshAabb};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_assets::materials::WowModelMaterial;
|
||||
use benilla_formats::CharSkinSlot;
|
||||
|
||||
use super::super::EntityPart;
|
||||
use super::char_skin::CharSkinMaterials;
|
||||
use super::dress::part_materials;
|
||||
|
||||
/// The members of a merged group, on the group's entity beside its `DressedPart` (whose `index`
|
||||
/// is the first member). Absent on a singleton.
|
||||
#[derive(Component, Clone)]
|
||||
pub(in crate::entities) struct DressedGroup(pub(in crate::entities) Arc<[u32]>);
|
||||
|
||||
/// What makes two of one body's batches interchangeable downstream of the spawn. Built from the
|
||||
/// part's *slot* and flags, never from a resolved texture — see the module doc.
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
struct MergeKey {
|
||||
/// The slot the material comes from: a character slot, or the batch's own built materials
|
||||
/// (identified by their handles — the same six for every batch sharing a texture).
|
||||
source: MaterialSource,
|
||||
blend: ModelBlend,
|
||||
additive: bool,
|
||||
two_sided: bool,
|
||||
skinned: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
enum MaterialSource {
|
||||
Char(CharSkinSlot),
|
||||
Own([Option<AssetId<WowModelMaterial>>; 6]),
|
||||
}
|
||||
|
||||
/// The key a part merges under, or `None` for a part that stays a singleton.
|
||||
fn merge_key(part: &EntityPart) -> Option<MergeKey> {
|
||||
if part.billboard.is_some()
|
||||
|| part.welded_billboard
|
||||
|| part.alpha_anim.is_some()
|
||||
|| part.ground_quad.is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let id = |h: &Option<Handle<WowModelMaterial>>| h.as_ref().map(Handle::id);
|
||||
let source = match part.char_slot {
|
||||
Some(slot) => MaterialSource::Char(slot),
|
||||
None => MaterialSource::Own([
|
||||
Some(part.material.id()),
|
||||
id(&part.material_interior),
|
||||
id(&part.material_interior_bake),
|
||||
id(&part.material_interior_bake_blend),
|
||||
id(&part.fade_blend),
|
||||
id(&part.zfill),
|
||||
]),
|
||||
};
|
||||
Some(MergeKey {
|
||||
source,
|
||||
blend: part.blend,
|
||||
additive: part.additive,
|
||||
two_sided: part.two_sided,
|
||||
skinned: part.skinned_mesh.is_some(),
|
||||
})
|
||||
}
|
||||
|
||||
/// One spawn unit: the member indices into the body's parts, first member lowest.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(in crate::entities) struct BodyGroup {
|
||||
pub(in crate::entities) members: Vec<u32>,
|
||||
}
|
||||
|
||||
impl BodyGroup {
|
||||
pub(in crate::entities) fn first(&self) -> u32 {
|
||||
self.members[0]
|
||||
}
|
||||
}
|
||||
|
||||
/// Partition the shown parts of a body into spawn groups, in first-member order. `shows` is the
|
||||
/// geoset predicate the dress and the redress both evaluate.
|
||||
pub(in crate::entities) fn group_parts(
|
||||
parts: &[EntityPart],
|
||||
shows: impl Fn(&EntityPart) -> bool,
|
||||
) -> Vec<BodyGroup> {
|
||||
let mut groups: Vec<BodyGroup> = Vec::new();
|
||||
let mut by_key: HashMap<MergeKey, usize> = HashMap::new();
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
if !shows(part) {
|
||||
continue;
|
||||
}
|
||||
let i = u32::try_from(i).expect("a model's batch count fits u32");
|
||||
match merge_key(part) {
|
||||
Some(key) => match by_key.get(&key) {
|
||||
Some(&g) => groups[g].members.push(i),
|
||||
None => {
|
||||
by_key.insert(key, groups.len());
|
||||
groups.push(BodyGroup { members: vec![i] });
|
||||
}
|
||||
},
|
||||
None => groups.push(BodyGroup { members: vec![i] }),
|
||||
}
|
||||
}
|
||||
groups.sort_by_key(BodyGroup::first);
|
||||
groups
|
||||
}
|
||||
|
||||
/// The members' geometry as one submesh: attributes concatenated, indices re-based. Every member
|
||||
/// shares the first's material facts by construction of the key, so the first's texture, flags
|
||||
/// and slots are the group's.
|
||||
fn concat_geometry(first: &RenderSubmesh, rest: &[&RenderSubmesh]) -> RenderSubmesh {
|
||||
let mut out = first.clone();
|
||||
// Per-vertex attributes that are either full-length or absent on every member: a mismatch
|
||||
// in presence would leave the group half-attributed, so the merge key keeps such batches
|
||||
// apart only implicitly (a model authors these per skin, not per batch); assert the
|
||||
// invariant rather than guess.
|
||||
for sub in rest {
|
||||
let base = u32::try_from(out.positions.len()).expect("merged vertex count fits u32");
|
||||
out.positions.extend_from_slice(&sub.positions);
|
||||
out.uvs.extend_from_slice(&sub.uvs);
|
||||
out.indices.extend(sub.indices.iter().map(|i| i + base));
|
||||
extend_matched(&mut out.normals, &sub.normals, out.positions.len());
|
||||
extend_matched(&mut out.joints, &sub.joints, out.positions.len());
|
||||
extend_matched(&mut out.weights, &sub.weights, out.positions.len());
|
||||
extend_matched(
|
||||
&mut out.vertex_colors,
|
||||
&sub.vertex_colors,
|
||||
out.positions.len(),
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Extend a per-vertex attribute so it stays either exactly full-length or empty: a member
|
||||
/// without it drops the attribute for the whole group (the renderer recomputes normals; a
|
||||
/// missing skin on any member means the group is not skinned, which the key already ensured).
|
||||
fn extend_matched<T: Copy>(dst: &mut Vec<T>, src: &[T], full: usize) {
|
||||
if dst.is_empty() && src.is_empty() {
|
||||
return;
|
||||
}
|
||||
let before = full - src.len().min(full);
|
||||
if dst.len() == before && !src.is_empty() {
|
||||
dst.extend_from_slice(src);
|
||||
} else {
|
||||
dst.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// A group's built render forms: the merged geometry (the group's `PickMesh`), its static form,
|
||||
/// its skinned twin when every member skins, and the static form's build-time bound.
|
||||
#[derive(Clone)]
|
||||
pub(in crate::entities) struct MergedForms {
|
||||
pub(in crate::entities) geometry: Arc<RenderSubmesh>,
|
||||
pub(in crate::entities) static_mesh: Handle<Mesh>,
|
||||
pub(in crate::entities) skinned_mesh: Option<Handle<Mesh>>,
|
||||
pub(in crate::entities) aabb: Option<Aabb>,
|
||||
}
|
||||
|
||||
/// The merged-form cache, keyed by (the first member's geometry, the member set) — the geometry
|
||||
/// `Arc` is the model's own, shared by every unit of that display, so the key names one model's
|
||||
/// one silhouette. Strong handles: a cached form outlives its units the way a model's per-batch
|
||||
/// forms do, and the whole table drops past [`MERGED_FORMS_CAP`] entries rather than growing
|
||||
/// with every silhouette a session ever sees.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct MergedFormsCache(HashMap<(usize, Vec<u32>), MergedForms>);
|
||||
|
||||
const MERGED_FORMS_CAP: usize = 1024;
|
||||
|
||||
impl MergedFormsCache {
|
||||
/// The forms of `group` over `parts`, built on a miss. A singleton borrows the model's own
|
||||
/// forms and never enters the cache.
|
||||
pub(in crate::entities) fn forms(
|
||||
&mut self,
|
||||
parts: &[EntityPart],
|
||||
group: &BodyGroup,
|
||||
meshes: &mut Assets<Mesh>,
|
||||
) -> MergedForms {
|
||||
let first = &parts[group.first() as usize];
|
||||
if group.members.len() == 1 {
|
||||
return MergedForms {
|
||||
geometry: first.geometry.clone(),
|
||||
static_mesh: first.mesh.clone(),
|
||||
skinned_mesh: first.skinned_mesh.clone(),
|
||||
aabb: first.aabb,
|
||||
};
|
||||
}
|
||||
let key = (Arc::as_ptr(&first.geometry) as usize, group.members.clone());
|
||||
if let Some(f) = self.0.get(&key) {
|
||||
return f.clone();
|
||||
}
|
||||
if self.0.len() >= MERGED_FORMS_CAP {
|
||||
self.0.clear();
|
||||
}
|
||||
let rest: Vec<&RenderSubmesh> = group.members[1..]
|
||||
.iter()
|
||||
.map(|&i| &*parts[i as usize].geometry)
|
||||
.collect();
|
||||
let merged = concat_geometry(&first.geometry, &rest);
|
||||
let stat = benilla_assets::submesh_to_static_mesh(&merged);
|
||||
let aabb = stat.compute_aabb();
|
||||
// The skinned twin exists only when every member skins AND the concatenation kept a
|
||||
// full joint set — a group drawn static on a rigged body would stand in bind pose.
|
||||
let skinned = (group
|
||||
.members
|
||||
.iter()
|
||||
.all(|&i| parts[i as usize].skinned_mesh.is_some())
|
||||
&& merged.joints.len() == merged.positions.len())
|
||||
.then(|| meshes.add(benilla_assets::submesh_to_skinned_mesh(&merged)));
|
||||
let forms = MergedForms {
|
||||
geometry: Arc::new(merged),
|
||||
static_mesh: meshes.add(stat),
|
||||
skinned_mesh: skinned,
|
||||
aabb,
|
||||
};
|
||||
self.0.insert(key, forms.clone());
|
||||
forms
|
||||
}
|
||||
}
|
||||
|
||||
/// The group as the part the spawner sees: the first member with the merged forms in place.
|
||||
pub(in crate::entities) fn group_part(
|
||||
parts: &[EntityPart],
|
||||
group: &BodyGroup,
|
||||
forms: MergedForms,
|
||||
) -> EntityPart {
|
||||
let mut part = parts[group.first() as usize].clone();
|
||||
part.geometry = forms.geometry;
|
||||
part.mesh = forms.static_mesh;
|
||||
part.skinned_mesh = forms.skinned_mesh;
|
||||
part.aabb = forms.aabb;
|
||||
part
|
||||
}
|
||||
|
||||
/// Do two members resolve the same materials? The key promises it for the static half; this is
|
||||
/// the check the character half makes at dress time (a slot resolving to `None` — no look, no
|
||||
/// tables — falls back to the batch's own materials, which the key did not compare).
|
||||
pub(in crate::entities) fn same_materials(
|
||||
parts: &[EntityPart],
|
||||
group: &BodyGroup,
|
||||
char_mats: &CharSkinMaterials,
|
||||
) -> bool {
|
||||
let first = part_materials(&parts[group.first() as usize], char_mats);
|
||||
let ids = |m: &super::dress::PartMaterials<'_>| {
|
||||
[
|
||||
Some(m.steady.id()),
|
||||
m.interior.map(Handle::id),
|
||||
m.fade_blend.map(Handle::id),
|
||||
m.bake.map(Handle::id),
|
||||
m.bake_blend.map(Handle::id),
|
||||
m.zfill.map(Handle::id),
|
||||
]
|
||||
};
|
||||
let want = ids(&first);
|
||||
group.members[1..]
|
||||
.iter()
|
||||
.all(|&i| ids(&part_materials(&parts[i as usize], char_mats)) == want)
|
||||
}
|
||||
|
||||
/// Split every group whose members do not resolve the same materials into singletons — the
|
||||
/// dress-time guard behind [`same_materials`]. Cheap: the resolve is a slot match.
|
||||
pub(in crate::entities) fn guard_groups(
|
||||
groups: Vec<BodyGroup>,
|
||||
parts: &[EntityPart],
|
||||
char_mats: &CharSkinMaterials,
|
||||
) -> Vec<BodyGroup> {
|
||||
let mut out = Vec::with_capacity(groups.len());
|
||||
for g in groups {
|
||||
if same_materials(parts, &g, char_mats) {
|
||||
out.push(g);
|
||||
} else {
|
||||
out.extend(g.members.iter().map(|&m| BodyGroup { members: vec![m] }));
|
||||
}
|
||||
}
|
||||
out.sort_by_key(BodyGroup::first);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tri(x: f32) -> RenderSubmesh {
|
||||
RenderSubmesh {
|
||||
positions: vec![[x, 0.0, 0.0], [x, 1.0, 0.0], [x, 0.0, 1.0]],
|
||||
normals: vec![[0.0, 0.0, 1.0]; 3],
|
||||
uvs: vec![[0.0, 0.0]; 3],
|
||||
indices: vec![0, 1, 2],
|
||||
joints: vec![[1, 0, 0, 0]; 3],
|
||||
weights: vec![[1.0, 0.0, 0.0, 0.0]; 3],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concatenation_rebases_indices_and_keeps_full_attributes() {
|
||||
let a = tri(0.0);
|
||||
let b = tri(5.0);
|
||||
let m = concat_geometry(&a, &[&b]);
|
||||
assert_eq!(m.positions.len(), 6);
|
||||
assert_eq!(m.indices, vec![0, 1, 2, 3, 4, 5]);
|
||||
assert_eq!(m.normals.len(), 6);
|
||||
assert_eq!(m.joints.len(), 6);
|
||||
assert_eq!(m.weights.len(), 6);
|
||||
assert!(m.vertex_colors.is_empty(), "absent on both ⇒ absent");
|
||||
}
|
||||
|
||||
fn part(material: u128, geoset: u16) -> EntityPart {
|
||||
EntityPart {
|
||||
mesh: Handle::default(),
|
||||
geometry: Arc::new(tri(0.0)),
|
||||
aabb: None,
|
||||
skinned_mesh: Some(Handle::default()),
|
||||
material: Handle::from(bevy::asset::uuid::Uuid::from_u128(material)),
|
||||
material_interior: None,
|
||||
material_interior_bake: None,
|
||||
material_interior_bake_blend: None,
|
||||
fade_blend: None,
|
||||
zfill: None,
|
||||
blend: ModelBlend::Opaque,
|
||||
additive: false,
|
||||
two_sided: false,
|
||||
geoset_id: geoset,
|
||||
char_slot: None,
|
||||
billboard: None,
|
||||
welded_billboard: false,
|
||||
alpha_anim: None,
|
||||
rgb_anim: None,
|
||||
ground_quad: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batches_group_by_material_and_state_stays_a_singleton() {
|
||||
let mut parts = vec![
|
||||
part(1, 0), // skin
|
||||
part(2, 100), // hair
|
||||
part(1, 400), // gloves — the skin's material
|
||||
part(1, 500), // boots — the skin's material, hidden below
|
||||
part(2, 200), // facial — the hair's material, but animated: singleton
|
||||
part(3, 1500), // cloak: static form, its own material
|
||||
];
|
||||
parts[4].welded_billboard = true;
|
||||
parts[5].skinned_mesh = None;
|
||||
let groups = group_parts(&parts, |p| p.geoset_id != 500);
|
||||
let members: Vec<Vec<u32>> = groups.iter().map(|g| g.members.clone()).collect();
|
||||
assert_eq!(members, vec![vec![0, 2], vec![1], vec![4], vec![5]]);
|
||||
assert_eq!(groups[0].first(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_static_and_a_skinned_batch_never_share_a_group() {
|
||||
let mut parts = vec![part(1, 0), part(1, 1)];
|
||||
parts[1].skinned_mesh = None;
|
||||
let groups = group_parts(&parts, |_| true);
|
||||
assert_eq!(groups.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_member_without_an_attribute_drops_it_for_the_group() {
|
||||
let a = tri(0.0);
|
||||
let mut b = tri(5.0);
|
||||
b.normals.clear();
|
||||
let m = concat_geometry(&a, &[&b]);
|
||||
assert!(
|
||||
m.normals.is_empty(),
|
||||
"half-attributed is worse than recomputed"
|
||||
);
|
||||
assert_eq!(m.joints.len(), 6);
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,9 @@ use super::{
|
||||
mod char_skin;
|
||||
use char_skin::{build_char_skin_materials, equip_geosets, resolve_char_look, resolve_worn_equip};
|
||||
mod dress;
|
||||
use dress::{spawn_part, PartDress};
|
||||
mod merge;
|
||||
use dress::{spawn_group, PartDress};
|
||||
pub(super) use merge::MergedFormsCache;
|
||||
mod preview;
|
||||
pub(crate) use preview::equip_slot;
|
||||
pub(super) use preview::{build_dressup_preview, build_glue_pet, build_glue_preview};
|
||||
@@ -299,6 +301,9 @@ pub(super) fn attach_entity_visuals(
|
||||
ResMut<SkinComposites>,
|
||||
Res<AssetServer>,
|
||||
benilla_world::model_render::M2BatchMaterials,
|
||||
// The merged-group forms (`merge`): built on a body's first silhouette, shared after.
|
||||
ResMut<Assets<Mesh>>,
|
||||
ResMut<merge::MergedFormsCache>,
|
||||
),
|
||||
// The owned skin-palette table (decision 0720): every skinned instance claims a rig slot.
|
||||
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
|
||||
@@ -307,8 +312,16 @@ pub(super) fn attach_entity_visuals(
|
||||
mut collider_epoch: ResMut<benilla_world::collision::ColliderEpoch>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
let (sections, world_assets, mut images, mut skin_composites, asset_server, mut mats) =
|
||||
skin_build;
|
||||
let (
|
||||
sections,
|
||||
world_assets,
|
||||
mut images,
|
||||
mut skin_composites,
|
||||
asset_server,
|
||||
mut mats,
|
||||
mut meshes,
|
||||
mut merged,
|
||||
) = skin_build;
|
||||
// Arm each entity's appear-fade at the moment its visual attaches (≈ its first-visible moment).
|
||||
let now = time.elapsed_secs();
|
||||
for (entity, net, equipment, reattached, mount_child, mount_body, anchored) in &pending {
|
||||
@@ -792,18 +805,21 @@ pub(super) fn attach_entity_visuals(
|
||||
JoinedFade::Pending { since: now }
|
||||
},
|
||||
};
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
// Skip a geoset this character doesn't show (an unselected hair/facial/body
|
||||
// variant, or a body region its worn gear replaces). The equipment half of that
|
||||
// selection is re-run in place on a gear change (`attach::redress`); this is the
|
||||
// same predicate, evaluated once at build.
|
||||
if visible_geosets
|
||||
// A geoset this character doesn't show (an unselected hair/facial/body variant, or a
|
||||
// body region its worn gear replaces) is skipped. The equipment half of that
|
||||
// selection is re-run in place on a gear change (`attach::redress`); this is the
|
||||
// same predicate, evaluated once at build. The shown batches spawn as material
|
||||
// GROUPS (`merge`): one entity per set of batches nothing downstream can tell apart.
|
||||
let shows = |part: &super::EntityPart| {
|
||||
visible_geosets
|
||||
.as_ref()
|
||||
.is_some_and(|vis| !vis.contains(&part.geoset_id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
unit_will_fade |= spawn_part(&mut commands, part, i, &dress);
|
||||
.is_none_or(|vis| vis.contains(&part.geoset_id))
|
||||
};
|
||||
let groups = merge::guard_groups(merge::group_parts(parts, shows), parts, &char_mats);
|
||||
for group in &groups {
|
||||
let forms = merged.forms(parts, group, &mut meshes);
|
||||
let part = merge::group_part(parts, group, forms);
|
||||
unit_will_fade |= spawn_group(&mut commands, &part, group, &dress);
|
||||
}
|
||||
// Mirror the appear-fade clock onto the unit root (see `unit_will_fade` above): a held item
|
||||
// / helm / shoulder attaching later reads this to join the same ramp
|
||||
|
||||
@@ -49,7 +49,8 @@ use super::char_skin::{
|
||||
build_char_skin_materials, equip_geosets, resolve_char_look, resolve_worn_equip,
|
||||
CharSkinMaterials,
|
||||
};
|
||||
use super::dress::{part_materials, spawn_part, DressedPart, PartDress};
|
||||
use super::dress::{part_materials, spawn_group, DressedPart, PartDress};
|
||||
use super::merge::{self, DressedGroup, MergedFormsCache};
|
||||
|
||||
/// The per-part write surface of a re-dress: the displayed material and the three records that
|
||||
/// decide what a part draws when something *else* owns that channel — the interior classifier's law
|
||||
@@ -90,6 +91,7 @@ pub(in crate::entities) fn redress_player_looks(
|
||||
// The parts already standing under those players — found again by the batch index each carries.
|
||||
mut standing: Query<(
|
||||
&DressedPart,
|
||||
Option<&DressedGroup>,
|
||||
Option<&mut MeshMaterial3d<WowModelMaterial>>,
|
||||
Option<&mut InteriorLit>,
|
||||
Option<&mut FadeMaterials>,
|
||||
@@ -110,11 +112,21 @@ pub(in crate::entities) fn redress_player_looks(
|
||||
ResMut<SkinComposites>,
|
||||
Res<AssetServer>,
|
||||
benilla_world::model_render::M2BatchMaterials,
|
||||
ResMut<Assets<Mesh>>,
|
||||
ResMut<MergedFormsCache>,
|
||||
),
|
||||
time: Res<Time>,
|
||||
) {
|
||||
let (sections, world_assets, mut images, mut skin_composites, asset_server, mut mats) =
|
||||
skin_build;
|
||||
let (
|
||||
sections,
|
||||
world_assets,
|
||||
mut images,
|
||||
mut skin_composites,
|
||||
asset_server,
|
||||
mut mats,
|
||||
mut meshes,
|
||||
mut merged,
|
||||
) = skin_build;
|
||||
let now = time.elapsed_secs();
|
||||
for (entity, net, live, mut applied, children, rig, bones, mut pose, bake_center, unit_fade) in
|
||||
&mut players
|
||||
@@ -168,12 +180,24 @@ pub(in crate::entities) fn redress_player_looks(
|
||||
// No look (a druid form, a GM morph — a Player-kind entity on a beast display) means no
|
||||
// geoset filter, exactly as at build: every batch the model authors draws.
|
||||
let shows = |geoset: u16| visible.as_ref().is_none_or(|v| v.contains(&geoset));
|
||||
// The grouping the new gear asks for (`merge`): a standing group survives exactly when
|
||||
// the new grouping contains its member set unchanged — then it re-points in place like
|
||||
// any part; otherwise it is torn down and the new group spawns whole. Keyed by first
|
||||
// member, which is what a standing entity's `DressedPart::index` carries.
|
||||
let groups = merge::guard_groups(
|
||||
merge::group_parts(parts, |p| shows(p.geoset_id)),
|
||||
parts,
|
||||
&char_mats,
|
||||
);
|
||||
let group_of: std::collections::HashMap<u32, &merge::BodyGroup> =
|
||||
groups.iter().map(|g| (g.first(), g)).collect();
|
||||
|
||||
// Pass 1 — the parts already standing: hide-by-despawn, or re-point.
|
||||
let mut present = vec![false; parts.len()];
|
||||
let (mut hidden, mut repointed) = (0usize, 0usize);
|
||||
for child in children.iter() {
|
||||
let Ok((dp, mat, lit, fade_mats, portrait, ramping, pending)) = standing.get_mut(child)
|
||||
let Ok((dp, group, mat, lit, fade_mats, portrait, ramping, pending)) =
|
||||
standing.get_mut(child)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
@@ -181,7 +205,14 @@ pub(in crate::entities) fn redress_player_looks(
|
||||
let Some(part) = parts.get(dp.index as usize) else {
|
||||
continue; // a stale index (the model rebuilt under us) — leave it to the teardown
|
||||
};
|
||||
if !shows(part.geoset_id) {
|
||||
let standing_members: &[u32] = match group {
|
||||
Some(g) => &g.0,
|
||||
None => std::slice::from_ref(&dp.index),
|
||||
};
|
||||
let still_grouped = group_of
|
||||
.get(&dp.index)
|
||||
.is_some_and(|g| g.members == standing_members);
|
||||
if !shows(part.geoset_id) || !still_grouped {
|
||||
// A billboard batch's card is a world ROOT following this anchor's joint, so it does
|
||||
// not cascade — reap it by name (decision 0153's lifecycle, [`DressedPart::card`]).
|
||||
if let Some(card) = dp.card {
|
||||
@@ -193,7 +224,9 @@ pub(in crate::entities) fn redress_player_looks(
|
||||
hidden += 1;
|
||||
continue;
|
||||
}
|
||||
present[dp.index as usize] = true;
|
||||
for &m in standing_members {
|
||||
present[m as usize] = true;
|
||||
}
|
||||
// A card's material is the model's own batch material — never a character slot — so a
|
||||
// billboard part has nothing to re-point.
|
||||
if part.billboard.is_none() {
|
||||
@@ -242,11 +275,13 @@ pub(in crate::entities) fn redress_player_looks(
|
||||
fade: join_unit_appear_fade(unit_fade.copied()),
|
||||
};
|
||||
let mut shown = 0usize;
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
if present[i] || !shows(part.geoset_id) {
|
||||
for group in &groups {
|
||||
if present[group.first() as usize] {
|
||||
continue;
|
||||
}
|
||||
spawn_part(&mut commands, part, i, &dress);
|
||||
let forms = merged.forms(parts, group, &mut meshes);
|
||||
let part = merge::group_part(parts, group, forms);
|
||||
spawn_group(&mut commands, &part, group, &dress);
|
||||
shown += 1;
|
||||
}
|
||||
// One line per re-dress — a handful per session, and the only readout of a mechanism whose
|
||||
@@ -328,13 +363,21 @@ mod tests {
|
||||
|
||||
/// One synthetic body batch at `geoset`.
|
||||
fn part(geoset: u16) -> EntityPart {
|
||||
// A DISTINCT material per stub batch: every real batch of a model has its own built
|
||||
// material unless it shares a texture, and a fixture whose batches all bound the one
|
||||
// default handle would merge into a single group (`merge`) and stop being the
|
||||
// per-batch hide/show/re-point walk these tests are about.
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
EntityPart {
|
||||
mesh: Handle::default(),
|
||||
geometry: std::sync::Arc::new(benilla_formats::RenderSubmesh::default()),
|
||||
aabb: None,
|
||||
skinned_mesh: None,
|
||||
welded_billboard: false,
|
||||
material: Handle::default(),
|
||||
material: Handle::from(bevy::asset::uuid::Uuid::from_u128(
|
||||
0xd0d0_0000 + u128::from(n),
|
||||
)),
|
||||
material_interior: None,
|
||||
material_interior_bake: None,
|
||||
material_interior_bake_blend: None,
|
||||
@@ -372,6 +415,7 @@ mod tests {
|
||||
.init_asset::<Image>()
|
||||
.init_asset::<WowModelMaterial>()
|
||||
.init_resource::<SkinComposites>()
|
||||
.init_resource::<MergedFormsCache>()
|
||||
// The engine's material cache — normally `model_render::plugin`'s, which this bare
|
||||
// harness does not install.
|
||||
.init_resource::<benilla_world::model_render::ModelMaterials>();
|
||||
|
||||
@@ -31,6 +31,7 @@ pub(crate) enum ModelHandle {
|
||||
/// `ModelPart` toggle). Built once the model asset loads **and** the model-forms furnisher has
|
||||
/// built its render forms (decision 0834 — the handles here come from the app-side cache, not the
|
||||
/// loader).
|
||||
#[derive(Clone)]
|
||||
pub(super) struct EntityPart {
|
||||
pub(super) mesh: Handle<Mesh>,
|
||||
/// The batch's decoded geometry — the model's resident CPU copy (`ModelSubmesh::geometry`),
|
||||
|
||||
@@ -134,6 +134,9 @@ fn fx_part_material(
|
||||
let Some(anim) = &part.rgb_anim else {
|
||||
return part.material.clone();
|
||||
};
|
||||
// The shared steady may still be parked (`model_render::lazy`): this copy is made before
|
||||
// anything binds it.
|
||||
benilla_world::model_render::lazy::realize(wow_materials, part.material.id());
|
||||
let Some(mut mat) = wow_materials.get(part.material.id()).cloned() else {
|
||||
return part.material.clone(); // shared material not built yet — parts were checked ready
|
||||
};
|
||||
@@ -378,6 +381,7 @@ pub(crate) fn attach_effect_visuals(
|
||||
};
|
||||
// The material's packed fog byte, handed over raw — the lane owns what it maps to.
|
||||
// `7` (Scene) is the no-material fallback the packer's own default agrees with.
|
||||
benilla_world::model_render::lazy::realize(wow_materials, part.material.id());
|
||||
let (texture, fog_bits) = match wow_materials.get(part.material.id()) {
|
||||
Some(mat) => (
|
||||
mat.base.base_color_texture.clone(),
|
||||
|
||||
@@ -43,6 +43,40 @@ pub(crate) fn enabled() -> bool {
|
||||
#[derive(Resource, Clone)]
|
||||
pub(crate) struct GpuMsShared(pub Arc<AtomicU64>);
|
||||
|
||||
/// **The wgpu resource census** — live buffers, textures and bind groups, off wgpu-core's own
|
||||
/// registry report, refreshed once a frame by the render app and read by `FPS_PROBE` under the
|
||||
/// same env (`wgpu_bufs=… wgpu_texs=… wgpu_bgs=…`). It is on the GPU meter because it prices
|
||||
/// the GPU *lane's* CPU: every render pass wgpu encodes allocates and clears usage trackers
|
||||
/// sized to the device's live buffer and texture counts (`wgpu_core::track::UsageScope`), so a
|
||||
/// scene's pass-encode CPU carries a term that scales with residency, not with what is drawn —
|
||||
/// a sampled profile of the crowd rig (1929's follow-on) put that term at roughly half of
|
||||
/// `encode_render_pass`. The count is the number that lever moves.
|
||||
#[derive(Resource, Clone)]
|
||||
pub(crate) struct WgpuCensusShared(pub Arc<WgpuCensus>);
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct WgpuCensus {
|
||||
pub buffers: AtomicU64,
|
||||
pub textures: AtomicU64,
|
||||
pub bind_groups: AtomicU64,
|
||||
}
|
||||
|
||||
/// Render world, `Cleanup`: refresh the census. wgpu-core's report takes each registry's read
|
||||
/// lock and counts live ids — microseconds, and only under the meter's env.
|
||||
fn census(instance: Res<bevy::render::renderer::RenderInstance>, shared: Res<WgpuCensusShared>) {
|
||||
let Some(report) = instance.generate_report() else {
|
||||
return;
|
||||
};
|
||||
let hub = &report.hub;
|
||||
let c = &shared.0;
|
||||
c.buffers
|
||||
.store(hub.buffers.num_allocated as u64, Ordering::Relaxed);
|
||||
c.textures
|
||||
.store(hub.textures.num_allocated as u64, Ordering::Relaxed);
|
||||
c.bind_groups
|
||||
.store(hub.bind_groups.num_allocated as u64, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
const RING: usize = 4;
|
||||
|
||||
/// Ring-slot states: a copy may only target a FREE slot — submitting into a mapped (or
|
||||
@@ -231,14 +265,17 @@ pub(crate) fn plugin(app: &mut App) {
|
||||
return;
|
||||
}
|
||||
let shared = Arc::new(AtomicU64::new(0));
|
||||
let counts = Arc::new(WgpuCensus::default());
|
||||
app.insert_resource(GpuMsShared(shared.clone()));
|
||||
app.insert_resource(WgpuCensusShared(counts.clone()));
|
||||
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
|
||||
return;
|
||||
};
|
||||
render_app.insert_resource(GpuMsShared(shared));
|
||||
render_app.insert_resource(WgpuCensusShared(counts));
|
||||
render_app.add_systems(
|
||||
Render,
|
||||
(init_stamp, readback)
|
||||
(init_stamp, readback, census)
|
||||
.chain()
|
||||
.in_set(RenderSystems::Cleanup),
|
||||
);
|
||||
|
||||
@@ -54,7 +54,7 @@ use bevy::prelude::*;
|
||||
use bevy::render::diagnostic::RenderDiagnosticsPlugin;
|
||||
|
||||
pub(crate) use clock::{process_cpu_secs, system_cpu_ticks};
|
||||
pub(crate) use gpu::GpuMsShared;
|
||||
pub(crate) use gpu::{GpuMsShared, WgpuCensusShared};
|
||||
pub(crate) use hud::PerfHud;
|
||||
pub(crate) use journal::FpsJournalPlugin;
|
||||
|
||||
|
||||
@@ -300,6 +300,9 @@ pub(super) fn spawn_menagerie(
|
||||
light,
|
||||
None, // the shared lane — see the note above
|
||||
);
|
||||
// The warmer reads what it builds (`model_render::lazy` parks a built material
|
||||
// until something visible binds it; the warmer's entities bind next frame).
|
||||
benilla_world::model_render::lazy::realize_all(materials);
|
||||
if let Some(m) = materials.get(&plain) {
|
||||
let mut m = m.clone();
|
||||
m.extension.clutter_fade = Vec4::new(52.5, 70.0, 0.0, 1.0);
|
||||
@@ -349,6 +352,7 @@ pub(super) fn spawn_menagerie(
|
||||
light,
|
||||
None, // the shared lane — see the note above
|
||||
);
|
||||
benilla_world::model_render::lazy::realize_all(materials);
|
||||
if let Some(m) = materials.get(&h) {
|
||||
let mut m = m.clone();
|
||||
m.base.depth_bias = bucket;
|
||||
@@ -595,6 +599,7 @@ fn far_twins_of(
|
||||
materials: &mut Assets<WowModelMaterial>,
|
||||
src: &[Handle<WowModelMaterial>],
|
||||
) -> Vec<Handle<WowModelMaterial>> {
|
||||
benilla_world::model_render::lazy::realize_all(materials);
|
||||
let twins: Vec<WowModelMaterial> = src
|
||||
.iter()
|
||||
.filter_map(|h| materials.get(h))
|
||||
|
||||
@@ -130,6 +130,9 @@ pub(super) fn material_variant(
|
||||
if let Some(twin) = variants.get(&world.id()) {
|
||||
return Some(twin.clone());
|
||||
}
|
||||
// A blend/zfill twin is cloned before the world ever binds it — realize the parked value
|
||||
// first (`model_render::lazy`); a no-op for a material already in the store.
|
||||
benilla_world::model_render::lazy::realize(materials, world.id());
|
||||
let mat = materials.get(world)?;
|
||||
let mut twin = mat.clone();
|
||||
twin.extension.light_buf = buffer.clone();
|
||||
|
||||
@@ -206,14 +206,32 @@ impl Stream {
|
||||
let observed_frames = Arc::new(AtomicU32::new(buffer_frames));
|
||||
|
||||
let errors = Arc::clone(notices);
|
||||
// Only two of cpal's four `StreamError`s are fatal to a stream, and getting that wrong is
|
||||
// expensive in exactly one direction. `BufferUnderrun` is a routine ALSA xrun — the
|
||||
// device starved below us — and `BackendSpecific` is a grab-bag; treating either as a
|
||||
// dead device means a full teardown and rebuild of the stream on every glitch, which is
|
||||
// the loudest possible response to the quietest problem (decision 1939). **Measured, not
|
||||
// reasoned:** with both mapped to `device_died`, one second of the Linux live test
|
||||
// carried a `Lost` and a re-`Opened`; with this split, three runs carry neither. kira
|
||||
// draws the same line (`stream_manager.rs`: `DeviceNotAvailable | StreamInvalidated`
|
||||
// restart, the other two are dropped).
|
||||
let reported = std::sync::atomic::AtomicBool::new(false);
|
||||
let on_error = move |e: cpal::StreamError| {
|
||||
// Both variants mean the same thing here: this stream will not produce audio again.
|
||||
// `service` tears it down and reopens on whatever the default is by then. This is
|
||||
// the one log on the audio thread, taken knowingly: the stream is already dead, it
|
||||
// fires once, and the host's own text is the only place the reason exists — the
|
||||
// notice above can only say "the device went away".
|
||||
bevy::log::warn!("audio: output stream error ({e})");
|
||||
errors.device_died.store(true, Ordering::Release);
|
||||
match e {
|
||||
cpal::StreamError::DeviceNotAvailable | cpal::StreamError::StreamInvalidated => {
|
||||
errors.device_died.store(true, Ordering::Release);
|
||||
}
|
||||
// Not fatal, so the stream stays. Said once per stream and no more: this runs on
|
||||
// the audio thread, and an xrun storm must not become a logging storm. The
|
||||
// audible cost of a starved device is already counted upstairs as an underrun.
|
||||
_ => {
|
||||
if !reported.swap(true, Ordering::Relaxed) {
|
||||
bevy::log::warn!("audio: output stream reported {e} (stream kept)");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
bevy::log::warn!("audio: output stream lost ({e})");
|
||||
};
|
||||
|
||||
let ctx = Callback {
|
||||
|
||||
@@ -503,6 +503,25 @@ fn send_spell_cast(
|
||||
.and_then(|f| f.unit_charmed_by())
|
||||
.is_some_and(|charmer| Some(charmer) != ctx.self_guid),
|
||||
def,
|
||||
// The per-arm exemption scan (decision 1946): the caster's RAW aura slot ids — the
|
||||
// reference reads them unfiltered — joined to the spell catalog.
|
||||
&mut |aura_types: &[u32]| {
|
||||
let Some((d, fields)) = def.zip(self_fields) else {
|
||||
return benilla_formats::CcExemption::default();
|
||||
};
|
||||
let catalog = spells.as_ref().map(|s| &s.catalog);
|
||||
aura_types
|
||||
.iter()
|
||||
.map(|&ty| {
|
||||
benilla_formats::cc_exemption(d, fields.unit_aura_ids(), ty, |id| {
|
||||
catalog.and_then(|c| c.get(id))
|
||||
})
|
||||
})
|
||||
// The arms scan several types; the reference's loop takes the FIRST rejection it
|
||||
// meets and stops, and reports exempt only if a match was found and accepted.
|
||||
.reduce(|a, b| if a.mechanic != 0 || a.exempt { a } else { b })
|
||||
.unwrap_or_default()
|
||||
},
|
||||
) {
|
||||
debug!("ui_action: cast {spell_id} refused locally — crowd control ({reason:#x})");
|
||||
cast_errors.push_local(spell_id, reason);
|
||||
|
||||
@@ -126,17 +126,20 @@ const PREVENTION_PACIFY: u32 = 2;
|
||||
/// Its byte-shape was nearly missed by a census twice: SILENCED's read is `f6 c4 20 test ah,0x20`,
|
||||
/// a sub-register byte-lane form with no dword immediate, invisible to an immediate scan.
|
||||
///
|
||||
/// **Unmodelled, and now known to be buildable rather than out of reach:** each arm first asks an
|
||||
/// exemption helper whether any of the caster's own auras grants immunity — a scan of
|
||||
/// `UNIT_FIELD_AURA[0..47]`'s spell ids against `EffectApplyAuraName` (Spell.dbc columns 91–93).
|
||||
/// A hit **lifts** the refusal; it never creates one, and a set flag with no matching aura still
|
||||
/// refuses with the generic reason above. So omitting it can only refuse where the reference would
|
||||
/// have allowed — never the reverse.
|
||||
/// **The exemption scan is built** (decision 1946, closing 1925's deferral): each arm first asks
|
||||
/// whether any of the caster's own auras grants immunity to what is blocking it — a scan of
|
||||
/// `UNIT_FIELD_AURA[0..47]`'s raw spell ids for an aura of the arm's own type, then
|
||||
/// [`benilla_formats::grants_immunity`] on each match. A hit **lifts** the refusal; a rejection
|
||||
/// names the blocking mechanic and turns the arm's own reason into `0x8d`. Each arm scans for a
|
||||
/// different set of aura types — charm `{6, 177, 2}`, stun `{12}`, silence `{27, 12, 60}`, pacify
|
||||
/// `{25, 12, 60}`, fear `{7}`, confuse `{5}` — and those sets are the reference's, not a family
|
||||
/// resemblance.
|
||||
pub(crate) fn cast_cc_refusal(
|
||||
unit_flags: u32,
|
||||
health: Option<u32>,
|
||||
charmed_by_other: bool,
|
||||
spell: Option<&SpellDisplay>,
|
||||
exempt: &mut impl FnMut(&[u32]) -> benilla_formats::CcExemption,
|
||||
) -> Option<u8> {
|
||||
use crate::player::UNIT_FLAG_STUNNED;
|
||||
/// `UNIT_FIELD_FLAGS` bits 13/17/22/23 (vmangos `UnitDefines.h`).
|
||||
@@ -150,27 +153,61 @@ pub(crate) fn cast_cc_refusal(
|
||||
return None;
|
||||
}
|
||||
let prevention = spell.map_or(0, |d| d.prevention_type);
|
||||
// One arm: ask its exemption scan first, and let its answer pick between silence and one of
|
||||
// two messages. `exempt` returning `exempt: true` means one of the caster's own auras grants
|
||||
// immunity to whatever is blocking — the arm is SKIPPED and the cast proceeds. Otherwise the
|
||||
// refusal is `0x8d` "Can't do that while %s" when the scan named a mechanic, and the arm's own
|
||||
// reason when it did not (decision 1946).
|
||||
let mut arm = |aura_types: &[u32], own_reason: u8| -> Option<u8> {
|
||||
let scan = exempt(aura_types);
|
||||
if scan.exempt {
|
||||
return None;
|
||||
}
|
||||
Some(if scan.mechanic != 0 {
|
||||
REASON_PREVENTED_BY_MECHANIC
|
||||
} else {
|
||||
own_reason
|
||||
})
|
||||
};
|
||||
|
||||
if charmed_by_other {
|
||||
return Some(0x14);
|
||||
if let Some(r) = arm(&[6, 177, 2], 0x14) {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
if unit_flags & UNIT_FLAG_STUNNED != 0 {
|
||||
return Some(0x64);
|
||||
if let Some(r) = arm(&[12], 0x64) {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
if unit_flags & UNIT_FLAG_SILENCED != 0 && prevention == PREVENTION_SILENCE {
|
||||
return Some(0x60);
|
||||
if let Some(r) = arm(&[27, 12, 60], 0x60) {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
if unit_flags & UNIT_FLAG_PACIFIED != 0 && prevention == PREVENTION_PACIFY {
|
||||
return Some(0x5a);
|
||||
if let Some(r) = arm(&[25, 12, 60], 0x5a) {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
if unit_flags & UNIT_FLAG_FLEEING != 0 {
|
||||
return Some(0x1e);
|
||||
if let Some(r) = arm(&[7], 0x1e) {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
if unit_flags & UNIT_FLAG_CONFUSED != 0 {
|
||||
return Some(0x16);
|
||||
if let Some(r) = arm(&[5], 0x16) {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `SPELL_FAILED_PREVENTED_BY_MECHANIC` — "Can't do that while %s", `%s` being the blocking aura's
|
||||
/// `SpellMechanic.dbc` name. Every crowd-control arm carries this **as well as** its own reason,
|
||||
/// and which one appears is decided by whether the exemption scan named a mechanic.
|
||||
const REASON_PREVENTED_BY_MECHANIC: u8 = 0x8d;
|
||||
|
||||
/// The **pre-send** mounted refusal (decision 0481) — the requirement validator `0x6094f0`'s
|
||||
/// mounted block (`0x609c6c`, wow-re `mounted-action-gate.md` §5): a live
|
||||
/// `UNIT_FIELD_MOUNTDISPLAYID` refuses the cast with reason `0x39` ("You are mounted") unless
|
||||
@@ -1165,9 +1202,16 @@ mod cc_refusal_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A live caster, nobody else at the reins.
|
||||
/// A scan that finds no aura at all — `exempt: false, mechanic: 0`, so every arm falls to its
|
||||
/// OWN reason. This is the ordinary case: the exemption only ever fires for a caster wearing
|
||||
/// an immunity.
|
||||
fn no_auras() -> impl FnMut(&[u32]) -> benilla_formats::CcExemption {
|
||||
|_: &[u32]| benilla_formats::CcExemption::default()
|
||||
}
|
||||
|
||||
/// A live caster, nobody else at the reins, no auras.
|
||||
fn cc(flags: u32, d: &SpellDisplay) -> Option<u8> {
|
||||
cast_cc_refusal(flags, Some(100), false, Some(d))
|
||||
cast_cc_refusal(flags, Some(100), false, Some(d), &mut no_auras())
|
||||
}
|
||||
|
||||
/// **The arms are not symmetric** (decision 1904, widened by 1925): stun, fear, charm and
|
||||
@@ -1190,7 +1234,7 @@ mod cc_refusal_tests {
|
||||
}
|
||||
// …and charm, which is not a flag at all but "somebody else holds the reins".
|
||||
assert_eq!(
|
||||
cast_cc_refusal(0, Some(100), true, Some(&neither)),
|
||||
cast_cc_refusal(0, Some(100), true, Some(&neither), &mut no_auras()),
|
||||
Some(0x14)
|
||||
);
|
||||
|
||||
@@ -1205,9 +1249,12 @@ mod cc_refusal_tests {
|
||||
|
||||
// Nothing up, nothing refused; and no record claims no prevention.
|
||||
assert_eq!(cc(0, &cast), None);
|
||||
assert_eq!(cast_cc_refusal(SILENCED, Some(100), false, None), None);
|
||||
assert_eq!(
|
||||
cast_cc_refusal(STUNNED, Some(100), false, None),
|
||||
cast_cc_refusal(SILENCED, Some(100), false, None, &mut no_auras()),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
cast_cc_refusal(STUNNED, Some(100), false, None, &mut no_auras()),
|
||||
Some(0x64),
|
||||
"the stun needs no record"
|
||||
);
|
||||
@@ -1221,7 +1268,13 @@ mod cc_refusal_tests {
|
||||
let cast = spell(1);
|
||||
// Charm is arm 1: it beats a simultaneous stun.
|
||||
assert_eq!(
|
||||
cast_cc_refusal(STUNNED | SILENCED, Some(100), true, Some(&cast)),
|
||||
cast_cc_refusal(
|
||||
STUNNED | SILENCED,
|
||||
Some(100),
|
||||
true,
|
||||
Some(&cast),
|
||||
&mut no_auras()
|
||||
),
|
||||
Some(0x14)
|
||||
);
|
||||
// Stun is arm 2: it beats silence, pacify, fear and confuse.
|
||||
@@ -1235,21 +1288,72 @@ mod cc_refusal_tests {
|
||||
assert_eq!(cc(SILENCED | FLEEING | CONFUSED, &spell(0)), Some(0x1e));
|
||||
}
|
||||
|
||||
/// **What the exemption does to an arm** (decision 1946) — the three outcomes, at the arm.
|
||||
#[test]
|
||||
fn the_exemption_skips_an_arm_or_renames_its_refusal() {
|
||||
let cast = spell(1);
|
||||
let scan = |exempt: bool, mechanic: u32| {
|
||||
move |_: &[u32]| benilla_formats::CcExemption { exempt, mechanic }
|
||||
};
|
||||
|
||||
// No aura of the arm's type: the arm's OWN reason, as everywhere else.
|
||||
assert_eq!(
|
||||
cast_cc_refusal(STUNNED, Some(100), false, Some(&cast), &mut scan(false, 0)),
|
||||
Some(0x64)
|
||||
);
|
||||
// A blocking aura the cast does NOT counter: the refusal survives but is renamed to
|
||||
// `0x8d` "Can't do that while %s", the mechanic naming the aura.
|
||||
assert_eq!(
|
||||
cast_cc_refusal(STUNNED, Some(100), false, Some(&cast), &mut scan(false, 12)),
|
||||
Some(0x8d)
|
||||
);
|
||||
// The cast grants immunity: the arm is SKIPPED and the cast goes out. This is the whole
|
||||
// point — Ice Block cast while stunned.
|
||||
assert_eq!(
|
||||
cast_cc_refusal(STUNNED, Some(100), false, Some(&cast), &mut scan(true, 0)),
|
||||
None
|
||||
);
|
||||
// …and skipping one arm does not skip the ladder: a silenced-and-stunned caster whose
|
||||
// spell counters only the stun still refuses on silence below it.
|
||||
assert_eq!(
|
||||
cast_cc_refusal(
|
||||
STUNNED | SILENCED,
|
||||
Some(100),
|
||||
false,
|
||||
Some(&cast),
|
||||
&mut |types: &[u32]| {
|
||||
// Exempt from the stun arm `{12}` only; the silence arm `{27, 12, 60}` is not.
|
||||
benilla_formats::CcExemption {
|
||||
exempt: types == [12],
|
||||
mechanic: 0,
|
||||
}
|
||||
}
|
||||
),
|
||||
Some(0x60)
|
||||
);
|
||||
}
|
||||
|
||||
/// **A dead caster skips all six** (`0x60980d`'s `jle` on `UNIT_FIELD_HEALTH`) — a corpse is
|
||||
/// refused by an earlier rung, and reporting a stun over it would be the wrong line.
|
||||
#[test]
|
||||
fn a_dead_caster_takes_no_crowd_control_refusal() {
|
||||
let cast = spell(1);
|
||||
assert_eq!(cast_cc_refusal(STUNNED, Some(0), false, Some(&cast)), None);
|
||||
assert_eq!(cast_cc_refusal(0, Some(0), true, Some(&cast)), None);
|
||||
assert_eq!(
|
||||
cast_cc_refusal(STUNNED, Some(0), false, Some(&cast), &mut no_auras()),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
cast_cc_refusal(0, Some(0), true, Some(&cast), &mut no_auras()),
|
||||
None
|
||||
);
|
||||
// Alive again, and the arm is back.
|
||||
assert_eq!(
|
||||
cast_cc_refusal(STUNNED, Some(1), false, Some(&cast)),
|
||||
cast_cc_refusal(STUNNED, Some(1), false, Some(&cast), &mut no_auras()),
|
||||
Some(0x64)
|
||||
);
|
||||
// No health field at all is not death — the descriptor simply has not landed.
|
||||
assert_eq!(
|
||||
cast_cc_refusal(STUNNED, None, false, Some(&cast)),
|
||||
cast_cc_refusal(STUNNED, None, false, Some(&cast), &mut no_auras()),
|
||||
Some(0x64)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -439,6 +439,7 @@ fn chat_vm() -> benilla_ui::script::UiScript {
|
||||
// dependency — its MenuBackdrop reads `TOOLTIP_DEFAULT_COLOR`.)
|
||||
for file in [
|
||||
"Interface\\FrameXML\\Fonts.xml",
|
||||
"MoneyFrame.xml",
|
||||
"GameTooltip.xml",
|
||||
"Interface\\FrameXML\\UIDropDownMenu.xml",
|
||||
// The UIMenu kit is the reference's own file since 1751 window 21, so this reads both
|
||||
|
||||
@@ -375,9 +375,12 @@ const UI_CAMERA_ORDER: isize = 1;
|
||||
struct PlayerUiCamera;
|
||||
|
||||
/// Marker on each rebuilt batch mesh entity, so [`rebuild_ui_mesh`] can despawn last frame's batches
|
||||
/// before spawning the new ones.
|
||||
/// before spawning the new ones. `pub(crate)` for one reader: `FPS_PROBE`'s `ui_batches=`, the
|
||||
/// live count of texture-identity runs — each is a `Mesh2d` draw in the 2D pass, and that pass
|
||||
/// node priced at ~0.5 ms a frame on the crowd rig's *alone* leg, ~1.2 with a raid's party
|
||||
/// frames (1929's trace), so the number a UI change moves is this one, not `quads=`.
|
||||
#[derive(Component)]
|
||||
struct UiQuadBatch;
|
||||
pub(crate) struct UiQuadBatch;
|
||||
|
||||
/// The shared 1×1 opaque-white texture flat-shaded/texture-less quads sample (see the module doc).
|
||||
#[derive(Resource)]
|
||||
|
||||
@@ -675,8 +675,8 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
|
||||
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"return GameTooltipMoneyCoin1:IsShown() \
|
||||
and GameTooltipMoneyCoin1Num:GetText() == '52'",
|
||||
"return GameTooltipMoneyFrame:IsShown() \
|
||||
and GameTooltipMoneyFrameCopperButton:GetText() == '52'",
|
||||
)
|
||||
.unwrap());
|
||||
assert_eq!(
|
||||
@@ -689,7 +689,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
|
||||
unhover(&mut s);
|
||||
assert_eq!(s.ui_cursor(), None, "ResetCursor on leave");
|
||||
assert!(s
|
||||
.eval::<bool>("return not GameTooltipMoneyCoin1:IsShown()")
|
||||
.eval::<bool>("return not GameTooltipMoneyFrame:IsShown()")
|
||||
.unwrap());
|
||||
|
||||
// The unsellable item: the ITEM_UNSELLABLE line, no coins.
|
||||
@@ -704,7 +704,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
|
||||
.unwrap();
|
||||
assert!(has_line, "SellPrice 0 shows the ITEM_UNSELLABLE line");
|
||||
assert!(s
|
||||
.eval::<bool>("return not GameTooltipMoneyCoin1:IsShown()")
|
||||
.eval::<bool>("return not GameTooltipMoneyFrame:IsShown()")
|
||||
.unwrap());
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ fn harness() -> UiScript {
|
||||
// `GameTooltip`, which the reference's BuffButton_Update indexes on EVERY repaint to ask
|
||||
// `IsOwned(this)` (BuffFrame.lua l.104) — not just on hover. Ours guarded it; the reference
|
||||
// does not, so a session without the tooltip loses the whole repaint.
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
// `SecondsToTimeAbbrev`, which 1.12 keeps in UIParent.lua and so do we since window 18.
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
|
||||
@@ -19,6 +19,7 @@ fn chat_frame() -> UiScript {
|
||||
// `benilla.toc` already orders both ahead of ChatFrame.xml (l.60/64 vs l.399); the harness
|
||||
// says so too, rather than a guard that would hide a real ordering fault. (The tooltip file is
|
||||
// the dropdown kit's own dependency — its MenuBackdrop reads `TOOLTIP_DEFAULT_COLOR`.)
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit the chat menus build from
|
||||
|
||||
@@ -20,6 +20,7 @@ fn load_combo_frame() -> UiScript {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
|
||||
load_xml(&s, "UnitPopup.xml");
|
||||
|
||||
@@ -22,6 +22,7 @@ fn load_dropdown_kit(s: &UiScript) {
|
||||
for file in [
|
||||
"Interface\\FrameXML\\Fonts.xml",
|
||||
"UIParent.xml",
|
||||
"MoneyFrame.xml",
|
||||
"GameTooltip.xml",
|
||||
"Interface\\FrameXML\\UIDropDownMenu.xml",
|
||||
] {
|
||||
|
||||
@@ -397,6 +397,8 @@ fn the_quest_tracker_stacks_below_the_durability_guy() {
|
||||
"MinimapCluster.xml",
|
||||
"ScrollTemplates.xml",
|
||||
"Interface\\FrameXML\\DurabilityFrame.xml",
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"QuestFrame.xml",
|
||||
"QuestLogFrame.xml",
|
||||
] {
|
||||
load_xml(&s, f);
|
||||
|
||||
@@ -122,6 +122,8 @@ fn an_error_toast_draws_over_an_open_panel_window() {
|
||||
// pane's UIPanelScrollFrameTemplate). A MISSING template is a loader *warning*, so an
|
||||
// under-loaded list passes and then dies on the first FauxScrollFrame_Update.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
|
||||
// A left-slot panel open, and the toast raised after it — the order that must not decide.
|
||||
|
||||
@@ -17,6 +17,7 @@ fn exp_bar_harness() -> UiScript {
|
||||
for file in [
|
||||
"Interface\\FrameXML\\Fonts.xml",
|
||||
"UIParent.xml",
|
||||
"MoneyFrame.xml",
|
||||
"GameTooltip.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
|
||||
@@ -556,6 +556,7 @@ fn escape_during_a_countdown_cancels_it_and_does_not_open_the_menu() {
|
||||
fn the_world_map_cannot_open_behind_the_menu_and_gives_its_slot_back() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let s = harness_with(&[
|
||||
"MoneyFrame.xml",
|
||||
"GameTooltip.xml",
|
||||
"Interface\\FrameXML\\UIDropDownMenu.xml", // the map's continent/zone pickers initialize into it at OnLoad
|
||||
"ScrollTemplates.xml",
|
||||
@@ -607,6 +608,7 @@ fn the_world_map_cannot_open_behind_the_menu_and_gives_its_slot_back() {
|
||||
fn nothing_opens_behind_the_world_map_and_escape_closes_it_first() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let s = harness_with(&[
|
||||
"MoneyFrame.xml",
|
||||
"GameTooltip.xml",
|
||||
"Interface\\FrameXML\\UIDropDownMenu.xml",
|
||||
"ScrollTemplates.xml",
|
||||
@@ -791,6 +793,7 @@ fn backpack() -> ContainerState {
|
||||
fn the_menu_rides_the_shared_era_window_scale() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = harness_with(&[
|
||||
"MoneyFrame.xml",
|
||||
"GameTooltip.xml",
|
||||
"Interface\\FrameXML\\UIDropDownMenu.xml",
|
||||
"ScrollTemplates.xml",
|
||||
|
||||
@@ -24,6 +24,7 @@ fn setup() -> UiScript {
|
||||
r"Interface\FrameXML\UIPanelTemplates.lua",
|
||||
r"Interface\FrameXML\UIPanelTemplates.xml",
|
||||
"ScrollTemplates.xml",
|
||||
"MoneyFrame.xml",
|
||||
"GameTooltip.xml",
|
||||
// Before UiPanels.xml: the shared StaticPopup carries a `SmallMoneyFrameTemplate` coin
|
||||
// row, whose OnLoad calls `SmallMoneyFrame_OnLoad` — the TOC's own order (1580's
|
||||
|
||||
@@ -478,6 +478,7 @@ fn shipped_loot_pushed_to_center_by_merchant() {
|
||||
load_xml(&s, f);
|
||||
}
|
||||
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "GameTooltip.xml"); // app load order: tooltip before merchant
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
|
||||
|
||||
@@ -247,7 +247,7 @@ fn the_talent_button_appears_at_level_ten_and_the_row_closes_up_below_it() {
|
||||
/// single missing argument is invisible until someone hovers that one button.
|
||||
#[test]
|
||||
fn every_micro_button_hovers_with_its_reference_explanation() {
|
||||
let mut s = harness_with(&["UIParent.xml", "GameTooltip.xml"]);
|
||||
let mut s = harness_with(&["MoneyFrame.xml", "UIParent.xml", "GameTooltip.xml"]);
|
||||
set_player_level(&mut s, 10);
|
||||
s.resolve();
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ fn minimap_zoom_buttons_resync_when_switching_inside_and_outside() {
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
// The shipped load order provides GameTooltip before the cluster; Minimap_Update's tooltip
|
||||
// half (the PVP tint slice, decision 0287) touches it from OnLoad on.
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, "MinimapCluster.xml");
|
||||
|
||||
@@ -87,6 +88,7 @@ fn tracking_frame_follows_get_tracking_texture_across_player_auras_changed() {
|
||||
.unwrap();
|
||||
s.run("function PlaySound() end").unwrap();
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, "MinimapCluster.xml");
|
||||
|
||||
@@ -135,6 +137,7 @@ fn game_time_session(hour: u32, minute: u32) -> UiScript {
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
// `TEXT()`, which the reference's own tooltip and label formatting passes every string through.
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, "MinimapCluster.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GameTime.xml");
|
||||
@@ -246,6 +249,7 @@ fn a_click_on_the_minimap_parks_a_centre_relative_ping_request() {
|
||||
.unwrap();
|
||||
s.run("function PlaySound() end").unwrap();
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, "MinimapCluster.xml");
|
||||
s.resolve();
|
||||
@@ -290,6 +294,7 @@ fn get_ping_position_is_nil_until_there_is_a_ping() {
|
||||
.unwrap();
|
||||
s.run("function PlaySound() end").unwrap();
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, "MinimapCluster.xml");
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ fn load_action_bar(s: &UiScript) {
|
||||
for file in [
|
||||
"Interface\\FrameXML\\Fonts.xml",
|
||||
"UIParent.xml",
|
||||
"MoneyFrame.xml",
|
||||
"GameTooltip.xml",
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
@@ -435,6 +436,7 @@ fn multibar_hover_renders_the_buttons_own_action() {
|
||||
for file in [
|
||||
"Interface\\FrameXML\\Fonts.xml",
|
||||
"UIParent.xml",
|
||||
"MoneyFrame.xml",
|
||||
"GameTooltip.xml",
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
|
||||
@@ -761,6 +761,7 @@ fn interface_harness() -> UiScript {
|
||||
r"Interface\FrameXML\ReputationFrame.xml",
|
||||
"ScrollTemplates.xml",
|
||||
"Interface\\FrameXML\\MerchantFrame.xml",
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"QuestFrame.xml",
|
||||
"QuestLogFrame.xml",
|
||||
],
|
||||
|
||||
@@ -220,7 +220,7 @@ fn get_net_stats_reports_the_pushed_latency() {
|
||||
/// tooltip that is quietly one line short.
|
||||
#[test]
|
||||
fn hovering_the_meter_shows_the_live_latency() {
|
||||
let mut s = harness(&["UIParent.xml", "GameTooltip.xml"]);
|
||||
let mut s = harness(&["MoneyFrame.xml", "UIParent.xml", "GameTooltip.xml"]);
|
||||
s.set_latency_ms(Some(42));
|
||||
s.resolve();
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ fn load_pet_frame() -> UiScript {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
|
||||
load_xml(&s, "UnitPopup.xml");
|
||||
|
||||
@@ -27,6 +27,7 @@ fn questgiver_show_hide_plays_open_and_close_kits() {
|
||||
// MerchantFrame.xml (the same documented cross-window dep the bag tests load).
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
|
||||
// Hidden at load: no open sound (never transitions on startup).
|
||||
@@ -87,6 +88,7 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
|
||||
let panels = [
|
||||
@@ -167,6 +169,7 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
|
||||
let choice = |name: &str, quality: u32| benilla_ui::script::QuestItemView {
|
||||
@@ -253,6 +256,7 @@ fn reward_panel_choice_click_selects_and_completes_with_zero_based_index() {
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
|
||||
let choice = |name: &str| benilla_ui::script::QuestItemView {
|
||||
@@ -319,6 +323,7 @@ fn greeting_goodbye_button_closes_the_window() {
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
|
||||
s.set_quest(Some(QuestState {
|
||||
@@ -363,6 +368,7 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
// Planted, not shipped: instant text is OFF out of the box (see the doc comment).
|
||||
s.eval::<()>(r#"QUEST_FADING_DISABLE = "1""#).unwrap();
|
||||
@@ -478,6 +484,7 @@ fn write_on_still_fades_when_instant_text_is_off() {
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
s.eval::<()>(r#"QUEST_FADING_DISABLE = "0""#).unwrap();
|
||||
s.set_quest(Some(QuestState {
|
||||
@@ -542,6 +549,7 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() {
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
|
||||
s.set_quest(Some(QuestState {
|
||||
@@ -608,6 +616,7 @@ fn greeting_panel_title_rows_grow_to_their_wrapped_titles() {
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
|
||||
s.set_quest(Some(QuestState {
|
||||
@@ -695,6 +704,7 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here
|
||||
load_xml(&s, "DressUpFrame.xml"); // DressUpItemLink lives here
|
||||
|
||||
@@ -37,6 +37,7 @@ fn harness() -> UiScript {
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestTimerFrame.xml");
|
||||
s
|
||||
|
||||
@@ -95,6 +95,8 @@ fn shipped_questlog_frame_loads_clean() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
}
|
||||
|
||||
@@ -119,6 +121,8 @@ fn shipped_questlog_frame_drives_end_to_end() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
|
||||
s.set_quest_log(eight_entries());
|
||||
@@ -314,6 +318,8 @@ fn shift_click_toggles_the_watch_checkbox_and_the_tracker_hud() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
|
||||
s.set_quest_log(eight_entries());
|
||||
@@ -438,6 +444,8 @@ fn watch_guards_no_op_without_erroring() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
|
||||
s.set_quest_log(eight_entries());
|
||||
@@ -534,6 +542,8 @@ fn progress_auto_watches_for_five_minutes() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
s.set_quest_log(eight_entries());
|
||||
|
||||
@@ -599,6 +609,8 @@ fn the_auto_watch_flag_is_the_references_uvar_and_gates_the_watch() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
s.set_quest_log(eight_entries());
|
||||
assert_eq!(s.eval::<String>("return AUTO_QUEST_WATCH").unwrap(), "1");
|
||||
@@ -646,6 +658,7 @@ fn the_row_tag_is_its_own_right_flush_string_and_the_state_word_wins() {
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
|
||||
let mut state = eight_entries();
|
||||
@@ -719,6 +732,8 @@ fn empty_quest_log_hides_rows_and_disables_abandon() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
|
||||
s.set_quest_log(QuestLogState::default());
|
||||
@@ -772,6 +787,8 @@ fn reward_rows_follow_the_refs_two_per_row_layout() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
|
||||
let mut state = eight_entries();
|
||||
@@ -931,6 +948,8 @@ fn overflowing_detail_content_clips_to_the_scrollframe_rect() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
|
||||
s.set_quest_log(overflowing_entry());
|
||||
@@ -995,6 +1014,8 @@ fn wheel_over_the_detail_pane_changes_vertical_scroll() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
|
||||
s.set_quest_log(overflowing_entry());
|
||||
@@ -1044,6 +1065,8 @@ fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
|
||||
s.set_quest_log(overflowing_entry());
|
||||
@@ -1109,6 +1132,8 @@ fn reward_row_hover_serves_the_shared_item_tooltip() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
|
||||
s.set_quest_log(eight_entries());
|
||||
@@ -1249,6 +1274,8 @@ fn reward_rows_preview_and_post_and_a_plain_click_stays_inert() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here
|
||||
load_xml(&s, "DressUpFrame.xml"); // DressUpItemLink lives here
|
||||
@@ -1332,6 +1359,8 @@ fn shift_click_on_a_title_posts_the_quest_name_with_chat_open_and_watches_with_i
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here
|
||||
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit its menus build from
|
||||
@@ -1426,6 +1455,8 @@ fn share_quest_needs_both_a_sharable_selection_and_a_party() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
|
||||
// Solo, quest 1 sharable and selected by the auto-selection.
|
||||
@@ -1503,6 +1534,8 @@ fn share_quest_is_dark_on_an_empty_log_even_in_a_party() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
|
||||
s.set_quest_log(QuestLogState::default());
|
||||
@@ -1534,6 +1567,8 @@ fn share_quest_click_queues_the_selected_quests_id() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
|
||||
s.set_quest_log(entries_sharable(&[1, 5]));
|
||||
|
||||
@@ -20,6 +20,7 @@ fn harness() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "GameTooltip.xml"); // TOOLTIP_DEFAULT_* (the dropdown kit's MenuBackdrop)
|
||||
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); // the unit popups' kit (TargetFrameDropDown's template)
|
||||
load_xml(&s, "Cooldown.xml");
|
||||
|
||||
@@ -22,6 +22,7 @@ fn harness(extra: &[&str]) -> UiScript {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
for f in extra {
|
||||
load_xml(&s, f);
|
||||
|
||||
@@ -44,6 +44,7 @@ fn harness() -> UiScript {
|
||||
for f in [
|
||||
"Interface\\FrameXML\\Fonts.xml",
|
||||
"UIParent.xml",
|
||||
"MoneyFrame.xml",
|
||||
"GameTooltip.xml",
|
||||
] {
|
||||
load_xml(&s, f);
|
||||
|
||||
@@ -62,6 +62,7 @@ fn load_tot() -> UiScript {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
|
||||
load_xml(&s, "UnitPopup.xml");
|
||||
|
||||
@@ -32,7 +32,8 @@ fn trainer_script() -> UiScript {
|
||||
load_xml(&s, "GameTooltip.xml"); // TOOLTIP_DEFAULT_* (the kit's MenuBackdrop), app order
|
||||
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); // the filter dropdown's kit
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the faux-scroll bar kit
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); // BenillaMoney_Set/_Clear/_SetColor live here
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); // the merchant kit; the money frames are MoneyFrame.xml's (1937)
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "TrainerFrame.xml");
|
||||
s.run(
|
||||
"TRAINER_FILTER_AVAILABLE = 1 TRAINER_FILTER_UNAVAILABLE = 1 TRAINER_FILTER_USED = 1 \
|
||||
|
||||
@@ -25,6 +25,7 @@ fn load_unit_frames(s: &UiScript) {
|
||||
// UnitFrames.xml and which every bar's OnLoad wires into since 1143.
|
||||
load_xml(s, "Interface\\FrameXML\\TextStatusBar.lua");
|
||||
load_xml(s, "Interface\\FrameXML\\TextStatusBar.xml");
|
||||
load_xml(s, "MoneyFrame.xml");
|
||||
load_xml(s, "GameTooltip.xml");
|
||||
load_xml(s, "Interface\\FrameXML\\UIDropDownMenu.xml");
|
||||
load_xml(s, "UnitPopup.xml");
|
||||
@@ -649,6 +650,8 @@ fn shipped_target_frame_runs_the_level_law() {
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
// The player at level 3, both feeds (the snapshot UnitLevel("player") reads; the req state
|
||||
// the −1 gate and GetQuestGreenRange read) — the app keeps the two in step.
|
||||
|
||||
@@ -12,7 +12,7 @@ const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui");
|
||||
/// The mail window's load prefix — the app's own order (`ui_script/mod.rs`), members only.
|
||||
/// MerchantFrame.xml rides along because MailFrame.xml reuses its global `BenillaMoney_*` coin
|
||||
/// helpers (postage display), so a load error in either fails here.
|
||||
const FILES: [&str; 9] = [
|
||||
const FILES: [&str; 10] = [
|
||||
"Interface\\FrameXML\\Fonts.xml",
|
||||
"MoneyFrame.xml",
|
||||
// The send tab's money entry comes off the chain since 1882 — `MoneyInputFrameTemplate` and
|
||||
@@ -23,6 +23,7 @@ const FILES: [&str; 9] = [
|
||||
r"Interface\FrameXML\UIPanelTemplates.lua",
|
||||
r"Interface\FrameXML\UIPanelTemplates.xml",
|
||||
"GameTooltip.xml",
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"MailFrame.xml",
|
||||
];
|
||||
|
||||
@@ -518,7 +519,7 @@ fn money_button_hover_shows_the_amount_tooltip() {
|
||||
"the money tooltip shows on hover"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return GameTooltipMoneyCoin1:IsShown()")
|
||||
s.eval::<bool>("return GameTooltipMoneyFrame:IsShown()")
|
||||
.unwrap(),
|
||||
"the coin row rendered (SetTooltipMoney path)"
|
||||
);
|
||||
|
||||
@@ -120,7 +120,7 @@ fn trade_frame_loads_and_key_regions_exist() {
|
||||
"TradeFrameTradeButton",
|
||||
"TradeFrameCancelButton",
|
||||
"TradePlayerInputMoneyGold", // our gold is now the editable input (P2)
|
||||
"TradeRecipientMoneyFrameCoin1",
|
||||
"TradeRecipientMoneyFrameCopperButton",
|
||||
"TradeHighlightPlayer",
|
||||
"TradeHighlightRecipientEnchant",
|
||||
] {
|
||||
@@ -191,10 +191,16 @@ fn trade_show_opens_and_both_columns_populate() {
|
||||
"Thrall"
|
||||
);
|
||||
|
||||
// The partner's read-only gold rendered a coin (5s → 1 coin); our own gold is the editable input,
|
||||
// exercised in `player_money_input_reflects_then_offers`.
|
||||
// The partner's read-only money rendered exactly one coin for 5s: TARGET_TRADE is a COLLAPSING
|
||||
// type (MoneyTypeInfo.collapse), so gold and copper hide and silver alone stands — the
|
||||
// reference's own rule, and the one this fixture was written to show. Our own gold is the
|
||||
// editable input, exercised in `player_money_input_reflects_then_offers`.
|
||||
assert!(s
|
||||
.eval::<bool>("return TradeRecipientMoneyFrameCoin1:IsShown()")
|
||||
.eval::<bool>(
|
||||
"return TradeRecipientMoneyFrameSilverButton:IsShown() \
|
||||
and not TradeRecipientMoneyFrameGoldButton:IsShown() \
|
||||
and not TradeRecipientMoneyFrameCopperButton:IsShown()"
|
||||
)
|
||||
.unwrap());
|
||||
|
||||
assert!(s.take_errors().is_empty(), "clean repaint");
|
||||
|
||||
@@ -345,7 +345,18 @@ fn is_instrument_consumer(rel: &str) -> bool {
|
||||
/// a ceiling — and it is the ratchet's own instruction: a count that cannot see the whole file
|
||||
/// cannot report a widening either, which is the one thing this test exists to do. The six are
|
||||
/// **not** hereby blessed as PUBLISH: they are un-sorted 1164 work, now visible enough to sort.
|
||||
const CEILING: usize = 172;
|
||||
/// And 172 → 173: `model_render::lazy::realize`, a PUBLISH. A built `WowModelMaterial` is no
|
||||
/// longer an asset the moment a spawner builds it — the engine parks the value behind a
|
||||
/// reserved handle and inserts the asset the first frame something visible is bound to it
|
||||
/// (decision 1940: 9.9k materials, 20.7k buffers and 10.2k bind groups were alive at the
|
||||
/// Stormwind auction house for 36 drawn entity batches, the variant set every spawner is handed
|
||||
/// and never switches to). A game lane that CLONES a material before anything binds it — the
|
||||
/// portrait booth relighting a part's twins onto its own light buffer, a spell kit deriving its
|
||||
/// per-instance tinted copy from the shared steady — asks the engine to realize it first. That
|
||||
/// is the whole of the crossing: one function, "make this handle's asset exist now", the
|
||||
/// doorway's own rule about when a material is real. The alternative — eager materials for
|
||||
/// the two lanes that read early — would put the store's law in the caller's hands.
|
||||
const CEILING: usize = 173;
|
||||
|
||||
/// How far under [`CEILING`] the real count may sit before this test asks for the ceiling to be
|
||||
/// lowered. Slack, not tolerance: it keeps a single closure from failing the gate, while making it
|
||||
|
||||
@@ -191,10 +191,11 @@ mod wmo_area;
|
||||
pub use wmo_area::{load_wmo_area_catalog, WmoArea, WmoAreaCatalog};
|
||||
mod spells;
|
||||
pub use spells::{
|
||||
load_shapeshift_forms, load_spell_cast_times, load_spell_catalog, load_spell_dispel_types,
|
||||
load_spell_durations, load_spell_radii, load_spell_ranges, substitute, FormRefusal, OpenLock,
|
||||
ShapeshiftForm, SpellCastTime, SpellCastTimeCatalog, SpellCatalog, SpellDispelTypes,
|
||||
SpellDisplay, SpellDuration, SpellDurationCatalog, SpellRadius, SpellRadiusCatalog, SpellRange,
|
||||
cc_exemption, grants_immunity, load_shapeshift_forms, load_spell_cast_times,
|
||||
load_spell_catalog, load_spell_dispel_types, load_spell_durations, load_spell_radii,
|
||||
load_spell_ranges, substitute, CcExemption, FormRefusal, OpenLock, ShapeshiftForm,
|
||||
SpellCastTime, SpellCastTimeCatalog, SpellCatalog, SpellDispelTypes, SpellDisplay,
|
||||
SpellDuration, SpellDurationCatalog, SpellRadius, SpellRadiusCatalog, SpellRange,
|
||||
SpellRangeCatalog, TokenContext, ATTR_CASTABLE_WHILE_DEAD, ATTR_NOT_IN_COMBAT,
|
||||
ATTR_ONLY_STEALTHED, SPELL_ATTR_IS_TRADESKILL, SPELL_EFFECT_CREATE_ITEM,
|
||||
SPELL_EFFECT_ENCHANT_ITEM, SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, SPELL_EFFECT_LEARN_PET_SPELL,
|
||||
|
||||
@@ -43,7 +43,7 @@ pub enum ModelBlend {
|
||||
/// `TextureName[1]` keyed by skinColor (`…Skin00_NN_Extra.blp`), loaded plain (never composited —
|
||||
/// the client's dedicated extra-texture loader is a bare TextureCreate). Only fur races author it:
|
||||
/// the tauren body binds its head/leg fur batches to this slot instead of the body atlas.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum CharSkinSlot {
|
||||
Body,
|
||||
Hair,
|
||||
|
||||
@@ -39,6 +39,15 @@ pub struct SpellDisplay {
|
||||
/// [`Self::cooldown_on_event`] (bit 25), and the aura-bar display filter
|
||||
/// [`Self::hidden_from_aura_bar`] (`0x80`).
|
||||
pub attributes: u32,
|
||||
/// **`School`** (column 1, `SpellRec+0x4`) — the spell's magic school as an INDEX, not a mask.
|
||||
/// The crowd-control exemption's school arm shifts it (`1 << School`) before testing it against
|
||||
/// an immunity effect's `EffectMiscValue`, which is a mask (decision 1946).
|
||||
pub school: u32,
|
||||
/// **`Mechanic`** (column 5, `SpellRec+0x14`) — the `SpellMechanic.dbc` id for the spell as a
|
||||
/// whole. Read by the crowd-control ladder twice over: the mechanic-immunity arm compares it,
|
||||
/// and it is the fallback value the scanner reports when a blocking aura's own
|
||||
/// `EffectMechanic` is 0 — which is what names the `0x8d` "Can't do that while %s" line.
|
||||
pub mechanic: u32,
|
||||
/// `AttributesEx` (column 7, `SpellRec+0x1c`) — only bit `0x10000000` is consumed
|
||||
/// ([`Self::hidden_from_aura_bar`]'s `SPELL_ATTR_EX_NO_AURA_ICON` half).
|
||||
pub attributes_ex: u32,
|
||||
@@ -268,6 +277,10 @@ pub struct SpellDisplay {
|
||||
/// `SpellAuraDefines` aura-type enum; `0` = not an apply-aura effect. Slot 0 is also read via
|
||||
/// [`Self::shapeshift_form`]'s derivation.
|
||||
pub effect_apply_aura: [u32; 3],
|
||||
/// **`EffectMechanic[0..2]`** (columns 79–81, `SpellRec+0x13c`) — the per-effect
|
||||
/// `SpellMechanic.dbc` id. The mechanic-immunity arm accepts a match against *either* this or
|
||||
/// [`Self::mechanic`], and the scanner prefers this one when naming the blocking mechanic.
|
||||
pub effect_mechanic: [u32; 3],
|
||||
/// `EffectRadiusIndex[3]` (column 88, module docs) — each effect's `SpellRadius.dbc` row (not
|
||||
/// loaded by this crate yet); `0` = no radius (a single-target effect).
|
||||
pub effect_radius_index: [u32; 3],
|
||||
@@ -312,6 +325,8 @@ impl Default for SpellDisplay {
|
||||
visual: 0,
|
||||
speed: 0.0,
|
||||
attributes: 0,
|
||||
school: 0,
|
||||
mechanic: 0,
|
||||
attributes_ex: 0,
|
||||
attributes_ex2: 0,
|
||||
modal_next_spell: 0,
|
||||
@@ -368,6 +383,7 @@ impl Default for SpellDisplay {
|
||||
effect_real_points_per_level: [0.0; 3],
|
||||
effect_amplitude: [0; 3],
|
||||
effect_apply_aura: [0; 3],
|
||||
effect_mechanic: [0; 3],
|
||||
effect_radius_index: [0; 3],
|
||||
effect_chain_targets: [0; 3],
|
||||
effect_multiple_value: [0.0; 3],
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
//! The crowd-control **exemption** — `0x6e9ca0`'s aura scan and `0x6e9d70`'s immunity matcher
|
||||
//! (decision 1946; wow-re `equipped-item-and-cc-cast-gates.md` §2.3/§2.4, byte-verified).
|
||||
//!
|
||||
//! Each of the six crowd-control arms in the cast validator asks this before it refuses: *does one
|
||||
//! of the caster's own auras grant immunity to the thing blocking me?* The answer is not a flag
|
||||
//! lookup — it is a join between two `Spell.dbc` records, the spell **being cast** and the aura
|
||||
//! **doing the blocking**, and the question it really asks is "is the spell I am casting an
|
||||
//! immunity that covers this aura?" — Ice Block cast while stunned, and its kin.
|
||||
//!
|
||||
//! **Its ordinary answer is "no", and that is the point.** The matcher's first gate is
|
||||
//! `AttributesEx` bit 15 on the *cast*, which is clear on essentially every ordinary spell — so a
|
||||
//! normal cast falls out immediately and the arm refuses. What the scan changes is *which message*
|
||||
//! the refusal carries, and the rare real exemption.
|
||||
|
||||
use super::SpellDisplay;
|
||||
|
||||
/// `SPELL_EFFECT_APPLY_AURA` — the only effect kind the matcher's loop considers (`6e9da0`).
|
||||
const SPELL_EFFECT_APPLY_AURA: u32 = 6;
|
||||
|
||||
/// `AttributesEx` bit 15 on the CAST spell, which must be **set** for any immunity to be
|
||||
/// considered (`6e9d81`). INFERRED `SPELL_ATTR_EX_DISPEL_AURAS_ON_IMMUNITY`.
|
||||
const ATTR_EX_DISPELS_ON_IMMUNITY: u32 = 0x0000_8000;
|
||||
|
||||
/// `Attributes` bit 29 on the BLOCKING aura, which must be **clear** (`6e9d89`). INFERRED
|
||||
/// `SPELL_ATTR_UNAFFECTED_BY_INVULNERABILITY` — an aura that says "no immunity touches me".
|
||||
const ATTR_UNAFFECTED_BY_INVULNERABILITY: u32 = 0x2000_0000;
|
||||
|
||||
/// `AttributesEx2` bit 26 on the BLOCKING aura — the school arm's own veto (`6e9dcd`).
|
||||
const ATTR_EX2_NO_SCHOOL_IMMUNITY: u32 = 0x0400_0000;
|
||||
|
||||
/// The four immunity aura types the matcher switches on, all on the **cast** spell's own effects.
|
||||
/// Its window is 38..=77 (`6e9dac`'s `sub 0x26 ; cmp 0x27`); every value inside it that is not one
|
||||
/// of these four falls through to the next effect.
|
||||
const AURA_STATE_IMMUNITY: u32 = 38;
|
||||
const AURA_SCHOOL_IMMUNITY: u32 = 39;
|
||||
const AURA_DISPEL_IMMUNITY: u32 = 41;
|
||||
const AURA_MECHANIC_IMMUNITY: u32 = 77;
|
||||
|
||||
/// **The matcher** (`0x6e9d70`) — does `cast` grant immunity to effect `aura_effect` of `aura`?
|
||||
///
|
||||
/// Two head gates, then a four-way switch over the cast's own `APPLY_AURA` effects:
|
||||
///
|
||||
/// | cast `EffectApplyAuraName[j]` | accepts when |
|
||||
/// |---|---|
|
||||
/// | 38 STATE | `cast.EffectMiscValue[j] == aura.EffectApplyAuraName[i]` |
|
||||
/// | 39 SCHOOL | the aura lacks `AttributesEx2` bit 26 **and** `cast.EffectMiscValue[j] & (1 << aura.School)` |
|
||||
/// | 41 DISPEL | `cast.EffectMiscValue[j] == aura.Dispel` |
|
||||
/// | 77 MECHANIC | `cast.EffectMiscValue[j] == aura.Mechanic` **or** `== aura.EffectMechanic[i]` |
|
||||
///
|
||||
/// Note the asymmetry the school arm carries: `School` is an **index** and `EffectMiscValue` a
|
||||
/// **mask**, so it shifts before testing. Getting that backwards silently makes every school
|
||||
/// immunity match school 0 and nothing else.
|
||||
pub fn grants_immunity(cast: &SpellDisplay, aura: &SpellDisplay, aura_effect: usize) -> bool {
|
||||
if cast.attributes_ex & ATTR_EX_DISPELS_ON_IMMUNITY == 0 {
|
||||
return false;
|
||||
}
|
||||
if aura.attributes & ATTR_UNAFFECTED_BY_INVULNERABILITY != 0 {
|
||||
return false;
|
||||
}
|
||||
let i = aura_effect.min(2);
|
||||
(0..3).any(|j| {
|
||||
if cast.effects[j] != SPELL_EFFECT_APPLY_AURA {
|
||||
return false;
|
||||
}
|
||||
// `EffectMiscValue` is signed in the DBC; every comparison here is against an unsigned id
|
||||
// or a mask, so a negative value simply matches nothing.
|
||||
let misc = cast.effect_misc_value[j];
|
||||
let misc_u = u32::try_from(misc).unwrap_or(u32::MAX);
|
||||
match cast.effect_apply_aura[j] {
|
||||
AURA_STATE_IMMUNITY => misc_u == aura.effect_apply_aura[i],
|
||||
AURA_SCHOOL_IMMUNITY => {
|
||||
aura.attributes_ex2 & ATTR_EX2_NO_SCHOOL_IMMUNITY == 0
|
||||
&& aura.school < 32
|
||||
&& misc_u & (1 << aura.school) != 0
|
||||
}
|
||||
AURA_DISPEL_IMMUNITY => misc_u == aura.dispel,
|
||||
AURA_MECHANIC_IMMUNITY => misc_u == aura.mechanic || misc_u == aura.effect_mechanic[i],
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// What one arm's scan concluded.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct CcExemption {
|
||||
/// **The arm is skipped and the cast proceeds.** True only when at least one aura of the
|
||||
/// wanted type was found *and* every matching effect the scan reached was accepted.
|
||||
pub exempt: bool,
|
||||
/// The blocking aura's mechanic, when one was rejected — `EffectMechanic[i]` if non-zero, else
|
||||
/// the aura's `Mechanic`. `0` means nothing was written, and the arm refuses with its **own**
|
||||
/// reason rather than the generic "Can't do that while %s".
|
||||
pub mechanic: u32,
|
||||
}
|
||||
|
||||
/// **The scanner** (`0x6e9ca0`) — walk the caster's aura slots for an aura of `wanted_aura_type`
|
||||
/// and ask [`grants_immunity`] about each matching effect.
|
||||
///
|
||||
/// Three outcomes, and the middle one is the whole reason the arms carry two reason codes:
|
||||
///
|
||||
/// - **exempt** — a matching aura existed and every matching effect was accepted;
|
||||
/// - **not exempt, mechanic written** — a matching aura was *rejected*; the refusal names its
|
||||
/// mechanic (the `0x8d` line). The scan **stops at the first rejection**;
|
||||
/// - **not exempt, mechanic 0** — no aura of that type at all.
|
||||
///
|
||||
/// `aura_ids` must be the **raw** `UNIT_FIELD_AURA` slot ids: the reference does not consult
|
||||
/// `UNIT_FIELD_AURAFLAGS` here, does not skip an "inactive" slot, and reads no duration, stack or
|
||||
/// caster state. Any non-zero, in-range id counts — which is why this takes ids rather than the
|
||||
/// filtered slot view the buff bar uses.
|
||||
pub fn cc_exemption<'a>(
|
||||
cast: &SpellDisplay,
|
||||
aura_ids: impl IntoIterator<Item = u32>,
|
||||
wanted_aura_type: u32,
|
||||
spell: impl Fn(u32) -> Option<&'a SpellDisplay>,
|
||||
) -> CcExemption {
|
||||
let mut found = false;
|
||||
for id in aura_ids {
|
||||
if id == 0 {
|
||||
continue;
|
||||
}
|
||||
let Some(aura) = spell(id) else {
|
||||
continue; // out of range for the id table — the reference's own bound test
|
||||
};
|
||||
for i in 0..3 {
|
||||
if aura.effect_apply_aura[i] != wanted_aura_type {
|
||||
continue;
|
||||
}
|
||||
found = true;
|
||||
if !grants_immunity(cast, aura, i) {
|
||||
let mechanic = if aura.effect_mechanic[i] != 0 {
|
||||
aura.effect_mechanic[i]
|
||||
} else {
|
||||
aura.mechanic
|
||||
};
|
||||
return CcExemption {
|
||||
exempt: false,
|
||||
mechanic,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
CcExemption {
|
||||
exempt: found,
|
||||
mechanic: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A blocking aura: one effect of `aura_type`, with a mechanic and a school.
|
||||
fn aura(aura_type: u32, mechanic: u32, effect_mechanic: u32, school: u32) -> SpellDisplay {
|
||||
let mut d = SpellDisplay {
|
||||
mechanic,
|
||||
school,
|
||||
..Default::default()
|
||||
};
|
||||
d.effect_apply_aura[0] = aura_type;
|
||||
d.effect_mechanic[0] = effect_mechanic;
|
||||
d
|
||||
}
|
||||
|
||||
/// An immunity spell: one `APPLY_AURA` effect of `immunity_type` carrying `misc`.
|
||||
fn immunity(immunity_type: u32, misc: i32) -> SpellDisplay {
|
||||
let mut d = SpellDisplay {
|
||||
attributes_ex: ATTR_EX_DISPELS_ON_IMMUNITY,
|
||||
..Default::default()
|
||||
};
|
||||
d.effects[0] = SPELL_EFFECT_APPLY_AURA;
|
||||
d.effect_apply_aura[0] = immunity_type;
|
||||
d.effect_misc_value[0] = misc;
|
||||
d
|
||||
}
|
||||
|
||||
/// **The two head gates**, which are why an ordinary cast is never exempt: the CAST must carry
|
||||
/// `AttributesEx` bit 15, and the blocking AURA must not carry `Attributes` bit 29.
|
||||
#[test]
|
||||
fn an_ordinary_cast_grants_no_immunity() {
|
||||
let stun = aura(12, 12, 0, 0);
|
||||
// A mechanic immunity that matches — but with bit 15 clear it never even looks.
|
||||
let mut ordinary = immunity(AURA_MECHANIC_IMMUNITY, 12);
|
||||
ordinary.attributes_ex = 0;
|
||||
assert!(!grants_immunity(&ordinary, &stun, 0));
|
||||
|
||||
// With the bit set it matches…
|
||||
let real = immunity(AURA_MECHANIC_IMMUNITY, 12);
|
||||
assert!(grants_immunity(&real, &stun, 0));
|
||||
|
||||
// …unless the aura declares itself unaffected by invulnerability.
|
||||
let stubborn = SpellDisplay {
|
||||
attributes: ATTR_UNAFFECTED_BY_INVULNERABILITY,
|
||||
..aura(12, 12, 0, 0)
|
||||
};
|
||||
assert!(!grants_immunity(&real, &stubborn, 0));
|
||||
}
|
||||
|
||||
/// The four arms, each on its own field of the blocking aura.
|
||||
#[test]
|
||||
fn each_immunity_arm_reads_its_own_field() {
|
||||
// 77 MECHANIC — matches the aura's `Mechanic` OR its per-effect `EffectMechanic[i]`.
|
||||
assert!(grants_immunity(
|
||||
&immunity(AURA_MECHANIC_IMMUNITY, 12),
|
||||
&aura(12, 12, 0, 0),
|
||||
0
|
||||
));
|
||||
assert!(grants_immunity(
|
||||
&immunity(AURA_MECHANIC_IMMUNITY, 7),
|
||||
&aura(12, 0, 7, 0),
|
||||
0
|
||||
));
|
||||
assert!(!grants_immunity(
|
||||
&immunity(AURA_MECHANIC_IMMUNITY, 5),
|
||||
&aura(12, 12, 7, 0),
|
||||
0
|
||||
));
|
||||
|
||||
// 41 DISPEL — the aura's `Dispel`.
|
||||
let magic = SpellDisplay {
|
||||
dispel: 1,
|
||||
..aura(12, 12, 0, 0)
|
||||
};
|
||||
assert!(grants_immunity(
|
||||
&immunity(AURA_DISPEL_IMMUNITY, 1),
|
||||
&magic,
|
||||
0
|
||||
));
|
||||
assert!(!grants_immunity(
|
||||
&immunity(AURA_DISPEL_IMMUNITY, 2),
|
||||
&magic,
|
||||
0
|
||||
));
|
||||
|
||||
// 38 STATE — the aura's own `EffectApplyAuraName[i]`.
|
||||
assert!(grants_immunity(
|
||||
&immunity(AURA_STATE_IMMUNITY, 12),
|
||||
&aura(12, 0, 0, 0),
|
||||
0
|
||||
));
|
||||
|
||||
// 39 SCHOOL — **`School` is an index and `EffectMiscValue` a mask**, so the arm shifts.
|
||||
// A frost aura (school 4) is covered by a mask with bit 4 set, not by the value 4.
|
||||
let frost = aura(12, 12, 0, 4);
|
||||
assert!(grants_immunity(
|
||||
&immunity(AURA_SCHOOL_IMMUNITY, 1 << 4),
|
||||
&frost,
|
||||
0
|
||||
));
|
||||
assert!(
|
||||
!grants_immunity(&immunity(AURA_SCHOOL_IMMUNITY, 4), &frost, 0),
|
||||
"the raw index must not match — that is the bug this shift exists to avoid"
|
||||
);
|
||||
// …and the aura can veto the school arm specifically.
|
||||
let unschooled = SpellDisplay {
|
||||
attributes_ex2: ATTR_EX2_NO_SCHOOL_IMMUNITY,
|
||||
..frost
|
||||
};
|
||||
assert!(!grants_immunity(
|
||||
&immunity(AURA_SCHOOL_IMMUNITY, 1 << 4),
|
||||
&unschooled,
|
||||
0
|
||||
));
|
||||
}
|
||||
|
||||
/// **The scanner's three outcomes** — and the middle one is why every arm carries two reasons.
|
||||
#[test]
|
||||
fn the_scan_reports_exempt_rejected_or_absent() {
|
||||
let stun = aura(12, 0, 9, 0); // EffectMechanic 9, so a rejection names 9
|
||||
let plain = SpellDisplay::default();
|
||||
let ice_block = immunity(AURA_MECHANIC_IMMUNITY, 9);
|
||||
let lookup = |id: u32| match id {
|
||||
100 => Some(&stun),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// No aura of that type at all: not exempt, nothing named — the arm uses its OWN reason.
|
||||
assert_eq!(
|
||||
cc_exemption(&plain, [0, 0, 0], 12, lookup),
|
||||
CcExemption {
|
||||
exempt: false,
|
||||
mechanic: 0
|
||||
}
|
||||
);
|
||||
|
||||
// A matching aura, rejected: the arm reports the blocking MECHANIC, which turns its
|
||||
// message into "Can't do that while %s".
|
||||
assert_eq!(
|
||||
cc_exemption(&plain, [100], 12, lookup),
|
||||
CcExemption {
|
||||
exempt: false,
|
||||
mechanic: 9
|
||||
}
|
||||
);
|
||||
|
||||
// A matching aura, accepted: EXEMPT — the arm is skipped and the cast goes out.
|
||||
assert_eq!(
|
||||
cc_exemption(&ice_block, [100], 12, lookup),
|
||||
CcExemption {
|
||||
exempt: true,
|
||||
mechanic: 0
|
||||
}
|
||||
);
|
||||
|
||||
// An id the catalog does not know is skipped, exactly as the reference's bound test does.
|
||||
assert_eq!(
|
||||
cc_exemption(&ice_block, [999], 12, lookup),
|
||||
CcExemption::default()
|
||||
);
|
||||
}
|
||||
|
||||
/// The mechanic the scan names prefers the per-effect column and falls back to the spell's.
|
||||
#[test]
|
||||
fn the_named_mechanic_prefers_the_per_effect_column() {
|
||||
let plain = SpellDisplay::default();
|
||||
let both = aura(12, 5, 9, 0);
|
||||
let only_spell = aura(12, 5, 0, 0);
|
||||
assert_eq!(
|
||||
cc_exemption(&plain, [1], 12, |_| Some(&both)).mechanic,
|
||||
9,
|
||||
"EffectMechanic[i] wins"
|
||||
);
|
||||
assert_eq!(
|
||||
cc_exemption(&plain, [1], 12, |_| Some(&only_spell)).mechanic,
|
||||
5,
|
||||
"…and Mechanic is the fallback"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,8 @@ pub use dispel_types::{load_spell_dispel_types, SpellDispelTypes};
|
||||
pub use display::{FormRefusal, OpenLock, SpellDisplay};
|
||||
pub use duration::{load_spell_durations, SpellDuration, SpellDurationCatalog};
|
||||
pub use forms::{load_shapeshift_forms, ShapeshiftForm};
|
||||
mod immunity;
|
||||
pub use immunity::{cc_exemption, grants_immunity, CcExemption};
|
||||
pub use radius::{load_spell_radii, SpellRadius, SpellRadiusCatalog};
|
||||
pub use ranges::{load_spell_ranges, SpellRange, SpellRangeCatalog};
|
||||
pub use tokens::{substitute, TokenContext};
|
||||
@@ -246,6 +248,12 @@ const COL_REQUIRES_SPELL_FOCUS: usize = 15;
|
||||
/// `Dispel` — the `SpellDispelType.dbc` id (`SpellRec+0x10`; the byte offset chain-locks it to
|
||||
/// `COL_CAST_UI` at `+0xc` and `COL_ATTRIBUTES` at `+0x18`). Decision 0257.
|
||||
const COL_DISPEL: usize = 4;
|
||||
/// `School` (`SpellRec+0x4`, `0x4/4 == 1`) — see [`SpellDisplay::school`].
|
||||
const COL_SCHOOL: usize = 1;
|
||||
/// `Mechanic` (`SpellRec+0x14`, `0x14/4 == 5`) — see [`SpellDisplay::mechanic`].
|
||||
const COL_MECHANIC: usize = 5;
|
||||
/// `EffectMechanic[0]` (`SpellRec+0x13c`, `0x13c/4 == 79`) — see [`SpellDisplay::effect_mechanic`].
|
||||
const COL_EFFECT_MECHANIC_1: usize = 79;
|
||||
const COL_ATTRIBUTES: usize = 6;
|
||||
/// `AttributesEx` (`SpellRec+0x1c` — chain-locked between `COL_ATTRIBUTES` at `+0x18` and
|
||||
/// `COL_ATTRIBUTES_EX2` at `+0x20`).
|
||||
@@ -754,6 +762,11 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result<SpellCatalog> {
|
||||
attributes_ex: u32_at(r, COL_ATTRIBUTES_EX).unwrap_or(0),
|
||||
attributes_ex2: u32_at(r, COL_ATTRIBUTES_EX2).unwrap_or(0),
|
||||
attributes_ex3: u32_at(r, COL_ATTRIBUTES_EX3).unwrap_or(0),
|
||||
school: u32_at(r, COL_SCHOOL).unwrap_or(0),
|
||||
mechanic: u32_at(r, COL_MECHANIC).unwrap_or(0),
|
||||
effect_mechanic: std::array::from_fn(|i| {
|
||||
u32_at(r, COL_EFFECT_MECHANIC_1 + i).unwrap_or(0)
|
||||
}),
|
||||
prevention_type: u32_at(r, COL_PREVENTION_TYPE).unwrap_or(0),
|
||||
passive: attributes & ATTR_PASSIVE != 0,
|
||||
cast_ui: u32_at(r, COL_CAST_UI).unwrap_or(0),
|
||||
|
||||
@@ -65,6 +65,46 @@ fn spell_catalog_resolves_known_spells() {
|
||||
distinguishable from 165 at all"
|
||||
);
|
||||
|
||||
// **The crowd-control exemption's three columns** (decision 1946): `School` 1, `Mechanic` 5,
|
||||
// `EffectMechanic[0..2]` 79–81. Pinned against spells whose values are common knowledge, and
|
||||
// the per-effect pair is the convincing half — Frostbolt carries its SNARE on effect 0 and
|
||||
// Frost Nova its ROOT on effect 1, which no neighbouring column would reproduce.
|
||||
assert_eq!(catalog.get(133).expect("Fireball").school, 2, "fire");
|
||||
assert_eq!(catalog.get(116).expect("Frostbolt").school, 4, "frost");
|
||||
assert_eq!(catalog.get(585).expect("Smite").school, 1, "holy");
|
||||
assert_eq!(
|
||||
catalog.get(78).expect("Heroic Strike").school,
|
||||
0,
|
||||
"physical"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
catalog.get(118).expect("Polymorph").mechanic,
|
||||
17,
|
||||
"MECHANIC_POLYMORPH"
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.get(5782).expect("Fear").mechanic,
|
||||
5,
|
||||
"MECHANIC_FEAR"
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.get(133).expect("Fireball").mechanic,
|
||||
0,
|
||||
"a plain nuke carries no mechanic"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
catalog.get(116).expect("Frostbolt").effect_mechanic[0],
|
||||
11,
|
||||
"Frostbolt's slow is MECHANIC_SNARE, on effect 0"
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.get(122).expect("Frost Nova").effect_mechanic[1],
|
||||
7,
|
||||
"Frost Nova's root is MECHANIC_ROOT, on effect 1 — not effect 0"
|
||||
);
|
||||
|
||||
let fireball = catalog.get(133).expect("Fireball");
|
||||
assert_eq!(fireball.visual, 67, "Fireball's SpellVisual id");
|
||||
assert_eq!(fireball.speed, 24.0, "Fireball's projectile speed");
|
||||
|
||||
@@ -283,6 +283,19 @@ impl ObjectFields {
|
||||
pub fn unit_auras(&self) -> impl Iterator<Item = UnitAuraSlot> + '_ {
|
||||
(0..UNIT_AURA_SLOTS).filter_map(|slot| self.unit_aura(slot))
|
||||
}
|
||||
/// Every `UNIT_FIELD_AURA` slot's **raw** spell id, unfiltered — no `AURAFLAGS` nibble test,
|
||||
/// no zero-skip, in ascending slot order.
|
||||
///
|
||||
/// This is deliberately NOT [`Self::unit_auras`], and the difference is the reference's, not
|
||||
/// ours: the cast validator's crowd-control exemption scan (`0x6e9ca0`) reads these slots
|
||||
/// straight and **does not consult `UNIT_FIELD_AURAFLAGS`** — it skips no "inactive" slot and
|
||||
/// reads no duration, stack or caster state. Any non-zero, in-range id counts, husk or not.
|
||||
/// The buff bar wants the filtered view; that scan wants this one (decision 1946).
|
||||
pub fn unit_aura_ids(&self) -> impl Iterator<Item = u32> + '_ {
|
||||
(0..UNIT_AURA_SLOTS)
|
||||
.map(|slot| self.get_u32(FIELD_UNIT_AURA + u16::from(slot)).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// The unit's active power type (`UNIT_FIELD_BYTES_0` byte 3): `0` mana, `1` rage, `2` focus,
|
||||
/// `3` energy, `4` happiness. Absent (no bytes_0 on the wire yet) reads as mana, the descriptor's
|
||||
/// zero-initialized default.
|
||||
|
||||
@@ -86,11 +86,11 @@ pub fn tuned_default_plugins(primary_window: Window) -> PluginGroupBuilder {
|
||||
..default()
|
||||
},
|
||||
})
|
||||
// Sound is kira behind our own mixer seam (decision 0070); Bevy's AudioPlugin would only
|
||||
// open a second, never-used OS output stream at startup. Off (0530). Its rodio/cpal stack
|
||||
// still compiles in via bevy's default feature — trimming the feature set is a separate,
|
||||
// wider call.
|
||||
.disable::<bevy::audio::AudioPlugin>()
|
||||
// Sound is kira behind our own mixer seam (decision 0070). Bevy's `AudioPlugin` used to be
|
||||
// disabled here (0530) so it would not open a second, never-used OS output stream — but
|
||||
// the crate behind it was still compiled and linked. Since 1932 `bevy_audio` is off at the
|
||||
// feature level, so there is no plugin to disable and no rodio/cpal/vorbis stack in the
|
||||
// binary; the feature list that keeps it out is in the workspace `Cargo.toml`.
|
||||
// The dead registrations (decision 1438): DefaultPlugins members whose only runtime
|
||||
// trace here was per-frame machinery for types nothing instantiates — every registered
|
||||
// asset/material type costs an `Assets<T>` event system in PostUpdate plus
|
||||
|
||||
@@ -12,6 +12,7 @@ use bevy::render::render_resource::{Buffer, Face};
|
||||
use benilla_assets::materials::{WowModelExt, WowModelMaterial, VANILLA_ALPHA_KEY_REF};
|
||||
|
||||
mod batch;
|
||||
pub mod lazy;
|
||||
mod visibility;
|
||||
|
||||
pub use batch::{BatchVariants, M2BatchMaterials, ModelMaterials, SkyboxBatch};
|
||||
@@ -351,141 +352,145 @@ pub fn model_material(
|
||||
..default()
|
||||
},
|
||||
};
|
||||
let handle = materials.add(ExtendedMaterial {
|
||||
base,
|
||||
extension: WowModelExt {
|
||||
// `clutter_fade.z` is unread by the shader, so it carries the per-batch pipeline markers
|
||||
// `specialize` keys on: bit0 = no-depth-write (M2 0x10), bit1 = no-depth-test (0x08),
|
||||
// bit2 = additive blend. (`x`/`y`/`w` stay the ground-clutter distance fade — `0` here.)
|
||||
clutter_fade: Vec4::new(
|
||||
0.0,
|
||||
0.0,
|
||||
// Bit 3 = OPAQUE-INTENT: an opaque/alpha-key steady batch whose output alpha is
|
||||
// semantically meaningless (opaque/mask pipelines ignore it — only blend pipelines
|
||||
// read it). The shader pins such batches' output alpha to 1.0: a spec-level no-op
|
||||
// that armors against a multi-view pipeline-state mixup observed on macOS/Metal
|
||||
// (opaque WMO/M2 draws intermittently bound with blending enabled when an extra
|
||||
// camera exists, bleeding the garbage BLP alpha channel — the "pale film on
|
||||
// buildings" regression; full measured chain in the fix commit).
|
||||
// Bits 4-6 = the per-batch FOG POLICY (`FogPolicy` discriminant, wow-re
|
||||
// rf-weather-emission-timeline ROUND 4): 0 = scene so clutter/water materials —
|
||||
// which leave the byte's high bits 0 (see `WorldAssets::model_material`,
|
||||
// `water_fx`) — keep ordinary scene fog; 1/2/3 = the additive/Mod/Mod2x BLACK/
|
||||
// WHITE/GREY fog colours; 4 = fog disabled outright (render flag 0x02).
|
||||
// Bits 7/8 = the MULTIPLY blends (decision 0528): `specialize` swaps the pipeline
|
||||
// to the byte-verified factors — Mod DST_COLOR/ZERO, Mod2x DST_COLOR/SRC_COLOR
|
||||
// (exact on the 0161 gamma lane: the framebuffer holds gamma, like the reference).
|
||||
// Bit 10 = TWIN CUTOUT (decision 0842): this fade twin's SOURCE batch alpha-tests,
|
||||
// so the shader re-applies the hard 224/255 cutout on the unfaded alpha (the
|
||||
// reference's promoted-AlphaKey ALPHAREF = A×224 — the same fixed silhouette). An
|
||||
// Opaque source sets no bit: the reference disables its alpha test outright, steady
|
||||
// and promoted alike (`m2-blend-promotion-zfill.md` §2 keys ALPHAREF on the STORED
|
||||
// blend mode, mode 0 → ref 0).
|
||||
f32::from(
|
||||
u16::from(no_depth_write)
|
||||
| (u16::from(no_depth_test) << 1)
|
||||
| (u16::from(is_additive) << 2)
|
||||
| (u16::from(
|
||||
matches!(blend, ModelBlend::Opaque | ModelBlend::AlphaTest)
|
||||
&& !fade_variant
|
||||
&& !is_additive,
|
||||
) << 3)
|
||||
| (u16::from(fog_policy as u8) << 4)
|
||||
| (u16::from(blend == ModelBlend::Mod) << 7)
|
||||
| (u16::from(blend == ModelBlend::Mod2x) << 8)
|
||||
| (u16::from(fade_variant && source_cutout) * TWIN_CUTOUT_MARKER)
|
||||
| (u16::from(env_map) * ENV_MAP_MARKER)
|
||||
| (u16::from(sky_depth) * SKY_DEPTH_MARKER),
|
||||
let handle = lazy::defer(
|
||||
materials,
|
||||
ExtendedMaterial {
|
||||
base,
|
||||
extension: WowModelExt {
|
||||
// `clutter_fade.z` is unread by the shader, so it carries the per-batch pipeline markers
|
||||
// `specialize` keys on: bit0 = no-depth-write (M2 0x10), bit1 = no-depth-test (0x08),
|
||||
// bit2 = additive blend. (`x`/`y`/`w` stay the ground-clutter distance fade — `0` here.)
|
||||
clutter_fade: Vec4::new(
|
||||
0.0,
|
||||
0.0,
|
||||
// Bit 3 = OPAQUE-INTENT: an opaque/alpha-key steady batch whose output alpha is
|
||||
// semantically meaningless (opaque/mask pipelines ignore it — only blend pipelines
|
||||
// read it). The shader pins such batches' output alpha to 1.0: a spec-level no-op
|
||||
// that armors against a multi-view pipeline-state mixup observed on macOS/Metal
|
||||
// (opaque WMO/M2 draws intermittently bound with blending enabled when an extra
|
||||
// camera exists, bleeding the garbage BLP alpha channel — the "pale film on
|
||||
// buildings" regression; full measured chain in the fix commit).
|
||||
// Bits 4-6 = the per-batch FOG POLICY (`FogPolicy` discriminant, wow-re
|
||||
// rf-weather-emission-timeline ROUND 4): 0 = scene so clutter/water materials —
|
||||
// which leave the byte's high bits 0 (see `WorldAssets::model_material`,
|
||||
// `water_fx`) — keep ordinary scene fog; 1/2/3 = the additive/Mod/Mod2x BLACK/
|
||||
// WHITE/GREY fog colours; 4 = fog disabled outright (render flag 0x02).
|
||||
// Bits 7/8 = the MULTIPLY blends (decision 0528): `specialize` swaps the pipeline
|
||||
// to the byte-verified factors — Mod DST_COLOR/ZERO, Mod2x DST_COLOR/SRC_COLOR
|
||||
// (exact on the 0161 gamma lane: the framebuffer holds gamma, like the reference).
|
||||
// Bit 10 = TWIN CUTOUT (decision 0842): this fade twin's SOURCE batch alpha-tests,
|
||||
// so the shader re-applies the hard 224/255 cutout on the unfaded alpha (the
|
||||
// reference's promoted-AlphaKey ALPHAREF = A×224 — the same fixed silhouette). An
|
||||
// Opaque source sets no bit: the reference disables its alpha test outright, steady
|
||||
// and promoted alike (`m2-blend-promotion-zfill.md` §2 keys ALPHAREF on the STORED
|
||||
// blend mode, mode 0 → ref 0).
|
||||
f32::from(
|
||||
u16::from(no_depth_write)
|
||||
| (u16::from(no_depth_test) << 1)
|
||||
| (u16::from(is_additive) << 2)
|
||||
| (u16::from(
|
||||
matches!(blend, ModelBlend::Opaque | ModelBlend::AlphaTest)
|
||||
&& !fade_variant
|
||||
&& !is_additive,
|
||||
) << 3)
|
||||
| (u16::from(fog_policy as u8) << 4)
|
||||
| (u16::from(blend == ModelBlend::Mod) << 7)
|
||||
| (u16::from(blend == ModelBlend::Mod2x) << 8)
|
||||
| (u16::from(fade_variant && source_cutout) * TWIN_CUTOUT_MARKER)
|
||||
| (u16::from(env_map) * ENV_MAP_MARKER)
|
||||
| (u16::from(sky_depth) * SKY_DEPTH_MARKER),
|
||||
),
|
||||
0.0,
|
||||
),
|
||||
0.0,
|
||||
),
|
||||
// x = WMO (FFP N·L × MOCV, not the M2 SH probe); y = distance-fade blend variant; z = WMO
|
||||
// interior group (sun off, baked MOCV carries the room); w = **unlit fullbright** (>0.5 ⇒
|
||||
// bypass lighting in wow_model.wgsl): the M2 UNLIT (0x01) flag, or WMO UNLIT on an
|
||||
// exterior-group batch (the interior drawer ignores it — section law, `wmo-lit-selector`).
|
||||
// Additive is NOT fullbright: the real client *lights* additive batches unless 0x01 is set
|
||||
// (wow-re `m2-no-envmap-texgen`'s lighting section — `DAT_00811fa8[4] = 1`), so an
|
||||
// un-flagged additive (e.g. ArmorReflect shine) is lit. **That note's headline is
|
||||
// otherwise wrong and this cite reaches only its lighting table** (decision 0971): the
|
||||
// M2 path DOES generate env-map texcoords — in the vertex program, which the note's
|
||||
// `SetRenderState` sweep could not see. See [`ENV_MAP_MARKER`].
|
||||
// M2 Mod/Mod2x ARE fullbright regardless of 0x01 — the lighting table
|
||||
// `DAT_00811fa8 = {1,1,1,1,1,0,0}` clears GL_LIGHTING for modes 5/6 (wow-re
|
||||
// `m2-depth-blend-state`); WMO lighting stays flag-driven only (decision 0528).
|
||||
model_flags: Vec4::new(
|
||||
if is_wmo { 1.0 } else { 0.0 },
|
||||
if fade_variant { 1.0 } else { 0.0 },
|
||||
if is_interior { 1.0 } else { 0.0 },
|
||||
if is_emissive || (!is_wmo && matches!(blend, ModelBlend::Mod | ModelBlend::Mod2x))
|
||||
{
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
),
|
||||
// x = the static MCSH terrain-shade SELECTOR ([`ShadeSel`]: 1.0 ADT-lit / 0.6 matte /
|
||||
// 0.2 shaded; shader thresholds at 0.85 and 0.5) that chooses which live sun LEVEL scales the
|
||||
// FFP matte's diffuse term. Static per material (a doodad doesn't move), so it dedups the
|
||||
// variants; moving entities are Lit here and mix per instance via the `MeshTag` shade byte.
|
||||
// y = the WMO authored batch order, read by `wow_model.wgsl`'s VERTEX stage: it scales
|
||||
// clip z by (1 + y·2⁻²³) so a later coplanar batch wins the reverse-Z depth test in any
|
||||
// draw order — the byte-verified MOBA draw-order determinism. Uniform data by design:
|
||||
// as a `WowModelKey` axis driving a fixed-function depth bias it made every batch index
|
||||
// its own PIPELINE, and a first city sight compiled ~3000 of them synchronously on the
|
||||
// render thread (decision 0837). `WOW_WMO_BIAS=0` (B38's A/B diagnostic) zeroes it here.
|
||||
// zw = the batch's live **UV-animation offset** (decision 0130 phase 3, wow-re
|
||||
// `m2-texanim-uv`: the real client adds the sampled translation to the stage UVs —
|
||||
// translation is un-pivoted, and no placed doodad uses rotation/scaling). Seeded at
|
||||
// t = 0 here; `doodad_anim::tick_anim_materials` re-samples it per drawn frame on the
|
||||
// shared clock (frozen in captures).
|
||||
sun_scale: {
|
||||
let uv0 = uv_anim.map_or([0.0, 0.0], |a| a.sample(0.0));
|
||||
// The clip-z nudge stays WMO-only: M2 coplanar layers pass GreaterEqual at exactly
|
||||
// equal depth (same mesh, same transform, same vertex path), and their ORDER is
|
||||
// the sort bias above — nudging their depth would be an unverified extra.
|
||||
let order =
|
||||
if !is_wmo || matches!(std::env::var("WOW_WMO_BIAS").as_deref(), Ok("0")) {
|
||||
0.0
|
||||
// x = WMO (FFP N·L × MOCV, not the M2 SH probe); y = distance-fade blend variant; z = WMO
|
||||
// interior group (sun off, baked MOCV carries the room); w = **unlit fullbright** (>0.5 ⇒
|
||||
// bypass lighting in wow_model.wgsl): the M2 UNLIT (0x01) flag, or WMO UNLIT on an
|
||||
// exterior-group batch (the interior drawer ignores it — section law, `wmo-lit-selector`).
|
||||
// Additive is NOT fullbright: the real client *lights* additive batches unless 0x01 is set
|
||||
// (wow-re `m2-no-envmap-texgen`'s lighting section — `DAT_00811fa8[4] = 1`), so an
|
||||
// un-flagged additive (e.g. ArmorReflect shine) is lit. **That note's headline is
|
||||
// otherwise wrong and this cite reaches only its lighting table** (decision 0971): the
|
||||
// M2 path DOES generate env-map texcoords — in the vertex program, which the note's
|
||||
// `SetRenderState` sweep could not see. See [`ENV_MAP_MARKER`].
|
||||
// M2 Mod/Mod2x ARE fullbright regardless of 0x01 — the lighting table
|
||||
// `DAT_00811fa8 = {1,1,1,1,1,0,0}` clears GL_LIGHTING for modes 5/6 (wow-re
|
||||
// `m2-depth-blend-state`); WMO lighting stays flag-driven only (decision 0528).
|
||||
model_flags: Vec4::new(
|
||||
if is_wmo { 1.0 } else { 0.0 },
|
||||
if fade_variant { 1.0 } else { 0.0 },
|
||||
if is_interior { 1.0 } else { 0.0 },
|
||||
if is_emissive
|
||||
|| (!is_wmo && matches!(blend, ModelBlend::Mod | ModelBlend::Mod2x))
|
||||
{
|
||||
1.0
|
||||
} else {
|
||||
f32::from(batch_order)
|
||||
0.0
|
||||
},
|
||||
),
|
||||
// x = the static MCSH terrain-shade SELECTOR ([`ShadeSel`]: 1.0 ADT-lit / 0.6 matte /
|
||||
// 0.2 shaded; shader thresholds at 0.85 and 0.5) that chooses which live sun LEVEL scales the
|
||||
// FFP matte's diffuse term. Static per material (a doodad doesn't move), so it dedups the
|
||||
// variants; moving entities are Lit here and mix per instance via the `MeshTag` shade byte.
|
||||
// y = the WMO authored batch order, read by `wow_model.wgsl`'s VERTEX stage: it scales
|
||||
// clip z by (1 + y·2⁻²³) so a later coplanar batch wins the reverse-Z depth test in any
|
||||
// draw order — the byte-verified MOBA draw-order determinism. Uniform data by design:
|
||||
// as a `WowModelKey` axis driving a fixed-function depth bias it made every batch index
|
||||
// its own PIPELINE, and a first city sight compiled ~3000 of them synchronously on the
|
||||
// render thread (decision 0837). `WOW_WMO_BIAS=0` (B38's A/B diagnostic) zeroes it here.
|
||||
// zw = the batch's live **UV-animation offset** (decision 0130 phase 3, wow-re
|
||||
// `m2-texanim-uv`: the real client adds the sampled translation to the stage UVs —
|
||||
// translation is un-pivoted, and no placed doodad uses rotation/scaling). Seeded at
|
||||
// t = 0 here; `doodad_anim::tick_anim_materials` re-samples it per drawn frame on the
|
||||
// shared clock (frozen in captures).
|
||||
sun_scale: {
|
||||
let uv0 = uv_anim.map_or([0.0, 0.0], |a| a.sample(0.0));
|
||||
// The clip-z nudge stays WMO-only: M2 coplanar layers pass GreaterEqual at exactly
|
||||
// equal depth (same mesh, same transform, same vertex path), and their ORDER is
|
||||
// the sort bias above — nudging their depth would be an unverified extra.
|
||||
let order =
|
||||
if !is_wmo || matches!(std::env::var("WOW_WMO_BIAS").as_deref(), Ok("0")) {
|
||||
0.0
|
||||
} else {
|
||||
f32::from(batch_order)
|
||||
};
|
||||
Vec4::new(shade.selector(), order, uv0[0], uv0[1])
|
||||
},
|
||||
// The animated M2Color tint's first key (identity white for static batches — their
|
||||
// constant tint rides the vertex colours instead). A lane that never re-samples this
|
||||
// shows exactly the old static bake; the effect lane clones + ticks it per instance.
|
||||
// `w` = the WMO interior batch-class lane: an interior group's INT batches draw UNLIT
|
||||
// (pure tex × MOCV) and its TRANS batches lerp lit↔bake by the MOCV alpha (wow-re
|
||||
// `trace-forensics-abbey-interior-d3d` §2 — observed on the abbey at close range, the
|
||||
// northshire "lit interior batch" datum having been a mis-identified unit). Exterior
|
||||
// groups' batches (and every M2) ride 0 = the exterior law.
|
||||
tint: {
|
||||
let t0 = rgb_anim.map_or([1.0, 1.0, 1.0], |a| a.sample(0.0));
|
||||
let class_lane = match (is_interior && is_wmo, wmo_class) {
|
||||
(true, Some(WmoBatchClass::Int)) => 1.0,
|
||||
(true, Some(WmoBatchClass::Trans)) => 2.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
Vec4::new(shade.selector(), order, uv0[0], uv0[1])
|
||||
Vec4::new(t0[0], t0[1], t0[2], class_lane)
|
||||
},
|
||||
// The WMO window/glass law (`WowModelExt::sidn`): xyz = the authored SIDN emissive
|
||||
// (gamma bytes /255 — the shader ramps it by the live night fraction on lit lanes),
|
||||
// w = the WINDOW midpoint-light flag.
|
||||
sidn: {
|
||||
let c = sidn.unwrap_or([0, 0, 0]);
|
||||
Vec4::new(
|
||||
f32::from(c[0]) / 255.0,
|
||||
f32::from(c[1]) / 255.0,
|
||||
f32::from(c[2]) / 255.0,
|
||||
if window { 1.0 } else { 0.0 },
|
||||
)
|
||||
},
|
||||
// Static until a sampler registers this material (decision 1381) — then the
|
||||
// table slot is baked in exactly once.
|
||||
anim_slots: Vec4::ZERO,
|
||||
light_buf: light.clone(),
|
||||
},
|
||||
// The animated M2Color tint's first key (identity white for static batches — their
|
||||
// constant tint rides the vertex colours instead). A lane that never re-samples this
|
||||
// shows exactly the old static bake; the effect lane clones + ticks it per instance.
|
||||
// `w` = the WMO interior batch-class lane: an interior group's INT batches draw UNLIT
|
||||
// (pure tex × MOCV) and its TRANS batches lerp lit↔bake by the MOCV alpha (wow-re
|
||||
// `trace-forensics-abbey-interior-d3d` §2 — observed on the abbey at close range, the
|
||||
// northshire "lit interior batch" datum having been a mis-identified unit). Exterior
|
||||
// groups' batches (and every M2) ride 0 = the exterior law.
|
||||
tint: {
|
||||
let t0 = rgb_anim.map_or([1.0, 1.0, 1.0], |a| a.sample(0.0));
|
||||
let class_lane = match (is_interior && is_wmo, wmo_class) {
|
||||
(true, Some(WmoBatchClass::Int)) => 1.0,
|
||||
(true, Some(WmoBatchClass::Trans)) => 2.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
Vec4::new(t0[0], t0[1], t0[2], class_lane)
|
||||
},
|
||||
// The WMO window/glass law (`WowModelExt::sidn`): xyz = the authored SIDN emissive
|
||||
// (gamma bytes /255 — the shader ramps it by the live night fraction on lit lanes),
|
||||
// w = the WINDOW midpoint-light flag.
|
||||
sidn: {
|
||||
let c = sidn.unwrap_or([0, 0, 0]);
|
||||
Vec4::new(
|
||||
f32::from(c[0]) / 255.0,
|
||||
f32::from(c[1]) / 255.0,
|
||||
f32::from(c[2]) / 255.0,
|
||||
if window { 1.0 } else { 0.0 },
|
||||
)
|
||||
},
|
||||
// Static until a sampler registers this material (decision 1381) — then the
|
||||
// table slot is baked in exactly once.
|
||||
anim_slots: Vec4::ZERO,
|
||||
light_buf: light.clone(),
|
||||
},
|
||||
});
|
||||
);
|
||||
cache.insert(key, handle.clone());
|
||||
handle
|
||||
}
|
||||
@@ -626,27 +631,30 @@ pub fn zfill_material(
|
||||
cull_mode,
|
||||
..default()
|
||||
};
|
||||
let handle = materials.add(ExtendedMaterial {
|
||||
base,
|
||||
extension: WowModelExt {
|
||||
// Bit 10 (the twin-cutout marker) drives the shader's hard 224/255 cutout — set
|
||||
// exactly when the colour pass discards, so depth and colour coverage agree.
|
||||
clutter_fade: Vec4::new(
|
||||
0.0,
|
||||
0.0,
|
||||
f32::from(ZFILL_MARKER | if cutout { TWIN_CUTOUT_MARKER } else { 0 }),
|
||||
0.0,
|
||||
),
|
||||
// Not a fade twin (`model_flags.y` = 0): the zfill pipeline branch forces its own
|
||||
// depth-write, and the cutout rides bit 10 above, so the bit has no work here.
|
||||
model_flags: Vec4::ZERO,
|
||||
sun_scale: Vec4::new(ShadeSel::Lit.selector(), 0.0, 0.0, 0.0),
|
||||
tint: Vec4::new(1.0, 1.0, 1.0, 0.0),
|
||||
sidn: Vec4::ZERO,
|
||||
anim_slots: Vec4::ZERO,
|
||||
light_buf: light.clone(),
|
||||
let handle = lazy::defer(
|
||||
materials,
|
||||
ExtendedMaterial {
|
||||
base,
|
||||
extension: WowModelExt {
|
||||
// Bit 10 (the twin-cutout marker) drives the shader's hard 224/255 cutout — set
|
||||
// exactly when the colour pass discards, so depth and colour coverage agree.
|
||||
clutter_fade: Vec4::new(
|
||||
0.0,
|
||||
0.0,
|
||||
f32::from(ZFILL_MARKER | if cutout { TWIN_CUTOUT_MARKER } else { 0 }),
|
||||
0.0,
|
||||
),
|
||||
// Not a fade twin (`model_flags.y` = 0): the zfill pipeline branch forces its own
|
||||
// depth-write, and the cutout rides bit 10 above, so the bit has no work here.
|
||||
model_flags: Vec4::ZERO,
|
||||
sun_scale: Vec4::new(ShadeSel::Lit.selector(), 0.0, 0.0, 0.0),
|
||||
tint: Vec4::new(1.0, 1.0, 1.0, 0.0),
|
||||
sidn: Vec4::ZERO,
|
||||
anim_slots: Vec4::ZERO,
|
||||
light_buf: light.clone(),
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
cache.insert(key, handle.clone());
|
||||
handle
|
||||
}
|
||||
@@ -1119,6 +1127,9 @@ pub fn plugin(app: &mut App) {
|
||||
// unbounded residency `art_scope` was written to end.
|
||||
app.init_resource::<ModelMaterials>()
|
||||
.add_systems(Update, (scope_model_materials, evict_model_materials));
|
||||
// Deferred realization (`lazy`): a built material becomes an asset the frame something
|
||||
// visible binds it. `Last`, after every writer and the `PostUpdate` twin spawns.
|
||||
app.add_systems(Last, lazy::realize_bound);
|
||||
}
|
||||
|
||||
/// Expire the material dedup by **distance** (decision 0785).
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
//! **Deferred material realization** — a `WowModelMaterial` becomes an *asset* the first frame
|
||||
//! something drawable is bound to it, not the moment a spawner builds it.
|
||||
//!
|
||||
//! Why: every material asset costs the GPU lane real per-frame CPU whether or not it is ever
|
||||
//! drawn. Bevy's non-bindless `AsBindGroup` gives each material its own bind group and one
|
||||
//! buffer per `#[uniform]` binding (two for this material), and wgpu's pass encoder allocates
|
||||
//! and clears usage trackers sized to the device's *live* buffer and texture counts for every
|
||||
//! render pass it encodes — a per-pass term that scales with residency, not with what is drawn.
|
||||
//! Measured on the crowd rig (1929's follow-on, the `wgpu_bufs=` census): 9.9k materials,
|
||||
//! 20.7k buffers and 10.2k bind groups alive at the Stormwind auction house with 36 entity
|
||||
//! batches drawn, because the batch builder ([`super::batch::M2BatchMaterials`]) hands every
|
||||
//! spawner its whole variant set — steady, the two interior lanes, their blend twins, the
|
||||
//! depth-prime twin — and the variants a batch never switches to sat in the store forever.
|
||||
//!
|
||||
//! The mechanism: the builder reserves a handle (`Assets::reserve_handle`) and parks the built
|
||||
//! value here, keyed by the reserved id. [`realize_bound`], in `Last`, walks the bound handles
|
||||
//! and inserts the asset for every one whose entity is view-visible this frame — before
|
||||
//! extraction, so the first drawn frame is the frame it was bound. A handle every holder has
|
||||
//! dropped arrives as the store's own `AssetEvent::Unused` (it fires for a reserved id too) and
|
||||
//! the parked value goes with it. Nothing about the handles changes: a part still stores and
|
||||
//! swaps `Handle<WowModelMaterial>`s, and every reader that clones a material *before* binding
|
||||
//! it — the portrait booth's relight twins, a spell kit's per-instance copy, the pipeline
|
||||
//! warmer — calls [`realize`] first, which is a no-op for an asset that already exists.
|
||||
//!
|
||||
//! **The table is a process global, deliberately.** It is a side of `Assets<WowModelMaterial>`
|
||||
//! — "reserved, value parked" — that the asset store has no slot for, and the builder that
|
||||
//! needs it ([`super::model_material`]) is a free function with a two-dozen-argument signature
|
||||
//! reached from seven lanes through three caches (the engine's, the streamer's, the warmer's
|
||||
//! `Local`). Threading one more resource through all of them for what is a property of the
|
||||
//! store, not of any cache, is the wrong shape; one mutex, locked once per system run, is the
|
||||
//! right one. Main-world only, like the store it shadows.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use bevy::asset::{Asset, AssetId};
|
||||
use bevy::camera::visibility::ViewVisibility;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_assets::materials::WowModelMaterial;
|
||||
|
||||
/// The parked values of one asset type: built, handle reserved, not yet in the store. Generic
|
||||
/// so the table's law is testable on a stub asset (a real `WowModelMaterial` carries a GPU
|
||||
/// buffer and cannot exist without a device).
|
||||
pub struct Parked<A: Asset>(HashMap<AssetId<A>, A>);
|
||||
|
||||
impl<A: Asset> Default for Parked<A> {
|
||||
fn default() -> Self {
|
||||
Self(HashMap::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: Asset> Parked<A> {
|
||||
/// Reserve a handle for `asset` and park the value.
|
||||
pub fn defer(&mut self, store: &Assets<A>, asset: A) -> Handle<A> {
|
||||
let handle = store.reserve_handle();
|
||||
self.0.insert(handle.id(), asset);
|
||||
handle
|
||||
}
|
||||
|
||||
/// Make the asset behind `id` exist now, if its value is parked. Returns whether the store
|
||||
/// holds it afterwards — `false` only for an id this table never saw and the store does not
|
||||
/// have (a foreign handle, or one already dropped everywhere).
|
||||
pub fn realize(&mut self, store: &mut Assets<A>, id: AssetId<A>) -> bool {
|
||||
if store.contains(id) {
|
||||
return true;
|
||||
}
|
||||
let Some(asset) = self.0.remove(&id) else {
|
||||
return false;
|
||||
};
|
||||
// `Err` = the reserved index was dropped and re-minted with a new generation since this
|
||||
// value was parked; the `Unused` purge simply has not run yet. Nothing to insert.
|
||||
store.insert(id, asset).is_ok()
|
||||
}
|
||||
|
||||
/// Insert every parked value.
|
||||
pub fn realize_all(&mut self, store: &mut Assets<A>) {
|
||||
for (id, asset) in self.0.drain() {
|
||||
let _ = store.insert(id, asset);
|
||||
}
|
||||
}
|
||||
|
||||
/// The store reported `id` unused: whoever held the handle is gone, and so is the value.
|
||||
pub fn purge(&mut self, id: AssetId<A>) {
|
||||
self.0.remove(&id);
|
||||
}
|
||||
|
||||
/// Realize every parked value some *visible* binding names. `bound` yields each binding
|
||||
/// with its view-visibility (`None` = not on the visibility lane at all — a booth part
|
||||
/// before its camera, a test world — which counts as visible: bound is enough).
|
||||
pub fn realize_visible(
|
||||
&mut self,
|
||||
store: &mut Assets<A>,
|
||||
bound: impl IntoIterator<Item = (AssetId<A>, Option<bool>)>,
|
||||
) {
|
||||
if self.0.is_empty() {
|
||||
return;
|
||||
}
|
||||
for (id, visible) in bound {
|
||||
if visible == Some(false) || store.contains(id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(asset) = self.0.remove(&id) {
|
||||
let _ = store.insert(id, asset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
static PENDING: Mutex<Option<Parked<WowModelMaterial>>> = Mutex::new(None);
|
||||
|
||||
fn with_pending<R>(f: impl FnOnce(&mut Parked<WowModelMaterial>) -> R) -> R {
|
||||
let mut guard = PENDING.lock().unwrap_or_else(|e| e.into_inner());
|
||||
f(guard.get_or_insert_with(Parked::default))
|
||||
}
|
||||
|
||||
/// Reserve a handle for `material` and park the value until something bound to the handle is
|
||||
/// drawn. The builder's replacement for `Assets::add`.
|
||||
pub fn defer(
|
||||
materials: &Assets<WowModelMaterial>,
|
||||
material: WowModelMaterial,
|
||||
) -> Handle<WowModelMaterial> {
|
||||
with_pending(|p| p.defer(materials, material))
|
||||
}
|
||||
|
||||
/// Make the asset behind `id` exist now — see [`Parked::realize`].
|
||||
pub fn realize(materials: &mut Assets<WowModelMaterial>, id: AssetId<WowModelMaterial>) -> bool {
|
||||
with_pending(|p| p.realize(materials, id))
|
||||
}
|
||||
|
||||
/// Insert every parked value. For a lane that reads materials back right after building them
|
||||
/// (the pipeline warmer, which inspects each variant it built to derive its far-side twins).
|
||||
pub fn realize_all(materials: &mut Assets<WowModelMaterial>) {
|
||||
with_pending(|p| p.realize_all(materials));
|
||||
}
|
||||
|
||||
/// How many values are parked — the census figure beside `mats=`.
|
||||
pub fn pending_len() -> usize {
|
||||
with_pending(|p| p.len())
|
||||
}
|
||||
|
||||
/// `Last`: realize the material of every bound, view-visible entity, and drop the parked value
|
||||
/// of every handle the store reports unused. After `PostUpdate` so a twin spawned there (the
|
||||
/// depth-prime lane) is bound and visible in the same walk; before extraction by construction.
|
||||
pub(super) fn realize_bound(
|
||||
bound: Query<(&MeshMaterial3d<WowModelMaterial>, Option<&ViewVisibility>)>,
|
||||
mut materials: ResMut<Assets<WowModelMaterial>>,
|
||||
mut unused: MessageReader<AssetEvent<WowModelMaterial>>,
|
||||
) {
|
||||
with_pending(|p| {
|
||||
for event in unused.read() {
|
||||
if let AssetEvent::Unused { id } = event {
|
||||
p.purge(*id);
|
||||
}
|
||||
}
|
||||
p.realize_visible(
|
||||
&mut materials,
|
||||
bound.iter().map(|(m, v)| (m.0.id(), v.map(|v| v.get()))),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Asset, TypePath)]
|
||||
struct Stub(u8);
|
||||
|
||||
#[test]
|
||||
fn deferred_asset_is_absent_until_realized() {
|
||||
let mut store = Assets::<Stub>::default();
|
||||
let mut parked = Parked::default();
|
||||
let handle = parked.defer(&store, Stub(1));
|
||||
assert!(!store.contains(handle.id()));
|
||||
assert_eq!(parked.len(), 1);
|
||||
assert!(parked.realize(&mut store, handle.id()));
|
||||
assert_eq!(store.get(&handle).map(|s| s.0), Some(1));
|
||||
assert!(parked.is_empty());
|
||||
// Realizing again is a no-op that still reports the asset present.
|
||||
assert!(parked.realize(&mut store, handle.id()));
|
||||
// An id nobody parked and the store lacks: false, nothing inserted.
|
||||
let stray = store.reserve_handle();
|
||||
assert!(!parked.realize(&mut store, stray.id()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bound_and_visible_realizes_in_the_walk_hidden_does_not() {
|
||||
let mut store = Assets::<Stub>::default();
|
||||
let mut parked = Parked::default();
|
||||
let shown = parked.defer(&store, Stub(1));
|
||||
let hidden = parked.defer(&store, Stub(2));
|
||||
let unlaned = parked.defer(&store, Stub(3));
|
||||
parked.realize_visible(
|
||||
&mut store,
|
||||
[
|
||||
(shown.id(), Some(true)),
|
||||
(hidden.id(), Some(false)),
|
||||
(unlaned.id(), None),
|
||||
],
|
||||
);
|
||||
assert!(store.contains(shown.id()), "visible ⇒ realized");
|
||||
assert!(!store.contains(hidden.id()), "hidden ⇒ still parked");
|
||||
assert!(
|
||||
store.contains(unlaned.id()),
|
||||
"no visibility lane ⇒ bound is enough"
|
||||
);
|
||||
assert_eq!(parked.len(), 1);
|
||||
parked.purge(hidden.id());
|
||||
assert!(
|
||||
parked.is_empty(),
|
||||
"the store's Unused drops the parked value"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,9 @@ pub(super) fn update_model_particles(
|
||||
&light.0,
|
||||
None, // a shard's material is shared by the whole emitter
|
||||
);
|
||||
// Realized at once: the over-life ramp writes this material through
|
||||
// `get_mut` every frame, from the first (`model_render::lazy`).
|
||||
crate::model_render::lazy::realize(&mut materials, material.id());
|
||||
// The owner-last draw-order rung, stamped on after the fact: a 3-D model
|
||||
// particle is one of its owner's emitters exactly like the quad cloud beside
|
||||
// it, and the reference draws them in one bracket after that model's batches
|
||||
|
||||
@@ -874,6 +874,8 @@ fn upload_rig_palettes(
|
||||
// Coalesce the per-rig dirty ranges before touching the queue: rigs allocate contiguously,
|
||||
// so a steady frame's ~750 one-rig ranges merge into a handful of runs — the 0724 ledger
|
||||
// measured the per-range `write_buffer` loop at 2.7 ms/frame, almost all call overhead.
|
||||
// Small gaps (a parked body between two live ones) are bridged for the same reason
|
||||
// (`COALESCE_GAP_BONES`).
|
||||
let all = coalesce_ranges(data.dirty.iter().map(|&(b, l, _)| (b, l)).collect());
|
||||
let mirrored_only = coalesce_ranges(
|
||||
data.dirty
|
||||
@@ -932,13 +934,30 @@ fn upload_rig_palettes(
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort + merge overlapping/adjacent `(base, len)` bone ranges into maximal runs.
|
||||
fn coalesce_ranges(mut ranges: Vec<(u32, u32)>) -> Vec<(u32, u32)> {
|
||||
/// The gap (in bones) two dirty runs may leave between them and still upload as ONE
|
||||
/// `write_buffer`. A `queue.write_buffer` is not a memcpy: wgpu allocates a staging buffer per
|
||||
/// call, registers it, records the copy and releases it — a few microseconds of fixed cost
|
||||
/// against ~10 GB/s of copying, so re-sending up to this many *unchanged* rows (they are the
|
||||
/// live rows, straight out of `rows`; nothing stale can be written) is cheaper than a second
|
||||
/// call. Sized so the whole tolerance costs about what one call does. The case that needed it:
|
||||
/// a 40-man raid at the Stormwind auction house uploaded ~310 KB of rows in ~93 calls a frame
|
||||
/// (1929's `[rig-upload]` census), because parked bodies and idle props sit between the live
|
||||
/// rigs in the slab and a strictly-adjacent merge stops at every one of them.
|
||||
const COALESCE_GAP_BONES: u32 = 256;
|
||||
|
||||
/// Sort + merge overlapping, adjacent, and *nearly* adjacent (gap ≤ [`COALESCE_GAP_BONES`])
|
||||
/// `(base, len)` bone ranges into maximal runs. Rows inside a bridged gap are written with their
|
||||
/// current contents.
|
||||
fn coalesce_ranges(ranges: Vec<(u32, u32)>) -> Vec<(u32, u32)> {
|
||||
coalesce_ranges_with_gap(ranges, COALESCE_GAP_BONES)
|
||||
}
|
||||
|
||||
fn coalesce_ranges_with_gap(mut ranges: Vec<(u32, u32)>, gap: u32) -> Vec<(u32, u32)> {
|
||||
ranges.sort_unstable_by_key(|r| r.0);
|
||||
let mut out: Vec<(u32, u32)> = Vec::with_capacity(ranges.len());
|
||||
for (base, len) in ranges {
|
||||
if let Some(last) = out.last_mut() {
|
||||
if base <= last.0 + last.1 {
|
||||
if base <= last.0 + last.1 + gap {
|
||||
last.1 = (base + len).max(last.0 + last.1) - last.0;
|
||||
continue;
|
||||
}
|
||||
@@ -1326,12 +1345,32 @@ mod tests {
|
||||
fn dirty_ranges_coalesce_into_runs() {
|
||||
// Adjacent + overlapping merge; a hole splits. Order-independent (upload sorts).
|
||||
assert_eq!(
|
||||
coalesce_ranges(vec![(70, 5), (0, 10), (10, 20), (25, 10), (40, 5)]),
|
||||
coalesce_ranges_with_gap(vec![(70, 5), (0, 10), (10, 20), (25, 10), (40, 5)], 0),
|
||||
vec![(0, 35), (40, 5), (70, 5)]
|
||||
);
|
||||
assert_eq!(coalesce_ranges(Vec::new()), Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_ranges_bridge_small_gaps_but_not_large_ones() {
|
||||
// A parked rig's rows between two live ones (the raid's ~93-call frame): bridged, so
|
||||
// the run is one call. A gap wider than the tolerance still splits.
|
||||
assert_eq!(
|
||||
coalesce_ranges_with_gap(vec![(0, 10), (14, 6), (30, 5)], 4),
|
||||
vec![(0, 20), (30, 5)]
|
||||
);
|
||||
// The shipped tolerance bridges a gap of exactly its size and no more.
|
||||
let g = COALESCE_GAP_BONES;
|
||||
assert_eq!(
|
||||
coalesce_ranges(vec![(0, 10), (10 + g, 5)]),
|
||||
vec![(0, 15 + g)]
|
||||
);
|
||||
assert_eq!(
|
||||
coalesce_ranges(vec![(0, 10), (11 + g, 5)]),
|
||||
vec![(0, 10), (11 + g, 5)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_layout_is_consistent() {
|
||||
// The slot table sits after the probe region, then the per-instance tint table (decision
|
||||
|
||||
@@ -125,6 +125,9 @@ pub struct CensusReport {
|
||||
/// Resident asset counts — the leak meter. A tour probe reading the same counts as a fresh
|
||||
/// control is what "torn down" means, machine-checked.
|
||||
pub mats: usize,
|
||||
/// Built materials parked by `model_render::lazy`, not yet assets — the variants nothing
|
||||
/// visible has bound. `mats` counts realized ones only.
|
||||
pub mats_parked: usize,
|
||||
pub meshes: usize,
|
||||
pub images: usize,
|
||||
pub uv_anims: usize,
|
||||
@@ -292,6 +295,7 @@ impl WorldCensus<'_, '_> {
|
||||
liquid_hidden: v.liquid_hidden,
|
||||
}),
|
||||
mats: self.mats.len(),
|
||||
mats_parked: crate::model_render::lazy::pending_len(),
|
||||
meshes: self.meshes.len(),
|
||||
images: self.images.len(),
|
||||
uv_anims: self.uv_reg.0.len(),
|
||||
|
||||
Reference in New Issue
Block a user