the cooldown goes stock, early-Z comes back, and lists reach the end
- a size-less `<Model>` pane's rect is its file's box in layout units: the reference's geometry overrides (`0x76d080`/`0x76d0d0`) answer the bbox extent whenever no size is authored, `bboxExtent · 768·√(a²+1) · layoutScale` FrameXML units (`1280 · extent` at 4:3), written into the pane's layout input when the file's facts land, re-derived on an aspect change, and ended for good by any authored size; `SetPosition` is the same unit, which is how `0x4a7b20` centres the arrow at `½·GetWidth, ½·GetHeight` through the override — so the map arrow's sprite arm and its hand-sized 33.6 px footprint go (its rect is `0.0262 × 0.0263` layout units, 33.5 × 33.7 FrameXML units at 4:3, 41.0 × 41.2 at 16:9), and the stock `ItemAnim` pane plays the item push unadapted (`ReplaceIconTexture` on the type-14 batch, the 1000 ms clamp, the stock `OnAnimFinished`), `ContainerFrameAdapters.xml` losing 0887's Lua card and its hand-measured 42.41 × 124.72 rect — the implicit one at 16:9 — and keeping the keyring fork and the three bag verbs (2015); and the cooldown is the reference's own model: the texture-transform law `uv' = R_q((uv + t − p) ⊙ s) + p`, `p = (½, ½)`, the raw quaternion component-lerped and never normalised, is one function (`uv_transform`) with the shader's UV fold as its twin — rotation and scale tracks bake per file sequence slot beside the translation, the mat-anim table gains an affine row `[cos − 1, sin, sx − 1, sy − 1]` on `anim_slots.z` whose identity is the zero row so every static batch reads exactly what it did, and a UI tile clones the twin material per animated batch and writes both rows every frame off the pane's play head, never the world clock; the stock `Cooldown.xml` runs off the chain on 2007's two handlers, the file's four quadrant quads turned by their own rotation tracks (`0 → −90°` over each quadrant's 250 ms window, clockwise from 12 o'clock) and its alpha tracks revealing the star for the flash; retired: the native `Cooldown` widget and `SetCooldown` (an Era API 1.12 never had — `CreateFrame("Cooldown")` is the reference's unknown-type refusal now), the loader's `Model → Cooldown` substitution, the app's hand-built pie and star, and our `Cooldown.xml`; `assets/ui` is eight files (2019)
- `UpdateMapHighlight` gets its zone-level exits (B360): at zone zoom ours returned the nil tail for every cursor position, so hovering Deadwind Pass from Duskwood showed no label where 1.12 shows "Deadwind Pass"; the reference's `0x4a7fa0` runs two lookups at zone level and answers a NAME only — first the current zone's exploration-gated overlay list (the builder at `0x4a6ad9` gating on the explored byte), each row's HitRect scaled by 1/1002 and 1/668 against the normalized cursor, edges inclusive, the first hit naming its Area1, then the continent grid re-windowed through the displayed zone's own rect, naming the neighbouring zone or city and nothing for the displayed zone itself — and only the name-only tail is reachable from there, fileName nil and the six coords zero, so the stock frame hides the highlight quad, as the director's own 1.12 shot shows; and `SetVerticalScroll` stores the offset as given (B370, B363): the reference's `0x786db0` compares the new offset against the old and writes it to `[+0x328]` verbatim, `0x787100` re-anchors the scroll child off it, and the range at `[+0x320]` is never read on that path — every clamp the reference exhibits is FrameXML's, through the scroll bar's `[min, max]`; ours clamped into `[0, GetVerticalScrollRange()]`, the child's overflow past the FRAME, which is 40 px short of where `FauxScrollFrame_Update` sends the bar on the 220-tall skills list (three rows lost) and 15 px short on the 287-tall who list (one); the setter fires `OnVerticalScroll` only on a change, as the bytes do, `BenillaScroll_Step` bounds its wheel in Lua where the reference's handler does, and the ScrollFrame kind's prose stops calling its offset unpinned (2017)
- overdraw's cheap half, the sky first: since 0588 every sky element wrote `frag_depth = 0.0`, which costs a pipeline its early-Z, so on an outdoor frame every full-screen dome fragment under the ground and the buildings was shaded in full (the gradient's `asin`, `atan2` and eight table lookups at dawn and dusk) and thrown away; one shared `sky_vertex.wgsl` — bevy's mesh vertex verbatim plus `out.position.z = 0.0` — is now the vertex stage of the gradient dome, the cloud dome, the star patches and the celestial discs and glares, and the model lane's `WOW_SKY_DEPTH` branch moves from its fragment to its vertex, so no sky fragment declares `frag_depth` and the rasterizer rejects the covered screen before the shader runs; every sky `specialize` zeroes the rasterizer bias constant the `sky_order` rung had been leaking into `DepthBiasState::constant`, harmless only while the fragment overwrote its depth, and the prose beside the pin follows it; then the retained `static_gx` pass draws BEFORE bevy's opaque pass (`StartMainPass → static_gx → MainOpaquePass`) — buildings and doodads are the frame's best occluders and a terrain fragment its dearest, six samples a pixel — so walls reject the terrain behind them instead of the reverse, the pass taking the first-use clear with it and the dev depth probe ordered after it; measured on 16 capture scenarios: 14 bit-identical, `northshire-dusk` 1 px and `water-night` 11 px, attributed to exact depth ties on static/terrain silhouettes and last-bit interpolation noise; a depth prepass and near-to-far bins are declined until a journal says where the time goes (2016)
- the grass cut goes per pixel because of a quarter-mip: `0x6813d0` sets `D3DSAMP_MIPMAPLODBIAS = +0.25` on stage 0 (`0x6813f4`) once per frame before the detail-doodad pass — capture-confirmed on all 29 doodad batches, and outside the `[0x6b1000, 0x6b3200)` scan 2004 had called complete; it decides the whole look because the `clutter_cut` probe finds 6 of 31 shipped detail atlases carry a BINARY alpha pyramid (mip 0 has DXT3's 16 levels, every level below exactly two, both starting zones among them), so `a × ramp` degenerates to a global on/off and every texel of a leaf crosses at the same depth — Elwynn's survivor coverage is flat at 47.6 % across the whole 52.5–61.1 yd band, then 0.0 %, while the probe's box-filtered control erodes 47.7 → 20.9 over the same band, so the art has the gradient the shipped pyramid threw away, and `clutter_size` puts the median tuft at 0.92 yd tall, 1.03 yd deep, a full yard for the boundary plane to sweep; the escape is bilinear: full alpha needs a 2×2 all-opaque texel neighbourhood, and those run out between mip 2 and 3 (179/56/10/0/0/0 on the dominant Elwynn cell), so the reference's λ ≈ 2.9–3.3 sits past the cliff and the tuft erodes continuously — 2.2 % of a tuft's pixels leave in the last half-yard with the bias, 11.6 % without, 24.2 % one mip sharper; wgpu has no sampler LOD bias, so the clutter lane re-samples base colour at `view.mip_bias + 0.25`, keeping the sampler every other model batch shares at the reference's 0; ruled out on the way: our BLP decoder (all nine uploaded levels byte-identical against the archive's 52 793 BLP2s), mip regeneration, the `has_mipmaps` byte's value 2 (a plain boolean, an authoring marker), anisotropy and geometry; what it does not fix is resolution-dependent, and so is the reference's own look — λ with the bias is 3.22 at the capture's 648p but 2.48 at 1080p and 1.48 at 2160p, so at a modern resolution the reference itself pops leaf by leaf, and scaling the bias with the viewport is one reversible line left for a look call (2021)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -165,277 +165,5 @@
|
||||
OpenBackpack = BenillaOpenBackpack
|
||||
CloseBackpack = BenillaCloseBackpack
|
||||
CloseAllBags = BenillaCloseAllBags
|
||||
|
||||
-- ══ THE ITEM-PUSH DROP CARD, over the reference's own bag bar (decision 0887) ═════════
|
||||
--
|
||||
-- The bar is `Interface\FrameXML\MainMenuBarBagButtons.xml` now (1751 window 3), and its
|
||||
-- `ItemAnimTemplate` is a `<Model>` playing `Interface\ItemAnimations\ForcedBackpackItem.mdx`.
|
||||
-- **This engine's `<Model>` holds the scene the Lua API reads and writes but draws no
|
||||
-- pixels** (`script/modelframe.rs` — the pane is the app renderer's job and no renderer
|
||||
-- claims a FrameXML model), so those six frames load, register, and animate nothing.
|
||||
--
|
||||
-- That is a genuine engine difference, which is the one thing 1751 §2 keeps an adapter
|
||||
-- for. So the reference's file is sourced unedited — its Models exist and are harmless —
|
||||
-- and the card the player actually sees is built HERE, as a sibling Frame + Texture on the
|
||||
-- same six buttons, driven by the model's OWN keyframes read out of the shipped .m2. The
|
||||
-- bodies below moved verbatim from the deleted `BagFrame.xml`; every number in them is
|
||||
-- byte-derived and the comments carry their provenance.
|
||||
--
|
||||
-- ONE THING CHANGED IN THE MOVE, and it is the reason the swap needed thought at all.
|
||||
-- `ItemAnim_OnEvent` matches on `this:GetParent():GetID()`, which for a bag button is its
|
||||
-- INVENTORY-slot id (20..23, via `GetInventorySlotInfo`). benilla used to translate
|
||||
-- `ITEM_PUSH`'s `arg1` into its own container vocabulary (1..4) because the old bar was
|
||||
-- keyed that way, and against the reference's body that comparison is false for every bag
|
||||
-- — four cards that would simply never have played, with nothing raising. The fix was not
|
||||
-- to adapt the reference: `ui_loot.rs`'s `push_container` stopped translating and now emits
|
||||
-- `0x491bb5`'s selector verbatim, which is what addons expect too. So the id a card matches
|
||||
-- on is `button:GetID()`, exactly as the reference's own body reads it.
|
||||
-- ── The item-push drop animation (decision 0887) ─────────────────────────────────────────
|
||||
-- An item lands in a bag: its icon pops in above the bag bar, hangs there, then falls into
|
||||
-- the button that took it and shrinks away. ref ItemAnim (MainMenuBarBagButtons.xml l.4-16 +
|
||||
-- .lua l.68-80, .lua l.92-94): each bag button owns a hidden child that, on ITEM_PUSH
|
||||
-- naming its own container, swaps in the pushed item's icon, rewinds to sequence time 0 and
|
||||
-- shows itself; OnAnimFinished hides it again. A second push on the same button RESTARTS it
|
||||
-- (SetSequenceTime(0,0)); pushes into different bags run independently. That is exactly the
|
||||
-- machine below, with the reference's ITEM_PUSH `arg1` translated from its inventory-slot
|
||||
-- vocabulary (20..23) into ours (bagId 1..4) app-side — ui_loot.rs `push_container` carries
|
||||
-- the byte-verified selector.
|
||||
--
|
||||
-- The reference's is a <Model> playing Interface\ItemAnimations\ForcedBackpackItem.mdx — a
|
||||
-- single 4-vertex quad on one animated bone, i.e. a 2D icon card with keyframed scale, alpha
|
||||
-- and offset. benilla has no Model widget (decision 0137's Cooldown took the same posture:
|
||||
-- model the mechanism, let our own renderer draw it), so it is a Texture driven by the
|
||||
-- model's OWN tracks, read out of the shipped .m2 with `benilla-extract m2anim`/`m2bones`:
|
||||
--
|
||||
-- sequence 0, CLAMP (plays once), duration 1.000 s
|
||||
-- texture-weight (alpha) 0.000→0.0 0.133→1.0 0.267→1.0 1.000→0.0 linear
|
||||
-- bone 0 scale 0.000→1.0 0.133→1.2 0.267→1.0 1.000→0.014 linear
|
||||
-- bone 0 translation 0.000→(0,0) 0.500→(0,0) 1.000→(+0.010,-0.040)
|
||||
-- the quad itself: 0.03 x 0.03 model units, blend Alpha, unlit, no depth write
|
||||
--
|
||||
-- So: fade in over the first 133 ms while swelling to 1.2x; settle back to full size by
|
||||
-- 267 ms; from there dwindle and fade continuously for the remaining 733 ms — while the
|
||||
-- POSITION holds until the half-second and only then falls. The three tracks deliberately
|
||||
-- do not share a schedule: the card is already down to ~0.69 size and ~0.68 alpha when it
|
||||
-- starts moving, so the drop reads as the icon being drawn into the bag rather than as a
|
||||
-- solid tile sliding. Those numbers are the file's, not ours.
|
||||
--
|
||||
-- THE MODEL-UNIT → PIXEL SCALE is no longer ours to calibrate: the <Model> widget's
|
||||
-- projection is RE-recorded now (wow-re `system/ui/scratch/modelframe-animation-clock.md`,
|
||||
-- dispatched over the pet bar's B228 and folded back with it). The ortho leg gives
|
||||
--
|
||||
-- px per model unit = (5/3) * modelScale * layoutScale * screenHeightPx
|
||||
--
|
||||
-- which in FrameXML units is **1280 * modelScale * layoutScale**, aspect- and
|
||||
-- resolution-independent, with the model ORIGIN at the frame rect's bottom-left. The ref's
|
||||
-- `ItemAnimTemplate` (MainMenuBarBagButtons.xml:4) carries **no `scale` attribute**, so
|
||||
-- modelScale is 1 and the constant is a flat 1280. Decision 0887's named gap is closed.
|
||||
--
|
||||
-- This REPLACES a back-fit, and the back-fit is worth naming because it looked like
|
||||
-- evidence. The old 1200 came from assuming CooldownFrameTemplate's 0.04-unit sweep
|
||||
-- *exactly* covers a 36 px action button at its scale="0.75" (⇒ K = 1200). The assumption
|
||||
-- is the error: at the real projection that sweep is 0.04 · 1280 · 0.75 = **38.4 px**, so
|
||||
-- it overhangs its button by 6.7% rather than fitting it — exactly as the autocast shine's
|
||||
-- 30.72 px square overhangs its 30 px button. Solving against a wrong premise produced a
|
||||
-- number that then "cross-checked" against a second model derived the same wrong way, and
|
||||
-- both files cited the agreement as the reason to trust it.
|
||||
--
|
||||
-- Everything below is the model's own ratios times this constant, so the correction lands
|
||||
-- in one place and the animation stays in proportion.
|
||||
BENILLA_ITEM_PUSH_PX_PER_UNIT = 1280
|
||||
BENILLA_ITEM_PUSH_DURATION = 1.0
|
||||
|
||||
-- ── The widget rect, and why the card is not placed against the button ─────────────────
|
||||
-- A `<Model>` with no `<Size>` and no `setAllPoints` takes its rect from the **M2 header
|
||||
-- bounding box** (`MD20+0xB4`, max−min per axis), consumed RAW as layout units — unlike
|
||||
-- CSimpleTexture's texel fallback, there is no AbsDimension conversion. So the ref's
|
||||
-- ItemAnim widget is **42.41 x 124.72**, nothing like the 36-square everyone assumes from
|
||||
-- looking at the XML, and **model-space (0,0,0) lands on that rect's BOTTOM-LEFT corner**.
|
||||
-- (wow-re `system/ui/scratch/modelframe-implicit-size-law.md`, §5-cross-checked: vtable
|
||||
-- 0x81c5d8 +0x1c/+0x20 -> 0x76d080/0x76d0d0, bbox source 0x713640 at `713661 add esi,0xb4`.
|
||||
-- The one worker who read `MD20+0xD0` — the VertexBox — got an all-zero box and concluded
|
||||
-- the widget was degenerate; that wrong premise is exactly what the pair round exists to
|
||||
-- catch, and it is why this looked unresolvable in 0887.)
|
||||
--
|
||||
-- That rect is the whole reason this animation reads the way it does: it stands 124.72 tall
|
||||
-- off the button's own bottom edge, so the card's travel band reaches **54 px above the
|
||||
-- button's top**. Decision 0887 could not know that and placed the card against the
|
||||
-- button's CENTER instead, which is what put it in the wrong place in x and gave it no
|
||||
-- horizontal drift at all.
|
||||
BENILLA_ITEM_PUSH_FRAME_W = 42.41
|
||||
BENILLA_ITEM_PUSH_FRAME_H = 124.72
|
||||
-- Where that rect hangs off the button: the ref anchors the widget's BOTTOMRIGHT to the
|
||||
-- button's, 10 in from the right for the five bag buttons and flush (0) for the keyring —
|
||||
-- MainMenuBarBagButtons.xml l.19-27 (BagSlotButtonTemplate) and l.152-160 (KeyRingButton).
|
||||
-- Per-button, so it is an OnLoad argument, not a constant.
|
||||
BENILLA_ITEM_PUSH_ANCHOR_X = -10
|
||||
|
||||
-- ── The model's own numbers, unrounded ────────────────────────────────────────────────
|
||||
-- Every one of these was rounded in 0887 and every rounding moved the card. The quad span
|
||||
-- read as 0.03 (it is 0.0288 — a 4% size error), and the translation as (0.010, -0.040)
|
||||
-- (which alone accounts for half a pixel of the landing point).
|
||||
--
|
||||
-- The bone transform is `v' = pivot + T(t) + S(t) * (v - pivot)` — three wow-re workers
|
||||
-- derived that independently in three matrix conventions and agreed. The pivot
|
||||
-- (M2CompBone+0x60) is NOT the quad's centre: it sits 0.23 px right and 0.07 px above it,
|
||||
-- so the card shrinks *almost* in place and drifts a fifth of a pixel doing it. Keeping
|
||||
-- the pivot as its own term rather than folding it into the centre is what makes this
|
||||
-- reproduce the reference exactly at all three sampled times instead of nearly.
|
||||
-- Bone 0's ROTATION track is empty (nKeys = 0), so there is no roll to model.
|
||||
BENILLA_ITEM_PUSH_PIVOT_X = 0.017580900341272354
|
||||
BENILLA_ITEM_PUSH_PIVOT_Y = 0.053958501666784286
|
||||
BENILLA_ITEM_PUSH_QUAD_X = 0.017400000942870975
|
||||
BENILLA_ITEM_PUSH_QUAD_Y = 0.053900001570582390
|
||||
BENILLA_ITEM_PUSH_QUAD_SPAN = 0.028800001833587885
|
||||
|
||||
-- {time, value} keyframes, linear between (M2 track interpolation 1) — the file's own keys,
|
||||
-- at full precision. The three tracks keep DIFFERENT schedules and that is the whole feel:
|
||||
-- the card fades up while swelling to 1.2x over 133 ms, settles back to full size at
|
||||
-- 267 ms, then dwindles and fades *continuously* for the remaining 733 ms — while its
|
||||
-- position holds until the half second and only then falls. So it is already at ~0.69 size
|
||||
-- and ~0.68 alpha when it starts to move, and it is invisible by the time it lands.
|
||||
BENILLA_ITEM_PUSH_ALPHA = { {0.000, 0.0}, {0.133, 1.0}, {0.267, 1.0}, {1.000, 0.0} }
|
||||
BENILLA_ITEM_PUSH_SCALE = { {0.000, 1.0}, {0.133, 1.2000000476837158}, {0.267, 1.0}, {1.000, 0.01441549975425005} }
|
||||
-- Translation is per-AXIS: the card falls 51.4 px and drifts 13.3 px RIGHT, so one shared
|
||||
-- 0..1 "drop" curve (0887's shape) cannot express it — the old code had no horizontal term
|
||||
-- at all and moved the card the wrong way in x when it inferred one.
|
||||
BENILLA_ITEM_PUSH_TRANS_X = { {0.000, 0.0}, {0.500, 0.0}, {1.000, 0.010394600220024586} }
|
||||
BENILLA_ITEM_PUSH_TRANS_Y = { {0.000, 0.0}, {0.500, 0.0}, {1.000, -0.040167398750782013} }
|
||||
|
||||
-- Sample a {time, value} keyframe list at `t`, linear between keys, clamped at both ends
|
||||
-- (the M2 sampler's own shape for a CLAMP sequence).
|
||||
function BenillaItemPushAnim_Sample(track, t)
|
||||
local last = table.getn(track)
|
||||
if t <= track[1][1] then return track[1][2] end
|
||||
for i = 2, last do
|
||||
local key = track[i]
|
||||
if t <= key[1] then
|
||||
local prev = track[i - 1]
|
||||
local span = key[1] - prev[1]
|
||||
if span <= 0 then return key[2] end
|
||||
return prev[2] + (key[2] - prev[2]) * (t - prev[1]) / span
|
||||
end
|
||||
end
|
||||
return track[last][2]
|
||||
end
|
||||
|
||||
-- Place/size/fade the card for the current clock, from the model's own transform.
|
||||
--
|
||||
-- The card's centre in MODEL space is `pivot + T(t) + S(t)*(quadCentre - pivot)`; times the
|
||||
-- projection constant that is its offset in layout units from the model origin, which is
|
||||
-- the widget rect's bottom-left corner. The rect's own BOTTOMRIGHT hangs off the button's,
|
||||
-- so the card is placed against the BUTTON'S BOTTOMRIGHT — never its centre — and the
|
||||
-- frame width is what converts between the two.
|
||||
--
|
||||
-- On screen: a button-sized card appears with its bottom edge ~13.6 px ABOVE the button's
|
||||
-- top, pops to 1.2x and back while fading in, hangs there through the first half second,
|
||||
-- then falls down-and-right INTO the button, shrinking to a half-pixel point at its centre
|
||||
-- as it fades out. Reproduces the reference at t = 0, 0.133 and 1.0 to the hundredth of a
|
||||
-- pixel (`modelframe-implicit-size-law.md` §3's table).
|
||||
--
|
||||
-- NOT modelled, deliberately, and both are named in the record: the reference's widget
|
||||
-- CLIPS the card to its own rect, which shaves 1.94 px off the right edge at the 1.2x peak
|
||||
-- **at 4:3 only** (16:10 and 16:9 clear it); and the widget clears the depth buffer inside
|
||||
-- that 124.72-tall rect every paint. benilla draws this as a Texture, not a Model, so it
|
||||
-- inherits neither — reproducing an aspect-conditional sliver would be hard-coding a
|
||||
-- rendering artefact rather than modelling the mechanism that causes it.
|
||||
function BenillaItemPushAnim_Paint(anim)
|
||||
local t = anim.elapsed
|
||||
local k = BENILLA_ITEM_PUSH_PX_PER_UNIT
|
||||
local scale = BenillaItemPushAnim_Sample(BENILLA_ITEM_PUSH_SCALE, t)
|
||||
local size = BENILLA_ITEM_PUSH_QUAD_SPAN * scale * k
|
||||
local cx = (BENILLA_ITEM_PUSH_PIVOT_X
|
||||
+ BenillaItemPushAnim_Sample(BENILLA_ITEM_PUSH_TRANS_X, t)
|
||||
+ scale * (BENILLA_ITEM_PUSH_QUAD_X - BENILLA_ITEM_PUSH_PIVOT_X)) * k
|
||||
local cy = (BENILLA_ITEM_PUSH_PIVOT_Y
|
||||
+ BenillaItemPushAnim_Sample(BENILLA_ITEM_PUSH_TRANS_Y, t)
|
||||
+ scale * (BENILLA_ITEM_PUSH_QUAD_Y - BENILLA_ITEM_PUSH_PIVOT_Y)) * k
|
||||
anim:SetWidth(size)
|
||||
anim:SetHeight(size)
|
||||
anim:SetPoint("CENTER", anim:GetParent(), "BOTTOMRIGHT",
|
||||
anim.anchorX - BENILLA_ITEM_PUSH_FRAME_W + cx, cy)
|
||||
anim:SetAlpha(BenillaItemPushAnim_Sample(BENILLA_ITEM_PUSH_ALPHA, t))
|
||||
end
|
||||
|
||||
-- ref ItemAnim_OnLoad (l.68-70): the card listens for ITEM_PUSH and nothing else. It is
|
||||
-- authored hidden, so it costs no OnUpdate until a push shows it (events reach hidden
|
||||
-- frames; OnUpdate does not — the reference's own split).
|
||||
function BenillaItemPushAnim_OnLoad(anim, containerId, anchorX)
|
||||
anim.containerId = containerId
|
||||
-- The widget's own offset from the button's BOTTOMRIGHT: -10 for the five bag buttons,
|
||||
-- flush 0 for the keyring (the ref's two different anchors — see the constant block).
|
||||
anim.anchorX = anchorX or BENILLA_ITEM_PUSH_ANCHOR_X
|
||||
anim.icon = anim.icon or getglobal(anim:GetName() .. "Icon")
|
||||
anim.elapsed = 0
|
||||
anim:RegisterEvent("ITEM_PUSH")
|
||||
end
|
||||
|
||||
-- ref ItemAnim_OnEvent (l.72-80): `if this:GetParent():GetID() == arg1`, then
|
||||
-- ReplaceIconTexture(arg2) / SetSequence(0) / SetSequenceTime(0,0) / Show. A push into a bag
|
||||
-- with no button (a bank bag) matches nothing and animates nowhere, there and here.
|
||||
function BenillaItemPushAnim_OnEvent(anim)
|
||||
if arg1 ~= anim.containerId then return end
|
||||
anim.icon:SetTexture(arg2)
|
||||
anim.elapsed = 0
|
||||
-- Seat the card above its button rather than at load: the bag bar's own level is set in
|
||||
-- the BUTTON's OnLoad (BenillaActionBarArt_SeatAbove), which the loader may run after
|
||||
-- this child exists. Re-deriving it per push is one call and can never read it stale.
|
||||
anim:SetFrameLevel(anim:GetParent():GetFrameLevel() + 1)
|
||||
BenillaItemPushAnim_Paint(anim)
|
||||
anim:Show()
|
||||
end
|
||||
|
||||
-- The CLAMP sequence's end is the ref's OnAnimFinished → this:Hide() (l.92-94).
|
||||
function BenillaItemPushAnim_OnUpdate(anim, elapsed)
|
||||
anim.elapsed = anim.elapsed + (elapsed or 0)
|
||||
if anim.elapsed >= BENILLA_ITEM_PUSH_DURATION then
|
||||
anim:Hide()
|
||||
return
|
||||
end
|
||||
BenillaItemPushAnim_Paint(anim)
|
||||
end
|
||||
|
||||
-- The six buttons and the offset each one's card hangs at, read off the reference's own
|
||||
-- anchors: BOTTOMRIGHT -10 for the five bag buttons (`BagSlotButtonTemplate` l.19-27 and
|
||||
-- `MainMenuBarBackpackButton` l.69-77), flush 0 for the keyring (l.152-160).
|
||||
BENILLA_ITEM_PUSH_BUTTONS = {
|
||||
{ "MainMenuBarBackpackButton", -10 },
|
||||
{ "CharacterBag0Slot", -10 },
|
||||
{ "CharacterBag1Slot", -10 },
|
||||
{ "CharacterBag2Slot", -10 },
|
||||
{ "CharacterBag3Slot", -10 },
|
||||
{ "KeyRingButton", 0 },
|
||||
}
|
||||
|
||||
-- Build one button's card. Authored hidden, so it costs no OnUpdate until a push shows it
|
||||
-- — the reference's own split (events reach hidden frames, OnUpdate does not).
|
||||
--
|
||||
-- Named `$parentBenillaItemPush` rather than `$parentItemAnim`: the reference's Model
|
||||
-- already owns that name on every one of these buttons, and an addon that reaches for it
|
||||
-- must find the reference's frame, not ours.
|
||||
function BenillaItemPushAnim_Attach(button, anchorX)
|
||||
local anim = CreateFrame("Frame", button:GetName() .. "BenillaItemPush", button)
|
||||
anim:Hide()
|
||||
anim:SetWidth(36)
|
||||
anim:SetHeight(36)
|
||||
anim:SetPoint("CENTER", button, "CENTER", 0, 0)
|
||||
anim.icon = anim:CreateTexture(anim:GetName() .. "Icon", "OVERLAY")
|
||||
anim.icon:SetAllPoints(anim)
|
||||
BenillaItemPushAnim_OnLoad(anim, button:GetID(), anchorX)
|
||||
anim:SetScript("OnEvent", function() BenillaItemPushAnim_OnEvent(this) end)
|
||||
anim:SetScript("OnUpdate", function() BenillaItemPushAnim_OnUpdate(this, arg1) end)
|
||||
return anim
|
||||
end
|
||||
|
||||
-- At load, because the bar is entry 48 and this file is entry 50 — every button exists and
|
||||
-- has run its own OnLoad, so `GetID()` is already the reference's. A button that is absent
|
||||
-- (a harness that loaded no bar) is skipped rather than raising: the card is a flourish,
|
||||
-- and a missing bar is not this file's error to report.
|
||||
for _, entry in ipairs(BENILLA_ITEM_PUSH_BUTTONS) do
|
||||
local button = getglobal(entry[1])
|
||||
if button then
|
||||
BenillaItemPushAnim_Attach(button, entry[2])
|
||||
end
|
||||
end
|
||||
]]></Script>
|
||||
</Ui>
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
<Ui>
|
||||
<!--
|
||||
The shared cooldown-frame machine — the ref's own file split (Cooldown.lua/Cooldown.xml,
|
||||
loaded before every consumer): CooldownFrame_SetTimer drives BOTH the action buttons
|
||||
(ActionBar.xml) and the bag slots (BagFrame.xml), exactly as the reference shares it.
|
||||
The ref's OnUpdateModel scrub/finish-flash machine lives inside our native <Cooldown>
|
||||
widget instead (decision 0263), armed by SetCooldown.
|
||||
|
||||
`CooldownFrameTemplate` below is the reference's own declaration, and it is here because
|
||||
it was MISSING: three corpus addons name it in an XML `inherits=` (Bagnon_Core,
|
||||
Bongos_ActionBar, BuffCheck2), and Bagnon hangs one off EVERY item button, so a single
|
||||
session logged roughly sixty
|
||||
WARN ui_script: unknown template 'CooldownFrameTemplate' referenced by inherits
|
||||
lines while each of those children came out as a bare, attribute-less frame.
|
||||
-->
|
||||
<Script><![CDATA[
|
||||
-- Ref Cooldown.lua's CooldownFrame_SetTimer, re-expressed: show-and-arm only when all three
|
||||
-- are positive, else hide.
|
||||
--
|
||||
-- THE ONE FORK, and why it is two faithful branches rather than a guard. The reference's
|
||||
-- body arms a Model (`this.start = start … this:SetSequence(0)`); ours arms our native
|
||||
-- <Cooldown> widget, which is where 0263 put that machine.
|
||||
--
|
||||
-- **Since decision 1751 the fork is almost never taken, and that is the fix.** The loader
|
||||
-- now materializes a `<Model>` whose file IS the cooldown indicator as a real `<Cooldown>`
|
||||
-- (`loader::frame_kind_of`) — the file is the thing's identity, and this engine draws that
|
||||
-- sweep natively. So `<Model … inherits="CooldownFrameTemplate"/>`, which is what every
|
||||
-- real caller writes (the reference's own ContainerFrameItemButtonTemplate, its action
|
||||
-- buttons, three corpus addons), arrives here as a Cooldown and sweeps.
|
||||
--
|
||||
-- The `if this.SetCooldown` guard stays for the case that remains: a Model built with
|
||||
-- some OTHER file, or a plain frame handed in by an addon. Calling SetCooldown
|
||||
-- unconditionally is what shipped, and it raised on exactly that: a Bagnon slot holding an
|
||||
-- item with a live cooldown died on `attempt to call method 'SetCooldown' (a nil value)`
|
||||
-- — and, because !OmniCC wraps this very function, it took OmniCC's countdown down with
|
||||
-- it. That branch shows and hides the frame and nothing more, which is a missing visual,
|
||||
-- not a broken window.
|
||||
function CooldownFrame_SetTimer(this, start, duration, enable)
|
||||
if ( start > 0 and duration > 0 and enable > 0 ) then
|
||||
if ( this.SetCooldown ) then
|
||||
this:SetCooldown(start, duration)
|
||||
end
|
||||
this:Show()
|
||||
else
|
||||
this:Hide()
|
||||
end
|
||||
end
|
||||
]]></Script>
|
||||
|
||||
<!--
|
||||
ref Cooldown.xml l.4-14. The reference's own attributes are kept verbatim (its .mdx file,
|
||||
its 0.75 scale, setAllPoints, hidden) — and the `file=` is no longer decoration: since
|
||||
decision 1751 it is what the loader reads to know that an inheriting `<Model>` is really
|
||||
this engine's `<Cooldown>` widget (`loader::frame_kind_of`). The template's other job is
|
||||
unchanged: to exist under the reference's name so an addon's `inherits=` resolves and its
|
||||
cooldown child inherits the geometry the addon is counting on.
|
||||
|
||||
Its two `<Scripts>` — OnUpdateModel / OnAnimFinished — are deliberately NOT transcribed.
|
||||
Both are real 1.12 script slots that this engine does not fire (see
|
||||
`benilla-ui/src/script/object/events_regions.rs`, which raises on any name nothing fires,
|
||||
and measured both at zero corpus call sites), so declaring them would trade sixty
|
||||
`unknown template` warnings for sixty `SetScript unsupported` ones and change nothing else.
|
||||
The scrub they drive is our <Cooldown> widget's, not this frame's.
|
||||
-->
|
||||
<Model name="CooldownFrameTemplate" file="Interface\Cooldown\UI-Cooldown-Indicator.mdx"
|
||||
scale="0.75" setAllPoints="true" hidden="true" virtual="true"/>
|
||||
</Ui>
|
||||
@@ -21,14 +21,13 @@
|
||||
ScrollFrame + Slider + Lua). The real `FauxScrollFrameTemplate` routes the scrollbar's value
|
||||
through its parent `ScrollFrame` (`OnValueChanged → parent:SetVerticalScroll(v)`; the
|
||||
ScrollFrame's `OnVerticalScroll` then derives `offset = floor(v/itemHeight + 0.5)`). That
|
||||
ScrollFrame adds exactly one thing over the Slider: a *scroll-range clamp* on the value. We
|
||||
don't need it — the offset comes straight off the Slider value here — and in benilla it would be
|
||||
a liability: the clamp reads the scroll child's RESOLVED height, which lags a tick behind the
|
||||
`SetHeight` the faux update does, so a content resize + scroll in one tick could clamp against a
|
||||
stale range. So benilla's faux frame is a plain `<Frame>` + a `Slider`: fewer moving parts, no
|
||||
dependency on within-tick resolved rects, and the identical on-screen result (a smooth,
|
||||
pixel-granularity thumb whose position maps 1:1 to the scroll offset). See 0251 for the full
|
||||
rationale + the divergence from 0250's described (real-client) shape.
|
||||
ScrollFrame adds nothing to the value on its way through: 0251 believed it clamped to the
|
||||
scroll range and called the clamp a liability, and decision 2017 read the bytes — the
|
||||
reference's `SetVerticalScroll` stores what the bar hands it. So the relay is a pure
|
||||
pass-through, and benilla's faux frame is a plain `<Frame>` + a `Slider`: fewer moving parts
|
||||
and the identical on-screen result (a smooth, pixel-granularity thumb whose position maps
|
||||
1:1 to the scroll offset). See 0251 for the full rationale + the divergence from 0250's
|
||||
described (real-client) shape.
|
||||
|
||||
VALUE MODEL. The scrollbar's value is in PIXELS: `[0, (numItems − numToDisplay) * itemHeight]`,
|
||||
`valueStep = itemHeight`. The row offset is `floor(value / itemHeight + 0.5)` — sub-pixel
|
||||
@@ -76,13 +75,12 @@
|
||||
-- over the template's on inherit (framexml.rs `merge`), so it really is one. Duck-typed for
|
||||
-- exactly that reason.
|
||||
--
|
||||
-- GAP, stated where it bites: `SetVerticalScroll` clamps into `[0, GetVerticalScrollRange()]`
|
||||
-- (scrollframe.rs), and that range comes from the frame's SCROLL CHILD — which the ref's
|
||||
-- `FauxScrollFrameTemplate` declares as `<ScrollChild><Frame name="$parentScrollChildFrame">`
|
||||
-- and our XML loader has no `<ScrollChild>` element for. Until it does, an addon frame's
|
||||
-- range reads 0, so the handler fires with arg1 = 0 and its list stays at the top. The
|
||||
-- offset our own kit computes above is unaffected — it comes straight off the Slider, which
|
||||
-- is the whole point of 0251.
|
||||
-- The engine's SetVerticalScroll takes the bar's value as given (decision 2017 — the
|
||||
-- reference's never reads the scroll range), so an addon frame's `<OnVerticalScroll>`
|
||||
-- fires with this very value and its `FauxScrollFrame_OnVerticalScroll` derives the offset
|
||||
-- from it, the reference's path end to end. The offset our own kit computes above is the
|
||||
-- same number by construction — it comes straight off the Slider, which is the whole point
|
||||
-- of 0251.
|
||||
function BenillaFauxScrollBar_OnValueChanged(scrollBar, value)
|
||||
local frame = scrollBar:GetParent()
|
||||
local step = scrollBar:GetValueStep()
|
||||
@@ -222,9 +220,16 @@
|
||||
BenillaScrollBar_SetShown(bar, range > 0)
|
||||
end
|
||||
|
||||
-- Pan by `delta` px; the engine clamps to [0, range].
|
||||
-- Pan by `delta` px, clamped to [0, range] HERE: the engine's SetVerticalScroll stores
|
||||
-- what it is given (decision 2017 — the reference's never reads the range), and the
|
||||
-- reference's own wheel handler bounds itself the same way, through its bar's [min, max]
|
||||
-- (ScrollFrameTemplate_OnMouseWheel → scrollBar:SetValue).
|
||||
function BenillaScroll_Step(sf, delta)
|
||||
sf:SetVerticalScroll(sf:GetVerticalScroll() + delta)
|
||||
local target = sf:GetVerticalScroll() + delta
|
||||
local range = sf:GetVerticalScrollRange()
|
||||
if target > range then target = range end
|
||||
if target < 0 then target = 0 end
|
||||
sf:SetVerticalScroll(target)
|
||||
BenillaScroll_SyncBar(sf)
|
||||
end
|
||||
|
||||
|
||||
@@ -413,9 +413,12 @@ OptionsFrame.xml
|
||||
# (l.73-74). Replaces ActionBar.xml, MultiBars.xml and StanceBar.xml.
|
||||
Interface\FrameXML\MainMenuBar.xml
|
||||
|
||||
# The shared CooldownFrame_SetTimer (the ref's Cooldown.lua file split) — before its
|
||||
# consumers (the action, multibar, bonus and shapeshift buttons; the bag slots).
|
||||
Cooldown.xml
|
||||
# The cooldown indicator — the reference's own file, off the player's chain (1751; decision
|
||||
# 2019): `CooldownFrameTemplate`, a <Model> playing UI-Cooldown-Indicator.mdx whose OnUpdateModel
|
||||
# scrubs the sweep and whose OnAnimFinished hides the flash (Cooldown.lua), rendered as a tile
|
||||
# (2013) with its four quadrant quads turned by the file's own texture-transform rotation tracks.
|
||||
# Before its consumers (the action, multibar, bonus and shapeshift buttons; the bag slots).
|
||||
Interface\FrameXML\Cooldown.xml
|
||||
# ══ THE ACTION-BUTTON REGIONS — the reference's own, off the player's chain (1751) ══
|
||||
# `ActionButtonTemplate`: the REGIONS-ONLY half of the reference's two-level split — icon, count,
|
||||
# hotkey, name, border, flash, cooldown and normal texture. It declares NO scripts and calls
|
||||
@@ -720,12 +723,11 @@ Interface\FrameXML\PetPaperDollFrame.xml
|
||||
# legibility, not necessity, in either direction: every verb each file calls of the other resolves
|
||||
# at call time, `KEYRING_CONTAINER` included (no top-level read of it exists in ContainerFrame.lua).
|
||||
#
|
||||
# TWO THINGS THE SWAP DID NOT INHERIT, and both live in ContainerFrameAdapters.xml below rather
|
||||
# than being edited into anything of Blizzard's: the item-push card (0887 — the reference's is a
|
||||
# `<Model>` playing an .mdx and this engine renders no FrameXML models, so ours samples that
|
||||
# model's own tracks and draws a quad), and the GlobalStrings fallbacks the install-less harnesses
|
||||
# need. The keyring's SHOW trigger moved the other way — into the main bar's file, which is exactly
|
||||
# where the reference keeps it (MainMenuBar.xml l.323-341; stock since 1938).
|
||||
# The item-push card is the reference's own `<Model>` since 2015 (0887's Lua card is gone with
|
||||
# it): `ItemAnim_OnEvent` puts the icon on the pane and arms it, the tile renderer draws the
|
||||
# file (2013), the pane's own clock ends it. The keyring's SHOW trigger moved the other way —
|
||||
# into the main bar's file, which is exactly where the reference keeps it (MainMenuBar.xml
|
||||
# l.323-341; stock since 1938).
|
||||
Interface\FrameXML\MainMenuBarBagButtons.xml
|
||||
# The stack-split spinner (decision 0216 §6/slice 2): loaded beside the bag windows, whose slot
|
||||
# click handler (the reference's `ContainerFrameItemButton_OnClick`) calls `OpenStackSplitFrame`.
|
||||
|
||||
@@ -2085,7 +2085,6 @@ const PROBE_FRAME_KINDS: &[&str] = &[
|
||||
"MovieFrame",
|
||||
"GameTooltip",
|
||||
"Minimap",
|
||||
"Cooldown",
|
||||
];
|
||||
|
||||
/// **Type the receiver of a `:` call where the file says what it is** — the whole basis of the
|
||||
|
||||
@@ -137,7 +137,9 @@ impl Plugin for DepthProbePlugin {
|
||||
// Between the opaque pass (which draws `Opaque3d` *and* `AlphaMask3d`) and the
|
||||
// transmissive/transparent ones. That is the depth as it stood when the opaque fight was
|
||||
// decided, which is the question — see [`DepthReadbackNode`] for what reading it later
|
||||
// gets you instead.
|
||||
// gets you instead. The retained static pass draws BEFORE the opaque pass (2016), so
|
||||
// its walls are in this read too; while it ran after, the graph left the two
|
||||
// unordered and a probe could read a wall-less depth on some frames.
|
||||
//
|
||||
// `WOW_DEPTH_AFTER=1` moves the copy to AFTER the transparent pass — deliberately
|
||||
// measuring the thing the placement note warns about: the depth the transparent pass
|
||||
|
||||
@@ -766,6 +766,8 @@ fn warm_quad(colors: bool, skinned: bool) -> RenderSubmesh {
|
||||
interior: false,
|
||||
emissive: false,
|
||||
icon_slot: false,
|
||||
uv_rot_seq: None,
|
||||
uv_scale_seq: None,
|
||||
sidn: None,
|
||||
window: false,
|
||||
additive: false,
|
||||
|
||||
@@ -44,17 +44,20 @@
|
||||
//! and not read here (the reference's `SetLight(0, …)` is a no-op too; the enabled form has no
|
||||
//! caller in 1.12's FrameXML).
|
||||
//!
|
||||
//! ## Texture transforms (decision 2019)
|
||||
//!
|
||||
//! A batch whose texture transform animates gets a material of its own per tile — a clone of
|
||||
//! the twin with two mat-anim rows: the translation delta (the world's lane, `anim_slots.x`)
|
||||
//! and the **affine** row (`anim_slots.z`: rotation and scale as deltas from the identity), both
|
||||
//! sampled here off the pane's play head at the sequence's file slot, never off the world clock.
|
||||
//! The shader composes them as the reference does — `uv' = R((uv + t − p) ⊙ s) + p` — which is
|
||||
//! how the cooldown indicator's four quadrant quads turn their mask into the clockwise sweep.
|
||||
//!
|
||||
//! ## What is deliberately NOT here yet
|
||||
//!
|
||||
//! - The **perspective leg** (`SetCamera(n)` naming a real M2 camera; the character panes): a
|
||||
//! plain `<Model>` in the shipped interface never picks one, and the character panes keep
|
||||
//! their booths. Named, not built.
|
||||
//! - **Texture-transform rotation** (the cooldown indicator's sweep is four quadrant quads whose
|
||||
//! UV rotation tracks turn the mask): the formats bake carries translation only. The cooldown
|
||||
//! stays on the native `Cooldown` widget until that channel lands; this module renders every
|
||||
//! other UI file.
|
||||
//! - The **implicit rect** of a size-less `<Model>` from the file's bounds (§3): the map arrow
|
||||
//! keeps its 1980 footprint and its sprite for now.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -67,11 +70,13 @@ use bevy::render::render_resource::Buffer;
|
||||
use bevy::render::renderer::{RenderDevice, RenderQueue};
|
||||
|
||||
use benilla_assets::materials::WowModelMaterial;
|
||||
use benilla_assets::{m2_url, M2Model, WorldAssets};
|
||||
use benilla_assets::{m2_url, quantize, M2Model, WorldAssets};
|
||||
use benilla_formats::{SeqLoops, UvAnim};
|
||||
use benilla_ui::script::{ModelPaneFrame, UiScript};
|
||||
use benilla_ui::widget::{FrameHandle, ModelFileFacts, SequenceFacts};
|
||||
use benilla_world::doodad_anim::spawn_anim_host;
|
||||
use benilla_world::lighting::LightBlob;
|
||||
use benilla_world::mat_anim_table::{affine_row, MatAnimTable};
|
||||
use benilla_world::model_forms::ModelForms;
|
||||
use benilla_world::model_render::M2BatchMaterials;
|
||||
use benilla_world::particles::buffer::EffectLightOverride;
|
||||
@@ -163,6 +168,80 @@ struct AlphaPart {
|
||||
anim: Arc<benilla_formats::AlphaAnim>,
|
||||
}
|
||||
|
||||
/// One batch whose texture transform animates: the tile's OWN clone of the batch's material
|
||||
/// (two panes on one file must not share a row — two cooldowns at different fractions), with
|
||||
/// the table rows it writes per frame off the pane's play head (decision 2019).
|
||||
struct UvPart {
|
||||
/// Held so the clone outlives its parts' handles by exactly the tile's lifetime.
|
||||
#[allow(dead_code)]
|
||||
material: Handle<WowModelMaterial>,
|
||||
/// The translation row: its slot, and the built seed the delta is measured from
|
||||
/// (`sun_scale.zw`, the loop's sample at 0).
|
||||
trans: Option<(u16, [f32; 2])>,
|
||||
/// The affine row's slot — rotation and scale ([`affine_row`]).
|
||||
affine: Option<u16>,
|
||||
uv_anim: Option<Arc<UvAnim>>,
|
||||
uv_seq: Option<Arc<SeqLoops<[f32; 2]>>>,
|
||||
uv_rot: Option<Arc<SeqLoops<[f32; 4]>>>,
|
||||
uv_scale: Option<Arc<SeqLoops<[f32; 2]>>>,
|
||||
}
|
||||
|
||||
impl UvPart {
|
||||
/// Write this frame's rows for the sequence at `(seq_slot, cursor_s)` on the pane's clock
|
||||
/// `gseq_s`: the translation delta (quantized like the world's lane), and the affine row
|
||||
/// from the raw quaternion and the scale.
|
||||
fn write_rows(
|
||||
&self,
|
||||
table: &mut MatAnimTable,
|
||||
seq_slot: Option<usize>,
|
||||
cursor_s: f32,
|
||||
gseq_s: f64,
|
||||
) {
|
||||
if let Some((slot, seed)) = self.trans {
|
||||
let uv = match (&self.uv_seq, &self.uv_anim) {
|
||||
(Some(seqs), _) => seqs
|
||||
.seq(seq_slot)
|
||||
.map_or([0.0, 0.0], |l| l.sample(l.clock(cursor_s, gseq_s))),
|
||||
(None, Some(a)) => a.sample(a.clock(cursor_s, gseq_s)),
|
||||
(None, None) => [0.0, 0.0],
|
||||
};
|
||||
table.set(
|
||||
slot,
|
||||
[
|
||||
quantize(uv[0], 4096.0) - seed[0],
|
||||
quantize(uv[1], 4096.0) - seed[1],
|
||||
0.0,
|
||||
0.0,
|
||||
],
|
||||
);
|
||||
}
|
||||
if let Some(slot) = self.affine {
|
||||
let q = self
|
||||
.uv_rot
|
||||
.as_ref()
|
||||
.and_then(|r| r.seq(seq_slot))
|
||||
.map_or([0.0, 0.0, 0.0, 1.0], |l| {
|
||||
l.sample(l.clock(cursor_s, gseq_s))
|
||||
});
|
||||
let sc = self
|
||||
.uv_scale
|
||||
.as_ref()
|
||||
.and_then(|r| r.seq(seq_slot))
|
||||
.map_or([1.0, 1.0], |l| l.sample(l.clock(cursor_s, gseq_s)));
|
||||
table.set(slot, affine_row(q, sc));
|
||||
}
|
||||
}
|
||||
|
||||
fn free(&self, table: &mut MatAnimTable) {
|
||||
if let Some((slot, _)) = self.trans {
|
||||
table.free(slot);
|
||||
}
|
||||
if let Some(slot) = self.affine {
|
||||
table.free(slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A live tile: its entity tree and what it was built from.
|
||||
struct Tile {
|
||||
root: Entity,
|
||||
@@ -178,11 +257,22 @@ struct Tile {
|
||||
/// Which id the player is currently arming (to re-arm only on change).
|
||||
armed: Option<u16>,
|
||||
alpha_parts: Vec<AlphaPart>,
|
||||
uv_parts: Vec<UvPart>,
|
||||
emitters: Vec<Entity>,
|
||||
/// The last frame this tile was on the engine's paint list.
|
||||
last_seen: u64,
|
||||
}
|
||||
|
||||
impl Tile {
|
||||
/// Tear the tile down: its tree, and the table rows its animated materials held.
|
||||
fn retire(self, commands: &mut Commands, table: &mut MatAnimTable) {
|
||||
for p in &self.uv_parts {
|
||||
p.free(table);
|
||||
}
|
||||
commands.entity(self.root).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames a tile survives off the paint list before its tree is torn down — long enough that a
|
||||
/// cooldown that re-arms every few seconds, or a ping, keeps its tree.
|
||||
const TILE_LINGER_FRAMES: u64 = 600;
|
||||
@@ -301,6 +391,8 @@ struct TileRender<'w> {
|
||||
mats: M2BatchMaterials<'w>,
|
||||
palettes: ResMut<'w, RigPalettes>,
|
||||
rig: ResMut<'w, TileRig>,
|
||||
/// The shared mat-anim table: the tiles' animated materials own rows in it (2019).
|
||||
table: ResMut<'w, MatAnimTable>,
|
||||
}
|
||||
|
||||
/// The per-frame pass: feed the engine the facts it asked for, keep one tile per visible pane,
|
||||
@@ -338,7 +430,7 @@ fn sync_tiles(
|
||||
let Some(mut script) = script else {
|
||||
// No VM: nothing paints. Tear everything down so a dead UI leaves no live camera.
|
||||
for (_, tile) in state.tiles.drain() {
|
||||
commands.entity(tile.root).despawn();
|
||||
tile.retire(&mut commands, &mut render.table);
|
||||
}
|
||||
bridge.cells.clear();
|
||||
set_camera_active(&mut cams, false);
|
||||
@@ -394,7 +486,7 @@ fn sync_tiles(
|
||||
.is_some_and(|t| t.key != key || t.icon != req.icon);
|
||||
if stale {
|
||||
if let Some(t) = state.tiles.remove(handle) {
|
||||
commands.entity(t.root).despawn();
|
||||
t.retire(&mut commands, &mut render.table);
|
||||
}
|
||||
}
|
||||
let tile = state.tiles.entry(*handle).or_insert_with(|| Tile {
|
||||
@@ -413,6 +505,7 @@ fn sync_tiles(
|
||||
clips: HashMap::new(),
|
||||
armed: None,
|
||||
alpha_parts: Vec::new(),
|
||||
uv_parts: Vec::new(),
|
||||
emitters: Vec::new(),
|
||||
last_seen: frame,
|
||||
});
|
||||
@@ -444,6 +537,7 @@ fn sync_tiles(
|
||||
);
|
||||
tile.clips = built.clips;
|
||||
tile.alpha_parts = built.alpha_parts;
|
||||
tile.uv_parts = built.uv_parts;
|
||||
tile.emitters = built.emitters;
|
||||
tile.built = true;
|
||||
}
|
||||
@@ -460,7 +554,7 @@ fn sync_tiles(
|
||||
.collect();
|
||||
for h in dead {
|
||||
if let Some(t) = state.tiles.remove(&h) {
|
||||
commands.entity(t.root).despawn();
|
||||
t.retire(&mut commands, &mut render.table);
|
||||
}
|
||||
bridge.requests.remove(&h);
|
||||
}
|
||||
@@ -588,6 +682,9 @@ fn sync_tiles(
|
||||
}
|
||||
}
|
||||
}
|
||||
for part in &tile.uv_parts {
|
||||
part.write_rows(&mut render.table, seq_slot, cursor_s, gseq_s);
|
||||
}
|
||||
for &e in &tile.emitters {
|
||||
if let Ok(mut em) = emitters.get_mut(e) {
|
||||
em.set_size_scale(req.star_px_per_unit);
|
||||
@@ -695,6 +792,7 @@ fn shelf_pack(sizes: &[UVec2], edge: u32) -> Vec<Cell> {
|
||||
struct BuiltTile {
|
||||
clips: HashMap<u16, (AnimationNodeIndex, usize)>,
|
||||
alpha_parts: Vec<AlphaPart>,
|
||||
uv_parts: Vec<UvPart>,
|
||||
emitters: Vec<Entity>,
|
||||
}
|
||||
|
||||
@@ -726,6 +824,7 @@ fn build_tile(
|
||||
// Materials first — every one must be resident before anything spawns, or a retry would
|
||||
// leave half a tree behind.
|
||||
let mut part_mats: Vec<Handle<WowModelMaterial>> = Vec::with_capacity(model.submeshes.len());
|
||||
let mut uv_parts: Vec<UvPart> = Vec::new();
|
||||
for (i, sub) in model.submeshes.iter().enumerate() {
|
||||
let texture = if sub.icon_slot {
|
||||
icon_tex.clone()
|
||||
@@ -742,7 +841,41 @@ fn build_tile(
|
||||
render.mats.materials(),
|
||||
true,
|
||||
)?;
|
||||
part_mats.push(twin);
|
||||
// A batch whose texture transform animates draws through a clone of its own, with its
|
||||
// own table rows — the rows are written off THIS pane's play head, so two panes on one
|
||||
// file cannot share them (decision 2019).
|
||||
let animated = sub.uv_anim.is_some()
|
||||
|| sub.uv_seq.is_some()
|
||||
|| sub.uv_rot_seq.is_some()
|
||||
|| sub.uv_scale_seq.is_some();
|
||||
if animated {
|
||||
let mut own = render.mats.materials().get(&twin).cloned()?;
|
||||
let seed = [own.extension.sun_scale.z, own.extension.sun_scale.w];
|
||||
let trans = (sub.uv_anim.is_some() || sub.uv_seq.is_some())
|
||||
.then(|| render.table.alloc())
|
||||
.flatten()
|
||||
.map(|slot| {
|
||||
own.extension.anim_slots.x = f32::from(slot);
|
||||
(slot, seed)
|
||||
});
|
||||
let affine = (sub.uv_rot_seq.is_some() || sub.uv_scale_seq.is_some())
|
||||
.then(|| render.table.alloc())
|
||||
.flatten()
|
||||
.inspect(|&slot| own.extension.anim_slots.z = f32::from(slot));
|
||||
let handle = render.mats.materials().add(own);
|
||||
uv_parts.push(UvPart {
|
||||
material: handle.clone(),
|
||||
trans,
|
||||
affine,
|
||||
uv_anim: sub.uv_anim.clone(),
|
||||
uv_seq: sub.uv_seq.clone(),
|
||||
uv_rot: sub.uv_rot_seq.clone(),
|
||||
uv_scale: sub.uv_scale_seq.clone(),
|
||||
});
|
||||
part_mats.push(handle);
|
||||
} else {
|
||||
part_mats.push(twin);
|
||||
}
|
||||
}
|
||||
|
||||
// The rig: the collapsed pose buffer + a palette slot, when the file has bones. The tile
|
||||
@@ -862,6 +995,7 @@ fn build_tile(
|
||||
Some(BuiltTile {
|
||||
clips,
|
||||
alpha_parts,
|
||||
uv_parts,
|
||||
emitters,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ fn action_ids(s: &mut UiScript) -> Vec<u32> {
|
||||
fn shipped_action_bar_drives_end_to_end() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
super::test_ui::load_ui(&s, "Cooldown.xml");
|
||||
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
// `load_ui` returns the same `report.frames` the disk reader asserted on, so this
|
||||
// count is the one that always stood here — moved, not re-derived.
|
||||
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
@@ -180,7 +180,7 @@ fn shipped_action_bar_drives_end_to_end() {
|
||||
}
|
||||
|
||||
fn load_action_bar(s: &UiScript) {
|
||||
super::test_ui::load_ui(s, "Cooldown.xml");
|
||||
super::test_ui::load_ui(s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
super::test_ui::load_ui(s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
super::test_ui::load_ui(s, "Interface\\FrameXML\\TextStatusBar.lua");
|
||||
super::test_ui::load_ui(s, "Interface\\FrameXML\\TextStatusBar.xml");
|
||||
@@ -251,17 +251,18 @@ fn state_feedback_drives_cooldown_checked_and_usable_through_the_xml() {
|
||||
}),
|
||||
);
|
||||
s.fire_event("ACTIONBAR_UPDATE_COOLDOWN", vec![]);
|
||||
// The stock machine (decision 2019): `CooldownFrame_SetTimer` arms sequence 0 and shows the
|
||||
// pane; the next paint's `OnUpdateModel` scrubs it to `(GetTime() − start) / duration`.
|
||||
super::test_ui::cooldown_facts(&mut s);
|
||||
s.tick(0.0);
|
||||
s.resolve();
|
||||
let sweep = s.extract().into_iter().find_map(|q| match q.content {
|
||||
QuadContent::Cooldown { fraction, flash } => Some((fraction, flash)),
|
||||
_ => None,
|
||||
});
|
||||
let (fraction, flash) = sweep.expect("the button's Cooldown widget is showing");
|
||||
assert!(
|
||||
(fraction - 0.4).abs() < 1e-3,
|
||||
"6 s of 10 s left ⇒ the sweep sits at 40%, got {fraction}"
|
||||
let play = super::test_ui::cooldown_play(&s, "ActionButton1Cooldown")
|
||||
.expect("the button's cooldown pane is showing, sequence 0 armed");
|
||||
assert_eq!(
|
||||
play,
|
||||
(0, 400),
|
||||
"6 s of 10 s left ⇒ the sweep sits at 40 %: sequence 0 at 400 ms"
|
||||
);
|
||||
assert_eq!(flash, None);
|
||||
|
||||
// The checked ring on the current action (the transcribed UpdateState).
|
||||
s.set_action_state(
|
||||
@@ -351,7 +352,11 @@ fn the_cooldown_sweep_paints_over_the_buttons_icon_and_ring() {
|
||||
&|c| matches!(c, QuadContent::Texture { path: Some(p), .. } if p.contains("UI-Quickslot2")),
|
||||
)
|
||||
.expect("the NormalTexture ring quad");
|
||||
let sweep = pos(&|c| matches!(c, QuadContent::Cooldown { .. })).expect("the sweep quad");
|
||||
let sweep = pos(&|c| {
|
||||
matches!(c, QuadContent::ModelPane { model: Some(m), .. }
|
||||
if m.eq_ignore_ascii_case(super::test_ui::COOLDOWN_MODEL))
|
||||
})
|
||||
.expect("the sweep pane's quad");
|
||||
assert!(
|
||||
icon < sweep,
|
||||
"the sweep (index {sweep}) must paint over the icon (index {icon})"
|
||||
@@ -829,7 +834,7 @@ fn shipped_bag_frame_drives_end_to_end() {
|
||||
let mut bar_frames = 0;
|
||||
for file in BAG_UI {
|
||||
let frames = load_ui(&s, file);
|
||||
if *file == "Cooldown.xml" {
|
||||
if *file == "Interface\\FrameXML\\Cooldown.xml" {
|
||||
load_ui(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
load_ui(&s, "Interface\\FrameXML\\TextStatusBar.lua");
|
||||
load_ui(&s, "Interface\\FrameXML\\TextStatusBar.xml");
|
||||
@@ -1351,7 +1356,7 @@ fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() {
|
||||
// Fonts first: the pane's check-box labels colour from `RED_FONT_COLOR` in their own OnLoad.
|
||||
"Interface\\FrameXML\\Fonts.xml",
|
||||
r"Interface\FrameXML\UIParent.xml",
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
@@ -1517,7 +1522,7 @@ fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() {
|
||||
fn bonus_bar_slides_up_with_sound_and_down_without() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
super::test_ui::load_ui(&s, "Cooldown.xml");
|
||||
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.lua");
|
||||
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.xml");
|
||||
@@ -1739,7 +1744,7 @@ fn bonus_bar_slides_up_with_sound_and_down_without() {
|
||||
fn bonus_bar_turnaround_continues_from_position() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
super::test_ui::load_ui(&s, "Cooldown.xml");
|
||||
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.lua");
|
||||
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.xml");
|
||||
@@ -1821,7 +1826,7 @@ fn bonus_bar_turnaround_continues_from_position() {
|
||||
fn the_page_arrows_do_not_steal_each_other_s_clicks() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
super::test_ui::load_ui(&s, "Cooldown.xml");
|
||||
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.lua");
|
||||
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.xml");
|
||||
|
||||
@@ -1394,19 +1394,15 @@ fn bag_slot_cooldown_sweeps_through_the_xml() {
|
||||
s.set_container(0, Some(backpack(Some((52_000, 60_000, true)))));
|
||||
s.run("OpenAllBags()").unwrap();
|
||||
s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(0)]);
|
||||
// The stock machine (decision 2019): the slot's cooldown pane, sequence 0 scrubbed by the
|
||||
// next paint's `OnUpdateModel` to the elapsed fraction.
|
||||
super::test_ui::cooldown_facts(&mut s);
|
||||
s.tick(0.0);
|
||||
s.resolve();
|
||||
|
||||
let sweep = |s: &UiScript| {
|
||||
s.extract().iter().find_map(|q| match q.content {
|
||||
QuadContent::Cooldown { fraction, .. } => Some(fraction),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let fraction = sweep(&s).expect("the bag slot sweeps");
|
||||
assert!(
|
||||
(fraction - 0.8).abs() < 1e-3,
|
||||
"48 of 60 s elapsed: fraction {fraction}"
|
||||
);
|
||||
let sweep = |s: &UiScript| super::test_ui::cooldown_play_any(s);
|
||||
let play = sweep(&s).expect("the bag slot sweeps");
|
||||
assert_eq!(play, (0, 800), "48 of 60 s elapsed: sequence 0 at 800 ms");
|
||||
|
||||
// The cooldown clears (a CLEAR_COOLDOWN, or it simply ran out before the re-push): the
|
||||
// refresh event re-reads the now-cold triple and hides the widget.
|
||||
@@ -1835,16 +1831,16 @@ fn a_key_dropped_on_the_button_files_itself() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The item-push drop animation (decision 0887): `ITEM_PUSH(container, icon)` runs the pushed item's
|
||||
/// icon down into **that** container's bag-bar button and nobody else's, along the curves read out of
|
||||
/// `ForcedBackpackItem.m2` — pop in, hang, fall, shrink away — and hides itself at the end.
|
||||
///
|
||||
/// This is the whole observable contract of `BenillaItemPushAnim_*`: the routing (which button), the
|
||||
/// motion (starts a fall above the button, lands centred on it), the fade/scale shape (the file's
|
||||
/// keys), and the CLAMP end (one play, then gone). It is also the regression net for the OnUpdate
|
||||
/// gate — an anim frame left shown would keep ticking forever.
|
||||
/// The item-push drop animation is the reference's own `<Model>` now (decisions 2013/2015):
|
||||
/// `ITEM_PUSH(container, icon)` reaches the stock `ItemAnim_OnEvent`, which puts the icon on the
|
||||
/// pane whose parent button owns that inventory slot (`ReplaceIconTexture`), arms sequence 0 at
|
||||
/// 0 and shows it; the pane's clock runs the file's 1000 ms clamp; `ItemAnim_OnAnimFinished`
|
||||
/// hides it. The pane authored no size, so its rect is the file's own bounding box in layout
|
||||
/// units — the 42.41 × 124.72 the 0887 card measured by hand at 16:9.
|
||||
#[test]
|
||||
fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
|
||||
use benilla_ui::widget::{ModelFileFacts, SequenceFacts};
|
||||
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1600.0, 900.0);
|
||||
@@ -1853,42 +1849,50 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
|
||||
}
|
||||
load_xml(&s, "ScrollTemplates.xml"); // our window tab template, before the window that inherits it (1988)
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
// `ForcedBackpackItem.m2` (`m2seq`/`m2batch`): one sequence, id 0, 1000 ms, clamp; the
|
||||
// header box spans the card's whole travel band — 0.02707 × 0.07962 model units, which at
|
||||
// 16:9 (a layout unit is 768·√(a²+1) = 1566.4 FrameXML units) is the 42.41 × 124.72 rect
|
||||
// 0887's card measured.
|
||||
const CARD: &str = r"Interface\ItemAnimations\ForcedBackpackItem.mdx";
|
||||
s.set_model_facts(
|
||||
CARD,
|
||||
ModelFileFacts {
|
||||
sequences: vec![SequenceFacts {
|
||||
anim_id: 0,
|
||||
duration_ms: 1000,
|
||||
looping: false,
|
||||
}],
|
||||
bbox: ([0.0, 0.0, 0.0], [0.02707, 0.07962, 0.0]),
|
||||
},
|
||||
);
|
||||
s.resolve();
|
||||
|
||||
let shown = |s: &UiScript, name: &str| {
|
||||
s.eval::<bool>(&format!("return {name}BenillaItemPush:IsShown()"))
|
||||
s.eval::<bool>(&format!("return {name}ItemAnim:IsShown()"))
|
||||
.unwrap()
|
||||
};
|
||||
// The card's centre relative to its button's centre, in screen px (y-up): (dx, dy).
|
||||
let offset = |s: &UiScript, name: &str| -> (f32, f32) {
|
||||
s.eval::<(f32, f32)>(&format!(
|
||||
"local a, b = {name}BenillaItemPush, {name} \
|
||||
local ax, ay = a:GetLeft() + a:GetWidth() / 2, a:GetBottom() + a:GetHeight() / 2 \
|
||||
local bx, by = b:GetLeft() + b:GetWidth() / 2, b:GetBottom() + b:GetHeight() / 2 \
|
||||
return ax - bx, ay - by"
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
let size = |s: &UiScript, name: &str| {
|
||||
s.eval::<f32>(&format!("return {name}BenillaItemPush:GetWidth()"))
|
||||
.unwrap()
|
||||
};
|
||||
let alpha = |s: &UiScript, name: &str| {
|
||||
s.eval::<f32>(&format!("return {name}BenillaItemPush:GetAlpha()"))
|
||||
.unwrap()
|
||||
};
|
||||
// The card's centre relative to its button's BOTTOMRIGHT corner — the reference's OWN frame of
|
||||
// reference for this widget (it anchors the `<Model>`'s BOTTOMRIGHT to the button's), and the
|
||||
// only one the numbers are stable in: the card's placement depends on the anchor offset and
|
||||
// the M2 bounding box, NOT on how big the button is. Measuring against the button's centre
|
||||
// instead is what let 0887's placement look plausible.
|
||||
let corner = |s: &UiScript, name: &str| -> (f32, f32) {
|
||||
s.eval::<(f32, f32)>(&format!(
|
||||
"local a, b = {name}BenillaItemPush, {name} \
|
||||
local ax, ay = a:GetLeft() + a:GetWidth() / 2, a:GetBottom() + a:GetHeight() / 2 \
|
||||
return ax - b:GetRight(), ay - b:GetBottom()"
|
||||
))
|
||||
.unwrap()
|
||||
// The shown card panes, as the renderer sees them: `(icon, play head)` per visible pane.
|
||||
type Card = (Option<String>, Option<(u16, u32)>);
|
||||
let cards = |s: &mut UiScript| -> Vec<Card> {
|
||||
let heads = s.visible_model_panes();
|
||||
s.extract()
|
||||
.into_iter()
|
||||
.filter_map(|q| match q.content {
|
||||
QuadContent::ModelPane {
|
||||
handle,
|
||||
model: Some(m),
|
||||
icon,
|
||||
..
|
||||
} if m == CARD => Some((
|
||||
icon,
|
||||
heads
|
||||
.iter()
|
||||
.find(|p| p.handle == handle)
|
||||
.and_then(|p| p.play.map(|ph| (ph.anim_id, ph.cursor_ms))),
|
||||
)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Nothing is animating until a push arrives.
|
||||
@@ -1899,11 +1903,11 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
|
||||
] {
|
||||
assert!(!shown(&s, b), "{b}'s card starts hidden");
|
||||
}
|
||||
assert!(cards(&mut s).is_empty());
|
||||
|
||||
// A push into equipped bag 2. `arg1` is the reference's own vocabulary since 1751 window 3 —
|
||||
// the button's INVENTORY-slot id, which is what `ItemAnim_OnEvent` compares against — so bag 2
|
||||
// is `CharacterBag1Slot`'s 21, not the container id 2 this used to send (`ui_loot.rs`
|
||||
// `push_container`, the `0x491bb5` selector).
|
||||
// is `CharacterBag1Slot`'s 21.
|
||||
s.fire_event(
|
||||
"ITEM_PUSH",
|
||||
vec![
|
||||
@@ -1911,159 +1915,70 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
|
||||
benilla_ui::script::ScriptValue::Str("Interface\\Icons\\INV_Misc_Bag_08".into()),
|
||||
],
|
||||
);
|
||||
assert!(
|
||||
shown(&s, "CharacterBag1Slot"),
|
||||
"the card that took it plays"
|
||||
);
|
||||
assert!(
|
||||
!shown(&s, "MainMenuBarBackpackButton") && !shown(&s, "KeyRingButton"),
|
||||
"…and nobody else's"
|
||||
);
|
||||
s.resolve();
|
||||
assert!(shown(&s, "CharacterBag1Slot"), "bag 2's card plays");
|
||||
for b in [
|
||||
"MainMenuBarBackpackButton",
|
||||
"CharacterBag0Slot",
|
||||
"KeyRingButton",
|
||||
] {
|
||||
assert!(!shown(&s, b), "{b} took nothing, so {b} animates nothing");
|
||||
}
|
||||
// The pushed icon reaches the RENDERER, not just the Lua state — exactly one quad carries it.
|
||||
let drawn = |s: &UiScript, icon: &str| {
|
||||
s.extract()
|
||||
.iter()
|
||||
.filter(
|
||||
|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == icon),
|
||||
)
|
||||
.count()
|
||||
};
|
||||
let live = cards(&mut s);
|
||||
assert_eq!(
|
||||
drawn(&s, "Interface\\Icons\\INV_Misc_Bag_08"),
|
||||
1,
|
||||
"the card wears the pushed item's icon (ITEM_PUSH's arg2)"
|
||||
live,
|
||||
vec![(
|
||||
Some(r"Interface\Icons\INV_Misc_Bag_08".to_string()),
|
||||
Some((0, 0))
|
||||
)],
|
||||
"one pane, the pushed icon on it, sequence 0 at 0"
|
||||
);
|
||||
// The implicit rect: the file's box in layout units, hung off the button's BOTTOMRIGHT
|
||||
// (−10, 0) as the stock template anchors it.
|
||||
let (w, h): (f32, f32) = s
|
||||
.eval("return CharacterBag1SlotItemAnim:GetWidth(), CharacterBag1SlotItemAnim:GetHeight()")
|
||||
.unwrap();
|
||||
assert!(
|
||||
(w - 42.41).abs() < 0.05 && (h - 124.72).abs() < 0.05,
|
||||
"the card's rect is the file's box: {w} × {h}"
|
||||
);
|
||||
let (dx, dy): (f32, f32) = s
|
||||
.eval(
|
||||
"local a, b = CharacterBag1SlotItemAnim, CharacterBag1Slot \
|
||||
return a:GetRight() - b:GetRight(), a:GetBottom() - b:GetBottom()",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
(dx + 10.0).abs() < 0.01 && dy.abs() < 0.01,
|
||||
"BOTTOMRIGHT (−10, 0) off the button: ({dx}, {dy})"
|
||||
);
|
||||
|
||||
// Every figure below is the REFERENCE's, from wow-re's §5-cross-checked
|
||||
// `modelframe-implicit-size-law.md` §3 — not a ratio we chose. The three it publishes
|
||||
// (t = 0, 0.133, 1.0) are asserted to a tenth of a pixel; the rest assert the SHAPE of the
|
||||
// motion, which is where 0887's authored placement actually went wrong.
|
||||
//
|
||||
// What changed from 0887, and why the old numbers looked right: it measured against the
|
||||
// button's CENTRE and drove both axes off one shared 0..1 "drop" curve, which happens to land
|
||||
// within ~1 px of the truth in y for a 36 px button. In x it was wrong in DIRECTION — the card
|
||||
// drifts RIGHT as it falls, and the old code had it starting left and ending centred.
|
||||
|
||||
// t=0: invisible, full size, parked high above the button. The .m2's alpha key (0.000, 0.0),
|
||||
// scale key (0.000, 1.0), translation (0.000, (0,0)).
|
||||
let (cx, cy) = corner(&s, "CharacterBag1Slot");
|
||||
assert!(
|
||||
(cx + 30.14).abs() < 0.1 && (cy - 68.99).abs() < 0.1,
|
||||
"starts at the ref's (-30.14, 68.99) from the button's bottom-right: got ({cx}, {cy})"
|
||||
);
|
||||
assert!(
|
||||
(size(&s, "CharacterBag1Slot") - 36.86).abs() < 0.1,
|
||||
"the quad is 0.0288 model units, not the 0.03 that 0887 rounded it to: got {}",
|
||||
size(&s, "CharacterBag1Slot")
|
||||
);
|
||||
assert!(
|
||||
alpha(&s, "CharacterBag1Slot") < 0.01,
|
||||
"fades in from nothing"
|
||||
);
|
||||
|
||||
// t=0.133: fully faded in, at the 1.2x swell.
|
||||
s.tick(0.133);
|
||||
s.resolve();
|
||||
assert!(
|
||||
(alpha(&s, "CharacterBag1Slot") - 1.0).abs() < 0.02,
|
||||
"opaque by the alpha track's second key"
|
||||
);
|
||||
assert!(
|
||||
(size(&s, "CharacterBag1Slot") - 44.24).abs() < 0.1,
|
||||
"swollen to the ref's 44.24 px peak: got {}",
|
||||
size(&s, "CharacterBag1Slot")
|
||||
);
|
||||
// The pop moves the centre by a twentieth of a pixel, and that tiny drift is the tell that the
|
||||
// bone's PIVOT is not the quad's centre — scaling up pushes the centre away from the pivot,
|
||||
// scaling down pulls it in, one mechanism at both ends. Folding the pivot into the centre (the
|
||||
// obvious simplification) loses this and misses the landing point by a fifth of a pixel.
|
||||
let (px, py) = corner(&s, "CharacterBag1Slot");
|
||||
assert!(
|
||||
(px + 30.18).abs() < 0.02 && (py - 68.98).abs() < 0.02,
|
||||
"the 1.2x pop drifts the centre off the pivot by the ref's (-0.046, -0.015): got ({px}, {py})"
|
||||
);
|
||||
|
||||
// t=0.267: back to the icon's own size, still opaque, still parked.
|
||||
s.tick(0.134);
|
||||
s.resolve();
|
||||
assert!(
|
||||
(size(&s, "CharacterBag1Slot") - 36.86).abs() < 0.1,
|
||||
"settled back at the scale track's third key"
|
||||
);
|
||||
let (_, cy) = corner(&s, "CharacterBag1Slot");
|
||||
assert!(
|
||||
(cy - 68.99).abs() < 0.1,
|
||||
"has not started falling: got {cy}"
|
||||
);
|
||||
|
||||
// t=0.5: STILL PARKED — the translation track's second key is (0,0), so the whole first half
|
||||
// second is a hang. The other two tracks are already running down, though: the three curves
|
||||
// keep different schedules, which is the thing a "hold everything then drop" reading would get
|
||||
// wrong (scale/alpha are 0.687/0.682 here, on the 0.267→1.000 ramps).
|
||||
s.tick(0.233);
|
||||
s.resolve();
|
||||
let (_, cy) = corner(&s, "CharacterBag1Slot");
|
||||
assert!(
|
||||
(cy - 69.0).abs() < 0.1,
|
||||
"has not moved yet at the half second: got {cy}"
|
||||
);
|
||||
assert!(
|
||||
(size(&s, "CharacterBag1Slot") - 25.31).abs() < 0.2,
|
||||
"but is already dwindling: got {}",
|
||||
size(&s, "CharacterBag1Slot")
|
||||
);
|
||||
assert!(
|
||||
(alpha(&s, "CharacterBag1Slot") - 0.682).abs() < 0.02,
|
||||
"and already fading: got {}",
|
||||
alpha(&s, "CharacterBag1Slot")
|
||||
);
|
||||
|
||||
// t=0.75: halfway through the fall — and moving RIGHT as it drops, which is the axis 0887 had
|
||||
// backwards. The translation is per-axis (+0.0104, -0.0402 model units), not one shared curve.
|
||||
s.tick(0.25);
|
||||
s.resolve();
|
||||
let (cx, cy) = corner(&s, "CharacterBag1Slot");
|
||||
assert!(
|
||||
(cx + 23.34).abs() < 0.2,
|
||||
"drifted RIGHT by half the 13.3 px total: got {cx}"
|
||||
);
|
||||
assert!(
|
||||
(cy - 43.33).abs() < 0.2,
|
||||
"half of the 51.4 px fall travelled by t=0.75: got {cy}"
|
||||
);
|
||||
|
||||
// Just short of the end: down on the button, shrunk to nothing, faded out.
|
||||
s.tick(0.24);
|
||||
s.resolve();
|
||||
let (cx, cy) = corner(&s, "CharacterBag1Slot");
|
||||
assert!(
|
||||
(cx + 16.87).abs() < 0.2 && (cy - 18.68).abs() < 0.2,
|
||||
"arrives at the ref's landing point: got ({cx}, {cy})"
|
||||
);
|
||||
// Which is also, within a couple of pixels, the button's own centre — the reference's card
|
||||
// really does drop INTO the bag button, and that is the one thing 0887 got right for the wrong
|
||||
// reason. The corner-relative assertion above is the exact one; this is the sanity check, and
|
||||
// its tolerance is a button-size term, not slack: the landing point is fixed relative to the
|
||||
// button's BOTTOMRIGHT, so the offset from its CENTRE moves by half of any size change. The
|
||||
// bar's buttons are 37x37 now — `ItemButtonTemplate`'s own size, inherited through the
|
||||
// reference's `PaperDollItemSlotButtonTemplate` — where the deleted BagFrame.xml authored 36.
|
||||
let (dx, dy) = offset(&s, "CharacterBag1Slot");
|
||||
assert!(
|
||||
dx.abs() < 2.0 && dy.abs() < 2.0,
|
||||
"lands on the button it went into: got ({dx}, {dy})"
|
||||
);
|
||||
assert!(
|
||||
size(&s, "CharacterBag1Slot") < 2.0,
|
||||
"collapsed to the scale track's 0.0144"
|
||||
);
|
||||
assert!(alpha(&s, "CharacterBag1Slot") < 0.05, "faded out");
|
||||
|
||||
// Past 1.000s the CLAMP sequence is over — the ref's OnAnimFinished → Hide.
|
||||
s.tick(0.05);
|
||||
// The clock runs the file's clamp: half a second in, the play head is at 500 ms…
|
||||
s.tick(0.5);
|
||||
assert_eq!(cards(&mut s)[0].1, Some((0, 500)));
|
||||
// …and past 1000 ms the completion fires `ItemAnim_OnAnimFinished` → `Hide()`.
|
||||
s.tick(0.55);
|
||||
assert!(
|
||||
!shown(&s, "CharacterBag1Slot"),
|
||||
"one play, then gone (and no OnUpdate left running)"
|
||||
"one play, then gone (the stock OnAnimFinished hides it)"
|
||||
);
|
||||
assert!(cards(&mut s).is_empty());
|
||||
|
||||
// A second push RESTARTS the card (SetSequenceTime(0, 0) on a fresh arm).
|
||||
s.fire_event(
|
||||
"ITEM_PUSH",
|
||||
vec![
|
||||
benilla_ui::script::ScriptValue::Int(21),
|
||||
benilla_ui::script::ScriptValue::Str("Interface\\Icons\\INV_Misc_Bag_09".into()),
|
||||
],
|
||||
);
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
cards(&mut s),
|
||||
vec![(
|
||||
Some(r"Interface\Icons\INV_Misc_Bag_09".to_string()),
|
||||
Some((0, 0))
|
||||
)]
|
||||
);
|
||||
|
||||
// The keyring is a real destination, not a rounding of the backpack.
|
||||
|
||||
@@ -474,6 +474,19 @@ fn the_item_push_card_shares_the_band_with_a_raised_bar_exactly_as_the_reference
|
||||
);
|
||||
let failures = super::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||||
// The card is the stock `<Model>` since 2015, sized by its file's box once the facts land
|
||||
// (`ForcedBackpackItem.m2`: one 1000 ms clamp; the box 0.02707 × 0.07962 model units).
|
||||
s.set_model_facts(
|
||||
r"Interface\ItemAnimations\ForcedBackpackItem.mdx",
|
||||
benilla_ui::widget::ModelFileFacts {
|
||||
sequences: vec![benilla_ui::widget::SequenceFacts {
|
||||
anim_id: 0,
|
||||
duration_ms: 1000,
|
||||
looping: false,
|
||||
}],
|
||||
bbox: ([0.0, 0.0, 0.0], [0.02707, 0.07962, 0.0]),
|
||||
},
|
||||
);
|
||||
s.run("MultiBarBottomLeft:Show() MultiBarBottomRight:Show() UIParent_ManageFramePositions()")
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
@@ -488,15 +501,23 @@ fn the_item_push_card_shares_the_band_with_a_raised_bar_exactly_as_the_reference
|
||||
s.tick(0.133); // the opaque peak — the instant the card is most visible
|
||||
s.resolve();
|
||||
assert!(
|
||||
shown(&s, "MainMenuBarBackpackButtonBenillaItemPush"),
|
||||
shown(&s, "MainMenuBarBackpackButtonItemAnim"),
|
||||
"the card plays"
|
||||
);
|
||||
|
||||
let card = rect(&s, "MainMenuBarBackpackButtonBenillaItemPush");
|
||||
// The pane's rect is the file's box in layout units at 16:9 — the card's whole travel band,
|
||||
// 42.41 × 124.72 hung off the button's BOTTOMRIGHT (−10, 0) — and the card the file's keys
|
||||
// put inside it at its opaque peak is the reference's 48.9..93.1 above the screen floor
|
||||
// (0887 measured that quad by hand; it sits inside this band).
|
||||
let card = rect(&s, "MainMenuBarBackpackButtonItemAnim");
|
||||
let bar = rect(&s, "MultiBarBottomRight");
|
||||
assert!(
|
||||
(card.1 - 48.9).abs() < 0.5 && (card.3 - 93.1).abs() < 0.5,
|
||||
"the card's band is the reference's 48.9..93.1 above the screen floor: got {card:?}"
|
||||
(card.2 - card.0 - 42.41).abs() < 0.05 && (card.3 - card.1 - 124.72).abs() < 0.05,
|
||||
"the pane's rect is the file's box: {card:?}"
|
||||
);
|
||||
assert!(
|
||||
card.1 <= 48.9 && card.3 >= 93.1,
|
||||
"the reference's card peak 48.9..93.1 lies inside the pane's band: {card:?}"
|
||||
);
|
||||
assert!(
|
||||
overlaps(bar, card),
|
||||
|
||||
@@ -42,7 +42,7 @@ fn harness() -> UiScript {
|
||||
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
|
||||
load_xml(&s, "Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
|
||||
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
|
||||
|
||||
@@ -36,7 +36,7 @@ fn chat_ui() -> UiScript {
|
||||
"Interface\\FrameXML\\BasicControls.xml",
|
||||
// `FCF_ValidateChatFramePosition` (a tab-drag stop) reads `MainMenuBar:GetHeight()`; the
|
||||
// bar's own load-time chain precedes it, as in the action-bar harness.
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
|
||||
@@ -34,7 +34,7 @@ fn chat_frame() -> UiScript {
|
||||
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
|
||||
// `FCF_ValidateChatFramePosition` (a tab-drag stop) reads `MainMenuBar:GetHeight()`; the bar's
|
||||
// own load-time chain precedes it, as in the action-bar harness.
|
||||
load_xml(&s, "Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
|
||||
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
|
||||
|
||||
@@ -72,7 +72,7 @@ fn load_room(s: &UiScript) {
|
||||
"Interface\\FrameXML\\BasicControls.xml",
|
||||
"Interface\\FrameXML\\StaticPopup.xml",
|
||||
"Interface\\FrameXML\\GameTooltip.xml",
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\Cooldown.xml",
|
||||
] {
|
||||
load_xml(s, file);
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ fn an_error_toast_draws_over_an_open_panel_window() {
|
||||
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\UIErrorsFrame.xml");
|
||||
load_xml(&s, "Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // our window tab template, before the window that inherits it (1988)
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); // BenillaMoney_* — QuestLogDetail's reward money row
|
||||
// pane's UIPanelScrollFrameTemplate). A MISSING template is a loader *warning*, so an
|
||||
|
||||
@@ -22,7 +22,7 @@ fn exp_bar_harness() -> UiScript {
|
||||
"Interface\\FrameXML\\GameTooltip.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"Interface\\FrameXML\\MainMenuBar.xml",
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
//! The Cooldown widget's pixels (decision 0137 phase 4): the radial dark wipe + the finish
|
||||
//! flash, rebuilt natively from the byte-read `UI-Cooldown-Indicator.m2`. Split from [`super`]
|
||||
//! purely for size — [`cooldown_quads`] is the only face, called from the extract loop's
|
||||
//! `QuadContent::Cooldown` arm.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::ui_pass::UiQuad;
|
||||
use benilla_assets::WorldAssets;
|
||||
|
||||
/// The cooldown wipe's paint: flat black at α = 0x99/255 — the byte content of
|
||||
/// `Interface\Cooldown\cooldown.blp` (a uniform 32² black texel field at alpha 0x99, with only a
|
||||
/// transparent clamp column for the sweep edge), constant through the sweep (the model's
|
||||
/// quadrant color-alpha tracks hold 1.0 for all of sequence 0 — the m2anim key dump).
|
||||
const COOLDOWN_WIPE_ALPHA: f32 = 153.0 / 255.0;
|
||||
|
||||
/// The finish-flash star's bone-scale pulse — sequence 1's 5 keys, byte-read off
|
||||
/// `UI-Cooldown-Indicator.m2` with the `m2anim` bone-scale dump (uniform XYZ, so one scalar per
|
||||
/// key): grow to 1.85× with the alpha rise, then a shrinking double-bounce while it fades.
|
||||
const STAR_SCALE_KEYS: [(f32, f32); 5] = [
|
||||
(0.0, 1.0),
|
||||
(0.333, 1.853),
|
||||
(0.666, 1.305),
|
||||
(0.833, 1.605),
|
||||
(1.0, 1.155),
|
||||
];
|
||||
|
||||
/// Linear sample of [`STAR_SCALE_KEYS`] at flash progress `t` (the M2 linear-track read).
|
||||
fn star_scale(t: f32) -> f32 {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
for w in STAR_SCALE_KEYS.windows(2) {
|
||||
let ((t0, v0), (t1, v1)) = (w[0], w[1]);
|
||||
if t <= t1 {
|
||||
return v0 + (v1 - v0) * ((t - t0) / (t1 - t0));
|
||||
}
|
||||
}
|
||||
STAR_SCALE_KEYS[4].1
|
||||
}
|
||||
|
||||
/// The Cooldown widget's pixels (decision 0137 phase 4): the radial dark wipe + the finish flash,
|
||||
/// rebuilt natively from the byte-read `UI-Cooldown-Indicator.m2`.
|
||||
///
|
||||
/// **The wipe.** The model draws 4 quadrant quads whose UVs rotate over a clamp-edged
|
||||
/// half-transparent texture — 2001's way of sweeping a straight edge through each quadrant; with
|
||||
/// a *flat* texture behind it (`cooldown.blp` is uniform black at α 0x99), the composed result is
|
||||
/// exactly a uniform dark pie whose bright sector grows **clockwise from 12 o'clock** as the
|
||||
/// cooldown elapses. Rebuilt as ≤4 quads: one full-dark rect per still-covered quadrant, plus
|
||||
/// the active quadrant's **wedge** — an exact convex fan (`c → ray exit → [outer corner] →
|
||||
/// quadrant-end edge midpoint`) through [`UiQuad::corners`]. Pixel-equivalent, no scissor
|
||||
/// interplay. (The wedge bypasses the scroll clip — no cooldown sits in a ScrollFrame; the
|
||||
/// full-dark rects still carry it.)
|
||||
///
|
||||
/// **The flash.** The model's sequence 1 (authored 1.000 s): the additive `star4` burst whose
|
||||
/// alpha is the byte-read texture-weight ramp — linear 0→1 over the first third, hold 1 to the
|
||||
/// half, linear 1→0 over the back half — scaled by the star bone's byte-read 5-key pulse
|
||||
/// ([`STAR_SCALE_KEYS`], the `m2anim` bone-scale dump): grow to 1.85× with the alpha rise, then
|
||||
/// a shrinking double-bounce as it fades.
|
||||
#[allow(clippy::too_many_arguments)] // one draw arm's full input set
|
||||
pub(super) fn cooldown_quads(
|
||||
rect: Rect,
|
||||
z_key: u64,
|
||||
frame_alpha: f32,
|
||||
fraction: f32,
|
||||
flash: Option<f32>,
|
||||
clip: Option<Rect>,
|
||||
assets: &mut Option<ResMut<WorldAssets>>,
|
||||
images: &mut Assets<Image>,
|
||||
out: &mut Vec<UiQuad>,
|
||||
) {
|
||||
let c = rect.center();
|
||||
let dark = [0.0, 0.0, 0.0, COOLDOWN_WIPE_ALPHA * frame_alpha];
|
||||
|
||||
if let Some(progress) = flash {
|
||||
// The finish flash: the byte-read weight ramp (keys 0→1 over 0..⅓, hold to ½, →0 at 1).
|
||||
let ramp = if progress < 1.0 / 3.0 {
|
||||
progress * 3.0
|
||||
} else if progress < 0.5 {
|
||||
1.0
|
||||
} else {
|
||||
(1.0 - progress) * 2.0
|
||||
};
|
||||
let handle = assets
|
||||
.as_mut()
|
||||
.and_then(|a| a.sprite_texture("Interface\\Cooldown\\star4", images));
|
||||
if let Some(handle) = handle {
|
||||
let s = star_scale(progress);
|
||||
let half = rect.half_size() * s;
|
||||
out.push(UiQuad {
|
||||
rect: Rect::from_center_half_size(c, half),
|
||||
z_key,
|
||||
texture: Some(handle),
|
||||
color: [1.0, 1.0, 1.0, ramp * frame_alpha],
|
||||
additive: true,
|
||||
clip,
|
||||
..default()
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// The sweep: bright sector [0, θ) clockwise from 12 o'clock; quadrants wholly past the edge
|
||||
// are full-dark rects, the active one carries the exact wedge.
|
||||
let theta = fraction.clamp(0.0, 1.0) * std::f32::consts::TAU;
|
||||
let active = ((theta / std::f32::consts::FRAC_PI_2) as usize).min(3);
|
||||
// Clockwise from 12 in y-down screen space: top-right, bottom-right, bottom-left, top-left.
|
||||
let quadrant = |k: usize| match k {
|
||||
0 => Rect::new(c.x, rect.min.y, rect.max.x, c.y),
|
||||
1 => Rect::new(c.x, c.y, rect.max.x, rect.max.y),
|
||||
2 => Rect::new(rect.min.x, c.y, c.x, rect.max.y),
|
||||
_ => Rect::new(rect.min.x, rect.min.y, c.x, c.y),
|
||||
};
|
||||
for k in (active + 1)..4 {
|
||||
out.push(UiQuad {
|
||||
rect: quadrant(k),
|
||||
z_key,
|
||||
color: dark,
|
||||
clip,
|
||||
..default()
|
||||
});
|
||||
}
|
||||
|
||||
// The active quadrant's wedge. The sweep ray from c at angle θ has direction
|
||||
// d(θ) = (sin θ, −cos θ) (y-down); per quadrant it can exit through two edges — the "first"
|
||||
// one (adjacent to the sweep's entry axis, whose crossing keeps the quadrant's outer corner
|
||||
// dark) or the "second" (past the corner). Vertices fan from c, clockwise:
|
||||
// c → E (ray exit) → [K (outer corner) if the ray exits the first edge] → M (the
|
||||
// quadrant-end axis point). A three-vertex wedge repeats M (the fan's degenerate second
|
||||
// triangle draws nothing).
|
||||
let d = Vec2::new(theta.sin(), -theta.cos());
|
||||
// Per active quadrant: (first-edge t, second-edge t, K, M). A division by ±0 yields ±inf,
|
||||
// which the min-pick handles (the boundary angles resolve to the finite edge).
|
||||
let (t_first, t_second, k_corner, m_point) = match active {
|
||||
0 => (
|
||||
(rect.min.y - c.y) / d.y,
|
||||
(rect.max.x - c.x) / d.x,
|
||||
Vec2::new(rect.max.x, rect.min.y),
|
||||
Vec2::new(rect.max.x, c.y),
|
||||
),
|
||||
1 => (
|
||||
(rect.max.x - c.x) / d.x,
|
||||
(rect.max.y - c.y) / d.y,
|
||||
Vec2::new(rect.max.x, rect.max.y),
|
||||
Vec2::new(c.x, rect.max.y),
|
||||
),
|
||||
2 => (
|
||||
(rect.max.y - c.y) / d.y,
|
||||
(rect.min.x - c.x) / d.x,
|
||||
Vec2::new(rect.min.x, rect.max.y),
|
||||
Vec2::new(rect.min.x, c.y),
|
||||
),
|
||||
_ => (
|
||||
(rect.min.x - c.x) / d.x,
|
||||
(rect.min.y - c.y) / d.y,
|
||||
Vec2::new(rect.min.x, rect.min.y),
|
||||
Vec2::new(c.x, rect.min.y),
|
||||
),
|
||||
};
|
||||
let e_point = c + d * t_first.min(t_second);
|
||||
let corners = if t_first <= t_second {
|
||||
[c, e_point, k_corner, m_point]
|
||||
} else {
|
||||
[c, e_point, m_point, m_point]
|
||||
};
|
||||
out.push(UiQuad {
|
||||
rect: quadrant(active),
|
||||
z_key,
|
||||
color: dark,
|
||||
corners: Some(corners),
|
||||
..default()
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cooldown_quad_tests {
|
||||
use super::{cooldown_quads, star_scale, STAR_SCALE_KEYS};
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Emit the sweep for `fraction` on a unit-friendly 40×40 button at (0,0)..(40,40).
|
||||
fn sweep(fraction: f32) -> Vec<crate::ui_pass::UiQuad> {
|
||||
let mut out = Vec::new();
|
||||
cooldown_quads(
|
||||
Rect::new(0.0, 0.0, 40.0, 40.0),
|
||||
7,
|
||||
1.0,
|
||||
fraction,
|
||||
None,
|
||||
None,
|
||||
&mut None,
|
||||
&mut Assets::<Image>::default(),
|
||||
&mut out,
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// Total dark coverage of the emitted quads (rect quads + the corner wedge's shoelace area).
|
||||
fn dark_area(quads: &[crate::ui_pass::UiQuad]) -> f32 {
|
||||
quads
|
||||
.iter()
|
||||
.map(|q| match q.corners {
|
||||
None => q.rect.width() * q.rect.height(),
|
||||
Some(c) => {
|
||||
// Shoelace over the fan's two triangles (0,1,2) + (0,2,3).
|
||||
let tri = |a: Vec2, b: Vec2, d: Vec2| ((b - a).perp_dot(d - a) / 2.0).abs();
|
||||
tri(c[0], c[1], c[2]) + tri(c[0], c[2], c[3])
|
||||
}
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// The square-cornered pie's exact per-phase geometry (a square pie's area is not linear in
|
||||
/// the swept angle, so the checkpoints below are the analytically known shapes: full, the
|
||||
/// 45° corner ray, the 180° half, the end sliver).
|
||||
#[test]
|
||||
fn cooldown_sweep_geometry_is_the_exact_square_pie() {
|
||||
// Fraction 0: everything dark — 3 full quadrant rects + a wedge covering the fourth.
|
||||
let q0 = sweep(0.0);
|
||||
assert_eq!(q0.len(), 4);
|
||||
assert!(
|
||||
(dark_area(&q0) - 1600.0).abs() < 1e-2,
|
||||
"fully dark at start"
|
||||
);
|
||||
|
||||
// Fraction 1/8 (45° — the ray through quadrant 0's outer corner): the bright half of
|
||||
// quadrant 0 is gone; the wedge is the triangle {center, corner, right-edge midpoint}.
|
||||
let q = sweep(0.125);
|
||||
assert_eq!(q.len(), 4);
|
||||
assert!(
|
||||
(dark_area(&q) - (1600.0 - 200.0)).abs() < 1.0,
|
||||
"45°: half of one 400px² quadrant is bright, got {}",
|
||||
dark_area(&q)
|
||||
);
|
||||
|
||||
// Fraction 0.5 (180°): the right half is bright — two full-dark quadrants + a wedge
|
||||
// that spans exactly quadrant 2 (t ties pick the corner-inclusive fan; area 400).
|
||||
let q = sweep(0.5);
|
||||
assert!((dark_area(&q) - 800.0).abs() < 1.0, "half bright at 180°");
|
||||
|
||||
// Fraction ~1: a sliver — near-zero dark area, no full quadrants left.
|
||||
let q = sweep(0.999);
|
||||
assert!(dark_area(&q) < 20.0, "almost done — a sliver remains");
|
||||
assert_eq!(q.len(), 1, "only the active quadrant's wedge remains");
|
||||
|
||||
// The wedge always fans from the button center.
|
||||
for f in [0.05, 0.3, 0.6, 0.9] {
|
||||
let q = sweep(f);
|
||||
let wedge = q
|
||||
.iter()
|
||||
.find_map(|u| u.corners)
|
||||
.expect("a wedge each phase");
|
||||
assert_eq!(wedge[0], Vec2::new(20.0, 20.0), "fan apex at the center");
|
||||
}
|
||||
}
|
||||
|
||||
/// The star pulse samples its byte-read keys exactly and lerps between them — the ⅓ peak
|
||||
/// coincides with the alpha ramp's own peak.
|
||||
#[test]
|
||||
fn star_scale_follows_the_five_key_curve() {
|
||||
for (t, v) in STAR_SCALE_KEYS {
|
||||
assert!((star_scale(t) - v).abs() < 1e-4, "key at {t}");
|
||||
}
|
||||
// Midway up the rise: halfway between 1.0 and 1.853.
|
||||
let mid = star_scale(0.333 / 2.0);
|
||||
assert!((mid - (1.0 + 1.853) / 2.0).abs() < 1e-2, "got {mid}");
|
||||
// Clamped outside the band.
|
||||
assert!((star_scale(-1.0) - 1.0).abs() < 1e-4);
|
||||
assert!((star_scale(2.0) - 1.155).abs() < 1e-4);
|
||||
}
|
||||
|
||||
/// The flash phase draws no dark pie — only the additive star (skipped here: no assets in a
|
||||
/// unit test — the pie's absence is the machine-checkable half).
|
||||
#[test]
|
||||
fn cooldown_flash_phase_emits_no_dark_pie() {
|
||||
let mut out = Vec::new();
|
||||
cooldown_quads(
|
||||
Rect::new(0.0, 0.0, 40.0, 40.0),
|
||||
7,
|
||||
1.0,
|
||||
1.2,
|
||||
Some(0.4),
|
||||
None,
|
||||
&mut None,
|
||||
&mut Assets::<Image>::default(),
|
||||
&mut out,
|
||||
);
|
||||
assert!(out.is_empty(), "no assets → no star; and never a dark quad");
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,7 @@ use crate::ui_text::UiFontAtlas;
|
||||
use benilla_assets::WorldAssets;
|
||||
|
||||
mod colorselect;
|
||||
mod cooldown;
|
||||
mod text;
|
||||
use cooldown::cooldown_quads;
|
||||
|
||||
/// `WOW_UI_COST=1` — the untraced per-frame cost meter for this system's phases (the premise
|
||||
/// instrument for the UI epoch-gate lane, 0730's warm slice): one `[ui-cost]` line per frame with
|
||||
@@ -94,7 +92,6 @@ fn report_ui_pick(eq: &benilla_ui::script::ExtractedQuad, rect: Rect, at: Vec2,
|
||||
let what = match &eq.content {
|
||||
QuadContent::Frame => "frame-slot".to_string(),
|
||||
QuadContent::Minimap { .. } => "minimap".to_string(),
|
||||
QuadContent::Cooldown { .. } => "cooldown".to_string(),
|
||||
QuadContent::Texture {
|
||||
path,
|
||||
color,
|
||||
@@ -118,7 +115,6 @@ fn content_kind(c: &QuadContent) -> &'static str {
|
||||
match c {
|
||||
QuadContent::Frame => "frame",
|
||||
QuadContent::Minimap { .. } => "minimap",
|
||||
QuadContent::Cooldown { .. } => "cooldown",
|
||||
QuadContent::ModelPane { .. } => "modelpane",
|
||||
QuadContent::Texture { .. } => "texture",
|
||||
QuadContent::ColorWheel => "colorwheel",
|
||||
@@ -354,7 +350,6 @@ fn span_bounds(spans: &[u32], i: usize) -> (usize, usize) {
|
||||
fn splice_simple(eq: &benilla_ui::script::ExtractedQuad) -> bool {
|
||||
match &eq.content {
|
||||
QuadContent::Frame
|
||||
| QuadContent::Cooldown { .. }
|
||||
| QuadContent::Backdrop { .. }
|
||||
// A model pane writes at most one quad and one idempotent tile request (decision 2008)
|
||||
// — and the map's arrow changes facing on every turn while the map is open.
|
||||
@@ -1310,14 +1305,6 @@ fn convert_entry(
|
||||
alpha: eq.alpha,
|
||||
});
|
||||
}
|
||||
// The Cooldown widget's pie wipe + finish flash (decision 0137 phase 4) — the
|
||||
// byte-pinned look of `UI-Cooldown-Indicator.m2`, rebuilt natively (see
|
||||
// [`cooldown_quads`]).
|
||||
QuadContent::Cooldown { fraction, flash } => {
|
||||
cooldown_quads(
|
||||
rect, eq.z, eq.alpha, fraction, flash, clip, assets, images, out,
|
||||
);
|
||||
}
|
||||
// A `<Model>`/`<PlayerModel>` pane's content: the off-screen body bake its window keeps,
|
||||
// sampled square edge to edge. The pane→booth join is
|
||||
// [`crate::portrait::model_pane_booth`], whose doc says why it is a table of frame names.
|
||||
@@ -1328,8 +1315,9 @@ fn convert_entry(
|
||||
// own file, which declares a bare `<PlayerModel>` and no Texture at all — so the widget
|
||||
// itself has to draw, or the character sheet's paper doll is an empty rectangle.
|
||||
//
|
||||
// A pane with no name, or one no window claims, draws nothing (`SetModel` panes included:
|
||||
// this engine holds their scene and renders no M2 into it). Nothing is stubbed white.
|
||||
// A FILE pane is a tile (2013; the map arrow among them since 2015); a UNIT pane joins
|
||||
// the booth its window keeps. A pane with no name, or one no window claims, draws
|
||||
// nothing. Nothing is stubbed white.
|
||||
QuadContent::ModelPane {
|
||||
handle,
|
||||
name,
|
||||
@@ -1341,44 +1329,6 @@ fn convert_entry(
|
||||
icon,
|
||||
} => {
|
||||
use crate::portrait::PortraitSource;
|
||||
// The world-map arrow (decision 1980): the stock `WorldMapFrame.lua` /
|
||||
// `Blizzard_BattlefieldMinimap.lua` create an anonymous `Model` child, `SetModel` it to
|
||||
// the minimap arrow, and steer it with `SetFacing`/`SetPosition` every update. benilla
|
||||
// draws that one file as the minimap's own arrow sprite — the same art the minimap
|
||||
// ring draws (`crate::minimap::blips`), turned the same way (`-facing`: the M2's yaw
|
||||
// is CCW-positive, the screen's is CW), filling the pane the engine sized to the
|
||||
// reference's 33.6 px footprint times the arrow's `SetModelScale`. No 3D pane needs
|
||||
// to exist for a two-triangle arrow.
|
||||
if model.as_deref() == Some(benilla_ui::script::ARROW_MODEL) {
|
||||
use crate::minimap::blips::{
|
||||
PLAYER_ARROW_OFFSET_PX, PLAYER_ARROW_QUAD_PX, PLAYER_ARROW_TEXTURE,
|
||||
};
|
||||
let Some(handle) = assets
|
||||
.as_mut()
|
||||
.and_then(|a| a.sprite_texture(PLAYER_ARROW_TEXTURE, images))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// The M2's single quad sits off its origin by an authored offset that turns with
|
||||
// the facing — the minimap's arrow applies the same one at its 33.6 px basis, and
|
||||
// the pane the engine sized (`worldmap_arrow.rs`: the footprint times the
|
||||
// `SetModelScale`) scales it here. `model_scale` is already in the rect.
|
||||
let rotation = -facing;
|
||||
let (sin, cos) = rotation.sin_cos();
|
||||
let off = PLAYER_ARROW_OFFSET_PX * (rect.width() / PLAYER_ARROW_QUAD_PX);
|
||||
let off = Vec2::new(off.x * cos - off.y * sin, off.x * sin + off.y * cos);
|
||||
let rect = Rect::from_center_size(rect.center() + off, rect.size());
|
||||
out.push(UiQuad {
|
||||
rect,
|
||||
z_key: eq.z,
|
||||
texture: Some(handle),
|
||||
color: [1.0, 1.0, 1.0, eq.alpha],
|
||||
rotation,
|
||||
clip,
|
||||
..default()
|
||||
});
|
||||
return;
|
||||
}
|
||||
// A FILE pane (decision 2008): publish what the tile renderer needs — the pane's
|
||||
// device-pixel size and the render law's unit ladder off it — and draw the tile's
|
||||
// atlas cell when the renderer has one. The request is idempotent, so the memoized
|
||||
|
||||
@@ -329,11 +329,11 @@ fn dragging_the_bar_steps_the_offset_by_rows_and_repaints() {
|
||||
/// on `FauxScrollFrameTemplate` whose `<OnVerticalScroll>` calls `FauxScrollFrame_OnVerticalScroll`,
|
||||
/// driven by a drag of the shared bar.
|
||||
///
|
||||
/// The `SetScrollChild` line is the gap, made visible: the reference's template declares that child
|
||||
/// as `<ScrollChild><Frame name="$parentScrollChildFrame">`, our XML loader has no `<ScrollChild>`
|
||||
/// element, and `SetVerticalScroll` clamps into `[0, GetVerticalScrollRange()]` — which is computed
|
||||
/// from that child. Without it the range is 0, the handler fires with `arg1 = 0` and an addon's list
|
||||
/// never leaves the top. With it, the whole reference path runs.
|
||||
/// The `SetScrollChild` line predates 1205 (the loader's `<ScrollChild>`) and stays as the
|
||||
/// addon-shaped way of seating a child by hand; the range it asserts is that child's overflow
|
||||
/// (1338). The offset itself never depended on the range — the engine stores what the bar hands it
|
||||
/// (decision 2017) — so what this drives is bar value → `SetVerticalScroll` → `<OnVerticalScroll>`
|
||||
/// → `FauxScrollFrame_OnVerticalScroll`'s `floor(v / step + 0.5)`, the reference's path end to end.
|
||||
#[test]
|
||||
fn the_reference_on_vertical_scroll_path_runs_once_a_scroll_child_exists() {
|
||||
let mut s = harness();
|
||||
|
||||
@@ -101,7 +101,6 @@ const FRAME_TAGS: &[&str] = &[
|
||||
"Minimap",
|
||||
"MovieFrame",
|
||||
"WorldFrame",
|
||||
"Cooldown",
|
||||
];
|
||||
|
||||
/// The five `<Scripts>` handler names that auto-enable the MOUSE kind, and only those five
|
||||
|
||||
@@ -672,3 +672,70 @@ fn selecting_a_row_reads_back_in_the_same_tick() {
|
||||
.take_social_requests()
|
||||
.contains(&SocialRequest::SelectFriend(2)));
|
||||
}
|
||||
|
||||
/// **B363 — the who list reaches its last rows.** Liho's `/who` found 49, showed 17, and the knob
|
||||
/// travelled while the rows stayed. On the stock window the mechanism is a one-row loss:
|
||||
/// `WhoListScrollFrame` is 287 tall (stock `FriendsFrame.xml` l.1661) against seventeen rows of
|
||||
/// sixteen, so the child's overflow past the frame, `n × 16 − 287`, sits fifteen pixels under the
|
||||
/// bar's `(n − 17) × 16`, and an engine that clamped `SetVerticalScroll` into that overflow
|
||||
/// stopped the row offset at `n − 18`. The reference stores the bar's value as given (decision
|
||||
/// 2017). Drives the bar to its end and reads the seventeenth row: the forty-ninth name.
|
||||
#[test]
|
||||
fn the_who_list_reaches_its_last_row_at_the_bars_end() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = setup();
|
||||
s.run("ShowWhoPanel()").unwrap();
|
||||
let who: Vec<WhoInfo> = (1..=49)
|
||||
.map(|i| WhoInfo {
|
||||
name: format!("Who{i:02}"),
|
||||
guild: String::new(),
|
||||
level: 60,
|
||||
race: "Human".to_string(),
|
||||
class: "Warrior".to_string(),
|
||||
zone: "Elwynn Forest".to_string(),
|
||||
})
|
||||
.collect();
|
||||
push(
|
||||
&mut s,
|
||||
SocialState {
|
||||
who,
|
||||
who_total: 49,
|
||||
..Default::default()
|
||||
},
|
||||
"WHO_LIST_UPDATE",
|
||||
);
|
||||
s.resolve();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
|
||||
// The reference's own numbers, and the control: the overflow is shorter than the bar.
|
||||
let (_, bar_max) = s
|
||||
.eval::<(f64, f64)>("return WhoListScrollFrameScrollBar:GetMinMaxValues()")
|
||||
.unwrap();
|
||||
assert_eq!(bar_max, 512.0, "(49 − 17) × 16");
|
||||
let overflow = s
|
||||
.eval::<f64>("return WhoListScrollFrame:GetVerticalScrollRange()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
overflow, 497.0,
|
||||
"49 × 16 − 287: the frame is taller than its rows"
|
||||
);
|
||||
|
||||
s.run("WhoListScrollFrameScrollBar:SetValue(512)").unwrap();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return FauxScrollFrame_GetOffset(WhoListScrollFrame)")
|
||||
.unwrap(),
|
||||
32
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return WhoFrameButton1Name:GetText()")
|
||||
.unwrap(),
|
||||
"Who33"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return WhoFrameButton17Name:GetText()")
|
||||
.unwrap(),
|
||||
"Who49",
|
||||
"the last hit is on the last row"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -582,7 +582,7 @@ fn the_world_map_cannot_open_behind_the_menu_and_gives_its_slot_back() {
|
||||
r"Interface\FrameXML\UIPanelTemplates.xml",
|
||||
// The stock map's OnShow/OnHide call `UpdateMicroButtons` unguarded (1980), which the
|
||||
// micro menu defines over the action bar it sits in — the spellbook kit's cluster.
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
@@ -643,7 +643,7 @@ fn nothing_opens_behind_the_world_map_and_escape_closes_it_first() {
|
||||
r"Interface\FrameXML\UIPanelTemplates.xml",
|
||||
// The action-bar cluster the micro menu sits in — the stock map's OnShow/OnHide call
|
||||
// `UpdateMicroButtons` unguarded (1980).
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
@@ -704,7 +704,7 @@ fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
|
||||
// BagFrame.xml precisely because most harnesses load no bar).
|
||||
let mut s = bag_harness_with(
|
||||
&[
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
|
||||
@@ -83,7 +83,7 @@ fn setup() -> UiScript {
|
||||
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml"); // PASS/NEED/GREED + item hovers
|
||||
load_xml(&s, "Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
|
||||
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
|
||||
|
||||
@@ -39,7 +39,7 @@ fn setup() -> UiScript {
|
||||
// The stock file's `HelpFrame_OnShow` calls `UpdateMicroButtons()` before
|
||||
// `GetGMStatus()`, so without the micro row the OnShow raises and the status ask never
|
||||
// happens. Ours never called it. The row needs the bar's button kit under it.
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
|
||||
@@ -32,7 +32,7 @@ fn harness() -> UiScript {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
for file in [
|
||||
"Interface\\FrameXML\\Fonts.xml",
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
|
||||
@@ -23,7 +23,7 @@ fn load_action_bar(s: &UiScript) {
|
||||
r"Interface\FrameXML\MoneyFrame.lua",
|
||||
r"Interface\FrameXML\MoneyFrame.xml",
|
||||
"Interface\\FrameXML\\GameTooltip.xml",
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
@@ -276,7 +276,7 @@ fn shipped_stance_bar_drives_end_to_end() {
|
||||
// bottom multibar is UP to raise the flag. Since 1500 that is a player option rather than a
|
||||
// given, so this test raises it; `the_stance_bar_sits_where_the_pass_puts_it` below owns the
|
||||
// other state.
|
||||
load_xml(&s, "Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
|
||||
@@ -465,7 +465,7 @@ fn multibar_hover_renders_the_buttons_own_action() {
|
||||
r"Interface\FrameXML\MoneyFrame.lua",
|
||||
r"Interface\FrameXML\MoneyFrame.xml",
|
||||
"Interface\\FrameXML\\GameTooltip.xml",
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
@@ -579,7 +579,7 @@ fn multibar_hover_renders_the_buttons_own_action() {
|
||||
fn the_vertical_multibars_exist_hidden_on_the_reference_pages() {
|
||||
let s = UiScript::new().unwrap();
|
||||
load_action_bar(&s);
|
||||
load_xml(&s, "Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
|
||||
@@ -641,7 +641,7 @@ fn every_extra_bar_stays_down_until_its_own_toggle_is_set() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_action_bar(&s);
|
||||
load_xml(&s, "Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
|
||||
@@ -728,7 +728,7 @@ fn raising_a_bottom_bar_moves_the_managed_bottom_stack() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_action_bar(&s);
|
||||
load_xml(&s, "Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
|
||||
@@ -792,7 +792,7 @@ fn viewable_action_bar_pages_follow_the_bar_toggles() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_action_bar(&s);
|
||||
load_xml(&s, "Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
|
||||
@@ -858,7 +858,7 @@ fn the_grid_option_holds_the_extra_bars_empty_wells_open() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_action_bar(&s);
|
||||
load_xml(&s, "Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
|
||||
@@ -961,7 +961,7 @@ fn a_held_payload_ghosts_the_empty_wells_it_opens() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_action_bar(&s);
|
||||
load_xml(&s, "Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
|
||||
@@ -1104,7 +1104,7 @@ fn a_bar_toggle_sends_the_byte_its_globals_pack_to() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_action_bar(&s);
|
||||
load_xml(&s, "Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
|
||||
@@ -1191,7 +1191,7 @@ fn the_shipped_setter_passes_exactly_four_arguments() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_action_bar(&s);
|
||||
load_xml(&s, "Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
|
||||
@@ -1243,7 +1243,7 @@ fn the_stance_bar_sits_where_the_pass_puts_it() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_action_bar(&s);
|
||||
load_xml(&s, "Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
|
||||
@@ -1257,7 +1257,7 @@ fn the_stance_bar_sits_where_the_pass_puts_it() {
|
||||
load_xml(&s, "KeyBindingsPage.xml");
|
||||
load_xml(&s, "OptionsFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
|
||||
load_xml(&s, "Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
s.set_shapeshift_forms(vec![ShapeshiftFormView {
|
||||
spell_id: 2457,
|
||||
@@ -1315,7 +1315,7 @@ fn the_stance_shelf_follows_the_bottom_left_bar() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_action_bar(&s);
|
||||
load_xml(&s, "Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
|
||||
@@ -1329,7 +1329,7 @@ fn the_stance_shelf_follows_the_bottom_left_bar() {
|
||||
load_xml(&s, "KeyBindingsPage.xml");
|
||||
load_xml(&s, "OptionsFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
|
||||
load_xml(&s, "Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
|
||||
let form = |id: u32| ShapeshiftFormView {
|
||||
@@ -1436,7 +1436,7 @@ fn the_stance_shelf_is_as_long_as_the_form_count() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_action_bar(&s);
|
||||
load_xml(&s, "Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
|
||||
@@ -1450,7 +1450,7 @@ fn the_stance_shelf_is_as_long_as_the_form_count() {
|
||||
load_xml(&s, "KeyBindingsPage.xml");
|
||||
load_xml(&s, "OptionsFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
|
||||
load_xml(&s, "Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
show_bars(&s, &[]); // unraised: the shelf art is the state under test
|
||||
|
||||
@@ -1553,7 +1553,7 @@ fn an_extra_bars_empty_well_keeps_its_bound_hotkey_label() {
|
||||
// the player binds it, as the reporter had).
|
||||
s.register_bindings(&crate::bindings::registry_commands());
|
||||
load_action_bar(&s);
|
||||
load_xml(&s, "Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
|
||||
|
||||
@@ -775,7 +775,7 @@ fn interface_harness() -> UiScript {
|
||||
// VARIABLES_LOADED fires the slider exists. A test that fires the event has to have
|
||||
// loaded it too.
|
||||
"Interface\\FrameXML\\ColorPickerFrame.xml",
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\MainMenuBar.xml",
|
||||
"Interface\\FrameXML\\ActionBarFrame.xml",
|
||||
@@ -850,7 +850,7 @@ fn actionbars_harness() -> UiScript {
|
||||
&[
|
||||
"Interface\\FrameXML\\Fonts.xml",
|
||||
r"Interface\FrameXML\UIParent.xml",
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
|
||||
@@ -850,7 +850,7 @@ fn gossip_bank_option_hands_the_left_slot_to_the_bank() {
|
||||
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
|
||||
load_xml(&s, "Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
|
||||
|
||||
@@ -15,7 +15,7 @@ fn harness(extra: &[&str]) -> 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, "Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
|
||||
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
|
||||
|
||||
@@ -19,7 +19,7 @@ pub(super) fn load_pet_bar(s: &UiScript) {
|
||||
for file in [
|
||||
"Interface\\FrameXML\\Fonts.xml",
|
||||
r"Interface\FrameXML\UIParent.xml",
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
|
||||
@@ -1225,7 +1225,7 @@ fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not
|
||||
s.resolve();
|
||||
answer_measures(&mut s);
|
||||
s.resolve(); // the measured span reaches the scroll range, and the bar, on this pass
|
||||
s.resolve(); // GetVerticalScrollRange (SetVerticalScroll's clamp) reads resolved rects.
|
||||
s.resolve(); // GetVerticalScrollRange (the bar's [min, max]) reads resolved rects.
|
||||
|
||||
// Scroll THROUGH the bar, as the wheel and the thumb do (ScrollFrameTemplate_OnMouseWheel sets
|
||||
// the bar; its OnValueChanged moves the frame): a reselect's `ScrollBar:SetValue(0)` is what
|
||||
|
||||
@@ -760,7 +760,7 @@ mod tests {
|
||||
"ScrollFrame",
|
||||
"Slider", "ColorSelect", "MessageFrame", "ScrollingMessageFrame",
|
||||
"SimpleHTML", "Model", "PlayerModel", "DressUpModel", "TabardModel",
|
||||
"Minimap", "MovieFrame", "Cooldown",
|
||||
"Minimap", "MovieFrame",
|
||||
}}
|
||||
for i = 1, table.getn(types) do
|
||||
local ok, w = pcall(function()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! The shipped **Skills tab** (`assets/ui/SkillFrame.xml`) driven end-to-end, engine-only (no
|
||||
//! Bevy) — the per-window test module the spellbook/trainer/bank files already establish, split
|
||||
//! The stock **Skills tab** (`Interface\FrameXML\SkillFrame.xml`, off the player's chain since
|
||||
//! 1956) driven end-to-end, engine-only (no Bevy) — the per-window test module the spellbook/trainer/bank files already establish, split
|
||||
//! out of `character_tests.rs` (which owns the paperdoll + the tab round-trip) so the skills-pane
|
||||
//! paint law has a home of its own.
|
||||
//!
|
||||
@@ -12,10 +12,9 @@
|
||||
//! hunter's `Beast Mastery` on vmangos arrives as `300/300` and must still read gray and
|
||||
//! numberless, exactly as it does in the real client.
|
||||
//!
|
||||
//! **The window around the page is the reference's own since 1751** — `CharacterFrame.xml` and
|
||||
//! `PaperDollFrame.xml` off the player's chain — so every test that opens the page loads
|
||||
//! [`super::test_ui::CHARACTER_UI`] and opens with `wow_data_or_skip!()`. The page itself
|
||||
//! (`assets/ui/SkillFrame.xml`) is still ours.
|
||||
//! **The whole window is the reference's own** — `CharacterFrame.xml` and `PaperDollFrame.xml`
|
||||
//! since 1751, the page itself since 1956 — so every test that opens the page loads
|
||||
//! [`super::test_ui::CHARACTER_UI`] and opens with `wow_data_or_skip!()`.
|
||||
|
||||
use benilla_ui::script::{QuadContent, SkillEntry, SkillsState, UiScript, UnitState};
|
||||
|
||||
@@ -506,3 +505,137 @@ fn the_expand_tab_fits_its_label_at_load() {
|
||||
);
|
||||
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **B370 — the list reaches its last rows.** MarcusAga's Skills tab stopped three rows short of
|
||||
/// the end: the knob mid-track, Maces the last row shown. The reference's own
|
||||
/// `FauxScrollFrame_Update` sizes the bar to `(n − 12) × 15` and the scroll child to `n × 15`, and
|
||||
/// `SkillListScrollFrame` is 220 tall (stock `SkillFrame.xml` l.468) against twelve rows of
|
||||
/// fifteen — so the child's overflow past the frame, `n × 15 − 220`, is forty pixels short of
|
||||
/// where the bar goes. An engine that clamped `SetVerticalScroll` into that overflow stopped the
|
||||
/// row offset at `n − 15`; the reference stores the bar's value as given (decision 2017).
|
||||
///
|
||||
/// Drives the bar to its end and reads the twelfth row: the block's last line. The control is the
|
||||
/// pre-fix mechanism itself — the overflow really is shorter than the bar's range.
|
||||
#[test]
|
||||
fn the_list_reaches_its_last_row_at_the_bars_end() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
for f in super::test_ui::CHARACTER_UI {
|
||||
super::test_ui::load_ui_strict(&s, f);
|
||||
}
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
level: 60,
|
||||
race: Some("Human".into()),
|
||||
race_file: Some("Human".into()),
|
||||
class: Some("Warrior".into()),
|
||||
class_file: Some("WARRIOR".into()),
|
||||
..UnitState::default()
|
||||
}),
|
||||
);
|
||||
// A warrior's kind of block — twenty-two lines under three headers, twenty-five rows: more than
|
||||
// twelve by more than the frame's slack, so the tail is only reachable past the overflow.
|
||||
let mut entries = Vec::new();
|
||||
for (i, name) in [
|
||||
"Axes",
|
||||
"Bows",
|
||||
"Crossbows",
|
||||
"Daggers",
|
||||
"Defense",
|
||||
"Guns",
|
||||
"Maces",
|
||||
"Polearms",
|
||||
"Staves",
|
||||
"Swords",
|
||||
"Thrown",
|
||||
"Two-Handed Axes",
|
||||
"Two-Handed Maces",
|
||||
"Two-Handed Swords",
|
||||
"Unarmed",
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
entries.push(skill(
|
||||
100 + i as u32,
|
||||
name,
|
||||
300,
|
||||
300,
|
||||
false,
|
||||
(6, "Weapon Skills", 5),
|
||||
));
|
||||
}
|
||||
for (i, name) in ["Cloth", "Leather", "Mail", "Plate", "Shield"]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
entries.push(skill(
|
||||
400 + i as u32,
|
||||
name,
|
||||
1,
|
||||
1,
|
||||
false,
|
||||
(8, "Armor Proficiencies", 6),
|
||||
));
|
||||
}
|
||||
for (i, name) in ["Language: Common", "Language: Dwarven"].iter().enumerate() {
|
||||
entries.push(skill(
|
||||
500 + i as u32,
|
||||
name,
|
||||
300,
|
||||
300,
|
||||
false,
|
||||
(10, "Languages", 8),
|
||||
));
|
||||
}
|
||||
s.set_skills(SkillsState { entries });
|
||||
s.run(r#"ToggleCharacter("SkillFrame")"#).unwrap();
|
||||
s.resolve();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
|
||||
let n = s.eval::<i64>("return GetNumSkillLines()").unwrap();
|
||||
assert_eq!(n, 25, "22 lines under 3 headers");
|
||||
let (last_name, last_is_header) = s
|
||||
.eval::<(String, Option<i64>)>(&format!("local n, h = GetSkillLineInfo({n}) return n, h"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
last_is_header.is_none(),
|
||||
"the tail row is a line, read off SkillRankFrame12"
|
||||
);
|
||||
|
||||
// The reference's own numbers: the bar runs to (n − 12) × 15, the child's overflow past the
|
||||
// 220-tall frame is 40 px less — the control that the reported mechanism is the real one.
|
||||
let (_, bar_max) = s
|
||||
.eval::<(f64, f64)>("return SkillListScrollFrameScrollBar:GetMinMaxValues()")
|
||||
.unwrap();
|
||||
assert_eq!(bar_max, f64::from((n as i32 - 12) * 15));
|
||||
let overflow = s
|
||||
.eval::<f64>("return SkillListScrollFrame:GetVerticalScrollRange()")
|
||||
.unwrap();
|
||||
assert_eq!(overflow, f64::from(n as i32 * 15 - 220));
|
||||
assert!(
|
||||
overflow < bar_max,
|
||||
"the frame is taller than its twelve rows"
|
||||
);
|
||||
|
||||
// The knob dragged to the end: bar → SetVerticalScroll → <OnVerticalScroll> →
|
||||
// FauxScrollFrame_OnVerticalScroll → SkillFrame_UpdateSkills.
|
||||
s.run("SkillListScrollFrameScrollBar:SetValue(select(2, SkillListScrollFrameScrollBar:GetMinMaxValues()))")
|
||||
.unwrap();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return FauxScrollFrame_GetOffset(SkillListScrollFrame)")
|
||||
.unwrap(),
|
||||
n - 12,
|
||||
"the row offset reaches the bar's end, not the overflow's"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return SkillRankFrame12SkillName:GetText()")
|
||||
.unwrap(),
|
||||
last_name,
|
||||
"the twelfth row is the block's last line"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ pub(super) fn spellbook_ui(w: f32, h: f32) -> UiScript {
|
||||
for f in [
|
||||
"Interface\\FrameXML\\Fonts.xml",
|
||||
r"Interface\FrameXML\UIParent.xml",
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
@@ -268,23 +268,19 @@ fn shipped_spellbook_shows_the_cooldown_pie() {
|
||||
b.slots[0].cooldown = Some((6_000, 10_000, true));
|
||||
s.set_spellbook(b);
|
||||
s.fire_event("SPELL_UPDATE_COOLDOWN", vec![]);
|
||||
// The stock machine (decision 2019): sequence 0 armed by `CooldownFrame_SetTimer`, scrubbed
|
||||
// by the next paint's `OnUpdateModel`.
|
||||
super::test_ui::cooldown_facts(&mut s);
|
||||
s.tick(0.0);
|
||||
s.resolve();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
let sweep = s.extract().into_iter().find_map(|q| {
|
||||
if s.quad_owner_name(q.target).as_deref() != Some("SpellButton1Cooldown") {
|
||||
return None;
|
||||
}
|
||||
match q.content {
|
||||
QuadContent::Cooldown { fraction, flash } => Some((fraction, flash)),
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
let (fraction, flash) = sweep.expect("SpellButton1's Cooldown widget is showing");
|
||||
assert!(
|
||||
(fraction - 0.4).abs() < 1e-3,
|
||||
"4 s elapsed of 10 ⇒ the sweep sits at 40%, got {fraction}"
|
||||
let play = super::test_ui::cooldown_play(&s, "SpellButton1Cooldown")
|
||||
.expect("SpellButton1's cooldown pane is showing");
|
||||
assert_eq!(
|
||||
play,
|
||||
(0, 400),
|
||||
"4 s elapsed of 10 ⇒ the sweep sits at 40 %: sequence 0 at 400 ms"
|
||||
);
|
||||
assert_eq!(flash, None);
|
||||
|
||||
// An on-hold triple: no sweep (CooldownFrame_SetTimer's enable gate), the icon dims to 40%.
|
||||
let mut b = book();
|
||||
@@ -706,7 +702,7 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
|
||||
"Interface\\FrameXML\\StaticPopup.xml",
|
||||
// `ShowMacroFrame` lives here since 1848.
|
||||
"Interface\\FrameXML\\GameTooltip.xml",
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\Cooldown.xml",
|
||||
// **ScrollTemplates BEFORE UIPanelTemplates, which is the manifest's own order.** Ours
|
||||
// still carries dead `FauxScrollFrame_*` copies that the chain overrides by loading after
|
||||
// (1846's step 3, deliberately not done); load them the other way round and OUR copies win
|
||||
|
||||
@@ -24,7 +24,7 @@ fn harness() -> UiScript {
|
||||
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\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");
|
||||
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
|
||||
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
//! catch a mistake in a file somebody just edited, and `content::read`'s dev-build probe already
|
||||
//! prefers disk for the same reason.
|
||||
|
||||
use benilla_ui::script::UiScript;
|
||||
use benilla_ui::script::{QuadContent, UiScript};
|
||||
|
||||
/// Load one interface file into `s`, panicking on any loader error, and return how many frames it
|
||||
/// materialized (`0` for a `.lua` entry, which materializes none).
|
||||
@@ -31,6 +31,70 @@ pub(crate) fn load_ui(s: &UiScript, entry: &str) -> usize {
|
||||
load_entry(s, entry, false, false)
|
||||
}
|
||||
|
||||
/// The cooldown indicator's file: `Interface\Cooldown\UI-Cooldown-Indicator.mdx`, as the
|
||||
/// stock `CooldownFrameTemplate` names it.
|
||||
pub(super) const COOLDOWN_MODEL: &str = r"Interface\Cooldown\UI-Cooldown-Indicator.mdx";
|
||||
|
||||
/// The cooldown indicator's file facts (`benilla-extract m2seq`): sequence 0 = id 0, 1000 ms,
|
||||
/// clamp — the sweep `CooldownFrame_OnUpdateModel` scrubs; sequence 1 = id 1, 1000 ms, clamp —
|
||||
/// the finish flash, whose completion hides the frame. Handed to the engine the way the app does
|
||||
/// once the asset lands (decisions 2007/2019); every pane holding the file arms its Stand.
|
||||
pub(super) fn cooldown_facts(s: &mut UiScript) {
|
||||
use benilla_ui::widget::{ModelFileFacts, SequenceFacts};
|
||||
let seq = |anim_id, duration_ms| SequenceFacts {
|
||||
anim_id,
|
||||
duration_ms,
|
||||
looping: false,
|
||||
};
|
||||
s.set_model_facts(
|
||||
COOLDOWN_MODEL,
|
||||
ModelFileFacts {
|
||||
sequences: vec![seq(0, 1000), seq(1, 1000)],
|
||||
bbox: ([0.0; 3], [0.0; 3]),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The play head `(anim_id, cursor_ms)` of the SHOWN cooldown pane `owner` names (`None` while
|
||||
/// it is hidden, or has nothing armed) — what the tile renderer samples the file at. Read the way
|
||||
/// the renderer reads it: the pane's quad in the extract joined to the engine's paint list.
|
||||
pub(super) fn cooldown_play(s: &UiScript, owner: &str) -> Option<(u16, u32)> {
|
||||
let heads = s.visible_model_panes();
|
||||
s.extract().into_iter().find_map(|q| match &q.content {
|
||||
QuadContent::ModelPane {
|
||||
handle,
|
||||
model: Some(m),
|
||||
..
|
||||
} if m.eq_ignore_ascii_case(COOLDOWN_MODEL)
|
||||
&& s.quad_owner_name(q.target).as_deref() == Some(owner) =>
|
||||
{
|
||||
heads
|
||||
.iter()
|
||||
.find(|p| p.handle == *handle)
|
||||
.and_then(|p| p.play.map(|ph| (ph.anim_id, ph.cursor_ms)))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// [`cooldown_play`] for whichever cooldown pane is shown — the bag windows number their slots
|
||||
/// from the far end (`ContainerFrame_GenerateFrame`: `Item{j}` carries id `size − j + 1`), so a
|
||||
/// test that seats one item asks for "the" sweep rather than a name.
|
||||
pub(super) fn cooldown_play_any(s: &UiScript) -> Option<(u16, u32)> {
|
||||
let heads = s.visible_model_panes();
|
||||
s.extract().into_iter().find_map(|q| match &q.content {
|
||||
QuadContent::ModelPane {
|
||||
handle,
|
||||
model: Some(m),
|
||||
..
|
||||
} if m.eq_ignore_ascii_case(COOLDOWN_MODEL) => heads
|
||||
.iter()
|
||||
.find(|p| p.handle == *handle)
|
||||
.and_then(|p| p.play.map(|ph| (ph.anim_id, ph.cursor_ms))),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// [`load_ui`], and **a missing template is a failure too**.
|
||||
///
|
||||
/// A frame that inherits a template nothing declares is a loader *warning*, not an error: the frame
|
||||
@@ -404,7 +468,7 @@ pub(super) const CHARACTER_UI: &[&str] = &[
|
||||
r"Interface\FrameXML\MoneyFrame.xml",
|
||||
r"Interface\FrameXML\UIParent.xml", // Model_OnLoad/_Rotate*/_OnUpdate — the model panes' turntable
|
||||
"Interface\\FrameXML\\GameTooltip.xml",
|
||||
"Cooldown.xml", // CooldownFrameTemplate + CooldownFrame_SetTimer, per equipment slot
|
||||
"Interface\\FrameXML\\Cooldown.xml", // CooldownFrameTemplate + CooldownFrame_SetTimer, per equipment slot
|
||||
r"Interface\FrameXML\UIPanelTemplates.lua",
|
||||
r"Interface\FrameXML\UIPanelTemplates.xml",
|
||||
"Interface\\FrameXML\\StaticPopup.xml",
|
||||
@@ -470,7 +534,7 @@ pub(super) const SOCIAL_UI: &[&str] = &[
|
||||
"Interface\\FrameXML\\LocaleProperties.lua",
|
||||
"Interface\\FrameXML\\BasicControls.xml",
|
||||
r"Interface\FrameXML\UIParent.xml",
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
@@ -540,7 +604,7 @@ pub(super) const BAG_UI: &[&str] = &[
|
||||
r"Interface\FrameXML\MoneyFrame.xml",
|
||||
"Interface\\FrameXML\\LocaleProperties.lua",
|
||||
"Interface\\FrameXML\\GameTooltip.xml",
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\Cooldown.xml",
|
||||
// The bag BAR declares `parent="MainMenuBarArtFrame"`, resolved at LOAD — so without this the
|
||||
// six buttons fall back to UIParent and sit at a level no production run ever puts them at.
|
||||
// It also carries `MainMenuBar_UpdateKeyRing`, which is what puts the keyring on the bar.
|
||||
@@ -588,9 +652,8 @@ pub(super) const BAG_UI: &[&str] = &[
|
||||
"Interface\\FrameXML\\UIPanelTemplates.lua",
|
||||
"Interface\\FrameXML\\UIPanelTemplates.xml",
|
||||
"Interface\\FrameXML\\FloatingChatFrame.xml",
|
||||
// Our adapters over the reference's container files — the keyring tooltip wrapper, the three
|
||||
// bag verbs 0561 shadows (`OpenBackpack`/`CloseBackpack`/`CloseAllBags`), and the item-push
|
||||
// card the reference draws with a `<Model>` this engine does not render (0887). It has to be
|
||||
// Our adapters over the reference's container files — the keyring tooltip wrapper and the
|
||||
// three bag verbs 0561 shadows (`OpenBackpack`/`CloseBackpack`/`CloseAllBags`). It has to be
|
||||
// AFTER `ContainerFrame.xml` and after the bar, which is why it is here and not up with
|
||||
// UiPanels.xml.
|
||||
"ContainerFrameAdapters.xml",
|
||||
|
||||
@@ -241,7 +241,7 @@ fn your_own_portrait_explains_the_menu_instead_of_showing_your_health() {
|
||||
#[test]
|
||||
fn action_button_hover_takes_the_default_corner() {
|
||||
let mut s = harness(&[
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
@@ -282,7 +282,7 @@ fn action_button_hover_takes_the_default_corner() {
|
||||
#[test]
|
||||
fn ubertooltips_off_seats_action_bar_plates_beside_the_button() {
|
||||
let mut s = harness(&[
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
@@ -362,7 +362,7 @@ fn ubertooltips_off_seats_action_bar_plates_beside_the_button() {
|
||||
#[test]
|
||||
fn ubertooltips_off_seats_stance_plates_beside_the_button() {
|
||||
let mut s = harness(&[
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
@@ -415,7 +415,7 @@ fn ubertooltips_off_seats_stance_plates_beside_the_button() {
|
||||
#[test]
|
||||
fn buff_hover_hangs_below_left_of_the_button() {
|
||||
let mut s = harness(&[
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
|
||||
@@ -68,7 +68,7 @@ fn load_tot() -> UiScript {
|
||||
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\UnitPopup.xml");
|
||||
load_xml(&s, "Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
|
||||
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
|
||||
|
||||
@@ -49,7 +49,7 @@ fn trainer_script_base() -> UiScript {
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"Interface\\FrameXML\\BasicControls.xml",
|
||||
r"Interface\FrameXML\UIParent.xml",
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
|
||||
@@ -506,7 +506,7 @@ fn load_pet_menu_frames(s: &UiScript) {
|
||||
"Interface\\FrameXML\\PartyFrame.xml",
|
||||
"Interface\\FrameXML\\TargetFrame.xml",
|
||||
"Interface\\FrameXML\\PetFrame.xml",
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\MainMenuBar.xml",
|
||||
"Interface\\FrameXML\\ActionBarFrame.xml",
|
||||
|
||||
@@ -51,6 +51,24 @@ fn harness() -> UiScript {
|
||||
/// The stock per-frame update, driven the way its `OnUpdate` script runs it: `this` is the map
|
||||
/// button and the elapsed time its one argument. Everything on the sheet — the arrow, the party
|
||||
/// and raid blips, the battleground teammates and flags, the corpse — is seated here.
|
||||
/// The map arrow's file facts (`MinimapArrow.m2`: one looping 3.333 s Stand keying no bone; the
|
||||
/// header box `x ∈ [−0.0127, 0.0135]`, `y ∈ [−0.0118, 0.0145]` — render law §2), handed to the
|
||||
/// engine the way the app does once the asset lands (2007/2015).
|
||||
fn arrow_facts(s: &mut UiScript) {
|
||||
use benilla_ui::widget::{ModelFileFacts, SequenceFacts};
|
||||
s.set_model_facts(
|
||||
ARROW_MODEL,
|
||||
ModelFileFacts {
|
||||
sequences: vec![SequenceFacts {
|
||||
anim_id: 0,
|
||||
duration_ms: 3333,
|
||||
looping: true,
|
||||
}],
|
||||
bbox: ([-0.0127, -0.0118, 0.0], [0.0135, 0.0145, 0.0]),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn update(s: &mut UiScript) {
|
||||
s.run("this = WorldMapButton WorldMapButton_OnUpdate(0.1) this = nil")
|
||||
.unwrap();
|
||||
@@ -336,6 +354,7 @@ fn the_player_arrow_is_the_stock_model_pane_seated_and_turned_by_the_update() {
|
||||
);
|
||||
let failures = super::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||||
arrow_facts(&mut s);
|
||||
s.resolve();
|
||||
s.run("ShowUIPanel(WorldMapFrame)").unwrap();
|
||||
s.resolve();
|
||||
@@ -354,9 +373,20 @@ fn the_player_arrow_is_the_stock_model_pane_seated_and_turned_by_the_update() {
|
||||
pane.content
|
||||
);
|
||||
let rect = pane.rect.expect("…with a resolved rect");
|
||||
// The implicit rect (2015): the file's box in layout units — at 16:9 a layout unit is
|
||||
// 768·√((16/9)²+1) = 1566.4 FrameXML units, so 0.0262 × 0.0263 reads 41.0 × 41.2.
|
||||
assert!(
|
||||
rect.right > rect.left && rect.top > rect.bottom,
|
||||
"sized: {rect:?}"
|
||||
(rect.right - rect.left - 0.0262 * 1566.4).abs() < 0.2
|
||||
&& (rect.top - rect.bottom - 0.0263 * 1566.4).abs() < 0.2,
|
||||
"sized by the file's box: {rect:?}"
|
||||
);
|
||||
// …and the model is re-centred on it every update (`0x4a7b20`'s ½·GetWidth, ½·GetHeight in
|
||||
// layout units = half the box).
|
||||
assert!(
|
||||
matches!(&pane.content, QuadContent::ModelPane { position, .. }
|
||||
if (position.0 - 0.0131).abs() < 1e-4 && (position.1 - 0.01315).abs() < 1e-4),
|
||||
"{:?}",
|
||||
pane.content
|
||||
);
|
||||
// The seat, read back in Lua: the arrow is anonymous, so it is found among the map's
|
||||
// children by kind and compared against the mouseover button the update seats at the same
|
||||
@@ -615,6 +645,7 @@ fn the_player_arrow_draws_over_the_zones_explored_overlays() {
|
||||
);
|
||||
assert!(super::load_default_ui(&s).is_empty());
|
||||
s.resolve();
|
||||
arrow_facts(&mut s);
|
||||
s.run("ShowUIPanel(WorldMapFrame)").unwrap();
|
||||
s.resolve();
|
||||
s.set_world_map_feed(None, Some((0.5, 0.5)), 0.0, None, Vec::new(), Vec::new());
|
||||
|
||||
@@ -217,6 +217,17 @@ fn load_world_map_ui(
|
||||
.filter(|&&aid| aid != 0)
|
||||
.filter_map(|&aid| areas.get(aid).map(|r| r.explore_flag))
|
||||
.collect(),
|
||||
hit_rect: (
|
||||
o.hit_rect_top,
|
||||
o.hit_rect_left,
|
||||
o.hit_rect_bottom,
|
||||
o.hit_rect_right,
|
||||
),
|
||||
// The zone-level hover's label inside that rect: the FIRST area
|
||||
// slot's AreaTable name — `0x4a7fa0` reads `+0x8` only (wow-re
|
||||
// 15b2a8ea §1d); a slot that resolves to no row makes the overlay
|
||||
// invisible to the hover, never to the draw.
|
||||
area_name: areas.name(o.area_id[0]).map(str::to_string),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
|
||||
@@ -54,7 +54,7 @@ const FILES: &[&str] = &[
|
||||
// buttons runs `PaperDollItemSlotButton_Update` from its own OnLoad, which calls
|
||||
// `CooldownFrame_SetTimer` on `$parentCooldown` unconditionally (`PaperDollFrame.lua:692`) —
|
||||
// so leaving this out is 20 loader errors before the first assertion.
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\Cooldown.xml",
|
||||
"Interface\\FrameXML\\PaperDollFrame.xml",
|
||||
];
|
||||
|
||||
|
||||
@@ -365,7 +365,15 @@ fn is_instrument_consumer(rel: &str) -> bool {
|
||||
/// each whole-scene input of the still-frame skips (1979) read as changed. Counted in the
|
||||
/// world crate so the probe names ONE static instead of the four resources the skips read; the
|
||||
/// reading is what says whether a gate ever engages.
|
||||
const CEILING: usize = 175;
|
||||
/// And 175 → 176: `mat_anim_table::affine_row`, a PUBLISH. The mat-anim table's second row
|
||||
/// kind (decision 2019): a texture transform's rotation and scale as deltas from the identity,
|
||||
/// `[cos − 1, sin, sx − 1, sy − 1]`, so that row 0 — the pinned zero every static material
|
||||
/// reads — IS the identity. That encoding is the table's own law (the same zero-is-identity
|
||||
/// rule its translation rows and the tint table run under), and the shader's fold is written
|
||||
/// against it; a lane that owns rows in the table — the UI model tiles, sampling the cooldown's
|
||||
/// quadrant rotations off the pane's play head — has to write them in the table's encoding,
|
||||
/// not one of its own. One function, "encode this affine the way the table reads it".
|
||||
const CEILING: usize = 176;
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -395,6 +395,8 @@ impl AssetLoader for M2ModelLoader {
|
||||
alpha_anim: sub.alpha_anim.clone().map(std::sync::Arc::new),
|
||||
uv_anim: sub.uv_anim.clone().map(std::sync::Arc::new),
|
||||
uv_seq: sub.uv_seq.clone().map(std::sync::Arc::new),
|
||||
uv_rot_seq: sub.uv_rot_seq.clone().map(std::sync::Arc::new),
|
||||
uv_scale_seq: sub.uv_scale_seq.clone().map(std::sync::Arc::new),
|
||||
rgb_anim: sub.rgb_anim.clone().map(std::sync::Arc::new),
|
||||
rgb_seq: sub.rgb_seq.clone().map(std::sync::Arc::new),
|
||||
wmo_batch: None, // M2 batches have no MOBA section
|
||||
|
||||
@@ -112,13 +112,13 @@ pub struct WowModelKey {
|
||||
far_side: bool,
|
||||
/// The **WMO-skybox lane** (`clutter_fade.z` bit 13 — `model_render::SKY_DEPTH_MARKER`): the
|
||||
/// building-owned painted sky, drawn as the ordinary M2 it is. `specialize` compiles the
|
||||
/// `WOW_SKY_DEPTH` fragment def, which emits `@builtin(frag_depth) = 0.0` — reverse-Z far, the
|
||||
/// sky depth law every other sky shader already obeys (`benilla_world::sky_order`). It is a key
|
||||
/// axis because writing `frag_depth` costs the whole pipeline its early-Z, and the model lane
|
||||
/// draws every doodad and wall in the frame; only this one camera-anchored model may pay it.
|
||||
/// Like [`Self::far_side`] it also zeroes the rasterizer bias constant: the lane's rung is a
|
||||
/// SORT rung (`sky_order::WMO_SKYBOX_BIAS`, ~−6e4), and the depth it writes is a constant the
|
||||
/// rasterizer must not be perturbing behind the shader's back.
|
||||
/// `WOW_SKY_DEPTH` def into BOTH stages: the vertex pins clip z to 0 — reverse-Z far, the sky
|
||||
/// depth law every other sky shader obeys (`benilla_world::sky_order`) — and the fragment
|
||||
/// writes no depth at all (it did until 2016, at the cost of the pipeline's early-Z). It is a
|
||||
/// key axis because the pin is one model's: every other draw on this lane keeps its real
|
||||
/// depth. Like [`Self::far_side`] it also zeroes the rasterizer bias constant: the lane's rung
|
||||
/// is a SORT rung (`sky_order::WMO_SKYBOX_BIAS`, ~−6e4), and the pinned depth is a constant
|
||||
/// the rasterizer must not be perturbing behind the shader's back.
|
||||
sky_depth: bool,
|
||||
// NB: the WMO authored batch order is deliberately NOT a key axis. It used to be (a
|
||||
// per-batch-index `DepthBiasState` constant), which made every batch index its own pipeline —
|
||||
@@ -205,7 +205,12 @@ pub struct WowModelExt {
|
||||
/// sampler; the per-frame samples live in the shared light buffer's `matanim` region, so an
|
||||
/// animating material is never mutated again (no per-frame `Modified`, no bind-group
|
||||
/// rebuild, no whole-population `AssetChanged` walks — B131's chain, severed at the root).
|
||||
/// `zw` free.
|
||||
/// `z` = the **affine** slot (decision 2019): its row is the texture transform's rotation
|
||||
/// and scale as deltas from the identity, `[cos − 1, sin, sx − 1, sy − 1]`
|
||||
/// (`mat_anim_table::affine_row`), composed about the pivot `(½, ½)` after the translation —
|
||||
/// the reference's `uv' = R((uv + t − p) ⊙ s) + p`. Row 0 is the identity, so a material
|
||||
/// with no rotation reads exactly the translated UV it always did. Written by the lanes that
|
||||
/// own a material per instance (the UI model tiles); `w` free.
|
||||
#[uniform(100)]
|
||||
pub anim_slots: Vec4,
|
||||
/// **The shared global light** (`lighting::global_light`): one storage buffer every material reads,
|
||||
@@ -326,11 +331,13 @@ impl MaterialExtension for WowModelExt {
|
||||
ds.bias.constant = 0;
|
||||
}
|
||||
}
|
||||
// The WMO-skybox lane: force the sky's far depth in the fragment, and — like the far-side
|
||||
// twin above — keep its big negative rung sort-only. The rung (`sky_order::WMO_SKYBOX_BIAS`)
|
||||
// exists to sink a camera-anchored backdrop under every world transparent; as a rasterizer
|
||||
// constant it would be perturbing an interpolated depth this pipeline discards anyway.
|
||||
// The WMO-skybox lane: pin the sky's far depth in the vertex stage (the def reaches the
|
||||
// fragment too, for symmetry with the other lane defs — it reads nothing there), and —
|
||||
// like the far-side twin above — keep its big negative rung sort-only. The rung
|
||||
// (`sky_order::WMO_SKYBOX_BIAS`) exists to sink a camera-anchored backdrop under every
|
||||
// world transparent; as a rasterizer constant it would be perturbing the pinned depth.
|
||||
if key.bind_group_data.sky_depth {
|
||||
descriptor.vertex.shader_defs.push("WOW_SKY_DEPTH".into());
|
||||
if let Some(fragment) = descriptor.fragment.as_mut() {
|
||||
fragment.shader_defs.push("WOW_SKY_DEPTH".into());
|
||||
}
|
||||
@@ -633,27 +640,30 @@ impl MaterialExtension for TerrainExtension {
|
||||
mod tests {
|
||||
/// The sky depth law, for the one sky element that draws on the MODEL lane: the WMO skybox
|
||||
/// ([`WowModelKey::sky_depth`]). Every other sky shader is checked the same way, together, in
|
||||
/// `benilla_world::sky_order::every_sky_shader_forces_the_far_depth` — this half lives here
|
||||
/// because the shader does. Without it a skybox silently goes back to being occluded by its own
|
||||
/// 94-yard shell radius instead of by world geometry (the regression decision 0588 fixed).
|
||||
/// `benilla_world::sky_order::the_sky_depth_is_pinned_at_the_vertex_and_nowhere_else` — this
|
||||
/// half lives here because the shader does. Without it a skybox silently goes back to being
|
||||
/// occluded by its own 94-yard shell radius instead of by world geometry (the regression
|
||||
/// decision 0588 fixed) — or, the other way, a `frag_depth` write comes back and every
|
||||
/// doodad, creature and wall in the frame loses its early-Z with it (decision 2016).
|
||||
#[test]
|
||||
fn the_sky_lane_forces_the_far_depth() {
|
||||
fn the_sky_lane_pins_the_far_depth_at_the_vertex() {
|
||||
let src = include_str!("shaders/wow_model.wgsl");
|
||||
// The pin, BEHIND its ifdef — matched with the guard attached, so ungating it (every model
|
||||
// draw at the far plane) fails here rather than as a blank world.
|
||||
let pin = src
|
||||
.find("#ifdef WOW_SKY_DEPTH")
|
||||
.expect("the sky-depth branch is gone");
|
||||
let branch = &src[pin..src[pin..].find("#endif").map_or(src.len(), |e| pin + e)];
|
||||
assert!(
|
||||
src.contains("#ifdef WOW_SKY_DEPTH\n out.depth = 0.0;\n#endif"),
|
||||
"the model lane's sky branch no longer forces the far depth — a WMO skybox's shell \
|
||||
radius is deciding occlusion again (benilla_world::sky_order, \"The depth law\")"
|
||||
branch.contains("out.position.z = 0.0;"),
|
||||
"the model lane's sky branch no longer pins the far depth at the vertex — a WMO \
|
||||
skybox's shell radius is deciding occlusion again (benilla_world::sky_order, \"The \
|
||||
depth law\")"
|
||||
);
|
||||
// The declaration must stay BEHIND the ifdef: writing `frag_depth` unconditionally costs
|
||||
// every doodad, creature and wall in the frame its early-Z, and this lane draws all of
|
||||
// them. Matched with its guard attached, so ungating it fails here rather than in a
|
||||
// frame-time regression nobody attributes to this file. (Counting occurrences instead
|
||||
// would trip over the prose above the struct, which names the builtin too.)
|
||||
assert!(
|
||||
src.contains("#ifdef WOW_SKY_DEPTH\n // Reverse-Z \"infinitely far\"",)
|
||||
&& src.contains(" @builtin(frag_depth) depth: f32,\n#endif"),
|
||||
"the model lane's sky output no longer declares frag_depth behind WOW_SKY_DEPTH — \
|
||||
either the sky writes no depth, or every model draw just lost its early-Z"
|
||||
!src.contains("@builtin(frag_depth)"),
|
||||
"the model lane writes a fragment depth again — the sky pin is the vertex stage's, \
|
||||
and a fragment write costs every draw on this lane its early-Z (decision 2016)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,12 @@ pub struct ModelSubmesh {
|
||||
/// because which loop applies depends on the sequence the *instance* is playing (decision
|
||||
/// 1408). Like [`Self::uv_anim`], the `Arc` doubles as a material-dedup identity.
|
||||
pub uv_seq: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 2]>>>,
|
||||
/// The batch's texture-transform **rotation** loop per file sequence slot (decision 2019)
|
||||
/// — the UI model tiles sample it off the pane's play head into the material's affine row;
|
||||
/// no world lane reads it (no placed doodad authors one). `None` for the rest.
|
||||
pub uv_rot_seq: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 4]>>>,
|
||||
/// The scaling twin of [`Self::uv_rot_seq`].
|
||||
pub uv_scale_seq: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 2]>>>,
|
||||
/// The batch's **animated RGB tint** (the M2Color colour track, time-varying only — a spell
|
||||
/// effect's white-hot flash cooling to red). When `Some`, the static vertex tint was skipped
|
||||
/// at parse (`benilla-formats`): the render side seeds the material's tint at the first key —
|
||||
|
||||
@@ -51,22 +51,14 @@
|
||||
mesh_functions,
|
||||
}
|
||||
|
||||
// Our own fragment output, so the SKY lane can force the far depth. Bevy's `forward_io::FragmentOutput`
|
||||
// is `@location(0) color` and nothing else (bevy_pbr 0.18.1 `forward_io.wgsl`), so this is that struct
|
||||
// plus one optional builtin — not a divergence from it.
|
||||
//
|
||||
// **`WOW_SKY_DEPTH` is a pipeline-key branch, never an unconditional field.** Declaring
|
||||
// `@builtin(frag_depth)` disables early-Z for the whole pipeline, and the model lane draws every
|
||||
// doodad, creature and WMO wall in the frame. The def is pushed only for the WMO-skybox lane
|
||||
// (`clutter_fade.z` bit 13, `model_render::SKY_DEPTH_MARKER`), which is one camera-anchored model.
|
||||
// Our own fragment output — bevy's `forward_io::FragmentOutput` verbatim (`@location(0) color` and
|
||||
// nothing else, bevy_pbr 0.18.1 `forward_io.wgsl`), kept as a named struct so the lane's output
|
||||
// has one place to grow. The WMO-skybox lane's forced far depth (`WOW_SKY_DEPTH`) used to add a
|
||||
// fragment-depth builtin here; it is a VERTEX-stage pin now (below, decision 2016) — a fragment
|
||||
// depth write costs the pipeline its early-Z, and even on the one camera-anchored model that lane
|
||||
// draws, the same number decided one stage earlier is free.
|
||||
struct WowFragOut {
|
||||
@location(0) color: vec4<f32>,
|
||||
#ifdef WOW_SKY_DEPTH
|
||||
// Reverse-Z "infinitely far", under bevy's `GreaterEqual` test — the sky depth law
|
||||
// (`benilla_world::sky_order`, "The depth law"): the world paints over the sky whatever the
|
||||
// shell's radius, and the sky can never land in front of world geometry.
|
||||
@builtin(frag_depth) depth: f32,
|
||||
#endif
|
||||
}
|
||||
|
||||
// Per-material model uniforms packed at binding 100 (see `WowModelExt` in terrain.rs). Light + fog + the
|
||||
@@ -190,9 +182,10 @@ struct WowLight {
|
||||
rig_origin: array<vec4<f32>, 2048>,
|
||||
// The mat-anim delta table (decision 1381; mat_anim_table.rs mirrors the size): row 0 is the
|
||||
// pinned-zero identity every static material's anim_slots = 0 reads; a live row is the drawn
|
||||
// batch's sampled UV-scroll delta (xy, added to sun_scale.zw) or tint delta (xyz, added to
|
||||
// tint.rgb). Zero region = every batch at its built seed — the studio buffers and
|
||||
// deterministic captures ride that exactly like the tint table's zero-identity.
|
||||
// batch's sampled UV-scroll delta (xy, added to sun_scale.zw), tint delta (xyz, added to
|
||||
// tint.rgb), or texture-transform affine (decision 2019: [cos − 1, sin, sx − 1, sy − 1]).
|
||||
// Zero region = every batch at its built seed — the studio buffers and deterministic
|
||||
// captures ride that exactly like the tint table's zero-identity.
|
||||
matanim: array<vec4<f32>, 2048>,
|
||||
palettes: array<vec4<f32>>,
|
||||
};
|
||||
@@ -203,6 +196,13 @@ struct WowLight {
|
||||
// twin so its silhouette matches the steady cutout exactly.
|
||||
const VANILLA_ALPHA_KEY: f32 = 0.8784314;
|
||||
|
||||
// The detail-doodad (ground clutter) texture LOD bias — the reference sets
|
||||
// `D3DSAMP_MIPMAPLODBIAS = +0.25` on stage 0 for its detail-doodad pass alone (`0x6813f4`,
|
||||
// capture-confirmed on all 29 doodad batches). It is what makes the distance cut resolve per pixel
|
||||
// rather than per leaf; the full derivation is at the use site in `fragment`, and in wow-re
|
||||
// `terrain/scratch/doodad-fade-pixel-granularity.md`.
|
||||
const DETAIL_DOODAD_LOD_BIAS: f32 = 0.25;
|
||||
|
||||
// bevy's `VertexOutput` (same fields, same locations, same defs) + the per-vertex dynamic
|
||||
// point-light term at a free location. One extra interpolant is why this can't BE `VertexOutput`;
|
||||
// the fragment rebuilds one for `pbr_input_from_standard_material`. (Our meshes never carry
|
||||
@@ -515,6 +515,15 @@ fn vertex(vertex: WowVertex) -> WowVsOut {
|
||||
// but as uniform DATA: as pipeline state it made every batch index its own pipeline, and a
|
||||
// first city sight synchronously compiled ~3000 of them on the render thread (decision 0837).
|
||||
out.position.z *= 1.0 + m.sun_scale.y * 1.1920929e-7;
|
||||
#ifdef WOW_SKY_DEPTH
|
||||
// The WMO-skybox lane (`clutter_fade.z` bit 13, `model_render::SKY_DEPTH_MARKER`): clip z
|
||||
// pinned to 0 — reverse-Z "infinitely far" for every fragment, whatever w — under bevy's
|
||||
// `GreaterEqual` test: the sky depth law (`benilla_world::sky_order`, "The depth law"). The
|
||||
// world paints over the painted sky whatever the shell's radius, and the sky can never land
|
||||
// in front of world geometry. A pipeline-key branch, never unconditional: every other model
|
||||
// draw keeps its real depth.
|
||||
out.position.z = 0.0;
|
||||
#endif
|
||||
#ifdef WOW_MERGED_FADE
|
||||
// The merged fader lane (decision 1418). Alpha channel: the faithful curve, per vertex.
|
||||
// Hidden channel: a fully-faded placement collapses its clip position past the far plane —
|
||||
@@ -612,11 +621,14 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
|
||||
}
|
||||
// Rebuild bevy's `VertexOutput` from our extended interstage struct (WowVsOut carries one extra
|
||||
// interpolant — the per-vertex point term — which the pbr entry point doesn't know about).
|
||||
// M2 UV animation folds in here (decision 0130 phase 3, wow-re m2-texanim-uv): add the batch's
|
||||
// live texture-transform translation to the stage UVs before the base-colour sample — the real
|
||||
// client's composed matrix collapses to exactly this for the translation-only doodad corpus
|
||||
// (translation is un-pivoted; rotation/scaling — pivoted at (0.5, 0.5) — are authored by no
|
||||
// placed world doodad). `sun_scale.zw` is 0 for static batches, so this is a no-op there.
|
||||
// M2 UV animation folds in here (decision 0130 phase 3, wow-re m2-texanim-uv; the full law
|
||||
// since decision 2019, wow-re modelframe-texanim-and-sequence-law §3.4):
|
||||
// uv' = R((uv + t − p) ⊙ s) + p, p = (½, ½)
|
||||
// — the batch's live texture-transform TRANSLATION is added to the stage UVs (`sun_scale.zw`
|
||||
// is the built seed, the mat-anim row its delta), then the scale is applied about the pivot,
|
||||
// then the rotation about the pivot. The affine row (`anim_slots.z`) carries `[cos − 1, sin,
|
||||
// sx − 1, sy − 1]`; row 0 is all zeros, so a static batch and every translation-only doodad
|
||||
// read `uv + t` exactly as before. `benilla_formats::uv_transform` is this fold's twin.
|
||||
var vo: VertexOutput;
|
||||
vo.position = in.position;
|
||||
vo.world_position = in.world_position;
|
||||
@@ -636,7 +648,11 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
|
||||
if ((u32(m.clutter_fade.z) & 4096u) != 0u) {
|
||||
vo.uv = in.uv;
|
||||
} else {
|
||||
vo.uv = in.uv + m.sun_scale.zw + wow_light.matanim[u32(m.anim_slots.x)].xy;
|
||||
let uv_t = in.uv + m.sun_scale.zw + wow_light.matanim[u32(m.anim_slots.x)].xy;
|
||||
let affine = wow_light.matanim[u32(m.anim_slots.z)];
|
||||
let d = (uv_t - vec2<f32>(0.5, 0.5)) * vec2<f32>(1.0 + affine.z, 1.0 + affine.w);
|
||||
let c = 1.0 + affine.x;
|
||||
vo.uv = vec2<f32>(0.5 + d.x * c - d.y * affine.y, 0.5 + d.x * affine.y + d.y * c);
|
||||
}
|
||||
#endif
|
||||
#ifdef VERTEX_UVS_B
|
||||
@@ -680,6 +696,45 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
// **The detail-doodad LOD BIAS** — the state that decides whether the fade cuts a leaf pixel by
|
||||
// pixel or takes it whole (wow-re `terrain/scratch/doodad-fade-pixel-granularity.md`, 2021).
|
||||
// `0x6813d0` sets `D3DSAMP_MIPMAPLODBIAS = +0.25` on stage 0 at `0x6813f4`, once per frame,
|
||||
// immediately before the pass dispatches `0x6c02d0` — outside `[0x6b1000, 0x6b3200)`, which is
|
||||
// why 2004's state census missed it. Capture-confirmed: all 29 doodad batches bind a sampler
|
||||
// with `GL_TEXTURE_LOD_BIAS = 0.25` where the draws just before them bind an otherwise
|
||||
// byte-identical sampler with bias 0.
|
||||
//
|
||||
// Why a quarter-mip is worth a re-sample: three of the shipped detail atlases (Elwynn's among
|
||||
// them) carry a **binary** alpha pyramid — mip 0 has DXT3's 16 levels, every level below it has
|
||||
// two. A fragment can only reach `a ≈ 1.0`, and so share the terminal death depth, inside a 2×2
|
||||
// all-opaque texel neighbourhood; those neighbourhoods run out between mip 2 and mip 3. Biasing
|
||||
// a quarter-mip blurrier lands the reference past that cliff, so no fragment holds full alpha
|
||||
// and the tuft erodes continuously instead of every leaf crossing together. Measured on the
|
||||
// dominant Elwynn cell, the share of a tuft's pixels leaving in the last half-yard: **2.2 %**
|
||||
// with this bias, **11.6 %** without it.
|
||||
//
|
||||
// wgpu has no sampler LOD bias (WebGPU dropped it), so it goes on the sample. Re-sampling here
|
||||
// rather than pre-biasing the sampler is what keeps it *this lane's* bias: the sampler is shared
|
||||
// with every other model batch, which must keep the reference's 0. Same UV, same implicit
|
||||
// derivatives, so the texture cache makes the second tap nearly free.
|
||||
// `pbr_input_from_standard_material` folds ATTRIBUTE_COLOR and the material tint into what it
|
||||
// sampled; for clutter the material tint is white and the vertex colour is the MCSH lit/shadow
|
||||
// grey, so re-applying `vo.color` reproduces the fold exactly — no divide-back-out, which is the
|
||||
// trap the WMO branch above documents.
|
||||
if (m.clutter_fade.w > 0.5) {
|
||||
#ifdef VERTEX_UVS_A
|
||||
var biased = textureSampleBias(
|
||||
pbr_bindings::base_color_texture,
|
||||
pbr_bindings::base_color_sampler,
|
||||
vo.uv,
|
||||
view.mip_bias + DETAIL_DOODAD_LOD_BIAS,
|
||||
);
|
||||
#ifdef VERTEX_COLORS
|
||||
biased = biased * vo.color;
|
||||
#endif
|
||||
base_color = biased;
|
||||
#endif
|
||||
}
|
||||
// Ground-clutter distance fade — the reference's stage-1 ramp, byte-exact and capture-confirmed
|
||||
// (wow-re `terrain/scratch/detail-doodad-distance-fade.md`, 2004). Three facts shape this:
|
||||
//
|
||||
@@ -1138,9 +1193,6 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
|
||||
|
||||
|
||||
var out: WowFragOut;
|
||||
#ifdef WOW_SKY_DEPTH
|
||||
out.depth = 0.0;
|
||||
#endif
|
||||
// Raw gamma out (GAMMA LANE, 0161 — the frame's one decode is the FFXGlow combine). Alpha = the
|
||||
// faded cutout alpha (tex × fade) for the blend twin; ignored (blend off) on steady/opaque draws.
|
||||
// OPAQUE-INTENT alpha pin (clutter_fade.z bit 3, set in model_render for steady opaque/alpha-key
|
||||
|
||||
@@ -670,6 +670,8 @@ impl AssetLoader for WmoModelLoader {
|
||||
alpha_anim: None, // WMO batches carry no M2 colour/weight tracks
|
||||
uv_anim: None, // …nor texture transforms
|
||||
uv_seq: None, // …so no per-sequence set either (1408)
|
||||
uv_rot_seq: None,
|
||||
uv_scale_seq: None,
|
||||
rgb_anim: None,
|
||||
rgb_seq: None, // …nor M2Color tints
|
||||
wmo_batch: sub.wmo_batch, // the MOBA section — an interior group's lighting law
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
//! **How the ground-clutter fade boundary cuts** — pixel by pixel, or leaf by leaf?
|
||||
//!
|
||||
//! `cargo run -p benilla-formats --example clutter_cut [substring]`
|
||||
//!
|
||||
//! The detail-doodad draw alpha-tests `texel.a × ramp(view_depth)` against `detailDoodadAlpha`
|
||||
//! (128/255), so as the ramp falls the surviving set is `{ texel.a ≥ 0.502 / ramp }`. Whether that
|
||||
//! reads as a tuft *eroding* — the boundary sweeping through a leaf a pixel at a time — or as the
|
||||
//! leaf *flipping whole* is decided entirely by the alpha distribution **at the mip the tuft is
|
||||
//! sampled at**. The alpha arithmetic is settled and identical either way, so when the cut looks
|
||||
//! wrong the answer is in what is being cut, not in the cutting.
|
||||
//!
|
||||
//! Per atlas, per mip level, three numbers:
|
||||
//!
|
||||
//! * **`distinct`** — how many distinct alpha values the level carries. **`2` means binary**: the
|
||||
//! product `a × ramp` is then either `0` or `ramp`, so the alpha test degenerates to a global
|
||||
//! on/off and *every texel of every leaf crosses the threshold at the same view depth*. No
|
||||
//! erosion is possible at such a level, at any distance, ever.
|
||||
//! * **coverage** across the fade band — the share of the atlas still drawn at each depth. Sliding
|
||||
//! down = the tuft erodes. Holding flat then dropping to zero = the tuft pops.
|
||||
//! * **cliff** — how much of the still-drawn coverage goes in the last 0.2 yd before the crossing.
|
||||
//! 100% means everything that was left leaves in one step.
|
||||
//!
|
||||
//! The header line also prints the BLP's own `compression / alpha_bits / alpha_type / has_mips`,
|
||||
//! because the shipped art is not uniform: some detail atlases carry a properly averaged mip
|
||||
//! pyramid and some carry a **binary-thresholded** one, and the two behave completely differently
|
||||
//! at the boundary. Parsed inline from the documented BLP2 layout rather than through the decoder,
|
||||
//! so this reports what the *file* says independently of how we read it.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// The reference's ramp (wow-re `terrain/scratch/detail-doodad-distance-fade.md`): a 64-texel
|
||||
/// CLAMP/LINEAR table read at texel centres, capped at texel 0's `252/255`.
|
||||
fn ramp(view_depth: f32, far: f32) -> f32 {
|
||||
let near = far * 0.75;
|
||||
let u = (view_depth - near) / (far - near);
|
||||
((254.0 - 256.0 * u) / 255.0).clamp(0.0, 252.0 / 255.0)
|
||||
}
|
||||
|
||||
/// The detail-doodad alpha-test reference (`detailDoodadAlpha` = 128).
|
||||
const CUTOUT: f32 = 128.0 / 255.0;
|
||||
|
||||
/// Where in the band to sample coverage — clustered at the 61.11 yd crossing, where even a fully
|
||||
/// opaque texel fails the test and the whole question lives.
|
||||
const BANDS: [f32; 8] = [52.5, 54.0, 56.0, 58.0, 60.0, 61.0, 61.1, 61.2];
|
||||
|
||||
/// What the BLP2 header says about itself: `compression` (1 = palettized, 2 = DXT, 3 = BGRA8),
|
||||
/// `alpha_bits`, `alpha_type` (DXT sub-form: 1 = DXT3, 7 = DXT5) and the `has_mipmaps` byte at
|
||||
/// offset 0x0B — whose value tracks, in the shipped detail art, whether the mip chain is averaged
|
||||
/// or thresholded.
|
||||
fn blp_header(bytes: &[u8]) -> Option<(u8, u8, u8, u8)> {
|
||||
(bytes.len() >= 20 && &bytes[0..4] == b"BLP2")
|
||||
.then(|| (bytes[8], bytes[9], bytes[10], bytes[11]))
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let want = std::env::args().nth(1).unwrap_or_default().to_uppercase();
|
||||
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
|
||||
let mut chain = benilla_formats::open_chain(&data)?;
|
||||
let catalog = benilla_formats::load_ground_effect_catalog(&mut chain, true)?;
|
||||
|
||||
// Every distinct detail texture the catalog can place.
|
||||
let mut models: BTreeSet<String> = BTreeSet::new();
|
||||
for id in 0..4096u32 {
|
||||
if let Some(e) = catalog.effect(id) {
|
||||
models.extend(e.models().into_iter().map(str::to_string));
|
||||
}
|
||||
}
|
||||
let mut textures: BTreeSet<String> = BTreeSet::new();
|
||||
for path in &models {
|
||||
let Ok(bytes) = chain.read_file(path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, "", &[]) else {
|
||||
continue;
|
||||
};
|
||||
for s in subs {
|
||||
if let Some(t) = s.texture {
|
||||
textures.insert(t.to_uppercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("cutout {CUTOUT:.4} (detailDoodadAlpha 128); view depth → ramp:");
|
||||
for d in BANDS {
|
||||
println!(" {d:5.1} yd ramp {:.4}", ramp(d, 70.0));
|
||||
}
|
||||
println!("\ncoverage% drawn at each of those depths; `distinct 2` = binary = cannot erode");
|
||||
|
||||
let (mut binary_atlases, mut graded_atlases) = (Vec::new(), Vec::new());
|
||||
for tex in textures.iter().filter(|t| t.contains(&want)) {
|
||||
let header = chain.read_file(tex).ok().and_then(|b| blp_header(&b));
|
||||
let Ok(mips) = benilla_formats::read_texture_mip_chain(&mut chain, tex) else {
|
||||
continue;
|
||||
};
|
||||
if !mips.is_rgba8() {
|
||||
continue; // block-compressed levels are not CPU-readable here
|
||||
}
|
||||
let name = tex.rsplit('\\').next().unwrap_or(tex);
|
||||
match header {
|
||||
Some((c, ab, at, hm)) => {
|
||||
println!("\n{name} compression={c} alpha_bits={ab} alpha_type={at} has_mips={hm}")
|
||||
}
|
||||
None => println!("\n{name}"),
|
||||
}
|
||||
|
||||
// What an AVERAGED chain would carry, for comparison — mip0's alpha box-filtered down.
|
||||
// This is the control for a binary chain: it says how much of the flat coverage is the
|
||||
// art's own and how much is the authored mip pyramid throwing the gradient away.
|
||||
let mip0_alpha: Vec<u8> = mips.mips[0].chunks_exact(4).map(|p| p[3]).collect();
|
||||
let mut averaged: Vec<Vec<u8>> = vec![mip0_alpha];
|
||||
for i in 1..5usize {
|
||||
let (pw, ph) = mips.mip_size(i as u32 - 1);
|
||||
let (w, h) = mips.mip_size(i as u32);
|
||||
let prev = &averaged[i - 1];
|
||||
if prev.len() != (pw * ph) as usize {
|
||||
break;
|
||||
}
|
||||
let mut next = Vec::with_capacity((w * h) as usize);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let sample = |dx: u32, dy: u32| {
|
||||
let (sx, sy) = ((2 * x + dx).min(pw - 1), (2 * y + dy).min(ph - 1));
|
||||
u32::from(prev[(sy * pw + sx) as usize])
|
||||
};
|
||||
let sum = sample(0, 0) + sample(1, 0) + sample(0, 1) + sample(1, 1);
|
||||
next.push((sum / 4) as u8);
|
||||
}
|
||||
}
|
||||
averaged.push(next);
|
||||
}
|
||||
|
||||
// Mips 0..4 span roughly 10 yd to 90 yd of viewing distance; 2-3 is the fade band itself.
|
||||
let mut binary_below_zero = false;
|
||||
for (i, mip) in mips.mips.iter().enumerate().take(5) {
|
||||
let (w, h) = mips.mip_size(i as u32);
|
||||
let alpha: Vec<u8> = mip.chunks_exact(4).map(|p| p[3]).collect();
|
||||
if alpha.len() != (w * h) as usize {
|
||||
println!(
|
||||
" mip{i}: SIZE MISMATCH — {} texels for {w}x{h}",
|
||||
alpha.len()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let mut seen = [false; 256];
|
||||
for &v in &alpha {
|
||||
seen[v as usize] = true;
|
||||
}
|
||||
let distinct = seen.iter().filter(|&&s| s).count();
|
||||
if i > 0 && distinct <= 2 {
|
||||
binary_below_zero = true;
|
||||
}
|
||||
|
||||
let drawn = |a: u8, r: f32| f32::from(a) / 255.0 * r >= CUTOUT;
|
||||
let coverage = |r: f32| {
|
||||
alpha.iter().filter(|&&a| drawn(a, r)).count() as f64 / alpha.len() as f64 * 100.0
|
||||
};
|
||||
let before = coverage(ramp(61.0, 70.0));
|
||||
let after = coverage(ramp(61.2, 70.0));
|
||||
let cliff = if before > 0.0 {
|
||||
100.0 * (before - after) / before
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let covs: Vec<String> = BANDS
|
||||
.iter()
|
||||
.map(|d| format!("{:5.1}", coverage(ramp(*d, 70.0))))
|
||||
.collect();
|
||||
println!(
|
||||
" mip{i} {w:3}x{h:3} distinct {distinct:<4} [{}] cliff@61 {cliff:3.0}%",
|
||||
covs.join(" ")
|
||||
);
|
||||
// The averaged control, printed only where the authored level is binary — that is the
|
||||
// only case where the two can disagree, and seeing them side by side is the point.
|
||||
if i > 0 && distinct <= 2 {
|
||||
if let Some(avg) = averaged.get(i) {
|
||||
let mut seen = [false; 256];
|
||||
for &v in avg {
|
||||
seen[v as usize] = true;
|
||||
}
|
||||
let cov_avg = |r: f32| {
|
||||
avg.iter().filter(|&&a| drawn(a, r)).count() as f64 / avg.len() as f64
|
||||
* 100.0
|
||||
};
|
||||
let b = cov_avg(ramp(61.0, 70.0));
|
||||
let a2 = cov_avg(ramp(61.2, 70.0));
|
||||
let covs: Vec<String> = BANDS
|
||||
.iter()
|
||||
.map(|d| format!("{:5.1}", cov_avg(ramp(*d, 70.0))))
|
||||
.collect();
|
||||
println!(
|
||||
" ^ averaged distinct {:<4} [{}] cliff@61 {:3.0}%",
|
||||
seen.iter().filter(|&&s| s).count(),
|
||||
covs.join(" "),
|
||||
if b > 0.0 { 100.0 * (b - a2) / b } else { 0.0 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if binary_below_zero {
|
||||
binary_atlases.push(name.to_string());
|
||||
} else {
|
||||
graded_atlases.push(name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
println!(
|
||||
"\n{} atlases carry a BINARY mip chain below level 0 — every leaf on them flips whole at \
|
||||
the crossing, at any distance:",
|
||||
binary_atlases.len()
|
||||
);
|
||||
for a in &binary_atlases {
|
||||
println!(" {a}");
|
||||
}
|
||||
println!(
|
||||
"\n{} carry a graded chain and erode normally.",
|
||||
graded_atlases.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! **How big is one tuft, and how much DEPTH does it span?** — the geometry half of the
|
||||
//! "why does the fade cut leaf-by-leaf instead of pixel-by-pixel" question.
|
||||
//!
|
||||
//! `cargo run -p benilla-formats --example clutter_size`
|
||||
//!
|
||||
//! The fade boundary is a **plane** at a fixed view depth (the reference texgens camera-space Z).
|
||||
//! Whether that plane *slices* a tuft — some of it drawn, the rest gone, a per-pixel cut — or
|
||||
//! passes it by in a single frame depends on how much view depth one tuft occupies. A tuft that
|
||||
//! spans 1 yd of depth is sliced for as long as it takes the player to walk 1 yd. A tuft that
|
||||
//! spans 5 cm is, for practical purposes, either wholly in or wholly out: it pops.
|
||||
//!
|
||||
//! So this prints, per detail model: the world-space bounding box, the tuft's **depth span at the
|
||||
//! worst yaw** (its horizontal diagonal — the widest it can present to the camera), and the
|
||||
//! screen-space size that implies at the 61.11 yd cutout crossing.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Vertical FOV benilla's camera runs at (Bevy's `PerspectiveProjection` default, ≈ the
|
||||
/// reference's 44.1°) over a 1080-tall viewport — the same basis `clutter_state` uses.
|
||||
fn px_per_yard(distance: f32) -> f32 {
|
||||
let fov: f32 = std::f32::consts::PI / 4.0;
|
||||
(1080.0 / 2.0) / ((fov / 2.0).tan() * distance)
|
||||
}
|
||||
|
||||
/// Where the `detailDoodadAlpha` = 128 cutout erases even a fully-opaque texel.
|
||||
const CROSSING: f32 = 61.113;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
|
||||
let mut chain = benilla_formats::open_chain(&data)?;
|
||||
let catalog = benilla_formats::load_ground_effect_catalog(&mut chain, true)?;
|
||||
|
||||
let mut models: BTreeSet<String> = BTreeSet::new();
|
||||
for id in 0..4096u32 {
|
||||
if let Some(e) = catalog.effect(id) {
|
||||
models.extend(e.models().into_iter().map(str::to_string));
|
||||
}
|
||||
}
|
||||
|
||||
println!(
|
||||
"{} detail models. WoW model space: +X north, +Y west, +Z up — so Z is the tuft's HEIGHT \
|
||||
and the XY diagonal is the most depth it can present to the camera.",
|
||||
models.len()
|
||||
);
|
||||
println!(
|
||||
"\n{:<44} {:>6} {:>6} {:>7} {:>8} {:>8}",
|
||||
"model", "w(yd)", "h(yd)", "depth", "px tall", "px deep"
|
||||
);
|
||||
|
||||
let mut heights: Vec<f32> = Vec::new();
|
||||
let mut depths: Vec<f32> = Vec::new();
|
||||
let mut shown = 0;
|
||||
for path in &models {
|
||||
let Ok(bytes) = chain.read_file(path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, "", &[]) else {
|
||||
continue;
|
||||
};
|
||||
let mut lo = [f32::MAX; 3];
|
||||
let mut hi = [f32::MIN; 3];
|
||||
for s in &subs {
|
||||
for p in &s.positions {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(p[k]);
|
||||
hi[k] = hi[k].max(p[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if lo[0] > hi[0] {
|
||||
continue;
|
||||
}
|
||||
let (dx, dy, dz) = (hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]);
|
||||
// The widest footprint the tuft can turn toward the camera: its horizontal diagonal.
|
||||
let depth = (dx * dx + dy * dy).sqrt();
|
||||
let width = dx.max(dy);
|
||||
heights.push(dz);
|
||||
depths.push(depth);
|
||||
if shown < 14 {
|
||||
let name = path.rsplit('\\').next().unwrap_or(path);
|
||||
println!(
|
||||
"{name:<44} {width:6.2} {dz:6.2} {depth:7.2} {:8.1} {:8.1}",
|
||||
dz * px_per_yard(CROSSING),
|
||||
depth * px_per_yard(CROSSING),
|
||||
);
|
||||
shown += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let stat = |v: &mut Vec<f32>| {
|
||||
v.sort_by(f32::total_cmp);
|
||||
(v[0], v[v.len() / 2], v[v.len() - 1])
|
||||
};
|
||||
let (hlo, hmed, hhi) = stat(&mut heights);
|
||||
let (dlo, dmed, dhi) = stat(&mut depths);
|
||||
println!(
|
||||
"\nheight min {hlo:.2} median {hmed:.2} max {hhi:.2} yd \
|
||||
({:.0} px tall at the {CROSSING:.1} yd crossing)",
|
||||
hmed * px_per_yard(CROSSING)
|
||||
);
|
||||
println!(
|
||||
"depth min {dlo:.2} median {dmed:.2} max {dhi:.2} yd \
|
||||
({:.0} px deep — the width of the slice the cutout plane can show)",
|
||||
dmed * px_per_yard(CROSSING)
|
||||
);
|
||||
println!(
|
||||
"\nA median tuft occupies {dmed:.2} yd of view depth. The cutout plane sweeps that in the \
|
||||
time the player walks {dmed:.2} yd, and while it does, the tuft is drawn PARTLY — that is \
|
||||
the per-pixel slice. Below roughly a tenth of a yard it is a single-frame pop instead."
|
||||
);
|
||||
|
||||
// **Which mip a tuft is sampled at, and why the answer moves with the window.** This is the
|
||||
// number the whole look turns on: the shipped Elwynn/Westfall/AeriePeaks atlases carry a binary
|
||||
// alpha pyramid, and a fragment can only hold full alpha — and so die at the same depth as
|
||||
// every other fragment of its leaf — inside a 2x2 all-opaque texel neighbourhood. Those run out
|
||||
// between mip 2 and mip 3 (wow-re counts 179/56/10/0/0/0 for levels 0-5 on the dominant Elwynn
|
||||
// cell), so sampling past ~3 erodes continuously and sampling below it takes leaves whole.
|
||||
//
|
||||
// The reference's `+0.25` stage-0 LOD bias buys a quarter of a mip toward that cliff — but the
|
||||
// sample LOD falls as the viewport grows, so the SAME bias lands differently on a bigger
|
||||
// window. wow-re measured the reference itself at lambda 2.9-3.3 in a 1152x648 capture and
|
||||
// lambda ~2.45 at 1920x1080, i.e. the reference pops leaf-by-leaf at a modern resolution too.
|
||||
const DENSITY: f32 = 100.0; // texels per yard, the median from `clutter_state`
|
||||
println!("\nsampled mip at the {CROSSING:.1} yd crossing (median {DENSITY:.0} texels/yd art):");
|
||||
for height in [648.0f32, 1080.0, 1440.0, 2160.0] {
|
||||
let fov: f32 = std::f32::consts::PI / 4.0;
|
||||
let px = (height / 2.0) / ((fov / 2.0).tan() * CROSSING);
|
||||
let lambda = (DENSITY / px).log2();
|
||||
println!(
|
||||
" {height:5.0}p lambda {lambda:.2} unbiased {:.2} with the reference's +0.25",
|
||||
lambda + 0.25
|
||||
);
|
||||
}
|
||||
println!(
|
||||
" (past ~3.0 a leaf's texels no longer share a death depth and the cut goes per pixel; \
|
||||
below it, whole leaves cross together)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -246,18 +246,18 @@ pub use models::{
|
||||
parse_m2_global_sequence_bones, parse_m2_lights, parse_m2_playable_animation_lookup,
|
||||
parse_m2_portrait_camera, parse_m2_render_submeshes, parse_m2_skeleton,
|
||||
parse_m2_string_anchors, parse_wmo_fogs, parse_wmo_lights, parse_wmo_portals, parse_wmo_root,
|
||||
shipped_glue_art_extent, wmo_group_doodad_refs, wmo_group_fixed_colors,
|
||||
wmo_group_footprint_tris, wmo_group_header, wmo_group_light_refs, wmo_group_liquid_mesh,
|
||||
wmo_group_raw_colors, wmo_group_submeshes, wmo_root_id, AlphaAnim, AlphaMap, AlphaSeq,
|
||||
AnimEvent, ArtExtent, BatchFootprint, Billboard, BillboardKind, BoneKeys, BoneScaleAnim,
|
||||
BoneSpin, CharSkinSlot, CollisionMesh, Coverage, CoverageReader, EmitterBoneLink, EventMarker,
|
||||
FogPolicy, FootprintTris, GlobalSeqBone, GlobalSeqChannel, GroundQuad, KeyAnim, M2AnimSummary,
|
||||
M2Attachment, M2Bounds, M2Light, M2PortraitCamera, ModelAnimation, ModelBlend, ParentArm,
|
||||
ParentBasis, PlayableAnim, RenderSubmesh, RgbAnim, ScalarAnim, SeqLoops, Skeleton,
|
||||
SkeletonBone, StringAnchors, UvAnim, WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog,
|
||||
WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot,
|
||||
ALPHA_KEY_REF, DEGENERATE_RING_FOOTPRINT, GLUE_AUTHORED_ASPECT, NO_GROUP_LIQUID,
|
||||
OWNER_RUNG_BUCKETS,
|
||||
rotation_2x2, shipped_glue_art_extent, uv_transform, wmo_group_doodad_refs,
|
||||
wmo_group_fixed_colors, wmo_group_footprint_tris, wmo_group_header, wmo_group_light_refs,
|
||||
wmo_group_liquid_mesh, wmo_group_raw_colors, wmo_group_submeshes, wmo_root_id, AlphaAnim,
|
||||
AlphaMap, AlphaSeq, AnimEvent, ArtExtent, BatchFootprint, Billboard, BillboardKind, BoneKeys,
|
||||
BoneScaleAnim, BoneSpin, CharSkinSlot, CollisionMesh, Coverage, CoverageReader,
|
||||
EmitterBoneLink, EventMarker, FogPolicy, FootprintTris, GlobalSeqBone, GlobalSeqChannel,
|
||||
GroundQuad, KeyAnim, M2AnimSummary, M2Attachment, M2Bounds, M2Light, M2PortraitCamera,
|
||||
ModelAnimation, ModelBlend, ParentArm, ParentBasis, PlayableAnim, RenderSubmesh, RgbAnim,
|
||||
ScalarAnim, SeqLoops, Skeleton, SkeletonBone, StringAnchors, UvAnim, UvRotAnim, WmoBatchClass,
|
||||
WmoDoodad, WmoDoodadSet, WmoFog, WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo,
|
||||
WmoPortalRef, WmoPortals, WmoRoot, ALPHA_KEY_REF, DEGENERATE_RING_FOOTPRINT,
|
||||
GLUE_AUTHORED_ASPECT, NO_GROUP_LIQUID, OWNER_RUNG_BUCKETS,
|
||||
};
|
||||
mod terrain;
|
||||
pub use terrain::{
|
||||
|
||||
@@ -578,6 +578,12 @@ pub fn parse_m2_render_submeshes(
|
||||
// the lane it has always taken.
|
||||
let uv_seq = tex_anim::bake_uv_seqs(model, batch.texture_transform_combo_index, &seq_slots)
|
||||
.filter(|set| set.uniform().is_none());
|
||||
// The rotation and scaling channels, per slot (decision 2019): consumed only by a lane
|
||||
// that owns its materials per instance (the UI model tiles), so they are carried whole.
|
||||
let uv_rot_seq =
|
||||
tex_anim::bake_uv_rot_seqs(model, batch.texture_transform_combo_index, &seq_slots);
|
||||
let uv_scale_seq =
|
||||
tex_anim::bake_uv_scale_seqs(model, batch.texture_transform_combo_index, &seq_slots);
|
||||
// The batch's animated RGB tint (the M2Color colour track's runtime half): only a
|
||||
// time-varying track bakes — a `Some` here *replaces* the static vertex tint below (a
|
||||
// spell effect's white-hot flash cooling to red would otherwise freeze on its first key).
|
||||
@@ -724,6 +730,8 @@ pub fn parse_m2_render_submeshes(
|
||||
sub.alpha_anim = alpha_anim.clone(); // every billboard-split group shares the batch's loops
|
||||
sub.uv_anim = uv_anim.clone();
|
||||
sub.uv_seq = uv_seq.clone();
|
||||
sub.uv_rot_seq = uv_rot_seq.clone();
|
||||
sub.uv_scale_seq = uv_scale_seq.clone();
|
||||
sub.rgb_anim = rgb_anim.clone();
|
||||
sub.rgb_seq = rgb_seq.clone();
|
||||
out.push(sub);
|
||||
|
||||
@@ -40,7 +40,7 @@ pub use key_anim::{KeyAnim, SeqLoops};
|
||||
pub use m2_batches::*;
|
||||
pub use mat_anim::{AlphaAnim, AlphaSeq, RgbAnim, ScalarAnim};
|
||||
pub use records::*;
|
||||
pub use tex_anim::UvAnim;
|
||||
pub use tex_anim::{rotation_2x2, uv_transform, UvAnim, UvRotAnim};
|
||||
pub use types::*;
|
||||
pub use wmo::*;
|
||||
|
||||
@@ -107,11 +107,13 @@ fn remap_submesh(
|
||||
alpha_anim: None, // set by the M2 path from the batch's colour/weight tracks
|
||||
uv_anim: None, // set by the M2 path from the batch's texture transform
|
||||
uv_seq: None, // …and its per-sequence set, when the slots disagree (1408)
|
||||
rgb_anim: None, // set by the M2 path from the batch's colour RGB track
|
||||
rgb_seq: None, // …ditto (1408)
|
||||
wmo_batch: None, // set by the WMO path from the MOGP batch-section counts
|
||||
env_map: false, // set by the M2 path from texture_unit_lookup[texCoordSet] > 2
|
||||
section: None, // set by the M2 path from the batch's skin_section_index
|
||||
uv_rot_seq: None,
|
||||
uv_scale_seq: None,
|
||||
rgb_anim: None, // set by the M2 path from the batch's colour RGB track
|
||||
rgb_seq: None, // …ditto (1408)
|
||||
wmo_batch: None, // set by the WMO path from the MOGP batch-section counts
|
||||
env_map: false, // set by the M2 path from texture_unit_lookup[texCoordSet] > 2
|
||||
section: None, // set by the M2 path from the batch's skin_section_index
|
||||
},
|
||||
globals,
|
||||
)
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
//! `0xffff`/out-of-range = none) → the record's **translation** track, baked to a loopable UV-offset
|
||||
//! loop on the same clocks as the material-alpha bake (gseq wrap / seq-0 band).
|
||||
//!
|
||||
//! Translation only, deliberately: the full 7968-model 1.12 corpus sweep found 281 of 286
|
||||
//! transforms translation-keyed, and every rotation/scaling user is a UI/spell-effect/goober model
|
||||
//! — **zero placed world doodads** — so the doodad lane (0130's scope) needs no rotation/scaling.
|
||||
//! The baked offset is the track's raw x/y; how the real client maps it onto U/V (sign/axis, and
|
||||
//! the application mechanism) is under RE in wow-re — the renderer applies the convention once,
|
||||
//! when it consumes this.
|
||||
//! The translation channel is what the world's shared-material lane consumes: the full 7968-model
|
||||
//! 1.12 corpus sweep found 281 of 286 transforms translation-keyed, and every rotation/scaling
|
||||
//! user is a UI/spell-effect/goober model — **zero placed world doodads**. The rotation and
|
||||
//! scaling channels bake per file sequence slot ([`bake_uv_rot_seqs`] / [`bake_uv_scale_seqs`],
|
||||
//! decision 2019) for the lanes that own their materials per instance — the UI model tiles, whose
|
||||
//! cooldown indicator is four quadrant quads turned by their rotation tracks. The baked values are
|
||||
//! the tracks' raw numbers; the law that composes them is [`uv_transform`], and the shader is its
|
||||
//! twin.
|
||||
|
||||
use benilla_m2::M2Model;
|
||||
|
||||
@@ -26,6 +28,18 @@ impl KeyAnim<[f32; 2]> {
|
||||
}
|
||||
}
|
||||
|
||||
/// One baked texture-transform **rotation** loop — the raw quaternion keys, component-lerped
|
||||
/// (see [`bake_uv_rot_seqs`]).
|
||||
pub type UvRotAnim = KeyAnim<[f32; 4]>;
|
||||
|
||||
impl KeyAnim<[f32; 4]> {
|
||||
/// The raw quaternion at `elapsed` seconds on the loop clock (the identity `(0, 0, 0, 1)`
|
||||
/// for a defensively-empty loop the bake never emits).
|
||||
pub fn sample(&self, elapsed: f32) -> [f32; 4] {
|
||||
self.sample_or(elapsed, [0.0, 0.0, 0.0, 1.0])
|
||||
}
|
||||
}
|
||||
|
||||
/// `(x, y)` within a hair of zero — the offset that moves nothing.
|
||||
fn is_zero(v: [f32; 2]) -> bool {
|
||||
v[0].abs() < 1e-6 && v[1].abs() < 1e-6
|
||||
@@ -90,11 +104,177 @@ pub(super) fn bake_uv_seqs(
|
||||
)
|
||||
}
|
||||
|
||||
/// A rotation quaternion within a hair of the identity `(0, 0, 0, 1)`.
|
||||
fn is_quat_identity(q: [f32; 4]) -> bool {
|
||||
q[0].abs() < 1e-6 && q[1].abs() < 1e-6 && q[2].abs() < 1e-6 && (q[3] - 1.0).abs() < 1e-6
|
||||
}
|
||||
|
||||
/// A scale within a hair of `(1, 1)`.
|
||||
fn is_scale_identity(v: [f32; 2]) -> bool {
|
||||
(v[0] - 1.0).abs() < 1e-6 && (v[1] - 1.0).abs() < 1e-6
|
||||
}
|
||||
|
||||
/// Bake the batch's texture-transform **rotation** loop per file sequence slot — the raw
|
||||
/// quaternion keys, **component-lerped and never normalised**, which is what the reference does
|
||||
/// (`0x713ea0` lerps each component and `0x7bddb0` consumes the result as is: between two 22.5°
|
||||
/// keys `|q|` dips to cos 11.25° and the block is a rotation with a slight shrink — wow-re
|
||||
/// `modelframe-texanim-and-sequence-law.md` §3.4). `None` when the transform is absent or its
|
||||
/// rotation never leaves the identity.
|
||||
pub(super) fn bake_uv_rot_seqs(
|
||||
model: &M2Model,
|
||||
combo_index: u16,
|
||||
slots: &[SeqSlot],
|
||||
) -> Option<SeqLoops<[f32; 4]>> {
|
||||
let ti = *model.texture_transform_lookup.get(combo_index as usize)?;
|
||||
let t = model.texture_transforms.get(ti as usize)?;
|
||||
SeqLoops::new(
|
||||
slots
|
||||
.iter()
|
||||
.map(|&slot| {
|
||||
bake_track(
|
||||
&t.rotation,
|
||||
&model.global_sequences,
|
||||
Some(slot),
|
||||
|q| q,
|
||||
is_quat_identity,
|
||||
is_quat_identity,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Bake the batch's texture-transform **scaling** loop per file sequence slot (`(x, y)` of the
|
||||
/// track's vec3). `None` when the transform is absent or its scale never leaves `(1, 1)`.
|
||||
pub(super) fn bake_uv_scale_seqs(
|
||||
model: &M2Model,
|
||||
combo_index: u16,
|
||||
slots: &[SeqSlot],
|
||||
) -> Option<SeqLoops<[f32; 2]>> {
|
||||
let ti = *model.texture_transform_lookup.get(combo_index as usize)?;
|
||||
let t = model.texture_transforms.get(ti as usize)?;
|
||||
SeqLoops::new(
|
||||
slots
|
||||
.iter()
|
||||
.map(|&slot| {
|
||||
bake_track(
|
||||
&t.scaling,
|
||||
&model.global_sequences,
|
||||
Some(slot),
|
||||
|v| [v[0], v[1]],
|
||||
is_scale_identity,
|
||||
is_scale_identity,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// **The texture transform's law**, from the file to the texel (wow-re
|
||||
/// `modelframe-texanim-and-sequence-law.md` §3.4, VERIFIED trio-convergent):
|
||||
///
|
||||
/// > `uv' = R_q((uv + t − p) ⊙ s) + p`, `p = (½, ½)`
|
||||
///
|
||||
/// — the translation is added first, the scale is applied about the pivot, then the rotation
|
||||
/// about the pivot. `R_q` is the standard active rotation of the raw (unnormalised) quaternion;
|
||||
/// for the z-only quaternions every shipped UI asset authors, `c = 1 − 2z²`, `s = 2zw`, and
|
||||
/// `+θ` turns counter-clockwise in the `u`-right / `v`-up frame. A quaternion with x/y components
|
||||
/// would turn the plane out of itself; this reads its z-rotation alone (the shipped population
|
||||
/// has none). `wow_model.wgsl`'s UV fold is this function's twin: keep the two in step.
|
||||
pub fn uv_transform(uv: [f32; 2], t: [f32; 2], q: [f32; 4], s: [f32; 2]) -> [f32; 2] {
|
||||
let (c, sn) = rotation_2x2(q);
|
||||
let dx = (uv[0] + t[0] - 0.5) * s[0];
|
||||
let dy = (uv[1] + t[1] - 0.5) * s[1];
|
||||
[0.5 + dx * c - dy * sn, 0.5 + dx * sn + dy * c]
|
||||
}
|
||||
|
||||
/// The `(cos, sin)` of a raw quaternion's z-rotation, as the reference's matrix builder yields
|
||||
/// them — `1 − 2z²`, `2zw`, unnormalised (see [`uv_transform`]).
|
||||
pub fn rotation_2x2(q: [f32; 4]) -> (f32, f32) {
|
||||
let (z, w) = (q[2], q[3]);
|
||||
(1.0 - 2.0 * z * z, 2.0 * z * w)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use benilla_m2::M2Vec3Track;
|
||||
|
||||
/// The law's handedness, on the note's own worked example: `θ = +90°` (`z = w = √½`),
|
||||
/// `p = (½, ½)`, no translation, unit scale — `(1, 0.5) ↦ (0.5, 1.0)`, a counter-clockwise
|
||||
/// turn in the `u`-right / `v`-up frame; and a `z < 0` key (every key of the cooldown model)
|
||||
/// turns the other way.
|
||||
#[test]
|
||||
fn the_uv_law_turns_counter_clockwise_for_a_positive_z() {
|
||||
let r = std::f32::consts::FRAC_1_SQRT_2;
|
||||
let got = uv_transform([1.0, 0.5], [0.0, 0.0], [0.0, 0.0, r, r], [1.0, 1.0]);
|
||||
assert!(
|
||||
(got[0] - 0.5).abs() < 1e-6 && (got[1] - 1.0).abs() < 1e-6,
|
||||
"{got:?}"
|
||||
);
|
||||
let cw = uv_transform([1.0, 0.5], [0.0, 0.0], [0.0, 0.0, -r, r], [1.0, 1.0]);
|
||||
assert!(
|
||||
(cw[0] - 0.5).abs() < 1e-6 && (cw[1] - 0.0).abs() < 1e-6,
|
||||
"{cw:?}"
|
||||
);
|
||||
// Identity in, identity out — the static path's whole population.
|
||||
let id = uv_transform([0.2, 0.7], [0.0, 0.0], [0.0, 0.0, 0.0, 1.0], [1.0, 1.0]);
|
||||
assert!((id[0] - 0.2).abs() < 1e-6 && (id[1] - 0.7).abs() < 1e-6);
|
||||
// A translation is added BEFORE the pivoted rotation: `(uv + t − p)` turns.
|
||||
let tr = uv_transform([0.5, 0.5], [0.5, 0.0], [0.0, 0.0, r, r], [1.0, 1.0]);
|
||||
assert!(
|
||||
(tr[0] - 0.5).abs() < 1e-6 && (tr[1] - 1.0).abs() < 1e-6,
|
||||
"{tr:?}"
|
||||
);
|
||||
// The lerped, unnormalised midpoint between 0° and 45° keys shrinks: |q| < 1 ⇒ the
|
||||
// block's scale is below 1 (the note's cos 11.25° effect, at a coarser pair here).
|
||||
let (c, sn) = rotation_2x2([
|
||||
0.0,
|
||||
0.0,
|
||||
0.5 * (0.0 + (22.5f32).to_radians().sin()),
|
||||
0.5 * (1.0 + (22.5f32).to_radians().cos()),
|
||||
]);
|
||||
assert!((c * c + sn * sn).sqrt() < 1.0);
|
||||
}
|
||||
|
||||
/// The cooldown indicator's own rotation tracks, straight off the client data: transform 0's
|
||||
/// keys turn `0 → −90°` over the first 250 ms of sequence 0 (`z` from 0 to `−√½`) and hold
|
||||
/// there to the sequence's end; sequence 1's window is the identity throughout (its keys at
|
||||
/// 1167 and 2167 ms are both `(0, 0, 0, 1)`). Skips without client data.
|
||||
#[test]
|
||||
fn the_cooldown_indicators_rotation_bakes_per_sequence() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let bytes = chain
|
||||
.read_file(r"Interface\Cooldown\UI-Cooldown-Indicator.m2")
|
||||
.expect("read the cooldown indicator");
|
||||
let subs = super::super::parse_m2_render_submeshes(&bytes, "", &[]).expect("parse");
|
||||
let rot = subs[0]
|
||||
.uv_rot_seq
|
||||
.as_ref()
|
||||
.expect("the top-right quadrant's rotation loop");
|
||||
let seq0 = rot.seq(Some(0)).expect("sequence 0 keys it");
|
||||
assert!(!seq0.wrap, "sequence 0 clamps (flags 0x1)");
|
||||
let r = std::f32::consts::FRAC_1_SQRT_2;
|
||||
let at = |t: f32| seq0.sample(t);
|
||||
assert!(at(0.0)[2].abs() < 1e-5, "{:?}", at(0.0));
|
||||
assert!(
|
||||
(at(0.25)[2] + r).abs() < 1e-3,
|
||||
"−90° at 250 ms: {:?}",
|
||||
at(0.25)
|
||||
);
|
||||
assert!(
|
||||
(at(0.9)[2] + r).abs() < 1e-3,
|
||||
"held to the end: {:?}",
|
||||
at(0.9)
|
||||
);
|
||||
// The lerp between the 0 and 62 ms keys is component-wise on the raw quaternion.
|
||||
let mid = at(0.031);
|
||||
assert!(mid[2] < 0.0 && mid[2] > -r);
|
||||
assert!(subs[0].uv_scale_seq.is_none(), "no scale track");
|
||||
assert!(subs[4].uv_rot_seq.is_none(), "the star has no transform");
|
||||
}
|
||||
|
||||
fn track(gseq: u16, interp: u16, keys: &[(u32, [f32; 3])]) -> M2Vec3Track {
|
||||
M2Vec3Track {
|
||||
interp,
|
||||
|
||||
@@ -493,6 +493,15 @@ pub struct RenderSubmesh {
|
||||
/// only slot the shared-material registry can read — is a dead hold). `None` for every batch
|
||||
/// whose slots agree, which is the shared lane unchanged.
|
||||
pub uv_seq: Option<SeqLoops<[f32; 2]>>,
|
||||
/// The batch's texture-transform **rotation** loop per file sequence slot — the raw
|
||||
/// quaternion keys ([`tex_anim::bake_uv_rot_seqs`](super::tex_anim::bake_uv_rot_seqs),
|
||||
/// decision 2019), for the lanes that own a material per instance (the UI model tiles;
|
||||
/// the cooldown indicator's sweep is this channel). `None` for a transform that never
|
||||
/// rotates — every placed world doodad — and all of WMO.
|
||||
pub uv_rot_seq: Option<SeqLoops<[f32; 4]>>,
|
||||
/// The batch's texture-transform **scaling** loop per file sequence slot (`(x, y)`), on the
|
||||
/// same rule as [`Self::uv_rot_seq`].
|
||||
pub uv_scale_seq: Option<SeqLoops<[f32; 2]>>,
|
||||
/// The batch's **animated RGB tint** (the M2Color colour track, time-varying only — a spell
|
||||
/// effect's white-hot flash cooling to red): baked by [`mat_anim`](super::mat_anim). When
|
||||
/// `Some`, the static vertex-colour tint is **skipped** for this batch (the two would
|
||||
@@ -616,6 +625,8 @@ impl Default for RenderSubmesh {
|
||||
alpha_anim: None,
|
||||
uv_anim: None,
|
||||
uv_seq: None,
|
||||
uv_rot_seq: None,
|
||||
uv_scale_seq: None,
|
||||
rgb_anim: None,
|
||||
rgb_seq: None,
|
||||
wmo_batch: None,
|
||||
|
||||
@@ -104,6 +104,8 @@ fn the_shape_ignores_edge_on_correct_and_degenerate_batches() {
|
||||
interior: false,
|
||||
emissive: false,
|
||||
icon_slot: false,
|
||||
uv_rot_seq: None,
|
||||
uv_scale_seq: None,
|
||||
sidn: None,
|
||||
window: false,
|
||||
additive: false,
|
||||
|
||||
@@ -32,18 +32,13 @@ impl Loader<'_> {
|
||||
// 1.2/1.22, the pings' 0.4, the dressing room's 2.0), and until decision 2007 the loader
|
||||
// read none of them. Whether the generic frame loader (`0x769820`) reads a `scale`
|
||||
// attribute of its own is not carved; a plain frame's `scale=` is left as it was.
|
||||
//
|
||||
// Gated on the kind the element MATERIALIZES as, not on its tag: a `<Model>` playing the
|
||||
// cooldown indicator is this engine's `Cooldown` widget (`frame_kind_of`), which has no
|
||||
// model scale — and the shipped template carries exactly that pairing (`scale="0.75"`
|
||||
// on the cooldown file), applied through every action and bag button that inherits it.
|
||||
|
||||
// `file=` on a model pane is `SetModel` (`CSimpleModel::LoadXML` `0x76cac0` installs the
|
||||
// file into the widget, resident or streaming — decision 2013). Until 2013 no XML-declared
|
||||
// pane ever held a file: the loader read `file=` for the cooldown mapping alone, and the
|
||||
// pings, the shine and the item card were bare panes to the engine. Gated on the
|
||||
// materialized kind like `scale=` below: a `<Model>` playing the cooldown indicator is
|
||||
// this engine's `Cooldown` widget, which has no file.
|
||||
let model_kind = super::model_kind_tag(&super::frame_kind_of(el));
|
||||
// pane ever held a file: the loader read `file=` only to turn the cooldown indicator's
|
||||
// pane into a native widget of ours (retired by 2019), and the pings, the shine and the
|
||||
// item card were bare panes to the engine.
|
||||
let model_kind = super::model_kind_tag(&el.tag);
|
||||
if model_kind {
|
||||
if let Some(file) = el.attr("file") {
|
||||
let text = self.resolve_text(file, dbg);
|
||||
|
||||
@@ -297,24 +297,6 @@ pub fn join_ref(base: &str, path: &str) -> String {
|
||||
out.join("/")
|
||||
}
|
||||
|
||||
/// The frame KIND an element materializes as — its tag, with one substitution.
|
||||
///
|
||||
/// **A `<Model>` playing the cooldown indicator IS this engine's `<Cooldown>` widget.** The
|
||||
/// reference draws a cooldown sweep as a `Model` running
|
||||
/// `Interface\Cooldown\UI-Cooldown-Indicator.mdx` — that is what `CooldownFrameTemplate` is, and
|
||||
/// every consumer writes `<Model … inherits="CooldownFrameTemplate"/>`: the reference's own
|
||||
/// `ContainerFrameItemButtonTemplate` (ContainerFrame.xml l.13), its action buttons, and three
|
||||
/// corpus addons. benilla renders no FrameXML models at all, and it does not need to for this one:
|
||||
/// decision 0263 put the whole scrub/finish-flash machine inside a NATIVE `Cooldown` widget, which
|
||||
/// is the same picture by a different route.
|
||||
///
|
||||
/// So the mapping is on the MODEL FILE, not on the template name — the file is the thing's
|
||||
/// identity, an addon may reach the same art without inheriting the template, and a template name
|
||||
/// is a string anyone can shadow. Anything else keeps its tag and stays an inert `Model`.
|
||||
///
|
||||
/// Without this, decision 1751's bag swap silently loses the bag-slot cooldown sweep: our own
|
||||
/// `BenillaBagSlotTemplate` carried a real `<Cooldown>` child, the reference's carries a `<Model>`,
|
||||
/// and `CooldownFrame_SetTimer`'s Model branch shows and hides the frame and nothing more.
|
||||
/// Is `tag` one of the four model-pane kinds — the `CSimpleModel` family, whose own `LoadXML`
|
||||
/// (`0x76cac0`) reads the `scale=` attribute into the MODEL scale (`geometry.rs`'s
|
||||
/// `apply_attrs`, decision 2007).
|
||||
@@ -324,18 +306,6 @@ pub(super) fn model_kind_tag(tag: &str) -> bool {
|
||||
.any(|k| k.eq_ignore_ascii_case(tag))
|
||||
}
|
||||
|
||||
fn frame_kind_of(el: &Element) -> String {
|
||||
const COOLDOWN_MODEL: &str = "ui-cooldown-indicator.mdx";
|
||||
if el.tag.eq_ignore_ascii_case("Model")
|
||||
&& el
|
||||
.attr("file")
|
||||
.is_some_and(|f| f.to_ascii_lowercase().ends_with(COOLDOWN_MODEL))
|
||||
{
|
||||
return "Cooldown".to_string();
|
||||
}
|
||||
el.tag.clone()
|
||||
}
|
||||
|
||||
/// The `.lua` test `0x6ede10` opens with: `strrchr(path, '.')` on the **whole resolved path**,
|
||||
/// then a **case-insensitive** compare against `".lua"` (`0x8710c8`, through `0x64a4c0`).
|
||||
///
|
||||
@@ -792,7 +762,7 @@ impl Loader<'_> {
|
||||
}
|
||||
};
|
||||
let wrapper: Table =
|
||||
match create.call((frame_kind_of(el), resolved_name.clone(), parent.cloned())) {
|
||||
match create.call((el.tag.clone(), resolved_name.clone(), parent.cloned())) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
self.report.errors.push(format!(
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
//! The `Cooldown` widget's method surface (decision 0137 phase 4) — the Era API's
|
||||
//! `Cooldown:SetCooldown(start, duration)` over the engine-modeled sweep/flash machine
|
||||
//! ([`crate::widget::CooldownState`]).
|
||||
//!
|
||||
//! The 1.12 reference has no such method: its cooldown is a `Model` playing
|
||||
//! `UI-Cooldown-Indicator.mdx`, driven by `Cooldown.lua`'s authored machine
|
||||
//! (`CooldownFrame_SetTimer` → `SetSequence(0)` + `Show`; `OnUpdateModel` scrubs sequence 0 to
|
||||
//! `(GetTime()-start)/duration`, flips to sequence 1 realtime at 1.0, `OnAnimFinished` → `Hide`).
|
||||
//! benilla keeps `CooldownFrame_SetTimer`'s show/hide gate ref-verbatim in the authored XML and
|
||||
//! models the sequence machine engine-side: `SetCooldown` stores the pair, the extraction derives
|
||||
//! the sweep fraction / flash phase from the engine clock, and `tick` hides a finished widget
|
||||
//! (the `OnAnimFinished` edge). The pixels — the byte-pinned 60 %-black pie and the `star4`
|
||||
//! flash — are the app renderer's ([`super::types::QuadContent::Cooldown`]).
|
||||
//!
|
||||
//! The methods live in their own registry table, consulted by the frame `__index` dispatcher only
|
||||
//! for Cooldown frames — the same duck-typing posture as StatusBar's.
|
||||
|
||||
use mlua::{Lua, Table};
|
||||
|
||||
use super::object::frame_handle_of;
|
||||
use super::Model;
|
||||
use crate::widget::{CooldownState, KindState};
|
||||
|
||||
/// Registry key of the Cooldown method table (the MAXCSTACK discipline: Lua-side root, named key).
|
||||
pub(super) const REG_COOLDOWN_METHODS: &str = "__benilla_cooldown_methods";
|
||||
|
||||
/// Run `f` over a frame's Cooldown state under one short write borrow.
|
||||
fn with_cooldown<T>(
|
||||
lua: &Lua,
|
||||
this: &Table,
|
||||
f: impl FnOnce(&mut CooldownState) -> T,
|
||||
) -> mlua::Result<T> {
|
||||
let h = frame_handle_of(lua, this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let frame = model
|
||||
.arena
|
||||
.frame_mut(h)
|
||||
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
|
||||
match &mut frame.kind_state {
|
||||
KindState::Cooldown(c) => Ok(f(c)),
|
||||
_ => Err(mlua::Error::runtime("not a Cooldown")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let m = lua.create_table()?;
|
||||
|
||||
m.set(
|
||||
"SetCooldown",
|
||||
// `SetCooldown(start, duration)` — GetTime-clock seconds, the pair
|
||||
// `CooldownFrame_SetTimer` stores (`this.start`/`this.duration`). Show/hide stays the
|
||||
// authored helper's job (ref-verbatim), so the method only (re)arms the timer.
|
||||
lua.create_function(|lua, (this, start, duration): (Table, f64, f64)| {
|
||||
with_cooldown(lua, &this, |c| {
|
||||
c.start = start;
|
||||
c.duration = duration;
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"GetCooldownTimes",
|
||||
// The Era read-back (milliseconds, like the live API) — no reference consumer; kept for
|
||||
// parity with SetCooldown so a transcription can round-trip.
|
||||
lua.create_function(|lua, this: Table| {
|
||||
with_cooldown(lua, &this, |c| (c.start * 1000.0, c.duration * 1000.0))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
lua.set_named_registry_value(REG_COOLDOWN_METHODS, m)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -91,7 +91,6 @@ impl UiScript {
|
||||
/// [`crate::order::traversal`] zipped with the resolved rects and region visuals. Already sorted
|
||||
/// ascending by `ZKey`. Call [`UiScript::resolve`] first for populated rects.
|
||||
pub fn extract(&self) -> Vec<ExtractedQuad> {
|
||||
let now = self.now();
|
||||
let model = self.model_ref();
|
||||
let list = order::traversal(&model.arena);
|
||||
let mut out = Vec::with_capacity(list.len());
|
||||
@@ -114,18 +113,6 @@ impl UiScript {
|
||||
zoom: m.zoom,
|
||||
inside_zoom: m.inside_zoom,
|
||||
},
|
||||
// A shown Cooldown's phase (the reference machine's derived state): the
|
||||
// sweep scrub while `now < start+duration`, else the 1 s flash's own
|
||||
// progress. `tick` hides the widget at flash end, so a stale slot never
|
||||
// draws (decision 0137 phase 4).
|
||||
Some(crate::widget::KindState::Cooldown(cd)) if cd.duration > 0.0 => {
|
||||
let fraction = ((now - cd.start) / cd.duration) as f32;
|
||||
let flash = (fraction >= 1.0).then(|| {
|
||||
((now - cd.sweep_end()) / crate::widget::COOLDOWN_FLASH_SECS)
|
||||
.clamp(0.0, 1.0) as f32
|
||||
});
|
||||
QuadContent::Cooldown { fraction, flash }
|
||||
}
|
||||
// A `<Model>`/`<PlayerModel>` pane's content hole, carrying the pane's own
|
||||
// name so the app can join it to the bake that window keeps (see
|
||||
// [`QuadContent::ModelPane`]). Both widget kinds share `KindState::Model`
|
||||
|
||||
@@ -64,7 +64,6 @@ mod chat_window;
|
||||
mod clip;
|
||||
mod colorselect;
|
||||
mod container;
|
||||
mod cooldown;
|
||||
mod craft;
|
||||
mod cursor;
|
||||
mod death;
|
||||
@@ -211,7 +210,7 @@ pub use tabard::{
|
||||
emblem_mask_path, TabardHost, TabardIntent, EMBLEM_MASK_TOKEN, TABARD_COUNTS,
|
||||
TABARD_CREATION_COST,
|
||||
};
|
||||
pub use worldmap_arrow::{ARROW_FOOTPRINT_PX, ARROW_MODEL};
|
||||
pub use worldmap_arrow::ARROW_MODEL;
|
||||
|
||||
pub(crate) use button::{set_label_font_justify_h_lua, LabelFont};
|
||||
pub use inspect::{InspectView, UnitReach};
|
||||
@@ -667,7 +666,6 @@ impl UiScript {
|
||||
colorselect::install(&lua)?;
|
||||
minimap::install(&lua)?;
|
||||
modelframe::install(&lua)?;
|
||||
cooldown::install(&lua)?;
|
||||
tooltip::install(&lua)?;
|
||||
worldmap::install(&lua)?;
|
||||
worldstate::install(&lua)?;
|
||||
@@ -790,6 +788,9 @@ impl UiScript {
|
||||
}
|
||||
model.screen = new;
|
||||
model.touch_layout();
|
||||
drop(model);
|
||||
// The implicit rects are measured in layout units, which follow the aspect (2015).
|
||||
self.reapply_implicit_rects();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1474,7 +1475,6 @@ impl UiScript {
|
||||
crate::widget::FrameKind::MovieFrame => "MovieFrame",
|
||||
crate::widget::FrameKind::GameTooltip => "GameTooltip",
|
||||
crate::widget::FrameKind::Minimap => "Minimap",
|
||||
crate::widget::FrameKind::Cooldown => "Cooldown",
|
||||
});
|
||||
}
|
||||
// The region leaves publish into their own name table (`region_names`), not the arena's —
|
||||
|
||||
@@ -94,9 +94,70 @@ use mlua::{Lua, MultiValue, Table, Value};
|
||||
|
||||
use super::object::frame_handle_of;
|
||||
use super::Model;
|
||||
use crate::widget::{model_key, KindState, ModelFileFacts, ModelState};
|
||||
use crate::widget::{model_key, FrameHandle, KindState, ModelFileFacts, ModelState};
|
||||
|
||||
impl Model {
|
||||
/// FrameXML units per **layout unit** — `768 · √(a²+1)` for the screen's aspect `a`
|
||||
/// (render law §3; the `G48 = 1/√(a²+1)` root scale against the `768`-tall FrameXML
|
||||
/// space). `SetPosition`'s space, and the unit a size-less pane's implicit rect is measured
|
||||
/// in. `4/3` before a screen exists.
|
||||
pub(crate) fn layout_unit(&self) -> f32 {
|
||||
let (w, h) = (self.screen.width(), self.screen.height());
|
||||
let a = if h > 0.0 && w > 0.0 { w / h } else { 4.0 / 3.0 };
|
||||
768.0 * (a * a + 1.0).sqrt()
|
||||
}
|
||||
|
||||
/// The **implicit rect** (decision 2015): a model pane that authored no size takes its
|
||||
/// file's bounding-box extent, in layout units, as its size — the reference's geometry
|
||||
/// overrides (`0x76d080`/`0x76d0d0`) answer the bbox whenever no size is authored, and its
|
||||
/// layout consumes them like any size. Written into the layout input when the facts are
|
||||
/// known; a pane with an authored size, or no file, or no facts yet, is left alone.
|
||||
pub(crate) fn apply_implicit_rect(&mut self, h: FrameHandle) {
|
||||
let unit = self.layout_unit();
|
||||
let extent = {
|
||||
let Some(f) = self.arena.frame(h) else { return };
|
||||
let KindState::Model(m) = &f.kind_state else {
|
||||
return;
|
||||
};
|
||||
let Some(path) = m.path.as_deref() else {
|
||||
return;
|
||||
};
|
||||
let Some(facts) = self.model_facts.get(&model_key(path)) else {
|
||||
return;
|
||||
};
|
||||
let authored = !m.implicit_size
|
||||
&& self
|
||||
.layout_inputs
|
||||
.get(&h)
|
||||
.is_some_and(|i| i.width != 0.0 || i.height != 0.0);
|
||||
if authored {
|
||||
return;
|
||||
}
|
||||
facts.extent()
|
||||
};
|
||||
let (w, ht) = (extent.0 * unit, extent.1 * unit);
|
||||
let input = self.layout_inputs.entry(h).or_default();
|
||||
let changed =
|
||||
input.width.to_bits() != w.to_bits() || input.height.to_bits() != ht.to_bits();
|
||||
input.width = w;
|
||||
input.height = ht;
|
||||
if changed {
|
||||
self.touch_layout_frame(h);
|
||||
}
|
||||
if let Some(KindState::Model(m)) = self.arena.frame_mut(h).map(|f| &mut f.kind_state) {
|
||||
m.implicit_size = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// A size was AUTHORED on `h` (`SetWidth`/`SetHeight`/`SetSize`, the XML `<Size>` through
|
||||
/// them): an implicit rect no longer applies to it, for good — the reference's override
|
||||
/// yields to any authored size.
|
||||
pub(crate) fn note_authored_size(&mut self, h: FrameHandle) {
|
||||
if let Some(KindState::Model(m)) = self.arena.frame_mut(h).map(|f| &mut f.kind_state) {
|
||||
m.implicit_size = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// The facts the host has handed over for `path`, or `None` — in which case the path is
|
||||
/// queued for the host to load ([`crate::script::UiScript::model_facts_wanted`]), once.
|
||||
pub(crate) fn model_facts_for(&mut self, path: &str) -> Option<Arc<ModelFileFacts>> {
|
||||
@@ -236,7 +297,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.model_facts_for(&path);
|
||||
with_model(lua, &this, |m| m.set_file(path, facts.as_deref()))
|
||||
with_model(lua, &this, |m| m.set_file(path, facts.as_deref()))?;
|
||||
// A size-less pane takes the file's rect the moment the file is known (2015).
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.apply_implicit_rect(h);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
@@ -559,6 +626,27 @@ impl crate::script::UiScript {
|
||||
if let Some(KindState::Model(m)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
|
||||
m.seed_from_facts(&facts);
|
||||
}
|
||||
model.apply_implicit_rect(h);
|
||||
}
|
||||
}
|
||||
|
||||
/// The screen's aspect moved: every implicit rect is measured in layout units, which scale
|
||||
/// with `√(a²+1)`, so each one is re-derived (decision 2015). An authored size is untouched.
|
||||
pub(crate) fn reapply_implicit_rects(&mut self) {
|
||||
let mut model = self.model_mut();
|
||||
let panes: Vec<FrameHandle> = model
|
||||
.arena
|
||||
.ticked_kinds()
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&h| {
|
||||
model.arena.frame(h).is_some_and(
|
||||
|f| matches!(&f.kind_state, KindState::Model(m) if m.implicit_size),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for h in panes {
|
||||
model.apply_implicit_rect(h);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -249,7 +249,6 @@ fn frame_kind_from_str(s: &str) -> Option<FrameKind> {
|
||||
"MOVIEFRAME" => FrameKind::MovieFrame,
|
||||
"GAMETOOLTIP" => FrameKind::GameTooltip,
|
||||
"MINIMAP" => FrameKind::Minimap,
|
||||
"COOLDOWN" => FrameKind::Cooldown,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -458,7 +457,6 @@ fn kind_method_registries(lua: &Lua, this: &Table) -> &'static [&'static str] {
|
||||
super::modelframe::REG_MODEL_METHODS,
|
||||
],
|
||||
Some(FrameKind::Minimap) => &[super::minimap::REG_MINIMAP_METHODS],
|
||||
Some(FrameKind::Cooldown) => &[super::cooldown::REG_COOLDOWN_METHODS],
|
||||
Some(FrameKind::GameTooltip) => &[super::tooltip::REG_TOOLTIP_METHODS],
|
||||
_ => &[],
|
||||
}
|
||||
|
||||
@@ -109,13 +109,12 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// anything derived from the variant name — `format!("{:?}")` and friends — would hand
|
||||
// addons `"SimpleHtml"`, and `GetObjectType` is compared with `==` (`IsObjectType`'s
|
||||
// case-folding would hide it; the getter's would not).
|
||||
// · **`Cooldown` answers `"Model"`.** 1.12.1 has no `Cooldown` type — the census finds 23
|
||||
// type-name globals and none is that. The reference builds its cooldown as a `Model` playing
|
||||
// `UI-Cooldown-Indicator.mdx` (`CooldownFrameTemplate`), and OUR `FrameKind::Cooldown` is a
|
||||
// deliberate Era-shaped divergence that models the mechanism first-class (0137 phase 4). So
|
||||
// the faithful answer is what the reference's own cooldown widget IS. Answering `"Cooldown"`
|
||||
// would announce an Era type to a Lua ecosystem that branches on presence — precisely the
|
||||
// superset 1189 had to take back out.
|
||||
// · **There is no `Cooldown` type.** 1.12.1's census finds 23 type-name globals and none is
|
||||
// that: the reference builds its cooldown as a `Model` playing `UI-Cooldown-Indicator.mdx`
|
||||
// (`CooldownFrameTemplate`), and since decision 2019 so does this engine — the Era-shaped
|
||||
// `FrameKind::Cooldown` that modelled the mechanism first-class (0137 phase 4) is gone, so
|
||||
// `CreateFrame("Cooldown")` is the reference's own unknown-type refusal, never an announced
|
||||
// Era type to a Lua ecosystem that branches on presence (the superset 1189 took back out).
|
||||
fn type_chain(kind: FrameKind) -> &'static [&'static str] {
|
||||
match kind {
|
||||
FrameKind::Frame => &["Frame", "Region"],
|
||||
@@ -144,7 +143,6 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
FrameKind::MovieFrame => &["MovieFrame", "Frame", "Region"],
|
||||
FrameKind::GameTooltip => &["GameTooltip", "Frame", "Region"],
|
||||
FrameKind::Minimap => &["Minimap", "Frame", "Region"],
|
||||
FrameKind::Cooldown => &["Model", "Frame", "Region"],
|
||||
}
|
||||
}
|
||||
fn chain_of(lua: &Lua, this: &Table) -> mlua::Result<&'static [&'static str]> {
|
||||
|
||||
@@ -155,6 +155,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// A size write moves no edge and no roster membership (decision 1388).
|
||||
model.touch_layout_frame(h);
|
||||
}
|
||||
model.note_authored_size(h);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
@@ -169,6 +170,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
if changed {
|
||||
model.touch_layout_frame(h);
|
||||
}
|
||||
model.note_authored_size(h);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
@@ -185,6 +187,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
if changed {
|
||||
model.touch_layout_frame(h);
|
||||
}
|
||||
model.note_authored_size(h);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
//! The `ScrollFrame` method surface (`CSimpleScrollFrame`) — the ScrollFrame mechanism (decision
|
||||
//! 0112, the engine's last structural gap): `SetScrollChild`/`GetScrollChild`, the vertical scroll
|
||||
//! offset (`SetVerticalScroll`/`GetVerticalScroll`), and the live range
|
||||
//! (`GetVerticalScrollRange`/`UpdateScrollChildRect`). Spec-faithful to the documented widget
|
||||
//! contract (the Era `ScrollFrameTemplate` Lua drives its scrollbar off exactly these), not
|
||||
//! byte-pinned — same posture as StatusBar's fill. Horizontal scroll (`SetHorizontalScroll`/…) is
|
||||
//! out of scope: no 1.12 template drives it.
|
||||
//! (`GetVerticalScrollRange`/`UpdateScrollChildRect`). The offset setter and the range are
|
||||
//! byte-pinned (decisions 1338, 2017): `0x786db0` stores the offset VERBATIM — the engine never
|
||||
//! clamps it — and `0x786e30` measures the range off the scroll child's subtree. The rest is
|
||||
//! spec-faithful to the documented widget contract (the Era `ScrollFrameTemplate` Lua drives its
|
||||
//! scrollbar off exactly these), same posture as StatusBar's fill. Horizontal scroll
|
||||
//! (`SetHorizontalScroll`/…) is out of scope: no 1.12 template drives it.
|
||||
//!
|
||||
//! The actual geometry — the scroll child's anchors pinned to the frame + the scroll offset, and
|
||||
//! the clip every descendant of the child draws/hits within — lives in [`super::mod@super`]'s
|
||||
@@ -203,26 +205,33 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SetVerticalScroll(px) — clamps into [0, GetVerticalScrollRange()] (computed live), stores, and
|
||||
// fires OnVerticalScroll(self, offset) — the scrollbar's OnValueChanged wiring's other half.
|
||||
// SetVerticalScroll(px) — stores the offset VERBATIM and, only when it actually changed,
|
||||
// re-lays the child out and fires OnVerticalScroll(self, offset) — the scrollbar's
|
||||
// OnValueChanged wiring's other half.
|
||||
//
|
||||
// **No clamp** (decision 2017). The reference's `0x786db0` compares the new value against the
|
||||
// OLD one alone (an epsilon change-gate: unchanged → nothing happens, not even the script),
|
||||
// writes it to `[+0x328]`, re-anchors the scroll child off it (`0x787100` →
|
||||
// `SetPoint(child, self, hScroll, vScroll)`) and fires the handler. `[+0x320]` — the range —
|
||||
// is never read on this path. Every clamp the reference exhibits is FrameXML's own, through
|
||||
// the scroll bar's `[min, max]` (`ScrollFrameTemplate_OnMouseWheel`,
|
||||
// `ScrollFrame_OnScrollRangeChanged`). The clamp this carried from 07-05 (a3fce6a76) to 2017
|
||||
// cost every faux list whose frame is taller than `rows × step` its last rows (B370): the
|
||||
// reference's `FauxScrollFrame_Update` drives the bar to `(n − rows) × step`, which is past
|
||||
// `n × step − frameHeight` whenever the frame has slack under its last row.
|
||||
m.set(
|
||||
"SetVerticalScroll",
|
||||
lua.create_function(|lua, (this, px): (Table, f32)| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let range = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
scroll_range(&model, h)
|
||||
};
|
||||
let clamped = px.clamp(0.0, range);
|
||||
let changed = with_scroll(lua, &this, |s| {
|
||||
let changed = s.vertical.to_bits() != clamped.to_bits();
|
||||
s.vertical = clamped;
|
||||
let changed = s.vertical.to_bits() != px.to_bits();
|
||||
s.vertical = px;
|
||||
changed
|
||||
})?;
|
||||
if changed {
|
||||
lua.app_data_mut::<Model>().expect("model").touch_layout();
|
||||
if !changed {
|
||||
return Ok(());
|
||||
}
|
||||
fire_vertical_scroll(lua, &this, clamped)
|
||||
lua.app_data_mut::<Model>().expect("model").touch_layout();
|
||||
fire_vertical_scroll(lua, &this, px)
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
|
||||
@@ -1,64 +1,10 @@
|
||||
//! The Cooldown widget kind (decision 0137 phase 4): the `SetCooldown` API, the reference
|
||||
//! machine's derived phases through extraction, and the tick auto-hide (`Cooldown.lua`'s
|
||||
//! `OnAnimFinished` → `Hide` edge), plus the per-action dynamic-state read API.
|
||||
//! The cooldown **bindings**: the per-action dynamic-state read API and the `GetTime`-space
|
||||
//! cooldown triples the stock `Cooldown.lua` machine consumes (the widget itself is the
|
||||
//! reference's own `<Model>` since decision 2019 — `tests::model_clock` runs that file verbatim).
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::*;
|
||||
|
||||
fn cooldown_content(s: &UiScript) -> Option<(f32, Option<f32>)> {
|
||||
s.extract().into_iter().find_map(|q| match q.content {
|
||||
QuadContent::Cooldown { fraction, flash } => Some((fraction, flash)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The machine, end to end on the engine clock: hidden → sweep (fraction scrubs with GetTime) →
|
||||
/// the 1 s finish flash → auto-hidden by tick.
|
||||
#[test]
|
||||
fn cooldown_widget_sweeps_flashes_and_hides() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
cd = CreateFrame("Cooldown", "TestCooldown")
|
||||
cd:SetWidth(36); cd:SetHeight(36); cd:SetPoint("CENTER")
|
||||
cd:Hide()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(cooldown_content(&s), None, "hidden — no slot emitted");
|
||||
|
||||
// Arm a 10 s cooldown that started 2.5 s ago (the reference's start-in-the-past shape from
|
||||
// GetActionCooldown) and show — the sweep reads 25 %.
|
||||
s.run("cd:SetCooldown(GetTime() - 2.5, 10); cd:Show()")
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let (fraction, flash) = cooldown_content(&s).expect("shown — the slot is emitted");
|
||||
assert!(
|
||||
(fraction - 0.25).abs() < 1e-3,
|
||||
"sweep at 25%, got {fraction}"
|
||||
);
|
||||
assert_eq!(flash, None, "no flash while the sweep runs");
|
||||
|
||||
// Advance past the sweep end into the flash's middle: fraction ≥ 1, flash ≈ 0.5.
|
||||
s.tick(8.0); // now 0.5 s into the flash window
|
||||
s.resolve();
|
||||
let (fraction, flash) = cooldown_content(&s).expect("flash phase still emits");
|
||||
assert!(fraction >= 1.0);
|
||||
let f = flash.expect("the finish flash runs for 1 s after the sweep");
|
||||
assert!((f - 0.5).abs() < 0.05, "flash midway, got {f}");
|
||||
|
||||
// Past the flash: tick hides the widget (the OnAnimFinished → Hide edge).
|
||||
s.tick(1.0);
|
||||
s.resolve();
|
||||
assert_eq!(cooldown_content(&s), None, "finished — tick hid the widget");
|
||||
assert!(
|
||||
!s.eval::<bool>("return cd:IsVisible() == 1").unwrap(),
|
||||
"the frame itself is hidden, not just skipped"
|
||||
);
|
||||
}
|
||||
|
||||
/// The per-action dynamic-state API: the 1/nil conventions, IsActionInRange's tri-state, and
|
||||
/// GetActionCooldown's GetTime-space triple that goes cold at expiry.
|
||||
#[test]
|
||||
|
||||
@@ -409,6 +409,73 @@ fn a_loop_completes_once_and_a_fileless_pane_paints_nothing() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The implicit rect** (decision 2015): a model pane that authored no size takes its file's
|
||||
/// bounding-box extent in LAYOUT units — `768·√(a²+1)` FrameXML units per unit, `1280` at 4:3
|
||||
/// — the moment the facts are known; it follows the screen's aspect; an authored size wins.
|
||||
#[test]
|
||||
fn a_size_less_pane_takes_its_files_rect_in_layout_units() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
// The map arrow's own box (`MinimapArrow.m2`, render law §2): 0.0262 × 0.0263 units.
|
||||
let arrow = |s: &mut UiScript| {
|
||||
s.set_model_facts(
|
||||
r"Interface\Minimap\MinimapArrow.mdx",
|
||||
ModelFileFacts {
|
||||
sequences: vec![SequenceFacts {
|
||||
anim_id: 0,
|
||||
duration_ms: 3333,
|
||||
looping: true,
|
||||
}],
|
||||
bbox: ([-0.0127, -0.0118, 0.0], [0.0135, 0.0145, 0.0]),
|
||||
},
|
||||
);
|
||||
};
|
||||
s.run(
|
||||
r#"
|
||||
a = CreateFrame("Model", "Sized", UIParent)
|
||||
a:SetPoint("CENTER")
|
||||
b = CreateFrame("Model", "Authored", UIParent)
|
||||
b:SetPoint("CENTER") b:SetWidth(50) b:SetHeight(20)
|
||||
a:SetModel("Interface\\Minimap\\MinimapArrow.mdx")
|
||||
b:SetModel("Interface\\Minimap\\MinimapArrow.mdx")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return Sized:GetWidth()").unwrap(),
|
||||
0.0,
|
||||
"no facts yet: no rect"
|
||||
);
|
||||
arrow(&mut s);
|
||||
s.resolve();
|
||||
let (w, h): (f32, f32) = s
|
||||
.eval("return Sized:GetWidth(), Sized:GetHeight()")
|
||||
.unwrap();
|
||||
assert!(
|
||||
(w - 0.0262 * 1280.0).abs() < 0.05 && (h - 0.0263 * 1280.0).abs() < 0.05,
|
||||
"{w}×{h}"
|
||||
);
|
||||
let (bw, bh): (f32, f32) = s
|
||||
.eval("return Authored:GetWidth(), Authored:GetHeight()")
|
||||
.unwrap();
|
||||
assert_eq!((bw, bh), (50.0, 20.0), "an authored size is untouched");
|
||||
assert!(pane(&s, "Sized").implicit_size && !pane(&s, "Authored").implicit_size);
|
||||
|
||||
// 16:9 — a layout unit is 768·√((16/9)²+1) = 1566.4 FrameXML units.
|
||||
s.set_screen_size(1600.0, 900.0);
|
||||
s.resolve();
|
||||
let w: f32 = s.eval("return Sized:GetWidth()").unwrap();
|
||||
assert!((w - 0.0262 * 1566.4).abs() < 0.1, "{w}");
|
||||
|
||||
// Authoring a size later ends the implicit rect for good.
|
||||
s.run("Sized:SetWidth(10)").unwrap();
|
||||
assert!(!pane(&s, "Sized").implicit_size);
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.resolve();
|
||||
assert_eq!(s.eval::<f32>("return Sized:GetWidth()").unwrap(), 10.0);
|
||||
}
|
||||
|
||||
/// `ReplaceIconTexture` is the type-14 texture override on the INSTANCE: stored over a file
|
||||
/// (queued and replayed while it streams), dropped with no file, released by `SetModel` and
|
||||
/// `ClearModel`.
|
||||
|
||||
@@ -1464,9 +1464,6 @@ fn a_frames_type_chain_matches_the_roster() {
|
||||
"DressUpModel",
|
||||
&["DressUpModel", "PlayerModel", "Model", "Frame", "Region"],
|
||||
),
|
||||
// **Our Era-shaped divergence reports what 1.12's cooldown IS**: a Model. 1.12.1 has no
|
||||
// `Cooldown` type name at all (the roster is 23 and none is that).
|
||||
("Cooldown", "Model", &["Model", "Frame", "Region"]),
|
||||
];
|
||||
for (kind, leaf, chain) in cases {
|
||||
s.run(&format!(r#"TC = CreateFrame("{kind}")"#))
|
||||
|
||||
@@ -68,10 +68,12 @@ fn get_scroll_child_roundtrips_wrapper_and_name_and_clears_on_nil() {
|
||||
}
|
||||
|
||||
/// The sign convention (verified against the design's own worked example): frame top 500, vertical
|
||||
/// 40 ⇒ child top 540. Also covers the 0 / clamped-to-range cases and that `SetScrollChild(nil)`
|
||||
/// restores the child's own authored anchor (never mutated — the override is a local map).
|
||||
/// 40 ⇒ child top 540. Also covers 0, an offset PAST the range and one below zero — both stored and
|
||||
/// applied verbatim, since the reference's `0x786db0` never reads the range (decision 2017) — and
|
||||
/// that `SetScrollChild(nil)` restores the child's own authored anchor (never mutated — the
|
||||
/// override is a local map).
|
||||
#[test]
|
||||
fn scroll_child_top_tracks_vertical_scroll_and_clamps_then_restores_on_clear() {
|
||||
fn scroll_child_top_tracks_vertical_scroll_unclamped_then_restores_on_clear() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0); // screen rect: bottom 0, left 0, top 600, right 800
|
||||
|
||||
@@ -112,18 +114,34 @@ fn scroll_child_top_tracks_vertical_scroll_and_clamps_then_restores_on_clear() {
|
||||
"vertical=40 -> child top = frame top + 40 (a positive offset lifts the child)"
|
||||
);
|
||||
|
||||
// range = child_h(600) - frame_h(200) = 400; asking for 9999 clamps to it.
|
||||
s.run("SF:SetVerticalScroll(9999)").unwrap();
|
||||
// range = child_h(600) - frame_h(200) = 400, and the offset is NOT clamped to it: 450 is
|
||||
// stored and applied as 450. (Keeping the bar inside the range is FrameXML's job, through the
|
||||
// Slider's [min, max] — the engine has no opinion.)
|
||||
s.run("SF:SetVerticalScroll(450)").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return SF:GetVerticalScroll()").unwrap(),
|
||||
400.0
|
||||
450.0,
|
||||
"stored verbatim, the range of 400 notwithstanding"
|
||||
);
|
||||
let quads = s.extract();
|
||||
assert_eq!(
|
||||
marker_rect(&quads, "marker:child").map(|r| r.top),
|
||||
Some(900.0),
|
||||
"clamped: frame top 500 + range 400"
|
||||
Some(950.0),
|
||||
"applied verbatim: frame top 500 + 450"
|
||||
);
|
||||
// Nor at zero.
|
||||
s.run("SF:SetVerticalScroll(-30)").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return SF:GetVerticalScroll()").unwrap(),
|
||||
-30.0
|
||||
);
|
||||
let quads = s.extract();
|
||||
assert_eq!(
|
||||
marker_rect(&quads, "marker:child").map(|r| r.top),
|
||||
Some(470.0),
|
||||
"a negative offset lowers the child: frame top 500 - 30"
|
||||
);
|
||||
|
||||
// SetScrollChild(nil): the override stops being computed — the child's own authored anchor
|
||||
@@ -138,6 +156,41 @@ fn scroll_child_top_tracks_vertical_scroll_and_clamps_then_restores_on_clear() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The reference's `0x786db0` is gated on the offset actually CHANGING (a compare against the
|
||||
/// stored value, nothing else): a `SetVerticalScroll` to the value already held re-lays nothing out
|
||||
/// and fires no `OnVerticalScroll`. The same shape as the Slider's `SetValue` change-gate (decision
|
||||
/// 0250) — the other half of what keeps the bar ↔ frame wiring from ringing — and a value past the
|
||||
/// range is a change like any other.
|
||||
#[test]
|
||||
fn set_vertical_scroll_fires_on_vertical_scroll_only_on_a_change() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
Fires = {}
|
||||
local frame = CreateFrame("ScrollFrame", "SF")
|
||||
frame:SetPoint("TOPLEFT", 0, -100)
|
||||
frame:SetSize(300, 200)
|
||||
frame:SetScript("OnVerticalScroll", function() table.insert(Fires, arg1) end)
|
||||
local child = CreateFrame("Frame", "Child")
|
||||
child:SetSize(300, 600)
|
||||
frame:SetScrollChild(child)
|
||||
frame:SetVerticalScroll(0) -- already 0: nothing
|
||||
frame:SetVerticalScroll(40) -- a change
|
||||
frame:SetVerticalScroll(40) -- the same again: nothing
|
||||
frame:SetVerticalScroll(700) -- past the range (400): a change, stored as given
|
||||
frame:SetVerticalScroll(0) -- back
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return table.concat(Fires, \",\")")
|
||||
.unwrap(),
|
||||
"40,700,0"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_scroll_range_is_live_and_zero_when_unresolved_or_childless_or_shorter() {
|
||||
let mut s = script();
|
||||
@@ -235,8 +288,11 @@ fn vertical_scroll_range_is_local_units_on_a_scaled_frame() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `OnVerticalScroll` carries the value AS STORED — past the range too (decision 2017: the
|
||||
/// reference's `0x786db0` fires with `[+0x328]`, which it never clamps) — under the RF-0025
|
||||
/// conventions, and `UpdateScrollChildRect` fires `OnScrollRangeChanged` with the live range.
|
||||
#[test]
|
||||
fn vertical_scroll_fires_clamped_and_update_rect_fires_range_changed() {
|
||||
fn vertical_scroll_fires_as_given_and_update_rect_fires_range_changed() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
@@ -265,8 +321,8 @@ fn vertical_scroll_fires_clamped_and_update_rect_fires_range_changed() {
|
||||
s.run("SF:SetVerticalScroll(9999)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return seen_v").unwrap(),
|
||||
400.0,
|
||||
"fires with the CLAMPED value"
|
||||
9999.0,
|
||||
"fires with the value as given — the range (400) is not consulted"
|
||||
);
|
||||
|
||||
s.run("SF:UpdateScrollChildRect()").unwrap();
|
||||
|
||||
@@ -14,6 +14,9 @@ fn push_catalog(s: &mut UiScript) {
|
||||
// Cell 8801 = the child (0.5, 0.5) resolves to through Elwynn's OWN loc rect — a "Stormwind
|
||||
// City" peer child. Clicking it from the Elwynn zone map drills into the city (the §3 path).
|
||||
ek_grid[8801] = 2;
|
||||
// Cell 9060 = the child (0.82, 0.82) resolves to through Elwynn's own rect — Elwynn itself,
|
||||
// the case the zone-level hover must stay silent on (the displayed zone never names itself).
|
||||
ek_grid[9060] = 1;
|
||||
s.set_world_map_catalog(vec![
|
||||
WorldMapContinentView {
|
||||
name: "Kalimdor".into(),
|
||||
@@ -52,9 +55,23 @@ fn push_catalog(s: &mut UiScript) {
|
||||
// A clean synthetic loc rect inside the EK continent rect: w=2000, h=1500 → the
|
||||
// continent-highlight formula lands on round-ish expected coords (asserted below).
|
||||
loc_rect: (-8000.0, -10000.0, -400.0, -1900.0),
|
||||
// Two overlays shaped like real Elwynn rows: Northshire reveals on explore bit
|
||||
// 125, Goldshire on bit 124 (the real AreaTable exploreFlags).
|
||||
// Three overlays. Two are shaped like real Elwynn rows: Northshire reveals
|
||||
// on explore bit 125, Goldshire on bit 124 (the real AreaTable exploreFlags);
|
||||
// their hit rects (top, left, bottom, right; px of 1002×668) sit apart so a
|
||||
// point is in at most one. The first is a row whose first area slot resolves
|
||||
// to nothing (bit 126; its rect overlaps Northshire's) — the zone-level hover
|
||||
// must walk past it, not stop at it.
|
||||
overlays: vec![
|
||||
WorldMapOverlayView {
|
||||
texture: "Interface\\WorldMap\\Elwynn\\STONECAIRNLAKE".into(),
|
||||
width: 128,
|
||||
height: 128,
|
||||
offset_x: 450,
|
||||
offset_y: 300,
|
||||
explore_bits: vec![126],
|
||||
hit_rect: (300, 450, 400, 560),
|
||||
area_name: None,
|
||||
},
|
||||
WorldMapOverlayView {
|
||||
texture: "Interface\\WorldMap\\Elwynn\\NORTHSHIREVALLEY".into(),
|
||||
width: 200,
|
||||
@@ -62,6 +79,8 @@ fn push_catalog(s: &mut UiScript) {
|
||||
offset_x: 490,
|
||||
offset_y: 200,
|
||||
explore_bits: vec![125],
|
||||
hit_rect: (200, 490, 376, 690),
|
||||
area_name: Some("Northshire Valley".into()),
|
||||
},
|
||||
WorldMapOverlayView {
|
||||
texture: "Interface\\WorldMap\\Elwynn\\GOLDSHIRE".into(),
|
||||
@@ -70,6 +89,8 @@ fn push_catalog(s: &mut UiScript) {
|
||||
offset_x: 380,
|
||||
offset_y: 320,
|
||||
explore_bits: vec![124],
|
||||
hit_rect: (400, 300, 520, 460),
|
||||
area_name: Some("Goldshire".into()),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -292,6 +313,73 @@ fn worldmap_click_containment_and_zone_grid() {
|
||||
assert_eq!(s.eval::<i64>("return GetCurrentMapContinent()").unwrap(), 0);
|
||||
}
|
||||
|
||||
/// At ZONE level `UpdateMapHighlight` answers a NAME only (report B360; wow-re 15b2a8ea §1a/1b/1d):
|
||||
/// a revealed overlay's sub-area when the cursor is inside its hit rect, else the neighbouring
|
||||
/// zone or city whose grid cell the cursor is in through the DISPLAYED zone's rect window — never
|
||||
/// the displayed zone itself — and always a nil fileName + six zeros, so the stock frame hides the
|
||||
/// highlight quad. Fogged sub-areas have no name; an overlay with no resolvable first area is
|
||||
/// walked past. The reference's own Duskwood shot: "Deadwind Pass" in the label, no highlight.
|
||||
#[test]
|
||||
fn worldmap_zone_level_hover_names_without_highlight() {
|
||||
let mut s = script();
|
||||
push_catalog(&mut s);
|
||||
s.run("SetMapZoom(2, 1)").unwrap(); // the Elwynn zone map
|
||||
|
||||
type Answer = (Option<String>, Option<String>, f64, f64, f64, f64, f64, f64);
|
||||
let hover = |s: &mut UiScript, x: f32, y: f32| {
|
||||
s.eval::<Answer>(&format!("return UpdateMapHighlight({x}, {y})"))
|
||||
.unwrap()
|
||||
};
|
||||
let name_only =
|
||||
|name: &str| -> Answer { (Some(name.into()), None, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) };
|
||||
let miss: Answer = (None, None, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
|
||||
|
||||
// Nothing explored. (0.5, 0.5) through Elwynn's rect is cell 8801 = the Stormwind City peer:
|
||||
// the neighbouring child is NAMED, and nothing else — the B360 answer, Deadwind Pass from
|
||||
// Duskwood in the report.
|
||||
assert_eq!(hover(&mut s, 0.5, 0.5), name_only("Stormwind City"));
|
||||
// Cell 9060 = Elwynn itself: the displayed zone never names itself.
|
||||
assert_eq!(hover(&mut s, 0.82, 0.82), miss);
|
||||
// An empty cell, no overlay: the tail.
|
||||
assert_eq!(hover(&mut s, 0.2, 0.2), miss);
|
||||
// Inside Goldshire's hit rect but fogged (bit 124 clear): no name — the pre-search walks the
|
||||
// REVEALED list, and cell 8929 under it is empty.
|
||||
assert_eq!(hover(&mut s, 0.42, 0.7), miss);
|
||||
|
||||
// Reveal Northshire (bit 125 = word 3, bit 29). Its rect contains (0.5, 0.5): the overlay
|
||||
// pre-search runs BEFORE the grid, so the sub-area wins over the city under the same point.
|
||||
let mut explored = vec![0u32; 64];
|
||||
explored[3] = 1 << 29;
|
||||
s.set_world_map_explored(explored.clone());
|
||||
assert_eq!(hover(&mut s, 0.5, 0.5), name_only("Northshire Valley"));
|
||||
assert_eq!(hover(&mut s, 0.42, 0.7), miss, "Goldshire is still fogged");
|
||||
// Reveal Goldshire too (bit 124).
|
||||
explored[3] |= 1 << 28;
|
||||
s.set_world_map_explored(explored.clone());
|
||||
assert_eq!(hover(&mut s, 0.42, 0.7), name_only("Goldshire"));
|
||||
|
||||
// Only the nameless overlay revealed (bit 126 = word 3, bit 30): its rect contains (0.5, 0.5)
|
||||
// but its first area resolves to nothing, so the walk passes it and the grid answers.
|
||||
let mut explored = vec![0u32; 64];
|
||||
explored[3] = 1 << 30;
|
||||
s.set_world_map_explored(explored.clone());
|
||||
assert_eq!(hover(&mut s, 0.5, 0.5), name_only("Stormwind City"));
|
||||
// Nameless first, Northshire second, both revealed: the walk continues to Northshire.
|
||||
explored[3] |= 1 << 29;
|
||||
s.set_world_map_explored(explored);
|
||||
assert_eq!(hover(&mut s, 0.5, 0.5), name_only("Northshire Valley"));
|
||||
|
||||
// Control: back at the continent level the hovered zone still lights up — fileName is the art
|
||||
// folder and the quad geometry is non-zero.
|
||||
s.run("SetMapZoom(2)").unwrap();
|
||||
let (name, file, tpx, _, tx, ty, _, _) = hover(&mut s, 0.452843, 0.720880);
|
||||
assert_eq!(
|
||||
(name.as_deref(), file.as_deref(), tpx),
|
||||
(Some("Elwynn Forest"), Some("Elwynn"), 1.0)
|
||||
);
|
||||
assert!(tx > 0.0 && ty > 0.0);
|
||||
}
|
||||
|
||||
/// SetMapZoom queues WORLD_MAP_UPDATE through the pending-event queue: nothing fires inside the
|
||||
/// call (a binding can't re-enter dispatch), the registered frame hears it on the next tick.
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The host runtime loop — an `impl UiScript` block beside its concern (the `layout.rs` pattern):
|
||||
//! the event fan-out to registered frames ([`UiScript::fire_event`]), the per-frame advance
|
||||
//! ([`UiScript::tick`]: OnUpdate + the engine-side fades/cooldowns), and the FrameXML session clock
|
||||
//! ([`UiScript::tick`]: OnUpdate + the engine-side fades and the model panes' clocks), and the FrameXML session clock
|
||||
//! ([`UiScript::now`]). The low-level handler-firing these drive lives in [`super::event`].
|
||||
|
||||
use mlua::Lua;
|
||||
@@ -188,7 +188,6 @@ impl super::UiScript {
|
||||
// Advance every ScrollingMessageFrame's per-line fade (the client's OnUpdate `0x788460`).
|
||||
// Independent of the frame's own OnUpdate script — the fade is C++ behavior, not Lua. The
|
||||
// AtBottom freeze gate lives inside `ScrollingMessageState::tick`.
|
||||
let now = self.now();
|
||||
let mut model = self.model_mut();
|
||||
// The sibling class's OnUpdate (`0x786200`): the same two-phase fade with no scroll gate,
|
||||
// plus the capacity law that is this class's stand-in for `maxLines` — the cap is what fits
|
||||
@@ -216,7 +215,6 @@ impl super::UiScript {
|
||||
mf.trim_to_viewport(viewport_rows);
|
||||
}
|
||||
}
|
||||
let mut finished_cooldowns: Vec<FrameHandle> = Vec::new();
|
||||
for &h in &ticked {
|
||||
let Some(frame) = model.arena.frame_mut(h) else {
|
||||
continue;
|
||||
@@ -224,17 +222,6 @@ impl super::UiScript {
|
||||
if let crate::widget::KindState::ScrollingMessage(smf) = &mut frame.kind_state {
|
||||
smf.tick(elapsed);
|
||||
}
|
||||
// A Cooldown whose flash has finished hides itself — the reference machine's
|
||||
// `OnAnimFinished` → `Hide()` edge (`Cooldown.lua`), modeled engine-side like the
|
||||
// message fade above (C++-equivalent behavior, not Lua).
|
||||
if let crate::widget::KindState::Cooldown(cd) = &frame.kind_state {
|
||||
if frame.shown && cd.duration > 0.0 && now >= cd.finished_at() {
|
||||
finished_cooldowns.push(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
for h in finished_cooldowns {
|
||||
model.arena.set_shown(h, false);
|
||||
}
|
||||
drop(model);
|
||||
// Advance fading tooltips (FadeOut's ramp + end-of-ramp hide) — engine behavior like the
|
||||
|
||||
@@ -85,24 +85,10 @@ pub enum QuadContent {
|
||||
/// `inside` flag it pushed down.
|
||||
inside_zoom: u8,
|
||||
},
|
||||
/// A `Cooldown` widget's draw slot (decision 0137 phase 4): the engine-derived phase of the
|
||||
/// reference machine (`Cooldown.lua`), for the app renderer's pie-wipe/flash draw. Emitted
|
||||
/// only while the widget is shown; `tick` hides it once the flash ends.
|
||||
Cooldown {
|
||||
/// Sweep progress `0..1` (`(now-start)/duration`, the `Cooldown.lua` scrub); `>= 1` =
|
||||
/// the sweep is over (the flash below runs) — no dark pie draws.
|
||||
fraction: f32,
|
||||
/// The finish flash's progress `0..1` over its authored 1.000 s (the model's sequence 1),
|
||||
/// `None` while the sweep still runs. The alpha ramp is the model's own texture-weight
|
||||
/// track — byte-read off `UI-Cooldown-Indicator.m2` (linear 0→1 over the first third,
|
||||
/// hold to the half, 1→0 over the back half) — applied app-side.
|
||||
flash: Option<f32>,
|
||||
},
|
||||
/// A `Model` / `PlayerModel` widget's own draw slot: the 3D pane's content hole. The engine
|
||||
/// core carries the resolved rect and the pane's identity; the app renderer puts pixels in it
|
||||
/// — the same division of labour [`QuadContent::Minimap`] and [`QuadContent::Cooldown`] use,
|
||||
/// and for the same reason (the scene state is [`crate::widget::ModelState`]; the render is
|
||||
/// not this crate's).
|
||||
/// — the same division of labour [`QuadContent::Minimap`] uses, and for the same reason (the
|
||||
/// scene state is [`crate::widget::ModelState`]; the render is not this crate's).
|
||||
///
|
||||
/// **The clock is deliberately NOT here.** A pane's play head moves every tick, and the
|
||||
/// host memoizes its whole quad conversion on the extracted list being unchanged — a
|
||||
@@ -125,10 +111,9 @@ pub enum QuadContent {
|
||||
/// The pane's global frame name (`$parent`-expanded), or `None` for an anonymous
|
||||
/// `CreateFrame("Model")` — pfUI's autocast shine is the corpus example of the latter.
|
||||
name: Option<String>,
|
||||
/// The `SetModel` path the pane holds (`None` for a unit pane or an empty one). Decision
|
||||
/// 1980: the world-map arrow is an anonymous `Model` child whose file is the minimap arrow
|
||||
/// (`crate::script::ARROW_MODEL`) — the one path the app draws as a sprite, so the seam
|
||||
/// carries it with the facing and the pane's `SetModelScale`.
|
||||
/// The `SetModel` path the pane holds (`None` for a unit pane or an empty one) — what the
|
||||
/// host's tile renderer draws (decision 2013), the map arrow's `crate::script::ARROW_MODEL`
|
||||
/// among them since 2015.
|
||||
model: Option<String>,
|
||||
/// `SetFacing`'s radians (0 default).
|
||||
facing: f32,
|
||||
|
||||
@@ -21,9 +21,13 @@
|
||||
//! bitmap (`Interface\WorldMap\<Continent>.zmp`, remapped app-side to 1-based zone indices; the
|
||||
//! source + cell law are wow-re-verified, `0x4a6ec0` / the Q1 §5 verdict 2026-07-07). The cell
|
||||
//! law itself is transcribed here ([`area_grid_cell`]) rather than in the app's `map_proj`
|
||||
//! because its only callers are these bindings. `UpdateMapHighlight` returns the hovered zone's
|
||||
//! NAME only for now — the highlight-texture halves (fileName + the `0x4a7fa0` POT extents) land
|
||||
//! with phase 3's overlay machinery, which shares them.
|
||||
//! because its only callers are these bindings. `UpdateMapHighlight` answers per level exactly
|
||||
//! as `0x4a7fa0` does (wow-re 15b2a8ea): at continent level the hovered zone's name **and** its
|
||||
//! highlight quad; at zone level a **name only** — a revealed overlay's sub-area when the cursor
|
||||
//! is inside its `WorldMapOverlay` hit rect, else the neighbouring zone or city whose grid cell
|
||||
//! the cursor is in through the displayed zone's rect window, never the displayed zone itself —
|
||||
//! with a nil fileName, so the frame hides the quad (report B360). The world sheet's continent
|
||||
//! highlight is not built: hovering a continent there answers the nil/zero tail.
|
||||
//!
|
||||
//! Zone maps carry **exploration fog**: the base tiles are the unexplored parchment, and each
|
||||
//! discovered sub-area's overlay art (`GetNumMapOverlays`/`GetMapOverlayInfo`, filtered by the
|
||||
@@ -95,6 +99,15 @@ pub struct WorldMapOverlayView {
|
||||
/// The AreaTable `exploreFlag` bit indices that reveal this overlay (any set bit shows it;
|
||||
/// empty = never shown — an overlay whose areas are unknown).
|
||||
pub explore_bits: Vec<u32>,
|
||||
/// The hover hit rect `(top, left, bottom, right)`, px of the 1002×668 detail frame — the
|
||||
/// DBC's own `HitRect*` fields. The zone-level `UpdateMapHighlight` scales it by 1/1002 and
|
||||
/// 1/668 and tests the normalized cursor against it, both edges inclusive (wow-re 15b2a8ea
|
||||
/// §1d, `0x4a7ffc..0x4a80ea`).
|
||||
pub hit_rect: (u32, u32, u32, u32),
|
||||
/// The name that hover shows inside the rect: the localized AreaTable name of the overlay's
|
||||
/// FIRST area slot (`0x4a7fa0` reads `+0x8` only). `None` when that slot resolves to no row —
|
||||
/// the client then walks past the overlay exactly as if the cursor were outside it.
|
||||
pub area_name: Option<String>,
|
||||
}
|
||||
|
||||
/// One continent. Its 1-based position IS the continent index Lua sees.
|
||||
@@ -249,6 +262,121 @@ fn explored_bit(explored: &[u32], n: u32) -> bool {
|
||||
.is_some_and(|w| w & (1 << (n % 32)) != 0)
|
||||
}
|
||||
|
||||
/// The reciprocals `0x4a7fa0` scales a `WorldMapOverlay` hit rect by: the rect is authored in
|
||||
/// pixels of the 1002×668 detail frame, the cursor arrives normalized, and the client brings the
|
||||
/// rect to the cursor (`fild` → `fmul` a f32 constant → `fstp dword`), so the edges are f32.
|
||||
const OVERLAY_RECIP_X: f32 = 1.0 / 1002.0;
|
||||
const OVERLAY_RECIP_Y: f32 = 1.0 / 668.0;
|
||||
|
||||
/// The zone-level **overlay pre-search** (`0x4a7ffc..0x4a80ea`, wow-re 15b2a8ea §1d): the first
|
||||
/// REVEALED overlay whose hit rect contains the cursor names its first area. Both edges are
|
||||
/// inclusive; a rect with a zero-width edge never hits; a NaN cursor never hits; an overlay whose
|
||||
/// first area resolves to no AreaTable row is walked past, not stopped at. The list is the one
|
||||
/// `GetNumMapOverlays` counts — the exploration-gated one (`[0xb6e630]`, built by `0x4a6ad9`
|
||||
/// under the explored-byte read at `0x4a6bfa`), so a fogged sub-area has no name.
|
||||
fn overlay_hover(state: &WorldMapState, x: f32, y: f32) -> Option<String> {
|
||||
revealed_overlays(state).into_iter().find_map(|o| {
|
||||
let (top, left, bottom, right) = o.hit_rect;
|
||||
let (xlo, xhi) = (
|
||||
left as f32 * OVERLAY_RECIP_X,
|
||||
right as f32 * OVERLAY_RECIP_X,
|
||||
);
|
||||
let (ylo, yhi) = (
|
||||
top as f32 * OVERLAY_RECIP_Y,
|
||||
bottom as f32 * OVERLAY_RECIP_Y,
|
||||
);
|
||||
let inside = xhi - xlo != 0.0
|
||||
&& yhi - ylo != 0.0
|
||||
&& (xlo..=xhi).contains(&x)
|
||||
&& (ylo..=yhi).contains(&y);
|
||||
if inside {
|
||||
o.area_name.clone()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// What `UpdateMapHighlight` answers for a cursor at map UV `(x, y)` under the current selection.
|
||||
enum Hover {
|
||||
/// Continent level, over a zone: its name and the highlight quad — fileName, texPercentageY,
|
||||
/// textureX/Y, scrollChildX/Y (texPercentageX is that branch's constant 1.0).
|
||||
Highlight {
|
||||
name: String,
|
||||
file: String,
|
||||
tex_pct_y: f64,
|
||||
texture: (f64, f64),
|
||||
scroll: (f64, f64),
|
||||
},
|
||||
/// Zone level: a name only — fileName nil, six zeros, the frame hides the quad.
|
||||
Name(String),
|
||||
/// Off every area: the nil/zero tail.
|
||||
Miss,
|
||||
}
|
||||
|
||||
/// The continent-level answer for the hovered zone (the `0x4a81de → 0x4a822a` highlight-compute
|
||||
/// path, zone-search branch): fileName = its art folder (the frame draws `<file>\<file>Highlight`),
|
||||
/// the six coords seat/size/crop that texture over the zone's rect within the continent, keeping
|
||||
/// the client's f32/f64 asymmetric narrowing.
|
||||
fn continent_hover(cont: &WorldMapContinentView, zone: &WorldMapZoneView) -> Option<Hover> {
|
||||
let (cl, cr, ct, cb) = cont.loc_rect;
|
||||
let (zl, zr, zt, zb) = zone.loc_rect;
|
||||
let (w, h) = (zl - zr, zt - zb);
|
||||
let (cont_w, cont_h) = (cl - cr, ct - cb);
|
||||
if w == 0.0 || h == 0.0 || cont_w == 0.0 || cont_h == 0.0 {
|
||||
return None;
|
||||
}
|
||||
let recip_x = 1.0f32 / cont_w;
|
||||
let recip_y_f32 = 1.0f32 / cont_h;
|
||||
let texture_x = f64::from(recip_x * w);
|
||||
let texture_y = (1.0f64 / f64::from(cont_h)) * f64::from(h);
|
||||
let scroll_x = f64::from(recip_x * (cl - zl));
|
||||
let scroll_y = f64::from(recip_y_f32 * (ct - zt));
|
||||
// texPctY = dim/potdim, dim = __ftol((h·128)/w) (rect-form K = 128).
|
||||
let dim = ((h * 128.0) / w) as i64;
|
||||
let potdim = next_pow2(dim);
|
||||
let tex_pct_y = if potdim > 0 {
|
||||
dim as f64 / potdim as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
Some(Hover::Highlight {
|
||||
name: zone.name.clone(),
|
||||
file: zone.map_file.clone(),
|
||||
tex_pct_y,
|
||||
texture: (texture_x, texture_y),
|
||||
scroll: (scroll_x, scroll_y),
|
||||
})
|
||||
}
|
||||
|
||||
/// `0x4a7fa0`'s level dispatch (wow-re 15b2a8ea §1a). Continent level: the grid cell's zone lights
|
||||
/// up. Zone level: the overlay pre-search first, then the same grid re-windowed by the displayed
|
||||
/// zone's own rect (`0x4a7620`), which returns nothing for the displayed zone itself — and only the
|
||||
/// name-only tail is reachable from there (`0x4a812e`). World level: the continent highlight is
|
||||
/// not built, so the tail.
|
||||
fn hover(wm: &WorldMapState, x: f32, y: f32) -> Hover {
|
||||
let (c, z) = wm.selection;
|
||||
let Some(cont) = c.checked_sub(1).and_then(|i| wm.continents.get(i as usize)) else {
|
||||
return Hover::Miss;
|
||||
};
|
||||
if z != 0 {
|
||||
if let Some(name) = overlay_hover(wm, x, y) {
|
||||
return Hover::Name(name);
|
||||
}
|
||||
return match grid_area(wm, x, y) {
|
||||
Some(zi) if u32::from(zi) != z => cont
|
||||
.zones
|
||||
.get(zi as usize - 1)
|
||||
.map_or(Hover::Miss, |zone| Hover::Name(zone.name.clone())),
|
||||
_ => Hover::Miss,
|
||||
};
|
||||
}
|
||||
grid_area(wm, x, y)
|
||||
.and_then(|zi| cont.zones.get(zi as usize - 1))
|
||||
.and_then(|zone| continent_hover(cont, zone))
|
||||
.unwrap_or(Hover::Miss)
|
||||
}
|
||||
|
||||
/// The current selection's zone view, if a zone map is displayed.
|
||||
fn current_zone(state: &WorldMapState) -> Option<&WorldMapZoneView> {
|
||||
let (c, z) = state.selection;
|
||||
@@ -570,82 +698,37 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
|
||||
// UpdateMapHighlight(x, y) → name, fileName, texPctX, texPctY, textureX, textureY,
|
||||
// scrollChildX, scrollChildY (wow-re 15b2a8ea, 0x4a7fa0). Continent level: the hovered zone
|
||||
// lights up — fileName = its art folder (the frame draws `<file>\<file>Highlight`), the six
|
||||
// coords seat/size/crop that texture over the zone's rect within the continent, keeping the
|
||||
// client's f32/f64 asymmetric narrowing. World-level continent highlight and zone-level
|
||||
// overlay-name are deferred: those exits return the nil/zero tail (the frame hides the quad).
|
||||
// scrollChildX, scrollChildY (wow-re 15b2a8ea, 0x4a7fa0) — the per-level law is [`hover`].
|
||||
// Eight values always: the frame reads `fileName` to decide whether a quad is drawn at all,
|
||||
// so a name-only answer carries a nil there and six zeros behind it.
|
||||
g.set(
|
||||
"UpdateMapHighlight",
|
||||
lua.create_function(|lua, (x, y): (f32, f32)| {
|
||||
// (name, fileName, texPctY, textureX, textureY, scrollChildX, scrollChildY); texPctX
|
||||
// is the constant 1.0 the continent branch always returns.
|
||||
let hit: Option<(String, String, f64, f64, f64, f64, f64)> = {
|
||||
let hit = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let wm = &model.worldmap;
|
||||
let (c, z) = wm.selection;
|
||||
if c == 0 || z != 0 {
|
||||
None
|
||||
} else {
|
||||
grid_area(wm, x, y)
|
||||
.and_then(|zi| wm.continents.get(c as usize - 1).zip(Some(zi)))
|
||||
.and_then(|(cont, zi)| cont.zones.get(zi as usize - 1).zip(Some(cont)))
|
||||
.and_then(|(zone, cont)| {
|
||||
let (cl, cr, ct, cb) = cont.loc_rect;
|
||||
let (zl, zr, zt, zb) = zone.loc_rect;
|
||||
let (w, h) = (zl - zr, zt - zb);
|
||||
let (cont_w, cont_h) = (cl - cr, ct - cb);
|
||||
if w == 0.0 || h == 0.0 || cont_w == 0.0 || cont_h == 0.0 {
|
||||
return None;
|
||||
}
|
||||
let recip_x = 1.0f32 / cont_w;
|
||||
let recip_y_f32 = 1.0f32 / cont_h;
|
||||
let texture_x = f64::from(recip_x * w);
|
||||
let texture_y = (1.0f64 / f64::from(cont_h)) * f64::from(h);
|
||||
let scroll_x = f64::from(recip_x * (cl - zl));
|
||||
let scroll_y = f64::from(recip_y_f32 * (ct - zt));
|
||||
// texPctY = dim/potdim, dim = __ftol((h·128)/w) (rect-form K = 128).
|
||||
let dim = ((h * 128.0) / w) as i64;
|
||||
let potdim = next_pow2(dim);
|
||||
let tex_pct_y = if potdim > 0 {
|
||||
dim as f64 / potdim as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
Some((
|
||||
zone.name.clone(),
|
||||
zone.map_file.clone(),
|
||||
tex_pct_y,
|
||||
texture_x,
|
||||
texture_y,
|
||||
scroll_x,
|
||||
scroll_y,
|
||||
))
|
||||
})
|
||||
}
|
||||
hover(&model.worldmap, x, y)
|
||||
};
|
||||
match hit {
|
||||
Some((name, file, tex_pct_y, tx, ty, sx, sy)) => Ok((
|
||||
Value::String(lua.create_string(&name)?),
|
||||
Value::String(lua.create_string(&file)?),
|
||||
let string = |s: &str| Ok::<_, mlua::Error>(Value::String(lua.create_string(s)?));
|
||||
Ok(match hit {
|
||||
Hover::Highlight {
|
||||
name,
|
||||
file,
|
||||
tex_pct_y,
|
||||
texture,
|
||||
scroll,
|
||||
} => (
|
||||
string(&name)?,
|
||||
string(&file)?,
|
||||
1.0f64,
|
||||
tex_pct_y,
|
||||
tx,
|
||||
ty,
|
||||
sx,
|
||||
sy,
|
||||
)),
|
||||
None => Ok((
|
||||
Value::Nil,
|
||||
Value::Nil,
|
||||
0.0f64,
|
||||
0.0f64,
|
||||
0.0f64,
|
||||
0.0f64,
|
||||
0.0f64,
|
||||
0.0f64,
|
||||
)),
|
||||
}
|
||||
texture.0,
|
||||
texture.1,
|
||||
scroll.0,
|
||||
scroll.1,
|
||||
),
|
||||
Hover::Name(name) => (string(&name)?, Value::Nil, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
|
||||
Hover::Miss => (Value::Nil, Value::Nil, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
|
||||
@@ -27,12 +27,18 @@ use super::Model;
|
||||
/// The arrow's model — `0x8453c0`, and NOT `Rotating-MinimapArrow`.
|
||||
pub const ARROW_MODEL: &str = "Interface\\Minimap\\MinimapArrow.mdx";
|
||||
|
||||
/// The arrow's footprint at the reference basis: `MinimapArrow.mdx`'s single quad at 1280 px per
|
||||
/// model unit — 33.6 px on the 1024×768 sheet, the same number the minimap's player arrow is
|
||||
/// drawn at (`minimap::blips::PLAYER_ARROW_QUAD_PX`). The world-map arrow's model scale is
|
||||
/// `G48 · 5/3` (`= 1.0` at 4:3, `G48 = 1/√(aspect² + 1)`) and the mini's `G48 · 10/9`, so both
|
||||
/// hold a constant apparent size as the window's shape changes.
|
||||
pub const ARROW_FOOTPRINT_PX: f32 = 33.6;
|
||||
/// The arrow's rect is the file's own bounding box — the implicit rect of a size-less pane
|
||||
/// (decision 2015; `MinimapArrow.mdx`'s `0.0262 × 0.0263` model units read as layout units,
|
||||
/// `1280·extent = 33.5 × 33.7` FrameXML units at 4:3). The world-map arrow's model scale is
|
||||
/// `G48 · 5/3` (`= 1.0` at 4:3, `G48 = 1/√(aspect² + 1)`) and the mini's `G48 · 10/9`, so the
|
||||
/// quad holds a constant apparent size as the window's shape changes while its rect grows with
|
||||
/// `√(a²+1)` (render law §2, the worked arrow).
|
||||
fn centre_of(facts: Option<&crate::widget::ModelFileFacts>) -> (f32, f32, f32) {
|
||||
facts.map_or((0.0, 0.0, 0.0), |f| {
|
||||
let (x, y) = f.extent();
|
||||
(x * 0.5, y * 0.5, 0.0)
|
||||
})
|
||||
}
|
||||
|
||||
/// Which of the two singletons a binding addresses.
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -144,18 +150,17 @@ fn create(lua: &Lua, which: Arrow, parent: Value) -> mlua::Result<()> {
|
||||
("Model".to_string(), None, Some(Value::Table(t)), None),
|
||||
)?;
|
||||
let id = decode_id(&wrapper)?;
|
||||
let (scale, side) = {
|
||||
let scale = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let scale = arrow_scale(&model, which);
|
||||
(scale, ARROW_FOOTPRINT_PX * scale)
|
||||
arrow_scale(&model, which)
|
||||
};
|
||||
// The widget's rect is the resident model's bounding box (§2.2); the footprint at this scale.
|
||||
wrapper.call_method::<()>("SetWidth", side)?;
|
||||
wrapper.call_method::<()>("SetHeight", side)?;
|
||||
// `SetModel("Interface\Minimap\MinimapArrow.mdx")` in C++ (`0x4a7a80` → `0x76c8e0`): the
|
||||
// same file set every pane takes, seeded with the file's facts when the host has them
|
||||
// (decision 2007 — the arrow's Stand loops its 3.333 s with no bone keyed, so nothing
|
||||
// moves; the arm is the reference's, not a look).
|
||||
// moves; the arm is the reference's, not a look). No authored size: the widget's rect is
|
||||
// the file's bounding box (§2.2, decision 2015), and `0x4a7b20`'s `SetPosition(½·GetWidth,
|
||||
// ½·GetHeight, 0)` — the geometry override's bbox extent, in layout units — centres the
|
||||
// model on it.
|
||||
let facts = lua
|
||||
.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
@@ -163,9 +168,11 @@ fn create(lua: &Lua, which: Arrow, parent: Value) -> mlua::Result<()> {
|
||||
super::modelframe::with_model(lua, &wrapper, |m| {
|
||||
m.set_file(ARROW_MODEL.to_string(), facts.as_deref());
|
||||
m.scale = scale;
|
||||
m.position = (side * 0.5, side * 0.5, 0.0);
|
||||
m.position = centre_of(facts.as_deref());
|
||||
})?;
|
||||
let h = frame_handle_of(lua, &wrapper)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.apply_implicit_rect(h);
|
||||
which.set_slot(&mut model, id);
|
||||
Ok(())
|
||||
}
|
||||
@@ -187,17 +194,16 @@ fn update(lua: &Lua) -> mlua::Result<()> {
|
||||
let Some(wrapper) = arrow_wrapper(lua, which)? else {
|
||||
continue;
|
||||
};
|
||||
let (facing, tracked) = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let (facing, tracked, facts) = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
(
|
||||
model.worldmap.player_facing,
|
||||
model.worldmap.player_uv.is_some(),
|
||||
model.model_facts_for(ARROW_MODEL),
|
||||
)
|
||||
};
|
||||
let w: f32 = wrapper.call_method("GetWidth", ())?;
|
||||
let h: f32 = wrapper.call_method("GetHeight", ())?;
|
||||
super::modelframe::with_model(lua, &wrapper, |m| {
|
||||
m.position = (w * 0.5, h * 0.5, 0.0);
|
||||
m.position = centre_of(facts.as_deref());
|
||||
if tracked {
|
||||
m.facing = facing;
|
||||
}
|
||||
@@ -334,6 +340,22 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::script::UiScript;
|
||||
|
||||
/// The arrow file's facts, as the host hands them over (2007/2015): the header box the
|
||||
/// implicit rect and the re-centring read.
|
||||
fn arrow_facts(s: &mut UiScript) {
|
||||
s.set_model_facts(
|
||||
ARROW_MODEL,
|
||||
crate::widget::ModelFileFacts {
|
||||
sequences: vec![crate::widget::SequenceFacts {
|
||||
anim_id: 0,
|
||||
duration_ms: 3333,
|
||||
looping: true,
|
||||
}],
|
||||
bbox: ([-0.0127, -0.0118, 0.0], [0.0135, 0.0145, 0.0]),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn vm() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
@@ -350,7 +372,7 @@ mod tests {
|
||||
/// loaded with the arrow, born shown, sized to its footprint; a second create a no-op.
|
||||
#[test]
|
||||
fn create_validates_its_parent_and_makes_one_arrow_per_session() {
|
||||
let s = vm();
|
||||
let mut s = vm();
|
||||
for (call, want) in [
|
||||
(
|
||||
"CreateWorldMapArrowFrame(5)",
|
||||
@@ -388,9 +410,28 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(kind, "Model");
|
||||
assert!(shown, "born shown");
|
||||
assert_eq!(w, 0.0, "no authored size, no facts yet: no rect");
|
||||
// The file lands: the rect is its box in layout units — 0.0262 × 1280 at 4:3 — and the
|
||||
// model is centred on it (`0x4a7b20`, half the box, in layout units).
|
||||
arrow_facts(&mut s);
|
||||
// The C++ re-centres on create and on every `UpdateWorldMapArrowFrames` (`0x4a7b20`);
|
||||
// a file that lands after the create is picked up by the next update.
|
||||
s.run("UpdateWorldMapArrowFrames()").unwrap();
|
||||
s.resolve();
|
||||
let w: f32 = s.eval("return ({WM:GetChildren()})[2]:GetWidth()").unwrap();
|
||||
assert!((w - 0.0262 * 1280.0).abs() < 0.05, "the box at 4:3: {w}");
|
||||
let pos = {
|
||||
let model = s.lua().app_data_ref::<Model>().expect("model");
|
||||
let id = model.worldmap.arrow_world.expect("the singleton");
|
||||
let fh = model.id_to_frame.get(&id).copied().expect("live");
|
||||
match &model.arena.frame(fh).expect("frame").kind_state {
|
||||
crate::widget::KindState::Model(m) => m.position,
|
||||
_ => panic!("not a Model"),
|
||||
}
|
||||
};
|
||||
assert!(
|
||||
(w - ARROW_FOOTPRINT_PX).abs() < 1e-3,
|
||||
"the footprint at 4:3: {w}"
|
||||
(pos.0 - 0.0131).abs() < 1e-5 && (pos.1 - 0.01315).abs() < 1e-5 && pos.2 == 0.0,
|
||||
"centred on the box: {pos:?}"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return select('#', CreateWorldMapArrowFrame(WM)) == 0")
|
||||
@@ -408,6 +449,8 @@ mod tests {
|
||||
s.run("PositionWorldMapArrowFrame(1, 2) ShowWorldMapArrowFrame('x') UpdateWorldMapArrowFrames()")
|
||||
.unwrap();
|
||||
s.run("CreateWorldMapArrowFrame(WM)").unwrap();
|
||||
// The file's facts: a size-less pane has no rect to anchor until its box is known.
|
||||
arrow_facts(&mut s);
|
||||
s.run("arrow = ({WM:GetChildren()})[2]").unwrap();
|
||||
for (call, want) in [
|
||||
(
|
||||
|
||||
@@ -123,8 +123,8 @@ pub enum FrameKind {
|
||||
ColorSelect,
|
||||
SimpleHtml,
|
||||
MovieFrame,
|
||||
/// The `GameTooltip` widget family (decision 0274). Like [`FrameKind::Minimap`]/
|
||||
/// [`FrameKind::Cooldown`] a *game-layer* factory over `CSimpleFrame`; its modeled behavior —
|
||||
/// The `GameTooltip` widget family (decision 0274). Like [`FrameKind::Minimap`], a
|
||||
/// *game-layer* factory over `CSimpleFrame`; its modeled behavior —
|
||||
/// the line stack, owner/anchor law, auto-size, fade — lives in [`KindState::Tooltip`] and
|
||||
/// `script::tooltip`. The real class's Lua surface is the 38-binding family wow-re pinned
|
||||
/// (`ui/scratch/bindings.md` 0x530c40–0x5364a0); the line/color primitives are byte-diffed
|
||||
@@ -137,12 +137,6 @@ pub enum FrameKind {
|
||||
/// the app renderer's job (decision 0203); the engine core carries only the rect and the zoom
|
||||
/// state ([`KindState::Minimap`]).
|
||||
Minimap,
|
||||
/// The cooldown sweep widget (decision 0137 phase 4). The 1.12 reference builds it as a
|
||||
/// `Model` playing `UI-Cooldown-Indicator.mdx` (`CooldownFrameTemplate` + `Cooldown.lua`'s
|
||||
/// scrub/flash/hide machine); benilla models the *mechanism* as a first-class widget — the
|
||||
/// Era API's own `Cooldown` frame type — whose state machine lives engine-side
|
||||
/// ([`KindState::Cooldown`]) and whose pie-wipe/flash pixels are the app renderer's job.
|
||||
Cooldown,
|
||||
}
|
||||
|
||||
/// Whether a [`Region`] leaf is a texture or a text string. These are the client's two non-frame
|
||||
@@ -190,9 +184,10 @@ pub enum KindState {
|
||||
Message(MessageFrameState),
|
||||
/// `CSimpleScrollFrame` (decision 0112 — the ScrollFrame mechanism, the engine's last structural
|
||||
/// gap: the quest log's detail pane, chat history, and every long-content window need it): the
|
||||
/// scroll child + the vertical scroll offset. The mechanism is spec-faithful (the documented
|
||||
/// `SetScrollChild`/`SetVerticalScroll` contract, same posture as StatusBar's fill), not
|
||||
/// byte-pinned. Horizontal scroll is out of scope (no 1.12 template drives it).
|
||||
/// scroll child + the vertical scroll offset. The offset setter and the range are byte-pinned
|
||||
/// (2017: `0x786db0` stores the offset as given, no clamp; 1338: `0x786e30` measures the
|
||||
/// child's subtree); the rest is spec-faithful to the documented contract, same posture as
|
||||
/// StatusBar's fill. Horizontal scroll is out of scope (no 1.12 template drives it).
|
||||
Scroll(ScrollFrameState),
|
||||
/// `CSimpleSlider` (factory `0x6eee40`; LoadXML table `0x789580`, RF-28): a value in `[min, max]`
|
||||
/// with a step and orientation, positioning a thumb texture along the track. The mechanism is
|
||||
@@ -207,16 +202,13 @@ pub enum KindState {
|
||||
/// widget's whole modeled behavior here; see [`crate::script::colorselect`].
|
||||
ColorSelect(ColorSelectState),
|
||||
/// The `Model` widget's scene state — the 3D pane an addon or a FrameXML frame parks a model
|
||||
/// in. **The same split as [`KindState::Minimap`] and [`KindState::Cooldown`]**: the engine
|
||||
/// core carries exactly what the Lua API reads and writes, and the pixels are the app
|
||||
/// renderer's job. See [`ModelState`].
|
||||
/// in. **The same split as [`KindState::Minimap`]**: the engine core carries exactly what
|
||||
/// the Lua API reads and writes, and the pixels are the app renderer's job. See
|
||||
/// [`ModelState`].
|
||||
Model(ModelState),
|
||||
/// The `<Minimap>` widget's zoom state (decision 0203). The engine core carries only what the
|
||||
/// Lua API reads/writes (`GetZoom`/`SetZoom`/`GetZoomLevels`); the tile/blip render is app-side.
|
||||
Minimap(MinimapState),
|
||||
/// The cooldown widget's timer ([`CooldownState`]) — the reference `Cooldown.lua` machine's
|
||||
/// inputs; the sweep/flash phases derive from them at extract time.
|
||||
Cooldown(CooldownState),
|
||||
/// The GameTooltip widget's line stack + owner/fade state ([`TooltipState`], decision 0274).
|
||||
Tooltip(TooltipState),
|
||||
}
|
||||
@@ -345,38 +337,6 @@ pub const TOOLTIP_FADE_SECS: f64 = 0.5;
|
||||
/// description/trigger-line wrap by eye.
|
||||
pub const TOOLTIP_WRAP_WIDTH: f32 = 260.0;
|
||||
|
||||
/// The cooldown widget's timer, in the engine's `GetTime` clock (seconds): the reference
|
||||
/// `CooldownFrame_SetTimer(start, duration, enable)` stores exactly this pair (its `enable == 0`
|
||||
/// / non-positive gate hides instead of storing — kept in the Lua helper, ref-verbatim). The
|
||||
/// three phases the reference machine derives (`Cooldown.lua`, byte-authored):
|
||||
/// - `t < start + duration` — the sweep: sequence 0 scrubbed to `(now-start)/duration`.
|
||||
/// - the next [`COOLDOWN_FLASH_SECS`] — the finish flash: sequence 1 played realtime.
|
||||
/// - after that — hidden (`OnAnimFinished` → `Hide`), done engine-side in `tick`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct CooldownState {
|
||||
/// `GetTime`-clock start seconds (0 = no timer set).
|
||||
pub start: f64,
|
||||
/// Duration seconds (0 = no timer set).
|
||||
pub duration: f64,
|
||||
}
|
||||
|
||||
/// The finish flash's length: the model's sequence 1 is authored at exactly 1.000 s
|
||||
/// (`UI-Cooldown-Indicator.m2` m2seq; played realtime by `CooldownFrame_OnUpdateModel`'s
|
||||
/// `AdvanceTime`).
|
||||
pub const COOLDOWN_FLASH_SECS: f64 = 1.0;
|
||||
|
||||
impl CooldownState {
|
||||
/// The end of the sweep (= the flash's start).
|
||||
pub fn sweep_end(&self) -> f64 {
|
||||
self.start + self.duration
|
||||
}
|
||||
|
||||
/// The moment the whole display is over (flash finished → hide).
|
||||
pub fn finished_at(&self) -> f64 {
|
||||
self.sweep_end() + COOLDOWN_FLASH_SECS
|
||||
}
|
||||
}
|
||||
|
||||
/// The model-pane scene state — **shared by [`FrameKind::Model`] and [`FrameKind::PlayerModel`]**,
|
||||
/// because the client's `CGCharacterModelBase` (`0x505680`) *extends* `CSimpleModel` (`0x76c8e0`)
|
||||
/// rather than replacing it: every field below is a `CSimpleModel` member both classes carry.
|
||||
@@ -385,8 +345,8 @@ impl CooldownState {
|
||||
/// little scene of its own — the character pane, the tabard designer, the minimap ping, the pet
|
||||
/// bar's autocast shine, and every addon that wants a 3D thing in a frame. The engine core holds
|
||||
/// exactly the scene an addon can read back or write; **the render is the app's**, the same
|
||||
/// contract [`MinimapState`] and [`CooldownState`] already run under, and the reason both of those
|
||||
/// exist as state-only kinds here.
|
||||
/// contract [`MinimapState`] already runs under, and the reason both exist as state-only kinds
|
||||
/// here.
|
||||
///
|
||||
/// **Every field is a 1.12 binding's storage, and which binding is read off the registrar, not off
|
||||
/// a string scan.** wow-re enumerated the whole family at the pair bytes on 2026-08-30
|
||||
@@ -442,6 +402,13 @@ pub struct ModelState {
|
||||
/// `0x710ec0`), which lives on the model instance and dies with it: `SetModel` and
|
||||
/// `ClearModel` clear it. `None` = the file's own textures.
|
||||
pub icon: Option<String>,
|
||||
/// The frame's size is the **implicit rect** — the file's bounding-box extent in layout units
|
||||
/// (`bboxExtent · 768·√(a²+1)` FrameXML units; render law §3, `implicit-size-law.md` §1),
|
||||
/// written by the engine because the pane authored no size (decision 2015). The geometry
|
||||
/// getters `0x76d080`/`0x76d0d0` answer it whenever no size is authored; here it is written
|
||||
/// into the layout input when the file's facts land and re-derived when the screen's aspect
|
||||
/// moves, and an authored `SetWidth`/`SetHeight`/`SetSize` clears it for good.
|
||||
pub implicit_size: bool,
|
||||
/// The pane's yaw in radians — `CSimpleModel+0x39c`. **One slot, written by two verbs on two
|
||||
/// different classes**: `Model:SetFacing` (`0x76dce0`) and `PlayerModel:SetRotation`
|
||||
/// (`0x505f00` → `0x505bb0`, whose last act is `0x505c44 mov [esi+0x39c], eax` — literally the
|
||||
@@ -489,6 +456,7 @@ impl Default for ModelState {
|
||||
armed: None,
|
||||
pending_seed: false,
|
||||
icon: None,
|
||||
implicit_size: false,
|
||||
facing: 0.0,
|
||||
scale: 1.0,
|
||||
camera: 0,
|
||||
@@ -575,6 +543,14 @@ impl ModelFileFacts {
|
||||
self.sequences.iter().find(|s| s.anim_id == anim_id)
|
||||
}
|
||||
|
||||
/// The header bounding box's `(x, y)` extent in model units — the implicit rect of a
|
||||
/// size-less pane (render law §3) and the arrow's re-centring (`0x4a7b20`: `½·GetWidth`,
|
||||
/// `½·GetHeight`, both the geometry override's bbox extent).
|
||||
pub fn extent(&self) -> (f32, f32) {
|
||||
let (min, max) = self.bbox;
|
||||
((max[0] - min[0]).max(0.0), (max[1] - min[1]).max(0.0))
|
||||
}
|
||||
|
||||
/// The loader's idle seed (`0x70ebd0`'s tail, `0x710153`–`0x71019b`): **id 0 (`Stand`) if
|
||||
/// the file owns it, else `animations[0]`'s own id**; `None` only for a file with no
|
||||
/// sequences at all.
|
||||
@@ -842,17 +818,19 @@ pub const MINIMAP_DEFAULT_PLAYER_MODEL: &str = "Interface\\Minimap\\MinimapArrow
|
||||
|
||||
/// A `CSimpleScrollFrame`'s runtime state: the frame whose anchors are overridden to track the
|
||||
/// scroll offset ([`crate::script::UiScript::resolve`]'s scroll-child override), and the current
|
||||
/// vertical scroll position. `SetVerticalScroll` clamps into `[0, GetVerticalScrollRange()]`, where
|
||||
/// the range is always computed live from the resolved rects (never cached here) — so this struct
|
||||
/// carries only the two members the client's `SetScrollChild`/`SetVerticalScroll` actually set.
|
||||
/// vertical scroll position. `SetVerticalScroll` stores the offset VERBATIM — the reference's
|
||||
/// `0x786db0` never reads the range (decision 2017) — and the range is always computed live from
|
||||
/// the resolved rects (never cached here), so this struct carries only the two members the
|
||||
/// client's `SetScrollChild`/`SetVerticalScroll` actually set (`[+0x318]`, `[+0x328]`).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ScrollFrameState {
|
||||
/// The scroll child (`SetScrollChild`) — the one frame whose content pans within this frame's
|
||||
/// rect. `None` = no child (nothing to clip or offset).
|
||||
pub child: Option<FrameHandle>,
|
||||
/// The vertical scroll offset in px (`SetVerticalScroll`), always in `[0, range]`. XML
|
||||
/// y-positive-up: a positive offset lifts the child (`child.top = scrollframe.top + vertical`),
|
||||
/// bringing content below the fold into view.
|
||||
/// The vertical scroll offset in px (`SetVerticalScroll`), unclamped — the reference's
|
||||
/// `[+0x328]`. XML y-positive-up: a positive offset lifts the child
|
||||
/// (`child.top = scrollframe.top + vertical`), bringing content below the fold into view. The
|
||||
/// scroll bar's `[min, max]` is what keeps it inside the range — in FrameXML, never here.
|
||||
pub vertical: f32,
|
||||
}
|
||||
|
||||
|
||||
@@ -174,13 +174,13 @@ impl<T> Arena<T> {
|
||||
mod kinds;
|
||||
pub use kinds::{
|
||||
model_key, slider_fraction, slider_grab, ArmedSequence, ButtonFont, ButtonState,
|
||||
ButtonVisualState, ColorSelectState, CooldownState, EditAction, EditBoxState, EditOutcome,
|
||||
EditUnit, FrameKind, InsertMode, KindState, MessageFrameState, MessageLine, MinimapState,
|
||||
ModelFileFacts, ModelPlayHead, ModelState, RegionKind, ScrollFrameState, ScrollingMessageState,
|
||||
SequenceFacts, SliderState, StatusBarState, TooltipState, COOLDOWN_FLASH_SECS,
|
||||
MINIMAP_DEFAULT_ARROW_MODEL, MINIMAP_DEFAULT_MASK, MINIMAP_DEFAULT_PLAYER_MODEL,
|
||||
MINIMAP_DEFAULT_ZOOM, MINIMAP_ENGINE_CHILDREN, MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP,
|
||||
TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD, TOOLTIP_WRAP_WIDTH,
|
||||
ButtonVisualState, ColorSelectState, EditAction, EditBoxState, EditOutcome, EditUnit,
|
||||
FrameKind, InsertMode, KindState, MessageFrameState, MessageLine, MinimapState, ModelFileFacts,
|
||||
ModelPlayHead, ModelState, RegionKind, ScrollFrameState, ScrollingMessageState, SequenceFacts,
|
||||
SliderState, StatusBarState, TooltipState, MINIMAP_DEFAULT_ARROW_MODEL, MINIMAP_DEFAULT_MASK,
|
||||
MINIMAP_DEFAULT_PLAYER_MODEL, MINIMAP_DEFAULT_ZOOM, MINIMAP_ENGINE_CHILDREN,
|
||||
MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP, TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD,
|
||||
TOOLTIP_WRAP_WIDTH,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -723,7 +723,6 @@ impl WidgetArena {
|
||||
| FrameKind::DressUpModel
|
||||
| FrameKind::TabardModel => KindState::Model(kinds::ModelState::default()),
|
||||
FrameKind::Minimap => KindState::Minimap(kinds::MinimapState::default()),
|
||||
FrameKind::Cooldown => KindState::Cooldown(kinds::CooldownState::default()),
|
||||
FrameKind::GameTooltip => KindState::Tooltip(kinds::TooltipState::default()),
|
||||
_ => KindState::None,
|
||||
},
|
||||
@@ -739,7 +738,6 @@ impl WidgetArena {
|
||||
kind,
|
||||
FrameKind::ScrollingMessageFrame
|
||||
| FrameKind::MessageFrame
|
||||
| FrameKind::Cooldown
|
||||
| FrameKind::Model
|
||||
| FrameKind::PlayerModel
|
||||
| FrameKind::DressUpModel
|
||||
|
||||
@@ -23,13 +23,19 @@
|
||||
use bevy::asset::RenderAssetUsages;
|
||||
use bevy::camera::Projection;
|
||||
use bevy::image::Image;
|
||||
use bevy::mesh::{Indices, PrimitiveTopology};
|
||||
use bevy::pbr::{ExtendedMaterial, MaterialExtension};
|
||||
use bevy::mesh::{Indices, MeshVertexBufferLayoutRef, PrimitiveTopology};
|
||||
use bevy::pbr::{
|
||||
ExtendedMaterial, MaterialExtension, MaterialExtensionKey, MaterialExtensionPipeline,
|
||||
};
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::render_resource::{AsBindGroup, Extent3d, TextureDimension, TextureFormat};
|
||||
use bevy::render::render_resource::{
|
||||
AsBindGroup, Extent3d, RenderPipelineDescriptor, SpecializedMeshPipelineError,
|
||||
TextureDimension, TextureFormat,
|
||||
};
|
||||
use bevy::shader::ShaderRef;
|
||||
|
||||
use crate::dev_state::DebugState;
|
||||
use crate::sky_order::{sky_pipeline_state, SKY_VERTEX_SHADER};
|
||||
use crate::view::WorldCamera;
|
||||
|
||||
use super::kernel::COLS;
|
||||
@@ -52,9 +58,24 @@ pub struct CloudExt {
|
||||
}
|
||||
|
||||
impl MaterialExtension for CloudExt {
|
||||
/// The shared sky vertex stage — the far-depth pin ([`crate::sky_order`], "The depth law").
|
||||
fn vertex_shader() -> ShaderRef {
|
||||
SKY_VERTEX_SHADER.into()
|
||||
}
|
||||
|
||||
fn fragment_shader() -> ShaderRef {
|
||||
"embedded://benilla_world/shaders/cloud.wgsl".into()
|
||||
}
|
||||
|
||||
fn specialize(
|
||||
_pipeline: &MaterialExtensionPipeline,
|
||||
descriptor: &mut RenderPipelineDescriptor,
|
||||
_layout: &MeshVertexBufferLayoutRef,
|
||||
_key: MaterialExtensionKey<Self>,
|
||||
) -> Result<(), SpecializedMeshPipelineError> {
|
||||
sky_pipeline_state(descriptor);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker for the cloud dome entity.
|
||||
@@ -208,7 +229,7 @@ pub(super) fn apply_cloud_visibility(
|
||||
/// Pin the dome to the camera at `far·0.87` — inside the opaque sky dome (`far·0.9`), sorted after
|
||||
/// the disc shells so the transparent pass draws the clouds over a setting sun, the reference's
|
||||
/// depth-band layering. The radius sets the dome's *screen* geometry only: occlusion against the
|
||||
/// world is the forced far depth in `cloud.wgsl` (`sky_order`, "The depth law"), not this shell —
|
||||
/// world is the far depth `sky_vertex.wgsl` pins (`sky_order`, "The depth law"), not this shell —
|
||||
/// the WDL horizon reaches past it (0588).
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) fn follow_cloud_dome(
|
||||
|
||||
@@ -83,7 +83,7 @@ impl MatAnimTable {
|
||||
/// Allocate a slot (1-based; 0 is the identity row). `None` when the table is full — the
|
||||
/// caller then simply doesn't register, and the batch stays frozen at its built seed: a
|
||||
/// degraded look only a >511-material session could see, never a wrong pixel.
|
||||
pub(crate) fn alloc(&mut self) -> Option<u16> {
|
||||
pub fn alloc(&mut self) -> Option<u16> {
|
||||
if let Some(slot) = self.free.pop() {
|
||||
return Some(slot);
|
||||
}
|
||||
@@ -98,7 +98,7 @@ impl MatAnimTable {
|
||||
|
||||
/// Free a slot when its registry entry dies (the material was unloaded): the row zeroes —
|
||||
/// back to identity — so the next allocation can never inherit a dead batch's delta.
|
||||
pub(crate) fn free(&mut self, slot: u16) {
|
||||
pub fn free(&mut self, slot: u16) {
|
||||
self.set(slot, [0.0; 4]);
|
||||
self.free.push(slot);
|
||||
}
|
||||
@@ -106,7 +106,7 @@ impl MatAnimTable {
|
||||
/// Write slot `slot`'s delta row; a same-value write costs nothing (the tick's quantized
|
||||
/// samples make equality the common case on slow loops). Slot 0 — the shared identity — is
|
||||
/// refused: writing it would scroll every static batch in the world at once.
|
||||
pub(crate) fn set(&mut self, slot: u16, row: [f32; 4]) {
|
||||
pub fn set(&mut self, slot: u16, row: [f32; 4]) {
|
||||
let i = slot as usize;
|
||||
if i == 0 || i >= MAX_MAT_ANIM_SLOTS || self.rows[i] == row {
|
||||
return;
|
||||
@@ -122,6 +122,15 @@ impl MatAnimTable {
|
||||
}
|
||||
}
|
||||
|
||||
/// The **affine row** (decision 2019): a texture transform's rotation and scale as the deltas
|
||||
/// from the identity the shader adds back — `[cos − 1, sin, sx − 1, sy − 1]`, with `cos`/`sin`
|
||||
/// the raw quaternion's `1 − 2z²` / `2zw` ([`benilla_formats::rotation_2x2`]). The identity
|
||||
/// encodes as the zero row, which is what lets slot 0 serve every material with no transform.
|
||||
pub fn affine_row(q: [f32; 4], scale: [f32; 2]) -> [f32; 4] {
|
||||
let (c, s) = benilla_formats::rotation_2x2(q);
|
||||
[c - 1.0, s, scale[0] - 1.0, scale[1] - 1.0]
|
||||
}
|
||||
|
||||
/// Render world (`PrepareResources`): write the whole 8 KB region when anything changed, gated on
|
||||
/// the generation — a scene with no drawn animated material (most of the world) never touches the
|
||||
/// queue. Whole-region writes for the same reason `instance_tint` chose them: the table is small
|
||||
@@ -161,6 +170,22 @@ pub fn plugin(app: &mut App) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The affine row's identity is the zero row — the whole reason the encoding is deltas.
|
||||
#[test]
|
||||
fn the_affine_identity_is_the_zero_row() {
|
||||
assert_eq!(affine_row([0.0, 0.0, 0.0, 1.0], [1.0, 1.0]), [0.0; 4]);
|
||||
let r = std::f32::consts::FRAC_1_SQRT_2;
|
||||
let row = affine_row([0.0, 0.0, r, r], [2.0, 0.5]);
|
||||
assert!(
|
||||
(row[0] + 1.0).abs() < 1e-6 && (row[1] - 1.0).abs() < 1e-6,
|
||||
"{row:?}"
|
||||
);
|
||||
assert!(
|
||||
(row[2] - 1.0).abs() < 1e-6 && (row[3] + 0.5).abs() < 1e-6,
|
||||
"{row:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Slot 0 is the identity row every static material in the world reads — the setter refuses
|
||||
/// it, and freeing can never zero it "again" into a generation bump.
|
||||
#[test]
|
||||
|
||||
@@ -115,7 +115,7 @@ pub struct MatKey {
|
||||
zfill: bool,
|
||||
/// This batch is part of a **WMO skybox** ([`crate::skybox`]) — the building-owned painted sky,
|
||||
/// drawn on this lane like any other M2 since decision 1264. Its own key axis because the lane
|
||||
/// changes pipeline state ([`SKY_DEPTH_MARKER`]: forced far depth, no early-Z) and sort rung
|
||||
/// changes pipeline state ([`SKY_DEPTH_MARKER`]: the far depth pinned at the vertex) and sort rung
|
||||
/// ([`skybox_sort_bias`]), so a skybox batch must never dedupe onto the identical-looking world
|
||||
/// batch — `CavernsOfTimeSky` and Elwynn share no texture today, but nothing enforces that and
|
||||
/// the collision would be a world doodad silently drawn at the far plane.
|
||||
@@ -514,11 +514,11 @@ pub(crate) const TWIN_CUTOUT_MARKER: u16 = 1 << 10;
|
||||
pub(crate) const ENV_MAP_MARKER: u16 = 1 << 12;
|
||||
|
||||
/// `clutter_fade.z` marker bit 13: the **WMO-skybox lane** — this batch is part of a building's
|
||||
/// painted sky, so `specialize` compiles the shader's `WOW_SKY_DEPTH` branch (forced far depth) and
|
||||
/// drops the rasterizer bias constant. It *is* a
|
||||
/// [`benilla_assets::materials::WowModelKey`] axis, unlike [`ENV_MAP_MARKER`]: writing
|
||||
/// `@builtin(frag_depth)` costs the pipeline its early-Z, so exactly one camera-anchored model may
|
||||
/// pay it and every other draw in the frame must keep the pipeline that doesn't.
|
||||
/// painted sky, so `specialize` compiles the shader's `WOW_SKY_DEPTH` branch (the far depth,
|
||||
/// pinned at the vertex — decision 2016) and drops the rasterizer bias constant. It *is* a
|
||||
/// [`benilla_assets::materials::WowModelKey`] axis, unlike [`ENV_MAP_MARKER`]: the pin belongs
|
||||
/// to exactly one camera-anchored model, and every other draw in the frame must keep the
|
||||
/// pipeline that leaves its depth real.
|
||||
pub(crate) const SKY_DEPTH_MARKER: u16 = 1 << 13;
|
||||
|
||||
/// Sort-bias step per authored batch on the **WMO-skybox lane** — [`BATCH_ORDER_SORT_EPS`]'s job,
|
||||
|
||||
@@ -30,6 +30,7 @@ use bevy::prelude::*;
|
||||
/// closes. `Plugin::build` runs at `add_plugins` time, and both binaries add `DefaultPlugins`
|
||||
/// (which creates the registry these calls fill) before `WorldPlugins`.
|
||||
pub(crate) fn plugin(app: &mut App) {
|
||||
bevy::asset::embedded_asset!(app, "shaders/sky_vertex.wgsl");
|
||||
bevy::asset::embedded_asset!(app, "shaders/sky.wgsl");
|
||||
bevy::asset::embedded_asset!(app, "shaders/star.wgsl");
|
||||
bevy::asset::embedded_asset!(app, "shaders/cloud.wgsl");
|
||||
@@ -79,7 +80,8 @@ mod tests {
|
||||
}
|
||||
// 6 since decision 1264 retired `skybox.wgsl` (a WMO skybox draws on the shared
|
||||
// model lane, whose forced-far-depth branch lives in `benilla_assets`'
|
||||
// `wow_model.wgsl`); 7 since 1429 added `static_gx.wgsl` (the B1 retained pass).
|
||||
assert_eq!(found, 7, "the engine's shader set changed size");
|
||||
// `wow_model.wgsl`); 7 since 1429 added `static_gx.wgsl` (the B1 retained pass);
|
||||
// 8 since 2016 added `sky_vertex.wgsl` (the sky's shared far-depth vertex stage).
|
||||
assert_eq!(found, 8, "the engine's shader set changed size");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,11 +38,13 @@
|
||||
// further back still (`[0.995, 1.0]`) — so the z-buffer occludes every sky element per-pixel behind
|
||||
// everything the world drew (a ridge, a wall, one leaf), while the sky itself (which writes no depth)
|
||||
// never blocks it. Same mechanism here: the quads' geometry stays on their shells (12 units for the
|
||||
// glare — its pinned screen footprint — and `far·0.85` for the discs), but EVERY fragment FORCES its
|
||||
// depth to 0.0 — reverse-Z "infinitely far" — under Bevy's GreaterEqual test: it passes only where the
|
||||
// depth buffer still holds the clear value, i.e. where no opaque geometry drew. Discs used to pass
|
||||
// their own rasterized depth through, on the assumption that their shell sits beyond all world
|
||||
// geometry; the WDL horizon ring reaches past it, so a distant hill lost to a disc it should occlude.
|
||||
// glare — its pinned screen footprint — and `far·0.85` for the discs), but EVERY vertex pins its
|
||||
// clip z to 0.0 — reverse-Z "infinitely far" (`sky_vertex.wgsl`, the stage every sky shader shares) —
|
||||
// under Bevy's GreaterEqual test: a fragment passes only where the depth buffer still holds the
|
||||
// clear value, i.e. where no opaque geometry drew. Discs used to pass their own rasterized depth
|
||||
// through, on the assumption that their shell sits beyond all world geometry; the WDL horizon ring
|
||||
// reaches past it, so a distant hill lost to a disc it should occlude. Until 2016 the pin was a
|
||||
// `frag_depth` write here, which cost the pipeline its early-Z; the vertex pin is the same number.
|
||||
|
||||
#import bevy_pbr::{
|
||||
pbr_fragment::pbr_input_from_standard_material,
|
||||
@@ -51,15 +53,6 @@
|
||||
mesh_view_bindings::view,
|
||||
}
|
||||
|
||||
// `forward_io::FragmentOutput` + the forced-depth builtin: every sky fragment writes the far depth.
|
||||
struct CelestialOutput {
|
||||
@location(0) color: vec4<f32>,
|
||||
@builtin(frag_depth) depth: f32,
|
||||
}
|
||||
|
||||
/// Reverse-Z "infinitely far" — the sky pass's forced depth (`sky_order.rs`).
|
||||
const SKY_FAR_DEPTH: f32 = 0.0;
|
||||
|
||||
// Per-material control (set in `sun/materials.rs` `CelestialExt`). `.x` = the horizon alpha-ramp scale `k`
|
||||
// (`k = 30.0`: the binary's 0.4-unit band at its radius-12 disc, in sin-elevation terms — 0x6d1960).
|
||||
// `.y` = brightness multiplier — 1.0 everywhere (the old moon HDR boost died with
|
||||
@@ -88,7 +81,7 @@ fn linear_to_srgb(c: vec3<f32>) -> vec3<f32> {
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> CelestialOutput {
|
||||
fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> @location(0) vec4<f32> {
|
||||
var pbr_input = pbr_input_from_standard_material(in, is_front);
|
||||
let base = alpha_discard(pbr_input.material, pbr_input.material.base_color);
|
||||
|
||||
@@ -124,17 +117,13 @@ fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> Celestia
|
||||
// premultiply by `a` in gamma space, emit raw onto the gamma buffer (0161). `celestial.y` =
|
||||
// brightness (1.0 everywhere; multiplies the RGB only, so the blend's dst term is unaffected).
|
||||
let gamma = linear_to_srgb(base.rgb);
|
||||
var out: CelestialOutput;
|
||||
// (The far depth for BOTH is the vertex stage's — see the header: a sky element survives only
|
||||
// on pixels no opaque geometry claimed, the reference's back-slice depth test.)
|
||||
if celestial.z >= 0.5 {
|
||||
// ADDITIVE GLARE: (rgb·a, alpha 0) under (ONE, 1−src_alpha) ⇒ `dst + gamma·a` — the
|
||||
// reference's SRC_ALPHA, ONE byte addition, `a` = the lens-flare intensity envelope.
|
||||
out.color = vec4<f32>(gamma * a * celestial.y, 0.0);
|
||||
} else {
|
||||
// DISC: premultiplied gamma blend (AlphaMode::Premultiplied).
|
||||
out.color = vec4<f32>(gamma * a * celestial.y, a);
|
||||
return vec4<f32>(gamma * a * celestial.y, 0.0);
|
||||
}
|
||||
// Forced far depth for BOTH (see the header): a sky element survives only on pixels no opaque
|
||||
// geometry claimed — the reference's back-slice depth test.
|
||||
out.depth = SKY_FAR_DEPTH;
|
||||
return out;
|
||||
// DISC: premultiplied gamma blend (AlphaMode::Premultiplied).
|
||||
return vec4<f32>(gamma * a * celestial.y, a);
|
||||
}
|
||||
|
||||
@@ -14,31 +14,22 @@
|
||||
//
|
||||
// SKY-PASS DEPTH (see `sky_order.rs`, "The depth law"): the dome's radius does not decide occlusion —
|
||||
// the reference draws the whole sky first, in a squashed back depth slice, and the opaque world paints
|
||||
// over it. Forcing the far depth reproduces that: clouds survive only where no world geometry drew.
|
||||
// over it. The far depth reproduces that: clouds survive only where no world geometry drew. It is
|
||||
// pinned in the VERTEX stage (`sky_vertex.wgsl`, shared by every sky shader); this fragment writes
|
||||
// colour only and keeps its early-Z (2016).
|
||||
|
||||
#import bevy_pbr::forward_io::VertexOutput
|
||||
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var cloud_tex: texture_2d<f32>;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(101) var cloud_samp: sampler;
|
||||
|
||||
/// Reverse-Z "infinitely far" — the sky pass's forced depth (`sky_order.rs`).
|
||||
const SKY_FAR_DEPTH: f32 = 0.0;
|
||||
|
||||
struct CloudOutput {
|
||||
@location(0) color: vec4<f32>,
|
||||
@builtin(frag_depth) depth: f32,
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fragment(in: VertexOutput) -> CloudOutput {
|
||||
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
let texel = textureSample(cloud_tex, cloud_samp, in.uv);
|
||||
var a = texel.a;
|
||||
#ifdef VERTEX_COLORS
|
||||
a *= in.color.a; // the dome's rim fade (ring alphas)
|
||||
#endif
|
||||
var out: CloudOutput;
|
||||
// Premultiplied gamma blend; the RGB is already the reference's byte math.
|
||||
out.color = vec4<f32>(texel.rgb * a, a);
|
||||
out.depth = SKY_FAR_DEPTH;
|
||||
return out;
|
||||
return vec4<f32>(texel.rgb * a, a);
|
||||
}
|
||||
|
||||
@@ -7,22 +7,16 @@
|
||||
// So SkyColor0 dominates the whole upper sky; the gradient is crushed into the bottom ~17° and
|
||||
// converges into the distance fog at the horizon. Linear interp in elevation ≈ WoW's ring Gouraud.
|
||||
// SKY-PASS DEPTH (see `sky_order.rs`, "The depth law"): the dome's radius does not decide occlusion —
|
||||
// the reference draws the sky FIRST and the opaque world paints over it. Forcing the far depth makes
|
||||
// the dome a true backdrop: it fills only pixels no world geometry claimed, so a WDL hill drawn beyond
|
||||
// the dome's shell can never be over-painted by the gradient.
|
||||
// the reference draws the sky FIRST and the opaque world paints over it. The far depth makes the
|
||||
// dome a true backdrop: it fills only pixels no world geometry claimed, so a WDL hill drawn beyond
|
||||
// the dome's shell can never be over-painted by the gradient. That depth is pinned in the VERTEX
|
||||
// stage (`sky_vertex.wgsl`, shared by every sky shader) — this fragment writes colour only, so the
|
||||
// rasterizer's early-Z rejects the covered fraction of the screen before the gradient runs (2016).
|
||||
#import bevy_pbr::{
|
||||
forward_io::VertexOutput,
|
||||
mesh_view_bindings::view,
|
||||
}
|
||||
|
||||
/// Reverse-Z "infinitely far" — the sky pass's forced depth (`sky_order.rs`).
|
||||
const SKY_FAR_DEPTH: f32 = 0.0;
|
||||
|
||||
struct SkyOutput {
|
||||
@location(0) color: vec4<f32>,
|
||||
@builtin(frag_depth) depth: f32,
|
||||
}
|
||||
|
||||
struct SkyColors {
|
||||
sky0: vec4<f32>, // zenith (90°)
|
||||
sky1: vec4<f32>, // 16.8°
|
||||
@@ -66,7 +60,7 @@ fn warp_one(base: vec3<f32>, g: f32, s: f32) -> vec3<f32> {
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fragment(in: VertexOutput) -> SkyOutput {
|
||||
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
// Direction from the camera to this dome fragment; elevation above the world horizon.
|
||||
let dir = normalize(in.world_position.xyz - view.world_position.xyz);
|
||||
let elev = degrees(asin(clamp(dir.y, -1.0, 1.0))); // −90..90, 0 = horizon
|
||||
@@ -125,8 +119,5 @@ fn fragment(in: VertexOutput) -> SkyOutput {
|
||||
// Raw gamma out (GAMMA LANE, 0161): the reference draws the sky with GL_FRAMEBUFFER_SRGB OFF
|
||||
// (CSky::Render @0x6d4940) — raw DBC bytes — which is now the whole pipeline's convention.
|
||||
let rgb = col;
|
||||
var out: SkyOutput;
|
||||
out.color = vec4<f32>(rgb, 1.0);
|
||||
out.depth = SKY_FAR_DEPTH;
|
||||
return out;
|
||||
return vec4<f32>(rgb, 1.0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// The sky pass's shared VERTEX stage — bevy 0.18.1's `mesh.wgsl` vertex verbatim (VERTEX_*
|
||||
// attributes; morph targets and skinning omitted — no sky mesh authors them) plus ONE line: the
|
||||
// clip-space z is pinned to the far plane. Every sky element — the gradient dome (`sky.wgsl`),
|
||||
// the cloud dome (`cloud.wgsl`), the star patches (`star.wgsl`), the celestial discs and glares
|
||||
// (`celestial.wgsl`) — draws through this stage; their fragments write colour only.
|
||||
//
|
||||
// THE DEPTH LAW, moved up a stage (`sky_order.rs`, "The depth law"; decision 2016). The reference
|
||||
// draws its whole sky first, in a squashed back depth slice, and the opaque world paints over it;
|
||||
// we draw the sky after the world, so the depth TEST does that job, and it only does it if the
|
||||
// sky's depth is behind everything. Until 2016 each sky fragment shader wrote
|
||||
// `@builtin(frag_depth) = 0.0` (reverse-Z "infinitely far"). That is the same number this stage
|
||||
// produces — clip z = 0 interpolates to NDC z = 0 for every fragment, exactly, whatever w is — but
|
||||
// a fragment-stage depth write costs the whole pipeline its early-Z: the hardware cannot reject a
|
||||
// fragment before the shader that decides its depth has run, so every sky fragment under a hill,
|
||||
// a wall or a leaf was shaded in full and then thrown away. A dome is a full-screen draw and its
|
||||
// gradient is not cheap; on an immediate-mode GPU (the Steam Deck's RDNA2, every Windows part)
|
||||
// that was the whole covered fraction of the screen shaded for nothing, twice a frame at night.
|
||||
// With the depth known at the vertex, the rasterizer's early test rejects those fragments
|
||||
// before the shader runs. Same pixels, less work — the change is invisible by construction.
|
||||
//
|
||||
// A `MaterialExtension` swaps the whole stage, so the mirror below must track bevy's on upgrades.
|
||||
|
||||
#import bevy_pbr::{
|
||||
mesh_functions,
|
||||
forward_io::{Vertex, VertexOutput},
|
||||
view_transformations::position_world_to_clip,
|
||||
}
|
||||
|
||||
/// Reverse-Z "infinitely far" — clip-space z = 0 ⇒ NDC depth 0.0 for every fragment, whatever w.
|
||||
/// Under bevy's `GreaterEqual` test a sky fragment then passes only where the depth buffer still
|
||||
/// holds its clear value (0.0): exactly the pixels no world geometry claimed.
|
||||
const SKY_FAR_CLIP_Z: f32 = 0.0;
|
||||
|
||||
@vertex
|
||||
fn vertex(vertex_no_morph: Vertex) -> VertexOutput {
|
||||
var out: VertexOutput;
|
||||
|
||||
var vertex = vertex_no_morph;
|
||||
|
||||
let mesh_world_from_local = mesh_functions::get_world_from_local(vertex_no_morph.instance_index);
|
||||
|
||||
// Use vertex_no_morph.instance_index instead of vertex.instance_index to work around a wgpu dx12 bug.
|
||||
// See https://github.com/gfx-rs/naga/issues/2416 .
|
||||
var world_from_local = mesh_world_from_local;
|
||||
|
||||
#ifdef VERTEX_NORMALS
|
||||
out.world_normal = mesh_functions::mesh_normal_local_to_world(
|
||||
vertex.normal,
|
||||
// Use vertex_no_morph.instance_index instead of vertex.instance_index to work around a wgpu dx12 bug.
|
||||
// See https://github.com/gfx-rs/naga/issues/2416
|
||||
vertex_no_morph.instance_index
|
||||
);
|
||||
#endif
|
||||
|
||||
#ifdef VERTEX_POSITIONS
|
||||
out.world_position = mesh_functions::mesh_position_local_to_world(world_from_local, vec4<f32>(vertex.position, 1.0));
|
||||
out.position = position_world_to_clip(out.world_position.xyz);
|
||||
// The one line that is ours (the header): the sky's depth is the far plane, decided here so
|
||||
// the fragment stage writes none and keeps its early-Z.
|
||||
out.position.z = SKY_FAR_CLIP_Z;
|
||||
#endif
|
||||
|
||||
#ifdef VERTEX_UVS_A
|
||||
out.uv = vertex.uv;
|
||||
#endif
|
||||
#ifdef VERTEX_UVS_B
|
||||
out.uv_b = vertex.uv_b;
|
||||
#endif
|
||||
|
||||
#ifdef VERTEX_TANGENTS
|
||||
out.world_tangent = mesh_functions::mesh_tangent_local_to_world(
|
||||
world_from_local,
|
||||
vertex.tangent,
|
||||
// Use vertex_no_morph.instance_index instead of vertex.instance_index to work around a wgpu dx12 bug.
|
||||
// See https://github.com/gfx-rs/naga/issues/2416
|
||||
vertex_no_morph.instance_index
|
||||
);
|
||||
#endif
|
||||
|
||||
#ifdef VERTEX_COLORS
|
||||
out.color = vertex.color;
|
||||
#endif
|
||||
|
||||
#ifdef VERTEX_OUTPUT_INSTANCE_INDEX
|
||||
// Use vertex_no_morph.instance_index instead of vertex.instance_index to work around a wgpu dx12 bug.
|
||||
// See https://github.com/gfx-rs/naga/issues/2416
|
||||
out.instance_index = vertex_no_morph.instance_index;
|
||||
#endif
|
||||
|
||||
#ifdef VISIBILITY_RANGE_DITHER
|
||||
out.visibility_range_dither = mesh_functions::get_visibility_range_dither_level(
|
||||
vertex_no_morph.instance_index, mesh_world_from_local[3]);
|
||||
#endif
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -11,27 +11,17 @@
|
||||
|
||||
// SKY-PASS DEPTH (see `sky_order.rs`, "The depth law"): the star dome's own geometry depth is not
|
||||
// what decides occlusion — the reference draws its whole sky FIRST, in a squashed back depth slice,
|
||||
// and the opaque world paints over it. Forcing the far depth reproduces that from the transparent
|
||||
// pass: stars survive only on pixels no world geometry claimed.
|
||||
// and the opaque world paints over it. The far depth reproduces that from the transparent pass:
|
||||
// stars survive only on pixels no world geometry claimed. It is pinned in the VERTEX stage
|
||||
// (`sky_vertex.wgsl`, shared by every sky shader); this fragment writes colour only (2016).
|
||||
#import bevy_pbr::{
|
||||
pbr_fragment::pbr_input_from_standard_material,
|
||||
forward_io::VertexOutput,
|
||||
}
|
||||
|
||||
/// Reverse-Z "infinitely far" — the sky pass's forced depth (`sky_order.rs`).
|
||||
const SKY_FAR_DEPTH: f32 = 0.0;
|
||||
|
||||
struct StarOutput {
|
||||
@location(0) color: vec4<f32>,
|
||||
@builtin(frag_depth) depth: f32,
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> StarOutput {
|
||||
fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> @location(0) vec4<f32> {
|
||||
let pbr_input = pbr_input_from_standard_material(in, is_front);
|
||||
let a = pbr_input.material.base_color.a; // dot texture alpha × the star-curve global alpha
|
||||
var out: StarOutput;
|
||||
out.color = vec4<f32>(vec3<f32>(a), a); // premultiplied white — raw (GAMMA LANE, 0161)
|
||||
out.depth = SKY_FAR_DEPTH;
|
||||
return out;
|
||||
return vec4<f32>(vec3<f32>(a), a); // premultiplied white — raw (GAMMA LANE, 0161)
|
||||
}
|
||||
|
||||
@@ -18,10 +18,10 @@
|
||||
//! elevation (tessellation-independent → no banding). Like the reference, the dome does NOT write
|
||||
//! depth (the [`SkyExt::specialize`] hook) — the z-buffer stays clean over sky pixels, which is what
|
||||
//! lets the far-forced glare quads (`celestial.wgsl`) be occluded per-pixel by world geometry alone.
|
||||
//! The dome's own fragments likewise force the far depth (`sky.wgsl`; the law is in
|
||||
//! [`crate::sky_order`]), so the world always paints over the backdrop no matter how the radius
|
||||
//! compares to a given piece of geometry — the WDL horizon ring reaches past this shell. The dome
|
||||
//! tracks the camera
|
||||
//! The dome's own depth is likewise the far plane, pinned in the vertex stage every sky material
|
||||
//! shares (`sky_vertex.wgsl`; the law is in [`crate::sky_order`]), so the world always paints over
|
||||
//! the backdrop no matter how the radius compares to a given piece of geometry — the WDL horizon
|
||||
//! ring reaches past this shell. The dome tracks the camera
|
||||
//! *position* but stays world-aligned (identity rotation) so the gradient is fixed to the world
|
||||
//! horizon regardless of look direction.
|
||||
//!
|
||||
@@ -82,6 +82,11 @@ pub struct SkyExt {
|
||||
}
|
||||
|
||||
impl MaterialExtension for SkyExt {
|
||||
/// The shared sky vertex stage — the far-depth pin ([`crate::sky_order`], "The depth law").
|
||||
fn vertex_shader() -> ShaderRef {
|
||||
crate::sky_order::SKY_VERTEX_SHADER.into()
|
||||
}
|
||||
|
||||
fn fragment_shader() -> ShaderRef {
|
||||
"embedded://benilla_world/shaders/sky.wgsl".into()
|
||||
}
|
||||
@@ -90,8 +95,9 @@ impl MaterialExtension for SkyExt {
|
||||
/// note in the module header, byte-confirmed by the `celestial-frame-anatomy` pin: every sky
|
||||
/// element inherits `CSky::Render`'s depth-write-off state). Depth-TEST stays on, so terrain
|
||||
/// still occludes the dome; what this buys is a clean z-buffer over sky pixels — the glare
|
||||
/// quads (forced to the far depth in `celestial.wgsl`) are occluded per-pixel by *world*
|
||||
/// geometry only, never by the backdrop dome they must shine over.
|
||||
/// quads (pinned to the far depth like the dome itself) are occluded per-pixel by *world*
|
||||
/// geometry only, never by the backdrop dome they must shine over. Plus the state every sky
|
||||
/// pipeline takes ([`crate::sky_order::sky_pipeline_state`]).
|
||||
fn specialize(
|
||||
_pipeline: &MaterialExtensionPipeline,
|
||||
descriptor: &mut RenderPipelineDescriptor,
|
||||
@@ -101,6 +107,7 @@ impl MaterialExtension for SkyExt {
|
||||
if let Some(depth) = descriptor.depth_stencil.as_mut() {
|
||||
depth.depth_write_enabled = false;
|
||||
}
|
||||
crate::sky_order::sky_pipeline_state(descriptor);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -220,7 +227,7 @@ fn setup_sky(
|
||||
}
|
||||
|
||||
/// Pin the dome to the camera and scale it to just inside the far plane (the radius only has to keep
|
||||
/// the dome unclipped — occlusion is `sky.wgsl`'s forced far depth). World-aligned (identity rotation)
|
||||
/// the dome unclipped — occlusion is `sky_vertex.wgsl`'s far-depth pin). World-aligned (identity rotation)
|
||||
/// → the gradient stays fixed to the world horizon regardless of camera look direction.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn follow_camera(
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
//! depth, below) but it perturbs the depth TEST of anything depth-tested, so the world-side rungs
|
||||
//! stay as small as their ordering job allows.
|
||||
//!
|
||||
//! ## The depth law — every sky fragment forces the far depth
|
||||
//! ## The depth law — every sky vertex pins the far depth
|
||||
//!
|
||||
//! The biases above order the sky *against itself*. What orders it against the **world** is depth,
|
||||
//! and there the reference gives one rule for the whole pass: the sky draws FIRST, in a squashed
|
||||
@@ -65,11 +65,42 @@
|
||||
//! passed the depth test in front of terrain the reference would have occluded them with (the
|
||||
//! sighting: stars showing *through* a fogged mountain range at night, decision 0588).
|
||||
//!
|
||||
//! So every sky fragment now writes `SKY_FAR_DEPTH = 0.0` — reverse-Z "infinitely far" — under
|
||||
//! Bevy's `GreaterEqual` test (`sky.wgsl`, `star.wgsl`, `cloud.wgsl`, `celestial.wgsl`, which
|
||||
//! already did it for the glare). A sky element survives only where the depth buffer still holds
|
||||
//! the clear value: exactly "the world paints over the sky", independent of any shell radius. The
|
||||
//! shells now decide only *screen size and sky-internal parallax*, never occlusion.
|
||||
//! So every sky VERTEX pins its clip z to `SKY_FAR_CLIP_Z = 0.0` — reverse-Z "infinitely far" —
|
||||
//! under Bevy's `GreaterEqual` test, in the one vertex stage every sky material shares
|
||||
//! (`sky_vertex.wgsl`, [`SKY_VERTEX_SHADER`]; `sky.wgsl`, `star.wgsl`, `cloud.wgsl` and
|
||||
//! `celestial.wgsl` are fragment stages that write colour only). Clip z = 0 interpolates to NDC
|
||||
//! depth 0 for every fragment whatever w is, so a sky element survives only where the depth
|
||||
//! buffer still holds the clear value: exactly "the world paints over the sky", independent of
|
||||
//! any shell radius. The shells now decide only *screen size and sky-internal parallax*, never
|
||||
//! occlusion.
|
||||
//!
|
||||
//! It was a `@builtin(frag_depth)` write in each fragment shader until decision 2016. Same number,
|
||||
//! wrong stage: a pipeline whose fragment decides its own depth cannot be early-Z rejected, so
|
||||
//! every dome fragment under a hill, a wall or a leaf was shaded in full and discarded late — a
|
||||
//! full-screen gradient (and the cloud dome, and the star patches) paid for the covered fraction
|
||||
//! of the screen on every frame. Pinned at the vertex the rasterizer rejects them first. The
|
||||
//! rasterizer's `DepthBiasState` constant then has to be zero on these pipelines
|
||||
//! ([`sky_pipeline_state`]): the rung each sky material carries in `depth_bias` is a SORT key,
|
||||
//! and the base `StandardMaterial` packs the same f32 into the bias constant — harmless while
|
||||
//! the fragment overwrote its depth, a perturbation of the pinned zero once it doesn't.
|
||||
|
||||
use bevy::render::render_resource::RenderPipelineDescriptor;
|
||||
|
||||
/// The one vertex stage every sky material draws through — the far-depth pin (module doc, "The
|
||||
/// depth law"). A sky `MaterialExtension` returns this from `vertex_shader()`; the test below
|
||||
/// holds each of them to it.
|
||||
pub(crate) const SKY_VERTEX_SHADER: &str = "embedded://benilla_world/shaders/sky_vertex.wgsl";
|
||||
|
||||
/// The pipeline state every sky material's `specialize` applies (module doc, "The depth law"):
|
||||
/// the rasterizer depth-bias constant back to zero, so the sort rung the material carries in
|
||||
/// `StandardMaterial::depth_bias` stays sort-only and the vertex stage's pinned far depth reaches
|
||||
/// the depth test untouched. Same split the model lane makes for its far-side and skybox twins
|
||||
/// (`benilla_assets::materials`, `WowModelExt::specialize`).
|
||||
pub(crate) fn sky_pipeline_state(descriptor: &mut RenderPipelineDescriptor) {
|
||||
if let Some(ds) = descriptor.depth_stencil.as_mut() {
|
||||
ds.bias.constant = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stars — the first celestial draw (`0x6d4a3f`): everything else in the sky paints over them, so
|
||||
/// theirs is the ladder's LOWEST rung (see the sign law above).
|
||||
@@ -140,7 +171,7 @@ pub(crate) const WATER_BIAS: f32 = -2.0e4;
|
||||
/// window and is not claimed (the decal band's precedent).
|
||||
pub(crate) const FOAM_BIAS: f32 = -1.0e4;
|
||||
/// The sun/moon glare quads — the frame's last render (`0x483740` tail): over the clouds and the
|
||||
/// rain, under the nameplates; the z-buffer (their forced far depth, `celestial.wgsl`) is what
|
||||
/// rain, under the nameplates; the z-buffer (their pinned far depth, `sky_vertex.wgsl`) is what
|
||||
/// occludes them. Only ~6× the far plane, not ~10⁵: a rung this far up is also a rasterizer bias
|
||||
/// (the sign law above), and nothing is gained by making it bigger than the ordering needs.
|
||||
pub(crate) const GLARE_BIAS: f32 = 2.0e4;
|
||||
@@ -423,12 +454,24 @@ const _: () = {
|
||||
assert!(Rung::RING + WORLD_VIEW_Z_FLOOR - WMO_SKYBOX_BIAS > 1.0e3);
|
||||
};
|
||||
|
||||
/// The depth law (module doc) is a property of the **shaders**, so it is checked there: every sky
|
||||
/// fragment shader must force `SKY_FAR_DEPTH`. Without this, a shell radius silently becomes
|
||||
/// The depth law (module doc) is a property of the **shaders**, so it is checked there: the shared
|
||||
/// vertex stage must pin the far depth, every sky material must draw through it, and no sky
|
||||
/// fragment shader may take the depth back (a `frag_depth` write is the same number at the cost of
|
||||
/// the pipeline's early-Z — decision 2016). Without this, a shell radius silently becomes
|
||||
/// load-bearing again the moment someone edits one of them — the exact regression 0588 fixed, and
|
||||
/// one that only shows up at night, on a mountainous horizon, past 2.6 km.
|
||||
#[test]
|
||||
fn every_sky_shader_forces_the_far_depth() {
|
||||
fn the_sky_depth_is_pinned_at_the_vertex_and_nowhere_else() {
|
||||
use bevy::pbr::Material;
|
||||
use bevy::shader::ShaderRef;
|
||||
|
||||
let vertex = include_str!("shaders/sky_vertex.wgsl");
|
||||
assert!(
|
||||
vertex.contains("const SKY_FAR_CLIP_Z: f32 = 0.0;")
|
||||
&& vertex.contains("out.position.z = SKY_FAR_CLIP_Z;"),
|
||||
"sky_vertex.wgsl no longer pins the far depth — every shell radius is deciding occlusion \
|
||||
again (sky_order.rs, \"The depth law\")"
|
||||
);
|
||||
for (name, src) in [
|
||||
("sky.wgsl", include_str!("shaders/sky.wgsl")),
|
||||
("star.wgsl", include_str!("shaders/star.wgsl")),
|
||||
@@ -436,16 +479,38 @@ fn every_sky_shader_forces_the_far_depth() {
|
||||
("celestial.wgsl", include_str!("shaders/celestial.wgsl")),
|
||||
// The WMO skybox is no longer a shader of its own: it draws on the shared model lane, whose
|
||||
// `WOW_SKY_DEPTH` branch obeys the same law and is asserted beside it
|
||||
// (`benilla_assets::materials`, `the_sky_lane_forces_the_far_depth`).
|
||||
// (`benilla_assets::materials`, `the_sky_lane_pins_the_far_depth_at_the_vertex`).
|
||||
] {
|
||||
assert!(
|
||||
src.contains("const SKY_FAR_DEPTH: f32 = 0.0;"),
|
||||
"{name}: the sky pass's forced-far-depth constant is gone"
|
||||
);
|
||||
assert!(
|
||||
src.contains("out.depth = SKY_FAR_DEPTH;"),
|
||||
"{name}: a sky fragment no longer forces the far depth — its shell radius is deciding \
|
||||
occlusion again (sky_order.rs, \"The depth law\")"
|
||||
!src.contains("@builtin(frag_depth)"),
|
||||
"{name}: a sky fragment writes its depth again — the pin is the vertex stage's, and a \
|
||||
fragment write costs the pipeline its early-Z (sky_order.rs, \"The depth law\")"
|
||||
);
|
||||
}
|
||||
fn shared(name: &str, shader: ShaderRef) {
|
||||
match shader {
|
||||
ShaderRef::Path(p) => assert_eq!(
|
||||
p.to_string(),
|
||||
SKY_VERTEX_SHADER,
|
||||
"{name}: not drawing through the shared sky vertex stage"
|
||||
),
|
||||
_ => panic!("{name}: vertex shader is not a path — not the shared sky vertex stage"),
|
||||
}
|
||||
}
|
||||
shared(
|
||||
"SkyMaterial",
|
||||
<crate::sky::SkyMaterial as Material>::vertex_shader(),
|
||||
);
|
||||
shared(
|
||||
"CloudMaterial",
|
||||
<crate::clouds::CloudMaterial as Material>::vertex_shader(),
|
||||
);
|
||||
shared(
|
||||
"StarMaterial",
|
||||
<crate::sun::StarMaterial as Material>::vertex_shader(),
|
||||
);
|
||||
shared(
|
||||
"CelestialMaterial",
|
||||
<crate::sun::CelestialMaterial as Material>::vertex_shader(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -893,13 +893,24 @@ pub(super) fn build(app: &mut App) {
|
||||
),
|
||||
)
|
||||
.add_render_graph_node::<ViewNodeRunner<StaticGxNode>>(Core3d, StaticGxLabel)
|
||||
// BEFORE bevy's opaque pass (decision 2016): the retained statics — every building and
|
||||
// every steady doodad — are the frame's best early-Z occluders, and terrain's fragment is
|
||||
// the frame's dearest (four splat layers, the alpha map, the baked shadow: six samples a
|
||||
// pixel). Drawn first, the walls and trunks fill the depth buffer with early writes and
|
||||
// the terrain behind them is rejected before it samples anything; drawn after (the order
|
||||
// 1429 inherited from "bevy's pass, then ours"), every terrain fragment under a building
|
||||
// was shaded in full and then overwritten. The same holds against the entity lane's
|
||||
// creatures and animated doodads, which stand in front of nothing static as a rule. The
|
||||
// pass takes the depth and colour CLEAR with it (bevy's attachments clear on first use,
|
||||
// `DepthAttachment::get_attachment`); when nothing is visible it returns before touching
|
||||
// either and the opaque pass clears as before. An immediate-mode GPU (the Steam Deck,
|
||||
// every Windows part) is where this counts; a tile-based one (Apple) resolves opaque
|
||||
// order in hardware and reads the same frame either way — which is why no measurement
|
||||
// of this exists on the rig, and the player journal's `gpu_opaque`/`gpu_static` columns
|
||||
// (2008) are where the number lands.
|
||||
.add_render_graph_edges(
|
||||
Core3d,
|
||||
(
|
||||
Node3d::MainOpaquePass,
|
||||
StaticGxLabel,
|
||||
Node3d::MainTransparentPass,
|
||||
),
|
||||
(Node3d::StartMainPass, StaticGxLabel, Node3d::MainOpaquePass),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -634,8 +634,8 @@ pub(super) fn follow_moons(
|
||||
|
||||
/// Camera-anchor the star dome (just inside the sky gradient dome, at `far*0.88` — over the gradient at
|
||||
/// `far*0.9`, behind the sun/moon at `far*0.85`; the reference draws stars FIRST among the bodies —
|
||||
/// though the sky-internal order is the `sky_order` bias ladder and world occlusion is `star.wgsl`'s
|
||||
/// forced far depth, so this radius decides neither, only the dots' screen scale) and
|
||||
/// though the sky-internal order is the `sky_order` bias ladder and world occlusion is the far depth
|
||||
/// `sky_vertex.wgsl` pins, so this radius decides neither, only the dots' screen scale) and
|
||||
/// drive its **global alpha** from the verified star curve. The reference quantises the curve to the
|
||||
/// model-global byte `trunc(curve·254 + 1)` and SKIPS the draw below 2 (`0x6d1b50`/`0x7e6120`, decision
|
||||
/// 0485); each patch then multiplies its own authored transparency weight ([`StarDome::weight`]) under
|
||||
|
||||
@@ -3,11 +3,18 @@
|
||||
//! `StandardMaterial`; the fragment shaders live in `src/shaders/{celestial,star}.wgsl`,
|
||||
//! compiled in and served as `embedded://benilla_world/shaders/…` (decision 1175).
|
||||
|
||||
use bevy::pbr::{ExtendedMaterial, MaterialExtension};
|
||||
use bevy::mesh::MeshVertexBufferLayoutRef;
|
||||
use bevy::pbr::{
|
||||
ExtendedMaterial, MaterialExtension, MaterialExtensionKey, MaterialExtensionPipeline,
|
||||
};
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::render_resource::AsBindGroup;
|
||||
use bevy::render::render_resource::{
|
||||
AsBindGroup, RenderPipelineDescriptor, SpecializedMeshPipelineError,
|
||||
};
|
||||
use bevy::shader::ShaderRef;
|
||||
|
||||
use crate::sky_order::{sky_pipeline_state, SKY_VERTEX_SHADER};
|
||||
|
||||
/// Material for every celestial body sprite — the **discs** (sun, white moon, moon02) *and* their
|
||||
/// additive lens-flare **glares** — an [`ExtendedMaterial`] over `StandardMaterial` whose fragment
|
||||
/// shader (`celestial.wgsl`) replicates the unlit look **in gamma space** (the 0161 composite lane) in
|
||||
@@ -48,9 +55,24 @@ pub struct CelestialExt {
|
||||
}
|
||||
|
||||
impl MaterialExtension for CelestialExt {
|
||||
/// The shared sky vertex stage — the far-depth pin ([`crate::sky_order`], "The depth law").
|
||||
fn vertex_shader() -> ShaderRef {
|
||||
SKY_VERTEX_SHADER.into()
|
||||
}
|
||||
|
||||
fn fragment_shader() -> ShaderRef {
|
||||
"embedded://benilla_world/shaders/celestial.wgsl".into()
|
||||
}
|
||||
|
||||
fn specialize(
|
||||
_pipeline: &MaterialExtensionPipeline,
|
||||
descriptor: &mut RenderPipelineDescriptor,
|
||||
_layout: &MeshVertexBufferLayoutRef,
|
||||
_key: MaterialExtensionKey<Self>,
|
||||
) -> Result<(), SpecializedMeshPipelineError> {
|
||||
sky_pipeline_state(descriptor);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Disc horizon alpha-ramp scale, shared by every celestial disc: `alpha *= clamp(30·dir.y, 0, 1)` —
|
||||
@@ -71,7 +93,22 @@ pub type StarMaterial = ExtendedMaterial<StandardMaterial, StarExt>;
|
||||
pub struct StarExt {}
|
||||
|
||||
impl MaterialExtension for StarExt {
|
||||
/// The shared sky vertex stage — the far-depth pin ([`crate::sky_order`], "The depth law").
|
||||
fn vertex_shader() -> ShaderRef {
|
||||
SKY_VERTEX_SHADER.into()
|
||||
}
|
||||
|
||||
fn fragment_shader() -> ShaderRef {
|
||||
"embedded://benilla_world/shaders/star.wgsl".into()
|
||||
}
|
||||
|
||||
fn specialize(
|
||||
_pipeline: &MaterialExtensionPipeline,
|
||||
descriptor: &mut RenderPipelineDescriptor,
|
||||
_layout: &MeshVertexBufferLayoutRef,
|
||||
_key: MaterialExtensionKey<Self>,
|
||||
) -> Result<(), SpecializedMeshPipelineError> {
|
||||
sky_pipeline_state(descriptor);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user