From c3555b4eeaedeba182eb503f7c9f15397cd4e62e Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 6 Sep 2026 19:35:31 +0700 Subject: [PATCH] the cooldown goes stock, early-Z comes back, and lists reach the end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - a size-less `` 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 --- .../assets/ui/ContainerFrameAdapters.xml | 272 --------------- crates/benilla-app/assets/ui/Cooldown.xml | 67 ---- .../benilla-app/assets/ui/ScrollTemplates.xml | 39 ++- crates/benilla-app/assets/ui/benilla.toc | 20 +- crates/benilla-app/src/addon_harness/mod.rs | 1 - crates/benilla-app/src/capture/depth_probe.rs | 4 +- crates/benilla-app/src/pipe_warm/menagerie.rs | 2 + crates/benilla-app/src/ui_models/mod.rs | 156 ++++++++- .../src/ui_script/action_bar_tests.rs | 39 ++- crates/benilla-app/src/ui_script/bag_tests.rs | 313 +++++++----------- .../src/ui_script/bottom_hud_tests.rs | 29 +- .../benilla-app/src/ui_script/buff_tests.rs | 2 +- .../src/ui_script/chat_resize_tests.rs | 2 +- .../benilla-app/src/ui_script/chat_tests.rs | 2 +- .../src/ui_script/dressup_tests.rs | 2 +- .../benilla-app/src/ui_script/errors_tests.rs | 2 +- .../src/ui_script/exp_bar_tests.rs | 2 +- .../src/ui_script/extract/cooldown.rs | 287 ---------------- .../benilla-app/src/ui_script/extract/mod.rs | 56 +--- .../src/ui_script/faux_scroll_tests.rs | 10 +- .../src/ui_script/frame_flag_gate.rs | 1 - .../src/ui_script/friends_tests.rs | 67 ++++ .../src/ui_script/game_menu_tests.rs | 6 +- .../src/ui_script/group_loot_tests.rs | 2 +- .../src/ui_script/help_frame_tests.rs | 2 +- .../src/ui_script/micro_menu_tests.rs | 2 +- .../src/ui_script/multibar_stance_tests.rs | 36 +- .../src/ui_script/options_tests.rs | 4 +- .../benilla-app/src/ui_script/panel_tests.rs | 2 +- .../src/ui_script/perf_bar_tests.rs | 2 +- .../src/ui_script/pet_bar_tests.rs | 2 +- .../src/ui_script/questlog_tests.rs | 2 +- .../benilla-app/src/ui_script/reference_ui.rs | 2 +- .../src/ui_script/skills_frame_tests.rs | 145 +++++++- .../src/ui_script/spellbook_tests.rs | 28 +- .../src/ui_script/target_aura_tests.rs | 2 +- crates/benilla-app/src/ui_script/test_ui.rs | 77 ++++- .../src/ui_script/tooltip_anchor_tests.rs | 8 +- .../src/ui_script/tot_frame_tests.rs | 2 +- .../src/ui_script/trainer_tests.rs | 2 +- .../src/ui_script/unit_popup_tests.rs | 2 +- .../src/ui_script/world_map_tests.rs | 35 +- crates/benilla-app/src/ui_world_map.rs | 11 + crates/benilla-app/tests/character_stats.rs | 2 +- crates/benilla-app/tests/world_api_wall.rs | 10 +- crates/benilla-assets/src/m2.rs | 2 + crates/benilla-assets/src/materials.rs | 66 ++-- crates/benilla-assets/src/model.rs | 6 + .../benilla-assets/src/shaders/wow_model.wgsl | 104 ++++-- crates/benilla-assets/src/wmo.rs | 2 + .../benilla-formats/examples/clutter_cut.rs | 219 ++++++++++++ .../benilla-formats/examples/clutter_size.rs | 139 ++++++++ crates/benilla-formats/src/lib.rs | 24 +- .../benilla-formats/src/models/m2_batches.rs | 8 + crates/benilla-formats/src/models/mod.rs | 14 +- crates/benilla-formats/src/models/tex_anim.rs | 192 ++++++++++- crates/benilla-formats/src/models/types.rs | 11 + .../tests/m2_billboard_card_face.rs | 2 + crates/benilla-ui/src/loader/geometry.rs | 15 +- crates/benilla-ui/src/loader/mod.rs | 32 +- crates/benilla-ui/src/script/cooldown.rs | 71 ---- crates/benilla-ui/src/script/extract.rs | 13 - crates/benilla-ui/src/script/mod.rs | 8 +- crates/benilla-ui/src/script/modelframe.rs | 92 ++++- crates/benilla-ui/src/script/object.rs | 2 - .../src/script/object/frame_state.rs | 14 +- .../src/script/object/layout_methods.rs | 3 + crates/benilla-ui/src/script/scrollframe.rs | 43 ++- .../benilla-ui/src/script/tests/cooldown.rs | 60 +--- .../src/script/tests/model_clock.rs | 67 ++++ .../src/script/tests/reference_surface.rs | 3 - .../src/script/tests/scrollframe.rs | 78 ++++- .../benilla-ui/src/script/tests/worldmap.rs | 92 ++++- crates/benilla-ui/src/script/tick.rs | 15 +- crates/benilla-ui/src/script/types.rs | 25 +- crates/benilla-ui/src/script/worldmap.rs | 227 +++++++++---- .../benilla-ui/src/script/worldmap_arrow.rs | 87 +++-- crates/benilla-ui/src/widget/kinds/mod.rs | 92 ++--- crates/benilla-ui/src/widget/mod.rs | 16 +- crates/benilla-world/src/clouds/layer.rs | 29 +- crates/benilla-world/src/mat_anim_table.rs | 31 +- crates/benilla-world/src/model_render.rs | 12 +- crates/benilla-world/src/shaders.rs | 6 +- .../benilla-world/src/shaders/celestial.wgsl | 37 +-- crates/benilla-world/src/shaders/cloud.wgsl | 19 +- crates/benilla-world/src/shaders/sky.wgsl | 23 +- .../benilla-world/src/shaders/sky_vertex.wgsl | 96 ++++++ crates/benilla-world/src/shaders/star.wgsl | 20 +- crates/benilla-world/src/sky.rs | 21 +- crates/benilla-world/src/sky_order.rs | 101 +++++- crates/benilla-world/src/static_gx/render.rs | 21 +- crates/benilla-world/src/sun/follow.rs | 4 +- crates/benilla-world/src/sun/materials.rs | 41 ++- 93 files changed, 2423 insertions(+), 1643 deletions(-) delete mode 100644 crates/benilla-app/assets/ui/Cooldown.xml delete mode 100644 crates/benilla-app/src/ui_script/extract/cooldown.rs create mode 100644 crates/benilla-formats/examples/clutter_cut.rs create mode 100644 crates/benilla-formats/examples/clutter_size.rs delete mode 100644 crates/benilla-ui/src/script/cooldown.rs create mode 100644 crates/benilla-world/src/shaders/sky_vertex.wgsl diff --git a/crates/benilla-app/assets/ui/ContainerFrameAdapters.xml b/crates/benilla-app/assets/ui/ContainerFrameAdapters.xml index 175dbf89..ef047402 100644 --- a/crates/benilla-app/assets/ui/ContainerFrameAdapters.xml +++ b/crates/benilla-app/assets/ui/ContainerFrameAdapters.xml @@ -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 `` playing `Interface\ItemAnimations\ForcedBackpackItem.mdx`. - -- **This engine's `` 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 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 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 `` with no `` 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 ]]> diff --git a/crates/benilla-app/assets/ui/Cooldown.xml b/crates/benilla-app/assets/ui/Cooldown.xml deleted file mode 100644 index d106f15b..00000000 --- a/crates/benilla-app/assets/ui/Cooldown.xml +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - diff --git a/crates/benilla-app/assets/ui/ScrollTemplates.xml b/crates/benilla-app/assets/ui/ScrollTemplates.xml index 91103240..6b8b7b63 100644 --- a/crates/benilla-app/assets/ui/ScrollTemplates.xml +++ b/crates/benilla-app/assets/ui/ScrollTemplates.xml @@ -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 `` + 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 `` + 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 `` - -- and our XML loader has no `` 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 `` + -- 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 diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc index 13092b22..5b24ee30 100644 --- a/crates/benilla-app/assets/ui/benilla.toc +++ b/crates/benilla-app/assets/ui/benilla.toc @@ -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 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 -# `` 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 `` 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`. diff --git a/crates/benilla-app/src/addon_harness/mod.rs b/crates/benilla-app/src/addon_harness/mod.rs index 8c0e4603..b9339c3f 100644 --- a/crates/benilla-app/src/addon_harness/mod.rs +++ b/crates/benilla-app/src/addon_harness/mod.rs @@ -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 diff --git a/crates/benilla-app/src/capture/depth_probe.rs b/crates/benilla-app/src/capture/depth_probe.rs index 919370fb..d537cef6 100644 --- a/crates/benilla-app/src/capture/depth_probe.rs +++ b/crates/benilla-app/src/capture/depth_probe.rs @@ -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 diff --git a/crates/benilla-app/src/pipe_warm/menagerie.rs b/crates/benilla-app/src/pipe_warm/menagerie.rs index 1b961472..bf625815 100644 --- a/crates/benilla-app/src/pipe_warm/menagerie.rs +++ b/crates/benilla-app/src/pipe_warm/menagerie.rs @@ -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, diff --git a/crates/benilla-app/src/ui_models/mod.rs b/crates/benilla-app/src/ui_models/mod.rs index e3efebff..bf0aac88 100644 --- a/crates/benilla-app/src/ui_models/mod.rs +++ b/crates/benilla-app/src/ui_models/mod.rs @@ -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 `` 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 `` 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, } +/// 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, + /// 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, + uv_anim: Option>, + uv_seq: Option>>, + uv_rot: Option>>, + uv_scale: Option>>, +} + +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, + 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, alpha_parts: Vec, + uv_parts: Vec, emitters: Vec, /// 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 { struct BuiltTile { clips: HashMap, alpha_parts: Vec, + uv_parts: Vec, emitters: Vec, } @@ -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> = Vec::with_capacity(model.submeshes.len()); + let mut uv_parts: Vec = 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, }) } diff --git a/crates/benilla-app/src/ui_script/action_bar_tests.rs b/crates/benilla-app/src/ui_script/action_bar_tests.rs index a6ead5d0..251b6ab1 100644 --- a/crates/benilla-app/src/ui_script/action_bar_tests.rs +++ b/crates/benilla-app/src/ui_script/action_bar_tests.rs @@ -14,7 +14,7 @@ fn action_ids(s: &mut UiScript) -> Vec { 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"); diff --git a/crates/benilla-app/src/ui_script/bag_tests.rs b/crates/benilla-app/src/ui_script/bag_tests.rs index bed12058..ca3827b0 100644 --- a/crates/benilla-app/src/ui_script/bag_tests.rs +++ b/crates/benilla-app/src/ui_script/bag_tests.rs @@ -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 `` 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::(&format!("return {name}BenillaItemPush:IsShown()")) + s.eval::(&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::(&format!("return {name}BenillaItemPush:GetWidth()")) - .unwrap() - }; - let alpha = |s: &UiScript, name: &str| { - s.eval::(&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 ``'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, Option<(u16, u32)>); + let cards = |s: &mut UiScript| -> Vec { + 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. diff --git a/crates/benilla-app/src/ui_script/bottom_hud_tests.rs b/crates/benilla-app/src/ui_script/bottom_hud_tests.rs index de48510b..424e93d1 100644 --- a/crates/benilla-app/src/ui_script/bottom_hud_tests.rs +++ b/crates/benilla-app/src/ui_script/bottom_hud_tests.rs @@ -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 `` 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), diff --git a/crates/benilla-app/src/ui_script/buff_tests.rs b/crates/benilla-app/src/ui_script/buff_tests.rs index 342603cc..c67225f1 100644 --- a/crates/benilla-app/src/ui_script/buff_tests.rs +++ b/crates/benilla-app/src/ui_script/buff_tests.rs @@ -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"); diff --git a/crates/benilla-app/src/ui_script/chat_resize_tests.rs b/crates/benilla-app/src/ui_script/chat_resize_tests.rs index e33a199e..7e388751 100644 --- a/crates/benilla-app/src/ui_script/chat_resize_tests.rs +++ b/crates/benilla-app/src/ui_script/chat_resize_tests.rs @@ -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", diff --git a/crates/benilla-app/src/ui_script/chat_tests.rs b/crates/benilla-app/src/ui_script/chat_tests.rs index 1058e482..71006896 100644 --- a/crates/benilla-app/src/ui_script/chat_tests.rs +++ b/crates/benilla-app/src/ui_script/chat_tests.rs @@ -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"); diff --git a/crates/benilla-app/src/ui_script/dressup_tests.rs b/crates/benilla-app/src/ui_script/dressup_tests.rs index 37a17ace..91e5e954 100644 --- a/crates/benilla-app/src/ui_script/dressup_tests.rs +++ b/crates/benilla-app/src/ui_script/dressup_tests.rs @@ -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); } diff --git a/crates/benilla-app/src/ui_script/errors_tests.rs b/crates/benilla-app/src/ui_script/errors_tests.rs index 7e3112e2..f504f6f1 100644 --- a/crates/benilla-app/src/ui_script/errors_tests.rs +++ b/crates/benilla-app/src/ui_script/errors_tests.rs @@ -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 diff --git a/crates/benilla-app/src/ui_script/exp_bar_tests.rs b/crates/benilla-app/src/ui_script/exp_bar_tests.rs index deb52b80..74d7e565 100644 --- a/crates/benilla-app/src/ui_script/exp_bar_tests.rs +++ b/crates/benilla-app/src/ui_script/exp_bar_tests.rs @@ -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", diff --git a/crates/benilla-app/src/ui_script/extract/cooldown.rs b/crates/benilla-app/src/ui_script/extract/cooldown.rs deleted file mode 100644 index 156d2b38..00000000 --- a/crates/benilla-app/src/ui_script/extract/cooldown.rs +++ /dev/null @@ -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, - clip: Option, - assets: &mut Option>, - images: &mut Assets, - out: &mut Vec, -) { - 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 { - 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::::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::::default(), - &mut out, - ); - assert!(out.is_empty(), "no assets → no star; and never a dark quad"); - } -} diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index 4d3fb944..369e8604 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -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 ``/`` 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 `` 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 diff --git a/crates/benilla-app/src/ui_script/faux_scroll_tests.rs b/crates/benilla-app/src/ui_script/faux_scroll_tests.rs index 21bd7802..44fb69a0 100644 --- a/crates/benilla-app/src/ui_script/faux_scroll_tests.rs +++ b/crates/benilla-app/src/ui_script/faux_scroll_tests.rs @@ -329,11 +329,11 @@ fn dragging_the_bar_steps_the_offset_by_rows_and_repaints() { /// on `FauxScrollFrameTemplate` whose `` 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 ``, our XML loader has no `` -/// 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 ``) 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` → `` +/// → `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(); diff --git a/crates/benilla-app/src/ui_script/frame_flag_gate.rs b/crates/benilla-app/src/ui_script/frame_flag_gate.rs index 52031e22..274169ba 100644 --- a/crates/benilla-app/src/ui_script/frame_flag_gate.rs +++ b/crates/benilla-app/src/ui_script/frame_flag_gate.rs @@ -101,7 +101,6 @@ const FRAME_TAGS: &[&str] = &[ "Minimap", "MovieFrame", "WorldFrame", - "Cooldown", ]; /// The five `` handler names that auto-enable the MOUSE kind, and only those five diff --git a/crates/benilla-app/src/ui_script/friends_tests.rs b/crates/benilla-app/src/ui_script/friends_tests.rs index f8597873..5d606876 100644 --- a/crates/benilla-app/src/ui_script/friends_tests.rs +++ b/crates/benilla-app/src/ui_script/friends_tests.rs @@ -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 = (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::("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::("return FauxScrollFrame_GetOffset(WhoListScrollFrame)") + .unwrap(), + 32 + ); + assert_eq!( + s.eval::("return WhoFrameButton1Name:GetText()") + .unwrap(), + "Who33" + ); + assert_eq!( + s.eval::("return WhoFrameButton17Name:GetText()") + .unwrap(), + "Who49", + "the last hit is on the last row" + ); +} diff --git a/crates/benilla-app/src/ui_script/game_menu_tests.rs b/crates/benilla-app/src/ui_script/game_menu_tests.rs index 4da2d78a..4368595e 100644 --- a/crates/benilla-app/src/ui_script/game_menu_tests.rs +++ b/crates/benilla-app/src/ui_script/game_menu_tests.rs @@ -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", diff --git a/crates/benilla-app/src/ui_script/group_loot_tests.rs b/crates/benilla-app/src/ui_script/group_loot_tests.rs index c138f84e..4a1d9c64 100644 --- a/crates/benilla-app/src/ui_script/group_loot_tests.rs +++ b/crates/benilla-app/src/ui_script/group_loot_tests.rs @@ -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"); diff --git a/crates/benilla-app/src/ui_script/help_frame_tests.rs b/crates/benilla-app/src/ui_script/help_frame_tests.rs index 68392dc2..c78978ed 100644 --- a/crates/benilla-app/src/ui_script/help_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/help_frame_tests.rs @@ -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", diff --git a/crates/benilla-app/src/ui_script/micro_menu_tests.rs b/crates/benilla-app/src/ui_script/micro_menu_tests.rs index 15cb47a0..78a24045 100644 --- a/crates/benilla-app/src/ui_script/micro_menu_tests.rs +++ b/crates/benilla-app/src/ui_script/micro_menu_tests.rs @@ -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", diff --git a/crates/benilla-app/src/ui_script/multibar_stance_tests.rs b/crates/benilla-app/src/ui_script/multibar_stance_tests.rs index 54743118..b4658c5a 100644 --- a/crates/benilla-app/src/ui_script/multibar_stance_tests.rs +++ b/crates/benilla-app/src/ui_script/multibar_stance_tests.rs @@ -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"); diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index 875eba47..5ad92870 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -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", diff --git a/crates/benilla-app/src/ui_script/panel_tests.rs b/crates/benilla-app/src/ui_script/panel_tests.rs index 751cdc43..6e2de057 100644 --- a/crates/benilla-app/src/ui_script/panel_tests.rs +++ b/crates/benilla-app/src/ui_script/panel_tests.rs @@ -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 diff --git a/crates/benilla-app/src/ui_script/perf_bar_tests.rs b/crates/benilla-app/src/ui_script/perf_bar_tests.rs index a4c2baac..11956e8e 100644 --- a/crates/benilla-app/src/ui_script/perf_bar_tests.rs +++ b/crates/benilla-app/src/ui_script/perf_bar_tests.rs @@ -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"); diff --git a/crates/benilla-app/src/ui_script/pet_bar_tests.rs b/crates/benilla-app/src/ui_script/pet_bar_tests.rs index 83e199cc..d2ebea92 100644 --- a/crates/benilla-app/src/ui_script/pet_bar_tests.rs +++ b/crates/benilla-app/src/ui_script/pet_bar_tests.rs @@ -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", diff --git a/crates/benilla-app/src/ui_script/questlog_tests.rs b/crates/benilla-app/src/ui_script/questlog_tests.rs index 0d7b15df..3931151c 100644 --- a/crates/benilla-app/src/ui_script/questlog_tests.rs +++ b/crates/benilla-app/src/ui_script/questlog_tests.rs @@ -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 diff --git a/crates/benilla-app/src/ui_script/reference_ui.rs b/crates/benilla-app/src/ui_script/reference_ui.rs index b1446772..a0cbc0f4 100644 --- a/crates/benilla-app/src/ui_script/reference_ui.rs +++ b/crates/benilla-app/src/ui_script/reference_ui.rs @@ -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() diff --git a/crates/benilla-app/src/ui_script/skills_frame_tests.rs b/crates/benilla-app/src/ui_script/skills_frame_tests.rs index 751d4bc0..8b47cf32 100644 --- a/crates/benilla-app/src/ui_script/skills_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/skills_frame_tests.rs @@ -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::("return GetNumSkillLines()").unwrap(); + assert_eq!(n, 25, "22 lines under 3 headers"); + let (last_name, last_is_header) = s + .eval::<(String, Option)>(&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::("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 → → + // 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::("return FauxScrollFrame_GetOffset(SkillListScrollFrame)") + .unwrap(), + n - 12, + "the row offset reaches the bar's end, not the overflow's" + ); + assert_eq!( + s.eval::("return SkillRankFrame12SkillName:GetText()") + .unwrap(), + last_name, + "the twelfth row is the block's last line" + ); +} diff --git a/crates/benilla-app/src/ui_script/spellbook_tests.rs b/crates/benilla-app/src/ui_script/spellbook_tests.rs index afdb8650..fb5fd543 100644 --- a/crates/benilla-app/src/ui_script/spellbook_tests.rs +++ b/crates/benilla-app/src/ui_script/spellbook_tests.rs @@ -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 diff --git a/crates/benilla-app/src/ui_script/target_aura_tests.rs b/crates/benilla-app/src/ui_script/target_aura_tests.rs index 5559accd..fe3c1a78 100644 --- a/crates/benilla-app/src/ui_script/target_aura_tests.rs +++ b/crates/benilla-app/src/ui_script/target_aura_tests.rs @@ -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"); diff --git a/crates/benilla-app/src/ui_script/test_ui.rs b/crates/benilla-app/src/ui_script/test_ui.rs index d337cd82..eca6a6fa 100644 --- a/crates/benilla-app/src/ui_script/test_ui.rs +++ b/crates/benilla-app/src/ui_script/test_ui.rs @@ -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 `` 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", diff --git a/crates/benilla-app/src/ui_script/tooltip_anchor_tests.rs b/crates/benilla-app/src/ui_script/tooltip_anchor_tests.rs index 7d4fe3c5..c13634a9 100644 --- a/crates/benilla-app/src/ui_script/tooltip_anchor_tests.rs +++ b/crates/benilla-app/src/ui_script/tooltip_anchor_tests.rs @@ -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", diff --git a/crates/benilla-app/src/ui_script/tot_frame_tests.rs b/crates/benilla-app/src/ui_script/tot_frame_tests.rs index d51f9a25..98d9d295 100644 --- a/crates/benilla-app/src/ui_script/tot_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/tot_frame_tests.rs @@ -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"); diff --git a/crates/benilla-app/src/ui_script/trainer_tests.rs b/crates/benilla-app/src/ui_script/trainer_tests.rs index 060bbf5a..a95eea3a 100644 --- a/crates/benilla-app/src/ui_script/trainer_tests.rs +++ b/crates/benilla-app/src/ui_script/trainer_tests.rs @@ -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", diff --git a/crates/benilla-app/src/ui_script/unit_popup_tests.rs b/crates/benilla-app/src/ui_script/unit_popup_tests.rs index b0ac70ba..422cbf92 100644 --- a/crates/benilla-app/src/ui_script/unit_popup_tests.rs +++ b/crates/benilla-app/src/ui_script/unit_popup_tests.rs @@ -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", diff --git a/crates/benilla-app/src/ui_script/world_map_tests.rs b/crates/benilla-app/src/ui_script/world_map_tests.rs index 4dec9c9c..bd066597 100644 --- a/crates/benilla-app/src/ui_script/world_map_tests.rs +++ b/crates/benilla-app/src/ui_script/world_map_tests.rs @@ -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()); diff --git a/crates/benilla-app/src/ui_world_map.rs b/crates/benilla-app/src/ui_world_map.rs index 6ed18a44..8942ac4f 100644 --- a/crates/benilla-app/src/ui_world_map.rs +++ b/crates/benilla-app/src/ui_world_map.rs @@ -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(), }) diff --git a/crates/benilla-app/tests/character_stats.rs b/crates/benilla-app/tests/character_stats.rs index 654adbca..372ba71d 100644 --- a/crates/benilla-app/tests/character_stats.rs +++ b/crates/benilla-app/tests/character_stats.rs @@ -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", ]; diff --git a/crates/benilla-app/tests/world_api_wall.rs b/crates/benilla-app/tests/world_api_wall.rs index a9d2ad51..ab5f8916 100644 --- a/crates/benilla-app/tests/world_api_wall.rs +++ b/crates/benilla-app/tests/world_api_wall.rs @@ -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 diff --git a/crates/benilla-assets/src/m2.rs b/crates/benilla-assets/src/m2.rs index 357e634f..e9701dd0 100644 --- a/crates/benilla-assets/src/m2.rs +++ b/crates/benilla-assets/src/m2.rs @@ -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 diff --git a/crates/benilla-assets/src/materials.rs b/crates/benilla-assets/src/materials.rs index db9859a5..2d1d3dd2 100644 --- a/crates/benilla-assets/src/materials.rs +++ b/crates/benilla-assets/src/materials.rs @@ -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)" ); } } diff --git a/crates/benilla-assets/src/model.rs b/crates/benilla-assets/src/model.rs index 00672e9c..907e4b95 100644 --- a/crates/benilla-assets/src/model.rs +++ b/crates/benilla-assets/src/model.rs @@ -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>>, + /// 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>>, + /// The scaling twin of [`Self::uv_rot_seq`]. + pub uv_scale_seq: Option>>, /// 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 — diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl index 375a00bc..76ddf398 100644 --- a/crates/benilla-assets/src/shaders/wow_model.wgsl +++ b/crates/benilla-assets/src/shaders/wow_model.wgsl @@ -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, -#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, 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, 2048>, palettes: array>, }; @@ -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(0.5, 0.5)) * vec2(1.0 + affine.z, 1.0 + affine.w); + let c = 1.0 + affine.x; + vo.uv = vec2(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 diff --git a/crates/benilla-assets/src/wmo.rs b/crates/benilla-assets/src/wmo.rs index fc89e9fa..f583e57b 100644 --- a/crates/benilla-assets/src/wmo.rs +++ b/crates/benilla-assets/src/wmo.rs @@ -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 diff --git a/crates/benilla-formats/examples/clutter_cut.rs b/crates/benilla-formats/examples/clutter_cut.rs new file mode 100644 index 00000000..5ce84665 --- /dev/null +++ b/crates/benilla-formats/examples/clutter_cut.rs @@ -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 = 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 = 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 = mips.mips[0].chunks_exact(4).map(|p| p[3]).collect(); + let mut averaged: Vec> = 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 = 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 = 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 = 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(()) +} diff --git a/crates/benilla-formats/examples/clutter_size.rs b/crates/benilla-formats/examples/clutter_size.rs new file mode 100644 index 00000000..77596e88 --- /dev/null +++ b/crates/benilla-formats/examples/clutter_size.rs @@ -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 = 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 = Vec::new(); + let mut depths: Vec = 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| { + 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(()) +} diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 3e715a7c..f4f0e552 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -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::{ diff --git a/crates/benilla-formats/src/models/m2_batches.rs b/crates/benilla-formats/src/models/m2_batches.rs index 1d444069..1865e8c4 100644 --- a/crates/benilla-formats/src/models/m2_batches.rs +++ b/crates/benilla-formats/src/models/m2_batches.rs @@ -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); diff --git a/crates/benilla-formats/src/models/mod.rs b/crates/benilla-formats/src/models/mod.rs index 7c3a46f0..5c6d327a 100644 --- a/crates/benilla-formats/src/models/mod.rs +++ b/crates/benilla-formats/src/models/mod.rs @@ -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, ) diff --git a/crates/benilla-formats/src/models/tex_anim.rs b/crates/benilla-formats/src/models/tex_anim.rs index 3a5e74a0..699b61bf 100644 --- a/crates/benilla-formats/src/models/tex_anim.rs +++ b/crates/benilla-formats/src/models/tex_anim.rs @@ -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> { + 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> { + 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, diff --git a/crates/benilla-formats/src/models/types.rs b/crates/benilla-formats/src/models/types.rs index 28f8a280..889120c6 100644 --- a/crates/benilla-formats/src/models/types.rs +++ b/crates/benilla-formats/src/models/types.rs @@ -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>, + /// 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>, + /// 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>, /// 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, diff --git a/crates/benilla-formats/tests/m2_billboard_card_face.rs b/crates/benilla-formats/tests/m2_billboard_card_face.rs index 8c715e53..5346fae7 100644 --- a/crates/benilla-formats/tests/m2_billboard_card_face.rs +++ b/crates/benilla-formats/tests/m2_billboard_card_face.rs @@ -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, diff --git a/crates/benilla-ui/src/loader/geometry.rs b/crates/benilla-ui/src/loader/geometry.rs index f99ee6e2..0aba2e7d 100644 --- a/crates/benilla-ui/src/loader/geometry.rs +++ b/crates/benilla-ui/src/loader/geometry.rs @@ -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 `` 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 `` 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); diff --git a/crates/benilla-ui/src/loader/mod.rs b/crates/benilla-ui/src/loader/mod.rs index fc47537a..891ad4bd 100644 --- a/crates/benilla-ui/src/loader/mod.rs +++ b/crates/benilla-ui/src/loader/mod.rs @@ -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 `` playing the cooldown indicator IS this engine's `` 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 ``: 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 `` child, the reference's carries a ``, -/// 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!( diff --git a/crates/benilla-ui/src/script/cooldown.rs b/crates/benilla-ui/src/script/cooldown.rs deleted file mode 100644 index 7d13060e..00000000 --- a/crates/benilla-ui/src/script/cooldown.rs +++ /dev/null @@ -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( - lua: &Lua, - this: &Table, - f: impl FnOnce(&mut CooldownState) -> T, -) -> mlua::Result { - let h = frame_handle_of(lua, this)?; - let mut model = lua.app_data_mut::().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(()) -} diff --git a/crates/benilla-ui/src/script/extract.rs b/crates/benilla-ui/src/script/extract.rs index b9ab758e..ef32e5e2 100644 --- a/crates/benilla-ui/src/script/extract.rs +++ b/crates/benilla-ui/src/script/extract.rs @@ -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 { - 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 ``/`` 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` diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 690fa647..72b93dde 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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 — diff --git a/crates/benilla-ui/src/script/modelframe.rs b/crates/benilla-ui/src/script/modelframe.rs index eb14c3ef..63bcc606 100644 --- a/crates/benilla-ui/src/script/modelframe.rs +++ b/crates/benilla-ui/src/script/modelframe.rs @@ -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 `` 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> { @@ -236,7 +297,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { .app_data_mut::() .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::() + .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 = 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); } } diff --git a/crates/benilla-ui/src/script/object.rs b/crates/benilla-ui/src/script/object.rs index 10a1a27f..61fa07c2 100644 --- a/crates/benilla-ui/src/script/object.rs +++ b/crates/benilla-ui/src/script/object.rs @@ -249,7 +249,6 @@ fn frame_kind_from_str(s: &str) -> Option { "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], _ => &[], } diff --git a/crates/benilla-ui/src/script/object/frame_state.rs b/crates/benilla-ui/src/script/object/frame_state.rs index 2afa9bdb..88426586 100644 --- a/crates/benilla-ui/src/script/object/frame_state.rs +++ b/crates/benilla-ui/src/script/object/frame_state.rs @@ -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]> { diff --git a/crates/benilla-ui/src/script/object/layout_methods.rs b/crates/benilla-ui/src/script/object/layout_methods.rs index cfb85a20..580bef1a 100644 --- a/crates/benilla-ui/src/script/object/layout_methods.rs +++ b/crates/benilla-ui/src/script/object/layout_methods.rs @@ -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(()) })?, )?; diff --git a/crates/benilla-ui/src/script/scrollframe.rs b/crates/benilla-ui/src/script/scrollframe.rs index 4c03a7c2..03999b7f 100644 --- a/crates/benilla-ui/src/script/scrollframe.rs +++ b/crates/benilla-ui/src/script/scrollframe.rs @@ -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::().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::().expect("model").touch_layout(); + if !changed { + return Ok(()); } - fire_vertical_scroll(lua, &this, clamped) + lua.app_data_mut::().expect("model").touch_layout(); + fire_vertical_scroll(lua, &this, px) })?, )?; m.set( diff --git a/crates/benilla-ui/src/script/tests/cooldown.rs b/crates/benilla-ui/src/script/tests/cooldown.rs index 3e5bf083..be3003b5 100644 --- a/crates/benilla-ui/src/script/tests/cooldown.rs +++ b/crates/benilla-ui/src/script/tests/cooldown.rs @@ -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 `` 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)> { - 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::("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] diff --git a/crates/benilla-ui/src/script/tests/model_clock.rs b/crates/benilla-ui/src/script/tests/model_clock.rs index d26790e0..33e3473a 100644 --- a/crates/benilla-ui/src/script/tests/model_clock.rs +++ b/crates/benilla-ui/src/script/tests/model_clock.rs @@ -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::("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::("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`. diff --git a/crates/benilla-ui/src/script/tests/reference_surface.rs b/crates/benilla-ui/src/script/tests/reference_surface.rs index ca889292..9b7c35d1 100644 --- a/crates/benilla-ui/src/script/tests/reference_surface.rs +++ b/crates/benilla-ui/src/script/tests/reference_surface.rs @@ -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}")"#)) diff --git a/crates/benilla-ui/src/script/tests/scrollframe.rs b/crates/benilla-ui/src/script/tests/scrollframe.rs index d9951e13..9082cd76 100644 --- a/crates/benilla-ui/src/script/tests/scrollframe.rs +++ b/crates/benilla-ui/src/script/tests/scrollframe.rs @@ -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::("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::("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::("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::("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(); diff --git a/crates/benilla-ui/src/script/tests/worldmap.rs b/crates/benilla-ui/src/script/tests/worldmap.rs index 99054718..92e943a3 100644 --- a/crates/benilla-ui/src/script/tests/worldmap.rs +++ b/crates/benilla-ui/src/script/tests/worldmap.rs @@ -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::("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, Option, f64, f64, f64, f64, f64, f64); + let hover = |s: &mut UiScript, x: f32, y: f32| { + s.eval::(&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] diff --git a/crates/benilla-ui/src/script/tick.rs b/crates/benilla-ui/src/script/tick.rs index cfd44c4e..7510e7e6 100644 --- a/crates/benilla-ui/src/script/tick.rs +++ b/crates/benilla-ui/src/script/tick.rs @@ -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 = 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 diff --git a/crates/benilla-ui/src/script/types.rs b/crates/benilla-ui/src/script/types.rs index 5f9a4551..e57d7e01 100644 --- a/crates/benilla-ui/src/script/types.rs +++ b/crates/benilla-ui/src/script/types.rs @@ -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, - }, /// 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, - /// 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, /// `SetFacing`'s radians (0 default). facing: f32, diff --git a/crates/benilla-ui/src/script/worldmap.rs b/crates/benilla-ui/src/script/worldmap.rs index c5d08ff7..4ae10b59 100644 --- a/crates/benilla-ui/src/script/worldmap.rs +++ b/crates/benilla-ui/src/script/worldmap.rs @@ -21,9 +21,13 @@ //! bitmap (`Interface\WorldMap\.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, + /// 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, } /// 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 { + 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 `\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 { + 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 `\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::().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), + }) })?, )?; diff --git a/crates/benilla-ui/src/script/worldmap_arrow.rs b/crates/benilla-ui/src/script/worldmap_arrow.rs index 91aa028f..a619d79b 100644 --- a/crates/benilla-ui/src/script/worldmap_arrow.rs +++ b/crates/benilla-ui/src/script/worldmap_arrow.rs @@ -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::().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::() .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::().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::().expect("model app_data"); + let (facing, tracked, facts) = { + let mut model = lua.app_data_mut::().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::().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::("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 [ ( diff --git a/crates/benilla-ui/src/widget/kinds/mod.rs b/crates/benilla-ui/src/widget/kinds/mod.rs index 5f7f9f9a..02cee06d 100644 --- a/crates/benilla-ui/src/widget/kinds/mod.rs +++ b/crates/benilla-ui/src/widget/kinds/mod.rs @@ -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 `` 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, + /// 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, - /// 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, } diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index dcb1979e..3303f619 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -174,13 +174,13 @@ impl Arena { 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 diff --git a/crates/benilla-world/src/clouds/layer.rs b/crates/benilla-world/src/clouds/layer.rs index 6ffa84ff..33ccd2f2 100644 --- a/crates/benilla-world/src/clouds/layer.rs +++ b/crates/benilla-world/src/clouds/layer.rs @@ -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, + ) -> 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( diff --git a/crates/benilla-world/src/mat_anim_table.rs b/crates/benilla-world/src/mat_anim_table.rs index a7c3246a..18e168ba 100644 --- a/crates/benilla-world/src/mat_anim_table.rs +++ b/crates/benilla-world/src/mat_anim_table.rs @@ -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 { + pub fn alloc(&mut self) -> Option { 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] diff --git a/crates/benilla-world/src/model_render.rs b/crates/benilla-world/src/model_render.rs index 7e8a7e32..b211082e 100644 --- a/crates/benilla-world/src/model_render.rs +++ b/crates/benilla-world/src/model_render.rs @@ -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, diff --git a/crates/benilla-world/src/shaders.rs b/crates/benilla-world/src/shaders.rs index b4be64cc..1c031f64 100644 --- a/crates/benilla-world/src/shaders.rs +++ b/crates/benilla-world/src/shaders.rs @@ -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"); } } diff --git a/crates/benilla-world/src/shaders/celestial.wgsl b/crates/benilla-world/src/shaders/celestial.wgsl index 717653d4..6700b09d 100644 --- a/crates/benilla-world/src/shaders/celestial.wgsl +++ b/crates/benilla-world/src/shaders/celestial.wgsl @@ -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, - @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) -> vec3 { } @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 { 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(gamma * a * celestial.y, 0.0); - } else { - // DISC: premultiplied gamma blend (AlphaMode::Premultiplied). - out.color = vec4(gamma * a * celestial.y, a); + return vec4(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(gamma * a * celestial.y, a); } diff --git a/crates/benilla-world/src/shaders/cloud.wgsl b/crates/benilla-world/src/shaders/cloud.wgsl index 2c6bfab2..ad0afed6 100644 --- a/crates/benilla-world/src/shaders/cloud.wgsl +++ b/crates/benilla-world/src/shaders/cloud.wgsl @@ -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; @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, - @builtin(frag_depth) depth: f32, -} - @fragment -fn fragment(in: VertexOutput) -> CloudOutput { +fn fragment(in: VertexOutput) -> @location(0) vec4 { 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(texel.rgb * a, a); - out.depth = SKY_FAR_DEPTH; - return out; + return vec4(texel.rgb * a, a); } diff --git a/crates/benilla-world/src/shaders/sky.wgsl b/crates/benilla-world/src/shaders/sky.wgsl index da057e00..2fabcc7d 100644 --- a/crates/benilla-world/src/shaders/sky.wgsl +++ b/crates/benilla-world/src/shaders/sky.wgsl @@ -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, - @builtin(frag_depth) depth: f32, -} - struct SkyColors { sky0: vec4, // zenith (90°) sky1: vec4, // 16.8° @@ -66,7 +60,7 @@ fn warp_one(base: vec3, g: f32, s: f32) -> vec3 { } @fragment -fn fragment(in: VertexOutput) -> SkyOutput { +fn fragment(in: VertexOutput) -> @location(0) vec4 { // 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(rgb, 1.0); - out.depth = SKY_FAR_DEPTH; - return out; + return vec4(rgb, 1.0); } diff --git a/crates/benilla-world/src/shaders/sky_vertex.wgsl b/crates/benilla-world/src/shaders/sky_vertex.wgsl new file mode 100644 index 00000000..4e952d54 --- /dev/null +++ b/crates/benilla-world/src/shaders/sky_vertex.wgsl @@ -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(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; +} diff --git a/crates/benilla-world/src/shaders/star.wgsl b/crates/benilla-world/src/shaders/star.wgsl index 15430882..66f746e7 100644 --- a/crates/benilla-world/src/shaders/star.wgsl +++ b/crates/benilla-world/src/shaders/star.wgsl @@ -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, - @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 { 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(vec3(a), a); // premultiplied white — raw (GAMMA LANE, 0161) - out.depth = SKY_FAR_DEPTH; - return out; + return vec4(vec3(a), a); // premultiplied white — raw (GAMMA LANE, 0161) } diff --git a/crates/benilla-world/src/sky.rs b/crates/benilla-world/src/sky.rs index a3acaaf0..3f6a95ca 100644 --- a/crates/benilla-world/src/sky.rs +++ b/crates/benilla-world/src/sky.rs @@ -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( diff --git a/crates/benilla-world/src/sky_order.rs b/crates/benilla-world/src/sky_order.rs index 832ce657..4e7d8f42 100644 --- a/crates/benilla-world/src/sky_order.rs +++ b/crates/benilla-world/src/sky_order.rs @@ -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", + ::vertex_shader(), + ); + shared( + "CloudMaterial", + ::vertex_shader(), + ); + shared( + "StarMaterial", + ::vertex_shader(), + ); + shared( + "CelestialMaterial", + ::vertex_shader(), + ); } diff --git a/crates/benilla-world/src/static_gx/render.rs b/crates/benilla-world/src/static_gx/render.rs index 993b054e..013430d9 100644 --- a/crates/benilla-world/src/static_gx/render.rs +++ b/crates/benilla-world/src/static_gx/render.rs @@ -893,13 +893,24 @@ pub(super) fn build(app: &mut App) { ), ) .add_render_graph_node::>(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), ); } diff --git a/crates/benilla-world/src/sun/follow.rs b/crates/benilla-world/src/sun/follow.rs index a6110de5..cf72c433 100644 --- a/crates/benilla-world/src/sun/follow.rs +++ b/crates/benilla-world/src/sun/follow.rs @@ -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 diff --git a/crates/benilla-world/src/sun/materials.rs b/crates/benilla-world/src/sun/materials.rs index e19d5276..7f6347bf 100644 --- a/crates/benilla-world/src/sun/materials.rs +++ b/crates/benilla-world/src/sun/materials.rs @@ -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, + ) -> 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; 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, + ) -> Result<(), SpecializedMeshPipelineError> { + sky_pipeline_state(descriptor); + Ok(()) + } }