- a comment that justifies code by an engine difference is a claim with an expiry date, and the tree had ~96 expired ones across `assets/ui`, `ui_script/` and `benilla-ui` — a hygiene pass, no behaviour change: dangling `Benilla*` names with zero definitions (`BenillaMoney_*` at thirteen sites after 1937/1962 converged the coin cluster onto the stock `SmallMoneyFrameTemplate`; `BenillaGossipRow_Resize`, `BenillaErrorsFrame_AddMessage`, the digit feed 1285 deleted, `BenillaMerchantItem_OnClick` and three more), "the shipped `assets/ui/<X>.xml`" for thirty-odd files the directory no longer holds — it holds nine — each site now naming the chain file that actually loads, `UiPanels.xml` in the present tense after 1988 took the panel manager and the dialog engine stock, and expired self-deprecation: SimpleHTML "a widget kind we do not have" (1337 built it), "benilla has no console" (1948 bound `ConsoleExec`), `Model`'s "seven verbs not published yet" and "`ClearFog` is a guess" (all 23 are published, the carve sits 500 lines below, `UNBUILT` has been `[&str; 0]` since 2027), `frame_flag_gate`'s doc describing four groups of accepted mouse divergences over a `KNOWN` list that is empty (the empty array and its enforcement stay), five comments saying key delivery is not gated on `keyboard_enabled` where `script::keyboard`'s walk filters on exactly that field (1319), `SetRaidTarget` called a verb this house lacks beside an assert that its intent IS queued (1820), two questlog tests deferring to a screenshot for `GetPoint`/`IsVisible` the regions already answer, `benilla.toc`'s "trade still needs four engine verbs" (all four bound, 1966) and its ping `Model`s "this engine draws no pixels for" (2013 renders `MinimapPing.mdx` itself), and `rig_anim`'s `set_paused` naming a booth caller it does not have; and the code the prose was propping up goes with it: `GameMenuFrame.xml`'s six `<OnLoad>` label setters, because `text=` on a `<Button>` is a global-string lookup at the loader's attribute step (0995, built 61 seconds before the file was written) — the buttons carry `text="LOGOUT"`/`"EXIT_GAME"`/`"MACROS"`/`"RETURN_TO_GAME"`/`"MAIN_MENU"` as the reference's own do; `ScriptLogFrame`'s five authored close-button lines, now `inherits="UIPanelCloseButton"` off the chain's `UIPanelTemplates.xml` seated 757 lines above it (1846); and the editbox's 22-line "gap" doc claiming only the self-acquire `autoFocus` path is built and its `false` default unverified — path 1 is 1686's `visibility_focus`, and the default is `true`, byte-read off `flags = 1` at `0x779a29`/`0x779a2e`, so the test named `autofocus_does_not_focus_on_show…` is renamed for what it actually pins (2044) - the `text=` divergence never existed: `resolve_text`'s doc had claimed since 0068 that falling back to the raw attribute on a GlobalStrings miss was "one deliberate divergence" from a reference that draws the empty string, and read at the byte it is the reference's own behaviour — `FrameScript_GetText 0x703bf0` does return a pre-seeded empty string (`0x882748`) when the global is not a string, but that return never reaches a label, because all three `text=` readers image-wide test it and substitute the raw attribute: `Button::LoadXML` at `0x778c07` (`0x778c31 mov eax,esi`), `CSimpleFontString::LoadXML` at `0x771006` byte-identically, and `0x7292a6`, which pushes `edi` on the empty arm; so the loader changes nothing, the two test comments that had called `PetLevelText`'s "Level level race class" placeholder and `CLOSE` our divergence now name the reference's arm (a real 1.12 client draws them too), and what IS ours is the extra warning on a key-shaped miss (0983 → 0991), a strict addition; beside it, `GetEffectiveScale`'s root factor is 1 not because "benilla has no `uiScale` CVar" — 1804 registered one, default 0.9 — but because the dial is applied at the raster seam, `768/uiScale` UI units of virtual-screen height rather than a scale on `UIParent`, so every coordinate the VM hands Lua is already in those units and the reference's `GetCursorPosition()/GetEffectiveScale()` transcriptions convert correctly with a root of 1 (2044) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
330 lines
13 KiB
Rust
330 lines
13 KiB
Rust
//! The stock `Interface\FrameXML\ActionBarFrame.xml`'s performance ("ping") meter — the tinted bar
|
||
//! in the main bar's last empty recess — over the real files, never a stub.
|
||
//!
|
||
//! What these guard, in order: the ref geometry that puts the bar IN the recess (the whole point of
|
||
//! the slice); the LOW-strata draw order that makes it show *through* the bar art instead of over
|
||
//! it; the latency→color law and its 10 s poll; and the hover tooltip's live number.
|
||
|
||
use benilla_ui::script::{QuadContent, UiScript};
|
||
|
||
use super::test_ui::load_ui as load_xml;
|
||
|
||
/// The bar on a 1024×768 screen. 1024 wide is deliberate: the 1024-wide bar then spans x 0..1024,
|
||
/// so every ref offset below is also an absolute screen coordinate.
|
||
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, "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");
|
||
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
|
||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||
load_xml(&s, "Interface\\FrameXML\\MainMenuBar.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\\ActionBarFrame.xml");
|
||
load_xml(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
|
||
for f in extra {
|
||
load_xml(&s, f);
|
||
}
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
s
|
||
}
|
||
|
||
/// The meter's own paint quad (its `MainMenuBarPerformanceBar` texture) and where it sits in the
|
||
/// painter's order.
|
||
fn bar_quad(s: &mut UiScript) -> (usize, [f32; 4], Option<[f32; 4]>) {
|
||
s.resolve();
|
||
let quads = s.extract();
|
||
let i = quads
|
||
.iter()
|
||
.position(|q| {
|
||
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
|
||
if p.contains("UI-MainMenuBar-PerformanceBar"))
|
||
})
|
||
.expect("the performance bar's texture quad");
|
||
let rect = quads[i].rect.expect("a resolved rect");
|
||
let color = match &quads[i].content {
|
||
QuadContent::Texture { color, .. } => *color,
|
||
_ => unreachable!(),
|
||
};
|
||
(i, [rect.left, rect.bottom, rect.right, rect.top], color)
|
||
}
|
||
|
||
/// ref-MainMenuBar.xml l.344-364: a 16×64 frame at the bar's BOTTOMRIGHT +(-227,-10), carrying a
|
||
/// 20×66 texture off its TOPRIGHT. On a 1024-wide bar that is x 781..797 for the frame and
|
||
/// x 777..797 for the texture — the empty recess between the last micro button (which ends at 763)
|
||
/// and the bag cluster. Both hang below the bar's own bottom, which is what drops the texture's
|
||
/// grey column into the slot rather than floating it above.
|
||
#[test]
|
||
fn the_meter_sits_in_the_bar_recess_the_reference_leaves_for_it() {
|
||
let mut s = harness(&[]);
|
||
s.resolve();
|
||
|
||
let (left, bottom, w, h) = s
|
||
.eval::<(f64, f64, f64, f64)>(
|
||
"return MainMenuBarPerformanceBarFrame:GetLeft(), MainMenuBarPerformanceBarFrame:GetBottom(), \
|
||
MainMenuBarPerformanceBarFrame:GetWidth(), MainMenuBarPerformanceBarFrame:GetHeight()",
|
||
)
|
||
.unwrap();
|
||
assert_eq!((w, h), (16.0, 64.0), "frame size");
|
||
assert_eq!(left, 781.0, "1024 − 227 − 16");
|
||
assert_eq!(bottom, -10.0, "hangs 10 below the bar's bottom");
|
||
|
||
// The texture overspills the frame: 20 wide off the TOPRIGHT ⇒ 4 further left, and 66 tall from
|
||
// the frame's top (54) ⇒ 2 further down.
|
||
let (_, rect, _) = bar_quad(&mut s);
|
||
assert_eq!(
|
||
rect,
|
||
[777.0, -12.0, 797.0, 54.0],
|
||
"[left, bottom, right, top]"
|
||
);
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// `frameStrata="LOW"` is the mechanism, not decoration: the bar art is a transparent WINDOW over
|
||
/// this slot, so the meter has to paint UNDERNEATH it. At the default MEDIUM the 20-wide texture
|
||
/// would paint over the metal surround that frames the 10-wide slot.
|
||
#[test]
|
||
fn the_meter_paints_under_the_bar_art_it_shows_through() {
|
||
let mut s = harness(&[]);
|
||
let (meter, _, _) = bar_quad(&mut s);
|
||
|
||
let quads = s.extract();
|
||
let art = quads
|
||
.iter()
|
||
.position(|q| {
|
||
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
|
||
if p.contains("UI-MainMenuBar-Dwarf"))
|
||
})
|
||
.expect("the bar's own dwarf art");
|
||
assert!(
|
||
meter < art,
|
||
"the meter must draw before (under) the bar art — it is seen through the art's \
|
||
transparent slot, not over it"
|
||
);
|
||
|
||
// And the hover button is above everything, the ref's own HIGH-strata split: the LOW frame
|
||
// beneath the art can't take the mouse itself.
|
||
assert_eq!(
|
||
s.eval::<String>("return MainMenuBarPerformanceBarFrameButton:GetFrameStrata()")
|
||
.unwrap(),
|
||
"HIGH"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return MainMenuBarPerformanceBarFrame:GetFrameStrata()")
|
||
.unwrap(),
|
||
"LOW",
|
||
"the parent's LOW must not have been dragged up by its HIGH child"
|
||
);
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// ref-MainMenuBar.xml l.375-392: the poll runs every `PERFORMANCEBAR_UPDATE_INTERVAL` seconds and
|
||
/// tints the bar green under 300 ms, yellow to 600, red beyond. 1.12 never scales the bar's HEIGHT
|
||
/// — color is the entire readout, which is why this test asserts the tint and the rect separately.
|
||
#[test]
|
||
fn the_meter_tints_by_latency_on_the_reference_thresholds() {
|
||
let mut s = harness(&[]);
|
||
|
||
// The first tick polls immediately (updateInterval starts at 0), so an unmeasured connection
|
||
// reads 0 ms and shows green rather than sitting untinted grey.
|
||
s.tick(0.016);
|
||
let (_, _, color) = bar_quad(&mut s);
|
||
assert_eq!(color, Some([0.0, 1.0, 0.0, 1.0]), "0 ms ⇒ green");
|
||
|
||
for (latency, want, band) in [
|
||
(299, [0.0, 1.0, 0.0, 1.0], "under LOW ⇒ green"),
|
||
(301, [1.0, 1.0, 0.0, 1.0], "past LOW ⇒ yellow"),
|
||
(601, [1.0, 0.0, 0.0, 1.0], "past MEDIUM ⇒ red"),
|
||
] {
|
||
s.set_latency_ms(Some(latency));
|
||
poll_beat(&mut s);
|
||
let (_, _, color) = bar_quad(&mut s);
|
||
assert_eq!(color, Some(want), "{latency} ms: {band}");
|
||
}
|
||
|
||
// A latency that crosses a threshold does NOT repaint before the next poll beat — the ref's
|
||
// own 10 s cadence, and the reason a 30 s ping's jitter can't strobe the bar.
|
||
s.set_latency_ms(Some(0));
|
||
s.tick(1.0);
|
||
let (_, _, color) = bar_quad(&mut s);
|
||
assert_eq!(
|
||
color,
|
||
Some([1.0, 0.0, 0.0, 1.0]),
|
||
"still red until the beat"
|
||
);
|
||
poll_beat(&mut s);
|
||
let (_, _, color) = bar_quad(&mut s);
|
||
assert_eq!(
|
||
color,
|
||
Some([0.0, 1.0, 0.0, 1.0]),
|
||
"the beat repaints it green"
|
||
);
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// The drawn colour of the tooltip's explanation line — found by its TEXT, so it can't be confused
|
||
/// with the label above it. `None` if that line isn't being painted at all.
|
||
fn newbie_line_color(s: &mut UiScript) -> Option<[f32; 4]> {
|
||
let want = s
|
||
.eval::<String>("return NEWBIE_TOOLTIP_LATENCY")
|
||
.expect("the string is declared with the meter");
|
||
s.resolve();
|
||
s.extract().iter().find_map(|q| match &q.content {
|
||
QuadContent::Text {
|
||
text: Some(t),
|
||
color,
|
||
..
|
||
} if *t == want => Some(*color),
|
||
_ => None,
|
||
})?
|
||
}
|
||
|
||
/// Advance exactly one poll beat. The ref's OnUpdate spends the whole remaining interval on one
|
||
/// tick and only polls on the tick that finds it non-positive, so a beat is always two ticks longer
|
||
/// than the interval: the first drains it, the second crosses zero and re-arms it at 10.
|
||
fn poll_beat(s: &mut UiScript) {
|
||
s.tick(11.0);
|
||
s.tick(11.0);
|
||
}
|
||
|
||
/// `GetNetStats()` is the whole seam: the app pushes the averaged RTT, the meter reads it. The two
|
||
/// bandwidth returns are the named gap (benilla tallies no throughput) and must stay NUMBERS, so
|
||
/// arithmetic on them can't error.
|
||
#[test]
|
||
fn get_net_stats_reports_the_pushed_latency() {
|
||
let mut s = harness(&[]);
|
||
assert_eq!(
|
||
s.eval::<(f64, f64, f64)>("return GetNetStats()").unwrap(),
|
||
(0.0, 0.0, 0.0),
|
||
"nothing measured yet"
|
||
);
|
||
s.set_latency_ms(Some(42));
|
||
assert_eq!(
|
||
s.eval::<(f64, f64, f64)>("return GetNetStats()").unwrap(),
|
||
(0.0, 0.0, 42.0)
|
||
);
|
||
// Back to unmeasured (a disconnect clears the app's ring) ⇒ 0, the reference's own reading for
|
||
// a connection it has no sample for.
|
||
s.set_latency_ms(None);
|
||
assert_eq!(
|
||
s.eval::<f64>("local _, _, ms = GetNetStats() return ms")
|
||
.unwrap(),
|
||
0.0
|
||
);
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// The hover tooltip (ref l.395-406): the HIGH-strata button owns the mouse over the meter, and its
|
||
/// tooltip reads the live number. The held-open plate re-reads it on the same 10 s beat.
|
||
///
|
||
/// The plate is the ref's TWO-line `GameTooltip_AddNewbieTip` — detailed tips ship ON in 1.12
|
||
/// (`SHOW_NEWBIE_TIPS = "1"`, ref UIOptionsFrame.lua l.100), so the explanation under the number is
|
||
/// the DEFAULT hover, not an opt-in. Decision 0661; this test is the guard on that, because a
|
||
/// regression here (the helper's branch flipping, the string going missing) shows up only as a
|
||
/// tooltip that is quietly one line short.
|
||
#[test]
|
||
fn hovering_the_meter_shows_the_live_latency() {
|
||
let mut s = harness(&[r"Interface\FrameXML\UIParent.xml"]);
|
||
s.set_latency_ms(Some(42));
|
||
// 1.12 ships detailed tips ON — `SHOW_NEWBIE_TIPS = "1"` is UIOptionsFrame_Init's (ref
|
||
// UIOptionsFrame.lua l.100; ours sits in OptionsFrame.xml's uvar block, 1968), and a harness
|
||
// without the options file says so itself, the way the reference's tooltip would read it.
|
||
s.run("SHOW_NEWBIE_TIPS = \"1\"").unwrap();
|
||
s.resolve();
|
||
|
||
assert_eq!(
|
||
s.hit_test_name(789.0, 20.0).as_deref(),
|
||
Some("MainMenuBarPerformanceBarFrameButton"),
|
||
"the button over the meter takes the mouse"
|
||
);
|
||
|
||
s.run("this = MainMenuBarPerformanceBarFrameButton MainMenuBarPerformanceBarFrameButton:GetScript(\"OnEnter\")()")
|
||
.unwrap();
|
||
assert_eq!(
|
||
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
|
||
.unwrap(),
|
||
"Latency: 42ms"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<i64>("return GameTooltip:NumLines()").unwrap(),
|
||
2,
|
||
"the number, then the explanation — the ref's newbie branch"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return GameTooltipTextLeft2:GetText()")
|
||
.unwrap(),
|
||
s.eval::<String>("return NEWBIE_TOOLTIP_LATENCY").unwrap(),
|
||
"line 2 is the ref's own NEWBIE_TOOLTIP_LATENCY, verbatim"
|
||
);
|
||
// The newbie branch seats the plate at the default screen corner, NOT beside the meter — the
|
||
// `default` flag GameTooltip_SetDefaultAnchor stamps is the observable half of that.
|
||
assert_eq!(
|
||
s.eval::<i64>("return GameTooltip.default").unwrap(),
|
||
1,
|
||
"the default-corner anchor, not ANCHOR_RIGHT off the button"
|
||
);
|
||
// …and the explanation draws in NORMAL_FONT_COLOR, the gold that separates it from the white
|
||
// label above it.
|
||
assert_eq!(
|
||
newbie_line_color(&mut s),
|
||
Some([1.0, 0.82, 0.0, 1.0]),
|
||
"NORMAL_FONT_COLOR (Fonts.xml l.37)"
|
||
);
|
||
|
||
// Still hovering, and the latency moved: the next poll beat rewrites the plate in place.
|
||
s.set_latency_ms(Some(7));
|
||
poll_beat(&mut s);
|
||
assert_eq!(
|
||
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
|
||
.unwrap(),
|
||
"Latency: 7ms"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<i64>("return GameTooltip:NumLines()").unwrap(),
|
||
2,
|
||
"the refresh rebuilds BOTH lines — a held-open plate never decays to one"
|
||
);
|
||
|
||
s.run("this = MainMenuBarPerformanceBarFrameButton MainMenuBarPerformanceBarFrameButton:GetScript(\"OnLeave\")()").unwrap();
|
||
assert!(
|
||
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
|
||
"leaving hides the plate"
|
||
);
|
||
// …and the poll no longer touches it.
|
||
s.set_latency_ms(Some(999));
|
||
poll_beat(&mut s);
|
||
assert!(
|
||
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
|
||
"an unhovered meter never re-opens the tooltip"
|
||
);
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// The meter is a frame slot in the bar's own child list — a regression guard on the frame count
|
||
/// the bar's end-to-end test pins, kept here so the two move together.
|
||
#[test]
|
||
fn the_meter_adds_its_two_frames_to_the_bar() {
|
||
let s = harness(&[]);
|
||
for name in [
|
||
"MainMenuBarPerformanceBarFrame",
|
||
"MainMenuBarPerformanceBarFrameButton",
|
||
] {
|
||
assert!(
|
||
s.eval::<bool>(&format!("return {name} ~= nil")).unwrap(),
|
||
"{name} must exist"
|
||
);
|
||
}
|
||
// The button is the frame's child (the ref's `parent=` attribute, expressed as nesting).
|
||
assert_eq!(
|
||
s.eval::<String>("return MainMenuBarPerformanceBarFrameButton:GetParent():GetName()")
|
||
.unwrap(),
|
||
"MainMenuBarPerformanceBarFrame"
|
||
);
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|