Files
benilla-octo/crates/benilla-app/src/ui_script/perf_bar_tests.rs
T
SamandClaude Fable 5.1 c3555b4eea the cooldown goes stock, early-Z comes back, and lists reach the end
- a size-less `<Model>` pane's rect is its file's box in layout units: the reference's geometry overrides (`0x76d080`/`0x76d0d0`) answer the bbox extent whenever no size is authored, `bboxExtent · 768·√(a²+1) · layoutScale` FrameXML units (`1280 · extent` at 4:3), written into the pane's layout input when the file's facts land, re-derived on an aspect change, and ended for good by any authored size; `SetPosition` is the same unit, which is how `0x4a7b20` centres the arrow at `½·GetWidth, ½·GetHeight` through the override — so the map arrow's sprite arm and its hand-sized 33.6 px footprint go (its rect is `0.0262 × 0.0263` layout units, 33.5 × 33.7 FrameXML units at 4:3, 41.0 × 41.2 at 16:9), and the stock `ItemAnim` pane plays the item push unadapted (`ReplaceIconTexture` on the type-14 batch, the 1000 ms clamp, the stock `OnAnimFinished`), `ContainerFrameAdapters.xml` losing 0887's Lua card and its hand-measured 42.41 × 124.72 rect — the implicit one at 16:9 — and keeping the keyring fork and the three bag verbs (2015); and the cooldown is the reference's own model: the texture-transform law `uv' = R_q((uv + t − p) ⊙ s) + p`, `p = (½, ½)`, the raw quaternion component-lerped and never normalised, is one function (`uv_transform`) with the shader's UV fold as its twin — rotation and scale tracks bake per file sequence slot beside the translation, the mat-anim table gains an affine row `[cos − 1, sin, sx − 1, sy − 1]` on `anim_slots.z` whose identity is the zero row so every static batch reads exactly what it did, and a UI tile clones the twin material per animated batch and writes both rows every frame off the pane's play head, never the world clock; the stock `Cooldown.xml` runs off the chain on 2007's two handlers, the file's four quadrant quads turned by their own rotation tracks (`0 → −90°` over each quadrant's 250 ms window, clockwise from 12 o'clock) and its alpha tracks revealing the star for the flash; retired: the native `Cooldown` widget and `SetCooldown` (an Era API 1.12 never had — `CreateFrame("Cooldown")` is the reference's unknown-type refusal now), the loader's `Model → Cooldown` substitution, the app's hand-built pie and star, and our `Cooldown.xml`; `assets/ui` is eight files (2019)
- `UpdateMapHighlight` gets its zone-level exits (B360): at zone zoom ours returned the nil tail for every cursor position, so hovering Deadwind Pass from Duskwood showed no label where 1.12 shows "Deadwind Pass"; the reference's `0x4a7fa0` runs two lookups at zone level and answers a NAME only — first the current zone's exploration-gated overlay list (the builder at `0x4a6ad9` gating on the explored byte), each row's HitRect scaled by 1/1002 and 1/668 against the normalized cursor, edges inclusive, the first hit naming its Area1, then the continent grid re-windowed through the displayed zone's own rect, naming the neighbouring zone or city and nothing for the displayed zone itself — and only the name-only tail is reachable from there, fileName nil and the six coords zero, so the stock frame hides the highlight quad, as the director's own 1.12 shot shows; and `SetVerticalScroll` stores the offset as given (B370, B363): the reference's `0x786db0` compares the new offset against the old and writes it to `[+0x328]` verbatim, `0x787100` re-anchors the scroll child off it, and the range at `[+0x320]` is never read on that path — every clamp the reference exhibits is FrameXML's, through the scroll bar's `[min, max]`; ours clamped into `[0, GetVerticalScrollRange()]`, the child's overflow past the FRAME, which is 40 px short of where `FauxScrollFrame_Update` sends the bar on the 220-tall skills list (three rows lost) and 15 px short on the 287-tall who list (one); the setter fires `OnVerticalScroll` only on a change, as the bytes do, `BenillaScroll_Step` bounds its wheel in Lua where the reference's handler does, and the ScrollFrame kind's prose stops calling its offset unpinned (2017)
- overdraw's cheap half, the sky first: since 0588 every sky element wrote `frag_depth = 0.0`, which costs a pipeline its early-Z, so on an outdoor frame every full-screen dome fragment under the ground and the buildings was shaded in full (the gradient's `asin`, `atan2` and eight table lookups at dawn and dusk) and thrown away; one shared `sky_vertex.wgsl` — bevy's mesh vertex verbatim plus `out.position.z = 0.0` — is now the vertex stage of the gradient dome, the cloud dome, the star patches and the celestial discs and glares, and the model lane's `WOW_SKY_DEPTH` branch moves from its fragment to its vertex, so no sky fragment declares `frag_depth` and the rasterizer rejects the covered screen before the shader runs; every sky `specialize` zeroes the rasterizer bias constant the `sky_order` rung had been leaking into `DepthBiasState::constant`, harmless only while the fragment overwrote its depth, and the prose beside the pin follows it; then the retained `static_gx` pass draws BEFORE bevy's opaque pass (`StartMainPass → static_gx → MainOpaquePass`) — buildings and doodads are the frame's best occluders and a terrain fragment its dearest, six samples a pixel — so walls reject the terrain behind them instead of the reverse, the pass taking the first-use clear with it and the dev depth probe ordered after it; measured on 16 capture scenarios: 14 bit-identical, `northshire-dusk` 1 px and `water-night` 11 px, attributed to exact depth ties on static/terrain silhouettes and last-bit interpolation noise; a depth prepass and near-to-far bins are declined until a journal says where the time goes (2016)
- the grass cut goes per pixel because of a quarter-mip: `0x6813d0` sets `D3DSAMP_MIPMAPLODBIAS = +0.25` on stage 0 (`0x6813f4`) once per frame before the detail-doodad pass — capture-confirmed on all 29 doodad batches, and outside the `[0x6b1000, 0x6b3200)` scan 2004 had called complete; it decides the whole look because the `clutter_cut` probe finds 6 of 31 shipped detail atlases carry a BINARY alpha pyramid (mip 0 has DXT3's 16 levels, every level below exactly two, both starting zones among them), so `a × ramp` degenerates to a global on/off and every texel of a leaf crosses at the same depth — Elwynn's survivor coverage is flat at 47.6 % across the whole 52.5–61.1 yd band, then 0.0 %, while the probe's box-filtered control erodes 47.7 → 20.9 over the same band, so the art has the gradient the shipped pyramid threw away, and `clutter_size` puts the median tuft at 0.92 yd tall, 1.03 yd deep, a full yard for the boundary plane to sweep; the escape is bilinear: full alpha needs a 2×2 all-opaque texel neighbourhood, and those run out between mip 2 and 3 (179/56/10/0/0/0 on the dominant Elwynn cell), so the reference's λ ≈ 2.9–3.3 sits past the cliff and the tuft erodes continuously — 2.2 % of a tuft's pixels leave in the last half-yard with the bias, 11.6 % without, 24.2 % one mip sharper; wgpu has no sampler LOD bias, so the clutter lane re-samples base colour at `view.mip_bias + 0.25`, keeping the sampler every other model batch shares at the reference's 0; ruled out on the way: our BLP decoder (all nine uploaded levels byte-identical against the archive's 52 793 BLP2s), mip regeneration, the `has_mipmaps` byte's value 2 (a plain boolean, an authoring marker), anisotropy and geometry; what it does not fix is resolution-dependent, and so is the reference's own look — λ with the bias is 3.22 at the capture's 648p but 2.48 at 1080p and 1.48 at 2160p, so at a modern resolution the reference itself pops leaf by leaf, and scaling the bias with the viewport is one reversible line left for a look call (2021)

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

330 lines
13 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 shipped `assets/ui/ActionBar.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());
}