seven more windows are the reference's own files

- seven more windows run off the player's own patch chain: the durability shield (`DurabilityFrame.xml`, with a Rust-side repair for the reference's own stale side-glyph inset the director caught on screen), `Localization.xml` + `GameTime.xml` (so `LocalizeFrames` finally runs from `VARIABLES_LOADED`, and the clock's 24-hour flag comes from where 1.12 sets it), the chat-link tooltip (`ItemRef.xml` — draggable now, and its shift-click arm fills the friend/ignore/who boxes as the reference's does), the mirror timers (`MirrorTimer.xml`, which exposed that our `STATICPOPUP_NUMDIALOGS` was 2 against the reference's 4, so a third breath/fatigue/feign bar could never be claimed), the zone splash with the fading kit, the item-text reader and the combo-point dots — each under the reference's published frame names, which is what an addon reaches for
- `AddMessage` swallows a text it cannot use, silently, as the reference does: `0x795590` is a `lua_isstring` gate whose failure edge jumps straight to the epilogue — no line, no error, no record — and stock FrameXML takes that path: a full keyring calls `AddMessage(NO_EMPTY_KEYRING_SLOTS, …)` and no such GlobalString exists in 1.12, so it is a dead click on a real client where benilla put a script-error dialog on it; byte-verified by a four-worker cross-check in wow-re, and NOT generalised — a bad r/g/b/a still adds the line opaque white, only a bad text shows nothing

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-02 16:58:13 +07:00
co-authored by Claude Fable 5.1
parent 6da7ed3fca
commit c3dd67e3f1
35 changed files with 735 additions and 2106 deletions
+9 -13
View File
@@ -1792,19 +1792,15 @@ fn a_key_dropped_on_the_button_files_itself() {
"the reference's own dangling string name prints nothing — un-quirking it is a decision, \
not a default"
);
// The nil ALSO raises, out of `MessageFrame:AddMessage`. That is deliberately not asserted as
// correct: our binding takes its text as an mlua `String`, which raises on nil by default —
// not from a carved gate (`messageframe/plain.rs` reads `0x795590`'s rgb/alpha parse off the
// bytes and says nothing about the text argument). Whether 1.12 raises here, no-ops, or prints
// a blank line is out with wow-re. So this pins the keyring's own behaviour — the refusal, and
// the silence — and lets exactly that one error through by name, which still fails the test on
// any OTHER error the click produces.
let errs = s.errors();
assert!(
errs.iter()
.all(|e| e.contains("AddMessage") && e.contains("PutKeyInKeyRing")),
"the only error may be the reference's own nil message: {errs:?}"
);
// And it does not RAISE either, which is the half this test could not assert until the bytes
// came back. `AddMessage`'s text is fetched through `lua_isstring 0x6f3510` whose failure edge
// is a silent jump to the function's own epilogue — no line, no error (wow-5875-re
// `addmessage-text-gate-silent-skip.md`, 4-worker cross-check). Ours took an mlua `String` and
// raised, which put a script-error dialog on an ordinary player gesture; the fix is in
// `messageframe/mod.rs::message_text` and its own test carries the full law.
//
// So a full keyring is a DEAD CLICK on a real 1.12 client, and now here.
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
@@ -1,5 +1,5 @@
//! The combo-point dots (decisions 0869, 0875) — our `ComboFrame` implementation driven
//! through the real loader: the show/hide edges, the per-point highlight/shine fade chain, the
//! The combo-point dots (decisions 0869, 0875) — the reference's own `ComboFrame.xml`/`.lua`,
//! executed off the player's chain since 1751's tenth window, driven through the real loader: the show/hide edges, the per-point highlight/shine fade chain, the
//! "only newly-earned points flare" rule that `COMBO_FRAME_LAST_NUM_POINTS` exists to enforce, and
//! the two gates that live in `GetComboPoints` rather than in this Lua — rogue-or-druid only, and
//! the points must be banked on the CURRENT target.
@@ -10,22 +10,7 @@ use benilla_ui::script::{PlayerReqState, UiScript, UnitState};
const MOB_A: u64 = 0xF130_0000_0000_0001;
const MOB_B: u64 = 0xF130_0000_0000_0002;
/// Load one shipped `assets/ui/<file>` (the panel tests' loader), panicking on any loader error.
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
use super::test_ui::load_ui as load_xml;
/// The manifest order this file actually ships in: `UiPanels.xml` for `UIFrameFade`,
/// `UnitFrames.xml` for the `TargetFrame` the dots anchor to.
@@ -40,7 +25,7 @@ fn load_combo_frame() -> UiScript {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "UnitFrames.xml");
load_xml(&s, "ComboFrame.xml");
load_xml(&s, "Interface\\FrameXML\\ComboFrame.xml");
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s
@@ -83,6 +68,7 @@ fn highlight_alpha(s: &mut UiScript, i: u32) -> f64 {
/// completion first, since the highlight arrives via `UIFrameFade`, not a straight `SetAlpha`.
#[test]
fn combo_frame_follows_the_point_count() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = load_combo_frame();
play_as(&mut s, 4, MOB_A); // rogue, mob A selected
@@ -135,6 +121,7 @@ fn combo_frame_follows_the_point_count() {
/// combo event, so re-targeting mid-window fires exactly this repaint.
#[test]
fn a_repaint_at_the_same_count_does_not_re_flare() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = load_combo_frame();
play_as(&mut s, 4, MOB_A);
s.set_combo_points(2, MOB_A);
@@ -164,6 +151,7 @@ fn a_repaint_at_the_same_count_does_not_re_flare() {
/// the rogue ladder's first rung, so only the class differs.
#[test]
fn a_warriors_overpower_point_lights_no_dot() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = load_combo_frame();
play_as(&mut s, 1, MOB_A); // warrior
s.set_combo_points(1, MOB_A);
@@ -184,6 +172,7 @@ fn a_warriors_overpower_point_lights_no_dot() {
/// target entirely (guid 0) empties them too.
#[test]
fn re_targeting_empties_the_dots_without_the_count_moving() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = load_combo_frame();
play_as(&mut s, 4, MOB_A);
s.set_combo_points(3, MOB_A);
@@ -30,7 +30,7 @@ const JERKY_LINK: &str = "|cffffffff|Hitem:117|h[Tough Jerky]|h|r";
const ROOM_UI: &[&str] = &[
"UIDropDownMenu.xml",
"UnitPopup.xml",
"ItemRef.xml",
"Interface\\FrameXML\\ItemRef.xml",
"MerchantFrame.xml",
"StackSplit.xml",
"DressUpFrame.xml",
@@ -1,30 +1,16 @@
//! The durability alert ("armor guy", `assets/ui/DurabilityFrame.xml`) against the shipped XML:
//! The durability alert ("armor guy") against the reference's own `DurabilityFrame.xml`/`.lua`,
//! executed off the player's chain since 1751's fourth window:
//! the engine's `GetInventoryAlertStatus` statuses (recomputed on every inventory push, a change
//! firing `UPDATE_INVENTORY_ALERTS`) drive the ref's own SetAlerts law — body pieces show
//! together when any body region alerts, showSeparate pieces (Weapon/Shield/Ranged) each show
//! only themselves, the shield glyph swaps for the off-weapon glyph when the off hand holds a
//! WEAPON, and the whole frame hides at zero alerts.
use super::test_ui::load_ui as load_xml;
use benilla_ui::script::{
InvSlotView, InventorySlots, ItemTemplateView, QuadContent, ScriptValue, UiScript,
};
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -35,10 +21,17 @@ fn harness() -> UiScript {
"UIParent.xml",
"GameTooltip.xml",
"MinimapCluster.xml",
"DurabilityFrame.xml",
// The reference's own file. This module carried a private disk-only `load_xml` until the
// swap, which structurally could not name a chain entry — [`super::test_ui::load_ui`] is
// the one reader that speaks both stores, and it is why that helper exists.
"Interface\\FrameXML\\DurabilityFrame.xml",
] {
load_xml(&s, f);
}
// The app installs this at the end of every real load; a test VM has to say so itself. It is
// benilla's stated repair of a reference bug — the seat going stale when a side glyph appears
// while the frame is already shown — and its whole reasoning is at the constant.
super::manifest::install_durability_reseat(&s).unwrap();
s
}
@@ -88,6 +81,7 @@ const YELLOW: [f32; 4] = [1.0, 0.82, 0.18, 1.0];
/// white), then repaired (hidden again).
#[test]
fn armor_guy_shows_red_broken_yellow_damaged_and_hides_clean() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
// The ref's initial settle: the frame is authored shown; PLAYER_ENTERING_WORLD's SetAlerts
// hides it while everything is clean.
@@ -166,6 +160,7 @@ fn armor_guy_shows_red_broken_yellow_damaged_and_hides_clean() {
/// 1.12 FrameXML never reads it.
#[test]
fn flag_bits_and_the_low_ammo_region() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Str("".into())]);
@@ -216,6 +211,7 @@ fn flag_bits_and_the_low_ammo_region() {
/// broken off-hand WEAPON (template class 2) swaps it for the off-weapon glyph.
#[test]
fn off_hand_glyph_follows_what_the_hand_holds() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.set_item_template(
2362,
@@ -269,6 +265,7 @@ fn off_hand_glyph_follows_what_the_hand_holds() {
/// the pass on every alert transition.
#[test]
fn manage_pass_seats_the_frame_inside_the_cluster_edge() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Str("".into())]);
@@ -313,6 +310,7 @@ fn manage_pass_seats_the_frame_inside_the_cluster_edge() {
/// (director-caught). The OnEvent re-manage keeps it fresh on every alert recompute.
#[test]
fn a_late_side_glyph_refreshes_the_seat_while_the_frame_stays_shown() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.set_item_template(
25,
@@ -383,6 +381,7 @@ fn a_late_side_glyph_refreshes_the_seat_while_the_frame_stays_shown() {
/// colliding there (director-caught, over "The Captain's Chest").
#[test]
fn the_quest_tracker_stacks_below_the_durability_guy() {
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 [
@@ -393,11 +392,12 @@ fn the_quest_tracker_stacks_below_the_durability_guy() {
"GameTooltip.xml",
"MinimapCluster.xml",
"ScrollTemplates.xml",
"DurabilityFrame.xml",
"Interface\\FrameXML\\DurabilityFrame.xml",
"QuestLogFrame.xml",
] {
load_xml(&s, f);
}
super::manifest::install_durability_reseat(&s).unwrap();
// A right-side glyph shows the 65-tall durability frame under the minimap.
let mut inv: InventorySlots = Default::default();
@@ -364,16 +364,6 @@ const KNOWN: &[Known] = &[
// question this flag exists to ask (decision 1757). A frame whose seat crosses the boundary
// between UIParent's tree and the top level is a defect, because `SetFullScreenFrame` hides
// `UIParent` and everything below it; a frame seated one rung along inside that tree is not.
Known {
frame: "FriendsDropDown",
flag: Flag::Parent,
why: "the reference nests this menu HOST in FriendsFrame; ours sits on UIParent in \
ItemRef.xml, beside the SetItemRef branch that is its only caller — and it has to, \
because FriendsFrame.xml loads 262 lines further down the manifest and a forward \
parent reference resolves to nothing. Both seats are inside UIParent's tree, and a \
host frame is never shown: ToggleDropDownMenu seats the visible DropDownList1, \
which is top-level on both sides.",
},
Known {
frame: "SendMailBodyEditBox",
flag: Flag::Parent,
@@ -658,8 +658,7 @@ fn right_clicking_a_who_row_opens_the_friend_menu() {
"the menu opens"
);
assert_eq!(
s.eval::<String>("return BenillaFriendsDropDown.name")
.unwrap(),
s.eval::<String>("return FriendsDropDown.name").unwrap(),
"Tigole",
"addressed by name, not by a unit token"
);
@@ -1108,8 +1108,7 @@ fn right_clicking_a_roster_row_offers_the_guild_rows() {
"the menu opens"
);
assert_eq!(
s.eval::<String>("return BenillaFriendsDropDown.name")
.unwrap(),
s.eval::<String>("return FriendsDropDown.name").unwrap(),
"Furor",
"addressed by name — a roster row has no unit token behind it"
);
@@ -1166,8 +1165,7 @@ fn the_guild_rows_stay_off_a_who_row_menu() {
// NOT our own name: the WHISPER row hides on yourself, and with INVITE gated the same way the
// menu would have nothing to show and never open — which would make the assertion below pass
// for the wrong reason.
s.run("BenillaFriendsFrame_ShowDropdown(\"Thrall\", 1)")
.unwrap();
s.run("FriendsFrame_ShowDropdown(\"Thrall\", 1)").unwrap();
assert!(s.eval::<bool>("return DropDownList1:IsVisible()").unwrap());
let present = r#"
for i = 1, UIDROPDOWNMENU_MAXBUTTONS do
@@ -66,9 +66,49 @@ fn bootstrap_positions(script: &UiScript) -> Vec<String> {
error!("ui_script: managed-positions bootstrap: {e}");
return vec![format!("managed-positions bootstrap: {e}")];
}
if let Err(e) = install_durability_reseat(script) {
error!("ui_script: durability re-seat hook: {e}");
return vec![format!("durability re-seat hook: {e}")];
}
Vec::new()
}
/// **A stated divergence from the reference, installed rather than edited in** (1751 window 4).
///
/// The reference seats `DurabilityFrame` 20 further in when one of its three side glyphs is up
/// (`UIParent.lua` l.1759-1768), and recomputes that only when something calls
/// `UIParent_ManageFramePositions`. Stock `DurabilityFrame.xml` calls it from `OnShow`/`OnHide`
/// only — so the case where the frame is **already shown** and a side glyph *then* appears leaves
/// the seat stale, and the shield glyph's ~17-unit overhang hangs off the screen edge until some
/// unrelated frame happens to trigger the next pass. That is the reference's own bug and the
/// director caught it on their screen; it is not ours to reproduce faithfully.
///
/// So the fix rides on the frame's `OnEvent` — which is exactly the recompute the glyphs change on
/// — and it is installed **here** rather than written into anything of Blizzard's. Two reasons for
/// that home rather than a FrameXML one: `assets/ui` does not grow (1779), and this is the wrong
/// side of the line for a `ContainerFrameAdapters`-class shim anyway — that clause is for a genuine
/// engine difference (1751 §2), and this is a deliberate repair of a reference defect. Rust is also
/// the durable home: our `UIParent.xml` is itself a transcription awaiting its own window, and a
/// hook parked there would be homeless again the day it migrates.
///
/// Idempotent by its own latch, so a `ReloadUI` cannot stack wrappers. The reference's handler
/// still runs first and unchanged: `this`, `event` and `arg1` are globals the engine has already
/// set for the dispatch, so calling it through the captured reference is the same call.
pub(super) fn install_durability_reseat(script: &UiScript) -> Result<(), String> {
script.run(DURABILITY_RESEAT).map_err(|e| e.to_string())
}
const DURABILITY_RESEAT: &str = r#"
if DurabilityFrame and not DurabilityFrame.benillaReseat then
DurabilityFrame.benillaReseat = 1
local refOnEvent = DurabilityFrame:GetScript("OnEvent")
DurabilityFrame:SetScript("OnEvent", function()
if refOnEvent then refOnEvent() end
UIParent_ManageFramePositions()
end)
end
"#;
/// Load benilla's own default UI — every file [`MANIFEST`] names — through the engine-free loader.
/// This is our content (MIT/Apache), committed and **compiled into the binary**
/// ([`super::content`], decision 1175); a dev build still prefers the copy on disk, so editing a
@@ -5,23 +5,7 @@
use benilla_ui::script::UiScript;
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error (the other tests'
/// loader, duplicated so this file is self-contained).
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
use super::test_ui::load_ui as load_xml;
fn enabled(s: &UiScript, button: &str) -> bool {
s.eval::<bool>(&format!("return {button}:IsEnabled() ~= 0"))
@@ -30,6 +14,7 @@ fn enabled(s: &UiScript, button: &str) -> bool {
#[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.
@@ -93,6 +78,7 @@ fn minimap_zoom_buttons_resync_when_switching_inside_and_outside() {
/// 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();
@@ -134,18 +120,24 @@ fn game_time_session(hour: u32, minute: u32) -> UiScript {
s.run("function GetMinimapZoneText() return '' end")
.unwrap();
s.run("function PlaySound() end").unwrap();
// Byte-exact 1.12 GlobalStrings (l.4251-4253) — the engine-only session loads no
// GlobalStrings off a chain.
s.run("TIME_TWELVEHOURAM = \"%d:%02d AM\"; TIME_TWELVEHOURPM = \"%d:%02d PM\"; TIME_TWENTYFOURHOURS = \"%d:%02d\"")
.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, "Fonts.xml");
// `TEXT()`, which the reference's own tooltip and label formatting passes every string through.
load_xml(&s, "BasicControls.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "MinimapCluster.xml");
load_xml(&s, "GameTime.xml");
load_xml(&s, "Interface\\FrameXML\\GameTime.xml");
s
}
@@ -161,6 +153,7 @@ fn tod_window(s: &UiScript) -> (f64, f64, f64, f64) {
/// 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.
@@ -194,6 +187,7 @@ fn game_time_frame_slides_the_sun_moon_window_on_the_game_clock() {
/// 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();
@@ -239,6 +233,7 @@ fn hovering_the_indicator_shows_and_live_updates_the_game_time_tooltip() {
/// 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")
@@ -282,6 +277,7 @@ fn a_click_on_the_minimap_parks_a_centre_relative_ping_request() {
/// under its own feet.
#[test]
fn get_ping_position_is_nil_until_there_is_a_ping() {
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")
@@ -1,7 +1,7 @@
//! The breath / fatigue bars against the transcribed reference behavior (decision 0874): the
//! extracted 1.12 `MirrorTimer.lua` is the spec — a bar per running timer, the timer's own colour
//! and caption, the value integrating `scale * elapsed` between packets, and the frame released
//! back to the pool on STOP.
//! The breath / fatigue bars against the reference's own `MirrorTimer.xml`/`.lua`, executed off
//! the player's chain since 1751's seventh window (decision 0874 is the arc): a bar per running
//! timer, the timer's own colour and caption, the value integrating `scale * elapsed` between
//! packets, and the frame released back to the pool on STOP.
//!
//! Two halves, and the second is the load-bearing one (the cast bar's lesson, `cast_tests`): the
//! state tests read the Lua back, which is blind to what actually *paints*. The
@@ -10,28 +10,22 @@
use benilla_ui::script::{ExtractedQuad, QuadContent, ScriptValue, UiScript};
/// Load one shipped `assets/ui/<file>`, panicking on any loader error.
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
use super::test_ui::load_ui as load_xml;
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MirrorTimer.xml");
// The bars' START driver: `UIParent_OnEvent`'s MIRROR_TIMER_START arm is what calls
// `MirrorTimer_Show`, which is where the reference keeps it (UIParent.lua l.97 + l.374-377)
// and where window 7 moved ours back to. Without this file the event reaches nothing.
load_xml(&s, "UIParent.xml");
// `STATICPOPUP_NUMDIALOGS`, which the reference's own `MirrorTimer_Show` bounds its free-bar
// search by (MirrorTimer.lua l.32 — a copy-paste from StaticPopup.lua, and its own bug). A
// session without it searches `1, nil` and finds no free bar at all, so this is not scenery.
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\MirrorTimer.xml");
s
}
@@ -105,6 +99,7 @@ fn tex_quad<'a>(quads: &'a [ExtractedQuad], leaf: &str) -> Option<&'a ExtractedQ
/// Every bar starts hidden — no timer, no chrome on screen.
#[test]
fn the_three_timers_load_hidden() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
for i in 1..=3 {
assert!(!shown(&s, &format!("MirrorTimer{i}")), "MirrorTimer{i}");
@@ -119,6 +114,7 @@ fn the_three_timers_load_hidden() {
/// seated at the value the server named.
#[test]
fn breath_takes_the_first_bar_in_the_reference_blue() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
start(&mut s, "BREATH", 45_000, 60_000, -1, "Breath");
@@ -140,6 +136,7 @@ fn breath_takes_the_first_bar_in_the_reference_blue() {
/// client's key for it is `EXHAUSTION`, not the server's `FATIGUE`.
#[test]
fn fatigue_is_the_reference_yellow_under_its_own_caption() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
start(&mut s, "EXHAUSTION", 60_000, 60_000, -1, "Fatigue");
@@ -156,6 +153,7 @@ fn fatigue_is_the_reference_yellow_under_its_own_caption() {
/// reference's draining rate that is one bar-second per real second.
#[test]
fn the_bar_drains_at_the_servers_signed_rate() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
start(&mut s, "BREATH", 45_000, 60_000, -1, "Breath");
@@ -184,6 +182,7 @@ fn the_bar_drains_at_the_servers_signed_rate() {
/// A paused timer holds its value — the frozen state the server sends when it stops the clock.
#[test]
fn a_paused_timer_holds_its_value() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.fire_event(
"MIRROR_TIMER_START",
@@ -205,8 +204,18 @@ fn a_paused_timer_holds_its_value() {
"frozen: no integration at all"
);
// MIRROR_TIMER_PAUSE(name, 0) releases it (our arg2 reading — the reference's own branch is
// bugged and the packet is never sent by vmangos; see MirrorTimer.xml departure 3).
// **And `MIRROR_TIMER_PAUSE` cannot release it, because the reference's own branch is
// bugged.** `MirrorTimerFrame_OnEvent` reads `arg1` as the timer NAME (`arg1 ~= this.timer`)
// and then, two lines later, as a number (`arg1 > 0`) — comparing a string to a number, which
// raises in Lua 5.0. So the handler dies before it can clear `paused` and the bar stays
// frozen.
//
// Ours read the flag from `arg2` until 1751's seventh window, which is what both branches
// plainly intend; the swap made the reference's body the live one and this pins what it
// actually does. It is unreachable in play: vmangos refuses to send that packet and
// substitutes a full START, saying so in `Player::SendMirrorTimers` — which is presumably why
// the bug survived 1.12 at all. Repairing it is a decision someone can make in the adapters;
// it is not the default, and it is not silent.
s.fire_event(
"MIRROR_TIMER_PAUSE",
vec![ScriptValue::Str("BREATH".into()), ScriptValue::Int(0)],
@@ -214,9 +223,15 @@ fn a_paused_timer_holds_its_value() {
for _ in 0..20 {
frame(&mut s, 0.05);
}
assert_eq!(
bar_value(&s, "MirrorTimer1"),
45.0,
"the reference's own bugged branch cannot unfreeze a bar"
);
let errs = s.errors();
assert!(
bar_value(&s, "MirrorTimer1") < 44.5,
"unfrozen: draining again"
errs.iter().any(|e| e.contains("compare")),
"…and says so, loudly, rather than failing quietly: {errs:?}"
);
}
@@ -225,6 +240,7 @@ fn a_paused_timer_holds_its_value() {
/// consuming a third.
#[test]
fn two_timers_stack_and_restate_in_place() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
start(&mut s, "EXHAUSTION", 60_000, 60_000, -1, "Fatigue");
start(&mut s, "BREATH", 45_000, 60_000, -1, "Breath");
@@ -244,6 +260,7 @@ fn two_timers_stack_and_restate_in_place() {
/// STOP hides only the named timer and hands its frame back to the pool.
#[test]
fn stop_hides_that_timer_and_frees_its_frame() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
start(&mut s, "EXHAUSTION", 60_000, 60_000, -1, "Fatigue");
start(&mut s, "BREATH", 45_000, 60_000, -1, "Breath");
@@ -269,6 +286,7 @@ fn stop_hides_that_timer_and_frees_its_frame() {
/// (the reference's `PLAYER_ENTERING_WORLD` arm).
#[test]
fn entering_the_world_clears_every_bar() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
start(&mut s, "EXHAUSTION", 60_000, 60_000, -1, "Fatigue");
start(&mut s, "BREATH", 45_000, 60_000, -1, "Breath");
@@ -283,6 +301,7 @@ fn entering_the_world_clears_every_bar() {
/// out from under a different timer.
#[test]
fn a_stop_for_an_idle_timer_touches_nothing() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
start(&mut s, "BREATH", 45_000, 60_000, -1, "Breath");
s.fire_event(
@@ -297,6 +316,7 @@ fn a_stop_for_an_idle_timer_touches_nothing() {
/// state tests above all pass on a bar that draws nothing.
#[test]
fn a_running_timer_paints_its_chrome_and_fill() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
start(&mut s, "BREATH", 45_000, 60_000, -1, "Breath");
let quads = frame(&mut s, 0.016);
@@ -326,6 +346,7 @@ fn a_running_timer_paints_its_chrome_and_fill() {
/// …and a stopped timer takes all of it off screen again.
#[test]
fn a_stopped_timer_paints_nothing() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
start(&mut s, "BREATH", 45_000, 60_000, -1, "Breath");
frame(&mut s, 0.016);
@@ -346,6 +367,7 @@ fn a_stopped_timer_paints_nothing() {
/// fill in the painter's order — i.e. strictly greater `z`.
#[test]
fn the_border_and_caption_draw_over_the_fill() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
start(&mut s, "BREATH", 45_000, 60_000, -1, "Breath");
let quads = frame(&mut s, 0.016);
@@ -31,7 +31,7 @@ fn harness() -> UiScript {
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"ItemRef.xml",
"Interface\\FrameXML\\ItemRef.xml",
"MerchantFrame.xml",
"Cooldown.xml",
"CharacterFrame.xml",
@@ -56,7 +56,11 @@ fn harness_with_bags() -> UiScript {
for f in BAG_UI {
load_xml(&s, f);
}
for f in ["ItemRef.xml", "MerchantFrame.xml", "CharacterFrame.xml"] {
for f in [
"Interface\\FrameXML\\ItemRef.xml",
"MerchantFrame.xml",
"CharacterFrame.xml",
] {
load_xml(&s, f);
}
s.set_money(0);
@@ -1,39 +1,35 @@
//! The unit right-click popups reached the two ways a solo player invites someone (director
//! report, 2026-07-17): right-clicking a **target** unit frame, and right-clicking a **chat
//! name**. Both drive the shared UnitPopup engine (`UnitPopup.xml`); the chat path adds the
//! `SetItemRef` player branch + its minimal `FriendsFrame` stand-in (`ItemRef.xml`). Proven
//! serverless through the real hit/route paths.
//! `SetItemRef` player branch — the reference's own since 1751 window 6 — and the FRIEND dropdown
//! it opens, which lives in `FriendsFrame.xml` where 1.12 keeps it. Proven serverless through the
//! real hit/route paths.
use benilla_ui::script::{FollowRequest, PartyRequest, UiScript, UnitState};
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
use super::test_ui::load_ui as load_xml;
/// The production prefix the popups need (ui_script/mod.rs order): the dropdown kit, the unit
/// popups, the chat-link router (`ItemRef.xml`, after UnitPopup — its `FriendsDropDown` inherits
/// `UIDropDownMenuTemplate` and opens a UnitPopup FRIEND menu), then the unit frames.
/// The production prefix the popups need, in `benilla.toc`'s own order: the dropdown kit, the
/// unit popups, the reference's chat-link router, the unit frames — and `FriendsFrame.xml`, which
/// is where `FriendsFrame_ShowDropdown` and the `FriendsDropDown` host live in 1.12 and where they
/// live here since window 6 (our ItemRef.xml used to carry a private second copy of both).
fn load_popup_frames(s: &UiScript) {
for file in [
"Fonts.xml",
"UIParent.xml",
// `SmallMoneyFrame_OnLoad`, which UiPanels' own StaticPopup money rows call at load.
"MoneyFrame.xml",
// `StaticPopupDialogs` and the `PanelTemplates_*` family, both of which FriendsFrame.xml
// reaches at LOAD (its tab row and its confirm dialogs).
"UiPanels.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"UnitPopup.xml",
"ItemRef.xml",
"Interface\\FrameXML\\ItemRef.xml",
"UnitFrames.xml",
"ScrollTemplates.xml",
"UIPanelTemplates.xml",
"FriendsFrame.xml",
] {
load_xml(s, file);
}
@@ -69,6 +65,7 @@ fn bake_strings(s: &UiScript) {
/// hit — the player branch of `SetItemRef` used to be a no-op stub.
#[test]
fn chat_name_right_click_opens_the_invite_menu() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
bake_strings(&s);
@@ -137,6 +134,7 @@ fn chat_name_right_click_opens_the_invite_menu() {
/// off for players — only NPC/self targets, whose every row needs a party, open nothing.
#[test]
fn solo_target_right_click_invites_a_player() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
bake_strings(&s);
@@ -236,6 +234,7 @@ fn solo_target_right_click_invites_a_player() {
/// so a break between `this.value == "TRADE"` and the queued initiate token shows up here.
#[test]
fn solo_target_trade_click_queues_an_initiate() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
bake_strings(&s);
@@ -303,6 +302,7 @@ fn solo_target_trade_click_queues_an_initiate() {
/// exactly the kind of break a row-label assertion cannot see.
#[test]
fn solo_target_follow_click_queues_an_exact_by_name_follow() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
bake_strings(&s);
@@ -369,6 +369,7 @@ fn solo_target_follow_click_queues_an_exact_by_name_follow() {
/// popup's dispatch, and `inspect_tests.rs` owns what the window then does.
#[test]
fn solo_target_inspect_click_reaches_inspect_unit() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
bake_strings(&s);
@@ -536,6 +537,7 @@ fn right_click_the_pet_frame(s: &mut UiScript) {
/// offered a row the reference never shows.
#[test]
fn the_pet_menu_forks_between_abandon_and_dismiss() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
bake_pet_strings(&s);
@@ -605,6 +607,7 @@ fn the_pet_menu_forks_between_abandon_and_dismiss() {
/// giving up a tamed pet is a server-side delete.
#[test]
fn dismiss_sends_immediately_and_abandon_waits_for_the_confirm() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
bake_pet_strings(&s);
@@ -661,6 +664,7 @@ fn dismiss_sends_immediately_and_abandon_waits_for_the_confirm() {
/// opens while the name dialog is still up, so the second one lands in instance 2.
#[test]
fn renaming_a_pet_reads_the_name_back_before_sending_it() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
bake_pet_strings(&s);
@@ -712,6 +716,7 @@ fn renaming_a_pet_reads_the_name_back_before_sending_it() {
/// was blocked on, and the only row here that opens a window rather than a wire verb.
#[test]
fn the_pet_details_row_opens_the_pet_paper_doll() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
bake_pet_strings(&s);
@@ -742,6 +747,7 @@ fn the_pet_details_row_opens_the_pet_paper_doll() {
/// unit reconnects mid-open — and closing the menu re-parks the driver.
#[test]
fn an_idle_unit_popup_driver_parks_itself_off_the_tick() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
bake_strings(&s);
@@ -1,4 +1,5 @@
//! The shipped `ZoneText.xml` driven engine-only (decision 0287): the zone splash shows on the
//! The reference's own `ZoneText.xml` — executed off the player's chain since 1751's eighth
//! window — driven engine-only (decision 0287): the zone splash shows on the
//! right events with the right strings/colors, fades on the reference 0.5/1.0/2.0 timeline, and
//! honors the subtle law — a plain `ZONE_CHANGED` re-caches the zone name *silently*, so a later
//! `ZONE_CHANGED_NEW_AREA` that lands on the already-cached name never re-splashes.
@@ -8,23 +9,7 @@
use benilla_ui::script::UiScript;
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error (the sibling
/// tests' loader, duplicated so this file is self-contained).
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
use super::test_ui::load_ui as load_xml;
fn visible(s: &UiScript, frame: &str) -> bool {
s.eval::<bool>(&format!("return {frame}:IsVisible() and true or false"))
@@ -50,16 +35,26 @@ fn set_area(s: &UiScript, zone: &str, sub: &str, pvp: &str, faction: &str) {
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The player's own strings: the splash formats CONTESTED_TERRITORY,
// FACTION_CONTROLLED_TERRITORY, FREE_FOR_ALL_TERRITORY and the AUTOFOLLOW pair straight out of
// GlobalStrings. Our deleted copy carried `X = X or "…"` fallbacks for a harness with no
// chain; the reference's file has none, and it should not.
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Fonts.xml");
// `TEXT()`, which AutoFollowStatus_OnEvent puts its message through.
load_xml(&s, "BasicControls.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "ZoneText.xml");
// The fading kit, its own manifest entry since window 8 — as it is in the reference's TOC.
load_xml(&s, "Interface\\FrameXML\\FadingFrame.xml");
load_xml(&s, "Interface\\FrameXML\\ZoneText.xml");
s
}
#[test]
fn new_area_splashes_zone_pvp_and_subzone_then_fades_out() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
// OnLoad hides both fading frames.
@@ -89,6 +84,7 @@ fn new_area_splashes_zone_pvp_and_subzone_then_fades_out() {
#[test]
fn subzone_hop_shows_only_the_small_line() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
set_area(&s, "Elwynn Forest", "", "friendly", "Alliance");
s.fire_event("ZONE_CHANGED_NEW_AREA", vec![]);
@@ -108,6 +104,7 @@ fn subzone_hop_shows_only_the_small_line() {
#[test]
fn plain_zone_changed_recaches_silently_so_new_area_wont_resplash() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
set_area(&s, "Elwynn Forest", "", "friendly", "Alliance");
s.fire_event("ZONE_CHANGED_NEW_AREA", vec![]);
@@ -143,6 +140,7 @@ fn plain_zone_changed_recaches_silently_so_new_area_wont_resplash() {
/// SubZoneTextFrame's handler calls SetZoneText(1), which SETS the (hidden) PVP string.
#[test]
fn abbey_grounds_subzone_hop_shows_no_territory_line() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
set_area(
&s,
@@ -178,6 +176,7 @@ fn abbey_grounds_subzone_hop_shows_no_territory_line() {
/// truth, produced by the engine feed rather than by handler-order luck.
#[test]
fn abbey_interior_shows_the_room_in_the_small_line_alone() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
set_area(
&s,
@@ -210,6 +209,7 @@ fn abbey_interior_shows_the_room_in_the_small_line_alone() {
/// shows under it, exactly as on a NEW_AREA splash.
#[test]
fn inn_entry_splashes_the_inn_name_with_territory_line() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
set_area(&s, "Elwynn Forest", "Goldshire", "friendly", "Alliance");
s.fire_event("ZONE_CHANGED_NEW_AREA", vec![]);
@@ -231,6 +231,7 @@ fn inn_entry_splashes_the_inn_name_with_territory_line() {
/// exactly like the entry hop.
#[test]
fn indoor_exit_returns_the_subzone_line_alone() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
set_area(
&s,
@@ -265,6 +266,7 @@ fn indoor_exit_returns_the_subzone_line_alone() {
/// the big frame never shows, so no territory line.
#[test]
fn room_to_room_hop_splashes_the_room_name_alone() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
set_area(&s, "Elwynn Forest", "Main Hall", "friendly", "Alliance");
s.fire_event("ZONE_CHANGED_INDOORS", vec![]);
@@ -284,6 +286,7 @@ fn room_to_room_hop_splashes_the_room_name_alone() {
/// the "PvP Area" arena string under it, red per the quote's PVPArenaTextString color.
#[test]
fn arena_pit_shows_the_ffa_line() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
set_area(&s, "Stranglethorn Vale", "", "contested", "");
s.fire_event("ZONE_CHANGED_NEW_AREA", vec![]);
@@ -304,6 +307,7 @@ fn arena_pit_shows_the_ffa_line() {
/// anchors to PVPInfoTextString's BOTTOM — the three-line stack the director's screenshot shows.
#[test]
fn subzone_seat_hangs_under_the_territory_line_on_new_area() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
set_area(
&s,
@@ -336,6 +340,7 @@ fn subzone_seat_hangs_under_the_territory_line_on_new_area() {
/// name at start rather than re-reading it — by the time a follow ends, the followee is often gone.
#[test]
fn the_autofollow_status_line_names_the_followee_and_fades_on_end() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
assert!(
!visible(&s, "AutoFollowStatus"),
@@ -382,6 +387,7 @@ fn the_autofollow_status_line_names_the_followee_and_fades_on_end() {
/// than inheriting the dying one's alpha.
#[test]
fn a_begin_during_the_end_fade_resets_the_line() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.fire_event(
"AUTOFOLLOW_BEGIN",