Files
benilla-octo/crates/benilla-app/src/ui_script/minimap_tests.rs
T
SamandClaude Fable 5.1 c957f98d84 grass fades on a plane, model panes render, and the FPS journal ships
- the grass fade is a plane across the view, not a ring around the player: `0x6b2b80` texgens the camera-space position through `T(0,0,−52.5)·Ry(π/2)·S(1/17.5)`, so the fade coordinate is view-space depth, `u = (z_eye − 52.5)/17.5` — we measured `distance(eye, fragment)`, a sphere, which meets the ground as a circle centred on the player that slides as they walk and bites a piece out of every tuft it crosses, and that was the hard border; the ramp is `0x6b2320`'s quantised 64×8 table, `alpha = 4·(63 − col)`, read bilinearly as `(254 − 256·u)/255` and capped at `252/255` — plateau to 52.64 yd, zero at 69.86 yd, near grass 98.8 % opaque and never 100 — where we had a clean 1 → 0 line; the product `tex0.a × ramp` is what the 128/255 `detailDoodadAlpha` cutout tests (both stages MODULATE, `[0xc7b2fc]`, confirmed verbatim in all four apitrace captures), so the tuft eroding out at 61.11 yd is the reference's own behaviour and the "fix" that would have removed it never landed; the pass owns its render state — blend mode 2, ALPHAREF 128, cull off, depth-write on, the per-slot draw `0x6b2d60` binding nothing but the texture — where we passed the batch's authored blend and cull through, and the `clutter_state` probe counted the cost: of 322 detail batches, 2 author Opaque (no cutout, their margin drawn as a solid card) and 5 single-sided; the build gate measures the chunk's box against the frustum corner ray derived from the live projection (≈1.31× at 16:9, ≈1.45× at 21:9), not its centre plus a 24 yd margin that popped whole chunks in on relief (2004)
- a `<Model>` widget's animation state is Lua-observable and lives in the engine: a private scene clock its own `OnUpdate` advances by `trunc(elapsed·1000)` ms on visible frames only (`0x76d7f0`), an arm with an anchor the sampler re-reads every frame (`0x7121a0`), `OnUpdateModel` at the top of every paint (`0x76d1a0`) and `OnAnimFinished` from the completion callback (`0x76cdc0`); the file's sequences and bounds are facts the host hands over, `SetModel` seeds Stand now or when they land (`0x70ebd0`) with a queued arm replaying after the seed, and the XML `scale=` on a model pane is the model's scale (`CSimpleModel::LoadXML` `0x76cac0`), which the loader had never read (2007); and the pane renders its M2 — every visible pane with a file gets a cell of one render-target atlas at its device size through one orthographic camera, the root at `1280·modelScale·layoutScale` FrameXML units per model unit, the tile's `AnimationPlayer` paused and seeked to the engine's play head, the light black, particles in their own unit (`768·√(a²+1)` per model unit, neither scale), composited as one premultiplied quad at the callback rank; two corrections off the bytes: `OnUpdateModel` fires only with a model installed (`[widget+0x318] ≠ 0`) and `OnAnimFinished` fires once per arm for a looping sequence too (`0x719370`); the loader now applies a `<Model>`'s `file=` at all, which every XML-declared pane had lacked; retired: the minimap ping's byte-measured sprite, since `MiniMapPing` and `WorldMapPing` draw their own file on the pane's clock (2013)
- the stance feed fires `UPDATE_SHAPESHIFT_FORMS` only for a list change: the reference fires it at `0x4b28ff`/`0x4b2e43` from learn, unlearn and rank-replace alone, while ours fired on any change of the pushed view, and every fire runs the stock `ShapeshiftBar_Update`, which shows `ShapeshiftBarMiddle` unconditionally past two forms and `Show()`s an already-shown frame — no `OnShow`, no manage pass — so a stance switch or the category-47 cooldown arming and expiring left the middle strip drawn alone over a raised bottom-left bar; castability now rides `SPELL_UPDATE_USABLE` (`0x4b31c0`), active/texture/cooldown are pushed silently and repainted on `PLAYER_AURAS_CHANGED` and `SPELL_UPDATE_COOLDOWN`, the feed ordered before both handler sets so they read the fresh list (2009); and the loot-roll frame waits for its item template (B371): `GroupLootFrame_OnShow` paints once and nothing repaints it, and we fired `START_LOOT_ROLL` on the frame the packet arrived, against a snapshot whose name, texture and quality were still `None` — the reference's `0x61b310` ends in the item-template cache lookup `0x55ba30`, a hit firing the event there and a miss firing it from the arrival callback `0x61b460`, so the feed holds a fresh roll's event until the template answers, with two releases that are ours: a negative answer (a frame that never opens cannot even be passed on) and a 2 s deadline paid out of the roll's minute; `arg2` stays the wire's countdown, as `0x61b430` pushes it (2010)
- the FPS journal ships to players: it had registered inside the dev-only `DevToolsPlugin` and been armed by an environment variable, so nothing we shipped could have written a row on the one GPU-bound frame in the corpus, a Steam Deck's at 94 % busy; `/console fpsJournal 1` — a CVar of our own, persisted like every CVar — opens `benilla-config/Diagnostics/fps-journal.csv` and appends one row a second, `0` closes it mid-run, and `WOW_FPS_JOURNAL=<path>` stays as the harness lever; eight GPU columns appended, `gpu_ms` then `gpu_opaque, gpu_static, gpu_transp, gpu_glow, gpu_post, gpu_ui, gpu_other`, read off bevy's `RenderDiagnosticsPlugin`, which times passes where the device offers `TIMESTAMP_QUERY_INSIDE_PASSES` (Vulkan and DX12) and leaves the cells empty on an Apple GPU, empty being the honest value; `static_gx`, the four `ffx_glow_*` passes and `ui_gamma_decode` open diagnostic spans so a city's own draw is named in the split; a fresh file opens with the adapter, backend, driver and `gpu_spans yes|no` line; and a probe's `/console` line, which skips the stock `ChatEdit_ParseText` and had landed in a parser arm that knew only `reloadUI`, now forwards to `ConsoleExec` and writes a CVar the way the typed line does (2008)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:33:58 +07:00

622 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The stock `Minimap.xml` (1974) driven engine-only: the +/- zoom buttons must re-sync their
//! enabled state when the active zoom index switches (stepping inside/outside a WMO flips to the
//! other, independent level). Regression guard for the director-caught stale-button bug (2026-07-09):
//! `ZoomIn` greyed from an outdoor max-zoom stayed greyed indoors at the middle default level.
use benilla_ui::script::UiScript;
use super::test_ui::load_ui as load_xml;
fn enabled(s: &UiScript, button: &str) -> bool {
s.eval::<bool>(&format!("return {button}:IsEnabled() ~= 0"))
.unwrap()
}
#[test]
fn minimap_zoom_buttons_resync_when_switching_inside_and_outside() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// Host globals the cluster's OnLoad/clicks lean on that a bare engine doesn't install.
s.run("function GetMinimapZoneText() return '' end")
.unwrap();
s.run("function PlaySound() end").unwrap();
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
// The shipped load order provides GameTooltip before the cluster; Minimap_Update's tooltip
// half (the PVP tint slice, decision 0287) touches it from OnLoad on.
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\BattlefieldFrame.xml");
load_xml(&s, "Interface\\FrameXML\\Minimap.xml");
// OnLoad seeds the +/- state from the live zoom. The CVar default is 3 — a middle level, so
// BOTH buttons start enabled (the old hardcoded `MinimapZoomOut:Disable()` assumed zoom 0).
assert_eq!(s.eval::<u8>("return Minimap:GetZoom()").unwrap(), 3);
assert!(enabled(&s, "MinimapZoomIn"));
assert!(enabled(&s, "MinimapZoomOut"));
// Outdoors, zoom fully in (index 5 = max): the click handler greys ZoomIn.
s.run("Minimap:SetZoom(5)").unwrap();
s.fire_event("MINIMAP_UPDATE_ZOOM", vec![]);
assert!(!enabled(&s, "MinimapZoomIn"), "at max zoom ZoomIn disables");
assert!(enabled(&s, "MinimapZoomOut"));
// Step INSIDE: the API now routes to the indoor index, still at its own untouched default 3.
// The app fires MINIMAP_UPDATE_ZOOM on the transition; the cluster must re-sync to the new level.
s.set_minimap_inside(true);
s.fire_event("MINIMAP_UPDATE_ZOOM", vec![]);
assert_eq!(
s.eval::<u8>("return Minimap:GetZoom()").unwrap(),
3,
"indoors reads the separate indoor index"
);
assert!(
enabled(&s, "MinimapZoomIn"),
"the stale outdoor max-zoom greying must clear — this is the reported bug"
);
assert!(enabled(&s, "MinimapZoomOut"));
// Indoors, zoom fully out (index 0 = widest): ZoomOut greys.
s.run("Minimap:SetZoom(0)").unwrap();
s.fire_event("MINIMAP_UPDATE_ZOOM", vec![]);
assert!(
!enabled(&s, "MinimapZoomOut"),
"at min zoom ZoomOut disables"
);
assert!(enabled(&s, "MinimapZoomIn"));
// Step back OUTSIDE: the outdoor index is exactly where we left it (5 = max), so ZoomIn is
// greyed again and the indoor min-zoom greying of ZoomOut is gone.
s.set_minimap_inside(false);
s.fire_event("MINIMAP_UPDATE_ZOOM", vec![]);
assert_eq!(s.eval::<u8>("return Minimap:GetZoom()").unwrap(), 5);
assert!(!enabled(&s, "MinimapZoomIn"), "outdoor max-zoom survived");
assert!(enabled(&s, "MinimapZoomOut"));
}
/// The tracking icon (ref-Minimap.xml l.109-174, transcribed verbatim): hidden at load, and the
/// verbatim OnEvent follows `GetTrackingTexture()` across `PLAYER_AURAS_CHANGED` — the event the
/// aura feed fires beside `UNIT_AURA` on every display-state change (`ui_aura`). Show with a
/// tracking state pushed, hide when it clears.
#[test]
fn tracking_frame_follows_get_tracking_texture_across_player_auras_changed() {
let _data = benilla_formats::wow_data_or_skip!();
use benilla_ui::script::TrackingState;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run("function GetMinimapZoneText() return '' end")
.unwrap();
s.run("function PlaySound() end").unwrap();
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\BattlefieldFrame.xml");
load_xml(&s, "Interface\\FrameXML\\Minimap.xml");
let vis = |s: &UiScript| {
s.eval::<bool>("return MiniMapTrackingFrame:IsVisible()")
.unwrap()
};
assert!(!vis(&s), "no tracking at load — the frame starts hidden");
// The feed pushes a tracking aura (a miner's Find Minerals) and fires the rebuild event.
s.set_tracking(Some(TrackingState {
spell_id: 2580,
name: Some("Find Minerals".into()),
icon: Some("Interface\\Icons\\Trade_Mining".into()),
cancelable: true,
}));
s.fire_event("PLAYER_AURAS_CHANGED", vec![]);
assert!(vis(&s), "a live tracking texture shows the icon");
// Tracking cancelled/expired: the same event path hides it.
s.set_tracking(None);
s.fire_event("PLAYER_AURAS_CHANGED", vec![]);
assert!(!vis(&s), "no tracking texture hides the frame again");
}
/// A session with the minimap cluster + the time-of-day indicator (`GameTime.xml`) loaded, the
/// game clock parked at `hour:minute` — the shape `crate::minimap::feed_game_time` pushes.
fn game_time_session(hour: u32, minute: u32) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run("function GetMinimapZoneText() return '' end")
.unwrap();
s.run("function PlaySound() end").unwrap();
s.run(&format!(
"__benilla_game_hour = {hour}; __benilla_game_minute = {minute}"
))
.unwrap();
// The player's own strings and locale rather than a hand-typed copy of three of them: the
// window is the reference's file since 1751 window 5, and `TIME_TWENTYFOURHOURS` and
// `TwentyFourHourTime` are exactly what it reads. `Localization.xml` only DEFINES
// `LocalizeFrames`; the reference calls it from `UIParent_OnEvent`'s VARIABLES_LOADED arm, and
// this session has no UIParent, so it calls it directly.
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\Localization.xml");
s.run("LocalizeFrames()").unwrap();
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
// `TEXT()`, which the reference's own tooltip and label formatting passes every string through.
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\BattlefieldFrame.xml");
load_xml(&s, "Interface\\FrameXML\\Minimap.xml");
load_xml(&s, "Interface\\FrameXML\\GameTime.xml");
s
}
/// `GameTimeTexture`'s current texcoord window, as the `(left, right, top, bottom)` rect this
/// file reasons in.
///
/// `GetTexCoord` answers EIGHT values since decision 1840 — `ULx, ULy, LLx, LLy, URx, URy, LRx,
/// LRy` — so the rect is positions 1, 5, 2, 4. Folded here rather than at each call site because
/// the window really is axis-aligned and every assertion below is about its edges.
fn tod_window(s: &UiScript) -> (f64, f64, f64, f64) {
let (ulx, uly, _, lly, urx, ..): (f64, f64, f64, f64, f64, f64, f64, f64) =
s.eval("return GameTimeTexture:GetTexCoord()").unwrap();
(ulx, urx, uly, lly)
}
/// `GameTimeFrame_Update`'s law, exactly: the 50-px window over the 128×64 UI-TOD-Indicator
/// sits on the LEFT half (the sun) through the game day and slides +0.5 to the RIGHT half (the
/// moon) outside it — night is before 5:30 AM or from 9:00 PM, boundaries included exactly as
/// the ref compares (`< DAWN or >= DUSK`).
#[test]
fn game_time_frame_slides_the_sun_moon_window_on_the_game_clock() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = game_time_session(10, 30);
// OnLoad seeded `timeOfDay = 0` and 10:30 ≠ 0, so the very first update already seated the
// window — no OnUpdate tick needed for the initial state.
let day = (0.0, 50.0 / 128.0, 0.0, 50.0 / 64.0);
assert_eq!(tod_window(&s), day, "mid-morning shows the sun half");
// 21:00 exactly is night (`>= DUSK`): the OnUpdate re-read slides the window +0.5.
s.run("__benilla_game_hour = 21; __benilla_game_minute = 0")
.unwrap();
s.tick(0.016);
assert_eq!(
tod_window(&s),
(0.5, 0.5 + 50.0 / 128.0, 0.0, 50.0 / 64.0),
"9:00 PM sharp is the moon half"
);
// 5:29 is still night; 5:30 exactly is day (`< DAWN`).
s.run("__benilla_game_hour = 5; __benilla_game_minute = 29")
.unwrap();
s.tick(0.016);
assert_eq!(tod_window(&s).0, 0.5, "5:29 AM is still the moon");
s.run("__benilla_game_minute = 30").unwrap();
s.tick(0.016);
assert_eq!(tod_window(&s), day, "5:30 AM sharp flips to the sun");
}
/// Hovering the indicator through the REAL pointer path (hit-test → OnEnter) shows the game-time
/// tooltip, live-updates it while owned (the `IsOwned` refresh branch), and hides it on leave.
/// This also pins the two loader-side pieces this frame leans on: the `<Scripts>` walker's
/// mouse auto-enable (the frame declares no enableMouse, like the reference — without the law the
/// hit-test never captures) and the `<HitRectInsets>` hull.
#[test]
fn hovering_the_indicator_shows_and_live_updates_the_game_time_tooltip() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = game_time_session(21, 7);
s.resolve();
// Hover the middle of the frame's hit rect: the resolved rect inset by (l=6, r=0, t=5, b=10).
let (l, r, t, b) = (
s.eval::<f32>("return GameTimeFrame:GetLeft()").unwrap(),
s.eval::<f32>("return GameTimeFrame:GetRight()").unwrap(),
s.eval::<f32>("return GameTimeFrame:GetTop()").unwrap(),
s.eval::<f32>("return GameTimeFrame:GetBottom()").unwrap(),
);
let (x, y) = ((l + 6.0 + r) * 0.5, (b + 10.0 + t - 5.0) * 0.5);
s.mouse_move(x, y);
assert!(
s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"OnEnter owns the tooltip (the Scripts-walker auto-enable capturing at {x},{y})"
);
// TwentyFourHourTime = 1 (enGB LocalizeFrames, per GameTime.xml's header): 21:07, not 9:07 PM.
let text = |s: &UiScript| {
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap()
};
assert_eq!(text(&s), "21:07");
// The minute ticks while hovered: GameTimeFrame_Update's IsOwned branch refreshes in place.
s.run("__benilla_game_minute = 8").unwrap();
s.tick(0.016);
assert_eq!(text(&s), "21:08", "the owned tooltip follows the clock");
// Leave: a point past the left inset — outside the hit hull but still inside the raw 50×50
// rect — must ALSO leave; the insets are part of the reference geometry.
s.mouse_move(l + 2.0, y);
assert!(
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"the 6-px left inset band is not hoverable"
);
}
/// **The ping's click path, driven by a real mouse event** (decision 1596).
///
/// The point of the test is the *path*, not the arithmetic: `Minimap_OnClick` is only reached if
/// the widget is mouse-enabled, hit-tests, and its `OnMouseUp` fires — and what it parks has to be
/// centre-relative UI units, because that is the contract the app's conversion is written against.
/// Calling `Minimap_OnClick()` from `s.run` would prove none of it (1234 §2).
#[test]
fn a_click_on_the_minimap_parks_a_centre_relative_ping_request() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run("function GetMinimapZoneText() return '' end")
.unwrap();
s.run("function PlaySound() end").unwrap();
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\BattlefieldFrame.xml");
load_xml(&s, "Interface\\FrameXML\\Minimap.xml");
s.resolve();
let cx = s
.eval::<f32>("local x = Minimap:GetCenter(); return x")
.unwrap();
let cy = s
.eval::<f32>("local _, y = Minimap:GetCenter(); return y")
.unwrap();
assert!(cx > 0.0 && cy > 0.0, "the widget resolved: ({cx}, {cy})");
// Nothing is parked until somebody clicks.
assert_eq!(s.take_minimap_ping_request(), None);
// A click 20 UI units right and 12 up of the centre — inside the 70-unit disc. The stock
// `Minimap_OnClick` adds its `CURSOR_OFFSET_X/Y` (−7, −9) to the cursor before subtracting
// the centre, so what it parks is the click less that offset.
s.mouse_button(cx + 20.0, cy + 12.0, "LeftButton", true);
s.mouse_button(cx + 20.0, cy + 12.0, "LeftButton", false);
let (dx, dy) = s
.take_minimap_ping_request()
.expect("OnMouseUp → Minimap_OnClick → PingLocation");
assert!((dx - 13.0).abs() < 0.01, "x right of centre, less 7: {dx}");
assert!((dy - 3.0).abs() < 0.01, "y UP from centre, less 9: {dy}");
// Draining is a drain: the app must not see the same click twice.
assert_eq!(s.take_minimap_ping_request(), None);
// A click that misses the widget entirely never reaches the handler.
s.mouse_button(10.0, 10.0, "LeftButton", true);
s.mouse_button(10.0, 10.0, "LeftButton", false);
assert_eq!(s.take_minimap_ping_request(), None, "off-widget is no ping");
}
/// `Minimap:GetPingPosition()` answers **two numbers always** (the reference recomputes them
/// from statics nothing clears — wow-re `minimap-ping-law.md`), and the stock
/// `Minimap_OnUpdate` leans on exactly that: while its 5 s timer runs it multiplies the answer
/// with no nil test. The pair is the app's last publish, `(0, 0)` before any.
#[test]
fn get_ping_position_answers_two_numbers_always() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run("function GetMinimapZoneText() return '' end")
.unwrap();
s.run("function PlaySound() end").unwrap();
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\BattlefieldFrame.xml");
load_xml(&s, "Interface\\FrameXML\\Minimap.xml");
assert_eq!(
s.eval::<i64>("return select('#', Minimap:GetPingPosition())")
.unwrap(),
2
);
assert_eq!(
s.eval::<f32>("return (Minimap:GetPingPosition())").unwrap(),
0.0,
"before any ping: a number, not nil"
);
s.set_minimap_ping((0.25, -0.125));
let x = s
.eval::<f32>("local x = Minimap:GetPingPosition(); return x")
.unwrap();
let y = s
.eval::<f32>("local _, y = Minimap:GetPingPosition(); return y")
.unwrap();
assert!(
(x - 0.25).abs() < 1e-6 && (y + 0.125).abs() < 1e-6,
"{x} {y}"
);
// The stock lifetime, end to end: MINIMAP_PING shows the model frame and starts the 5 s
// timer; OnUpdate re-seats it from GetPingPosition every frame; past 5 s it "fades" and
// hides. The frame's own file draws in it (`crate::ui_models`, decision 2008).
assert!(
!s.eval::<bool>("return MiniMapPing:IsVisible()").unwrap(),
"hidden until a ping"
);
s.fire_event(
"MINIMAP_PING",
vec![
benilla_ui::script::ScriptValue::Str("player".into()),
benilla_ui::script::ScriptValue::Number(0.25),
benilla_ui::script::ScriptValue::Number(-0.125),
],
);
assert!(s.eval::<bool>("return MiniMapPing:IsVisible()").unwrap());
s.tick(1.0);
assert!(
s.eval::<bool>("return MiniMapPing:IsVisible()").unwrap(),
"held at 1 s"
);
let cx = s
.eval::<f32>(
"local x = MiniMapPing:GetCenter(); local mx = Minimap:GetCenter(); return x - mx",
)
.unwrap();
assert!(
(cx - 0.25 * 140.0).abs() < 0.5,
"re-seated at the normalized offset times the width: {cx}"
);
s.tick(4.2);
s.tick(0.6);
assert!(
!s.eval::<bool>("return MiniMapPing:IsVisible()").unwrap(),
"5 s hold + 0.5 s fade, then hidden"
);
}
/// The meeting-stone icon (stock `Minimap.xml`, 1974): hidden until `IsInMeetingStoneQueue()`
/// answers across `MEETINGSTONE_CHANGED`, its hover the cached status text, hidden again when
/// the queue empties — and a click raises the leave-queue question.
#[test]
fn the_meeting_stone_icon_follows_the_queue_across_meetingstone_changed() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run("function GetMinimapZoneText() return '' end")
.unwrap();
s.run("function PlaySound() end").unwrap();
for f in [
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\Localization.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\BasicControls.xml",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\BattlefieldFrame.xml",
"Interface\\FrameXML\\Minimap.xml",
] {
load_xml(&s, f);
}
s.resolve();
let vis = |s: &UiScript| {
s.eval::<bool>("return MiniMapMeetingStoneFrame:IsVisible()")
.unwrap()
};
assert!(!vis(&s), "hidden at load");
s.set_meeting_stone(1519, Some("Looking for more for Stormwind City".into()));
s.fire_event("MEETINGSTONE_CHANGED", vec![]);
assert!(vis(&s), "a queued area shows the icon");
s.run("this = MiniMapMeetingStoneFrame; MiniMapMeetingStoneFrame:GetScript('OnEnter')()")
.unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
"Looking for more for Stormwind City"
);
s.run("MiniMapMeetingStoneFrame:Click()").unwrap();
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"a click asks CONFIRM_LEAVE_QUEUE"
);
s.run("StaticPopup1Button1:Click()").unwrap();
assert_eq!(
s.take_meeting_stone_cancels(),
1,
"Accept is CancelMeetingStoneRequest()"
);
s.set_meeting_stone(0, Some("Looking for more for Unknown".into()));
s.fire_event("MEETINGSTONE_CHANGED", vec![]);
assert!(!vis(&s), "area 0 hides it again");
}
/// **The ping's pixels are the stock `<Model>`'s own file** (decision 2008): shown by
/// `MINIMAP_PING` with the file's facts landed, the extract publishes ONE tile request for the
/// pane — at its device size and the render law's unit ladder — and, once the renderer has
/// handed back a cell, draws that cell as one premultiplied quad over the pane's rect. Drives the
/// real `drive_script` in the headless harness the clip-plumb tests use.
#[test]
fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() {
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use benilla_ui::widget::{ModelFileFacts, SequenceFacts};
use crate::ui_models::{Cell, UiModelTiles};
use crate::ui_pass::UiQuads;
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run("function GetMinimapZoneText() return '' end")
.unwrap();
s.run("function PlaySound() end").unwrap();
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\BattlefieldFrame.xml");
load_xml(&s, "Interface\\FrameXML\\Minimap.xml");
// The file's facts, as `benilla-extract m2seq` reads them off the install: the ping's
// `SetSequence(0)` from its OnLoad was queued behind them and replays now.
const PING: &str = r"Interface\MiniMap\Ping\MinimapPing.mdx";
let seq = |anim_id, duration_ms, looping| SequenceFacts {
anim_id,
duration_ms,
looping,
};
s.set_model_facts(
PING,
ModelFileFacts {
sequences: vec![seq(127, 1333, false), seq(0, 833, true), seq(1, 333, false)],
bbox: ([0.0; 3], [0.0; 3]),
},
);
assert!(
s.visible_model_panes().is_empty(),
"hidden until a ping: nothing to paint"
);
let ping_at = |s: &mut UiScript, nx: f64, ny: f64| {
s.fire_event(
"MINIMAP_PING",
vec![
benilla_ui::script::ScriptValue::Str("player".into()),
benilla_ui::script::ScriptValue::Number(nx),
benilla_ui::script::ScriptValue::Number(ny),
],
);
};
ping_at(&mut s, 0.25, -0.125);
let panes = s.visible_model_panes();
assert_eq!(panes.len(), 1, "the ping pane is on the paint list");
let pane = panes[0];
assert_eq!(
pane.play.map(|p| (p.anim_id, p.cursor_ms)),
Some((0, 0)),
"the OnLoad's SetSequence(0) replayed over the seed: Stand, at 0"
);
let mut app = App::new();
app.insert_non_send_resource(s);
app.init_resource::<UiQuads>();
app.init_resource::<Assets<Image>>();
app.init_resource::<crate::portrait::PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::autocast_shine::ShineSites>();
app.init_resource::<crate::ui_script::UiFrameCost>();
app.init_resource::<crate::ui_script::UiCostWanted>();
app.init_resource::<Time>();
app.init_resource::<Time<Real>>();
app.init_resource::<crate::ui_script::UiClock>();
app.init_resource::<crate::ui_script::UiScaleCvar>();
app.world_mut().spawn((
Window {
resolution: UVec2::new(1024, 768).into(),
..default()
},
PrimaryWindow,
));
app.add_systems(Update, super::extract::drive_script);
app.update();
let premultiplied = |app: &App| {
app.world()
.resource::<UiQuads>()
.quads
.iter()
.filter(|q| q.premultiplied)
.cloned()
.collect::<Vec<_>>()
};
{
let tiles = app.world().resource::<UiModelTiles>();
let req = tiles
.requests
.get(&pane.handle)
.expect("one request for the ping pane");
assert_eq!(req.path, PING);
// `<Size>50×50</Size>` at s = 1 (768-tall window), DPI 1: 50 device px a side.
assert_eq!(req.size_px, UVec2::new(50, 50));
// `scale="0.4"`: 1280 · 0.4 = 512 px per model unit — 1596's own "1 model unit at 512 px".
assert!(
(req.px_per_unit - 512.0).abs() < 1e-3,
"{}",
req.px_per_unit
);
// A layout unit, and a particle's unit: 768 · √((4/3)² + 1) = 1280 at 4:3.
assert!(
(req.pos_px_per_unit - 1280.0).abs() < 0.1,
"{}",
req.pos_px_per_unit
);
assert!(
(req.star_px_per_unit - 1280.0).abs() < 0.1,
"{}",
req.star_px_per_unit
);
assert_eq!(req.icon, None);
assert!(premultiplied(&app).is_empty(), "no cell yet: nothing drawn");
}
// The renderer hands a cell back; a fresh ping moves the pane, so the memoized conversion
// runs again and the arm draws the cell.
{
let atlas = app
.world_mut()
.resource_mut::<Assets<Image>>()
.add(Image::default());
let mut tiles = app.world_mut().resource_mut::<UiModelTiles>();
tiles.atlas = Some(atlas);
tiles.atlas_size = UVec2::splat(512);
tiles.cells.insert(
pane.handle,
Cell {
origin: UVec2::new(2, 2),
size: UVec2::new(50, 50),
},
);
}
{
let mut script = app.world_mut().non_send_resource_mut::<UiScript>();
// The stock handler seats the frame from `GetPingPosition()`, not the event's args.
script.set_minimap_ping((-0.125, 0.25));
ping_at(&mut script, -0.125, 0.25);
}
app.update();
let drawn = premultiplied(&app);
assert_eq!(drawn.len(), 1, "the cell, once");
let q = &drawn[0];
assert!((q.rect.width() - 50.0).abs() < 1e-3 && (q.rect.height() - 50.0).abs() < 1e-3);
let [tl, _, br, _] = q.uv.corners;
assert!((tl[0] - 2.0 / 512.0).abs() < 1e-6 && (tl[1] - 2.0 / 512.0).abs() < 1e-6);
assert!((br[0] - 52.0 / 512.0).abs() < 1e-6 && (br[1] - 52.0 / 512.0).abs() < 1e-6);
assert_eq!(q.color, [1.0, 1.0, 1.0, 1.0], "the frame's own alpha");
assert!(q.texture.is_some());
}