The host's state leaves `_G`: the eleven `__benilla_*` globals, which any addon could read and overwrite, move into the host's model (and, for the error handler, the Lua registry, as the reference keeps it), and the getters over them are Rust bindings answering the reference's shapes. - `GetTime` returns one number (`0x515ea0`), and `GetGameTime` returns hour then minute (`0x515ee0`). The tick advances the model clock, and every cooldown and buff reader reads it. - `GetZoneText`, `GetRealZoneText`, `GetSubZoneText` and `GetMinimapZoneText` always answer a string, `""` when unset (`0x48a0a0`-`0x48a100`). `GetZonePVPInfo` answers three values (`0x48d540`). - `seterrorhandler`/`geterrorhandler` work over a registry slot that starts empty, as the reference's does (`0x7039e0`, `0x702950`, `0x702900`). A non-function raises `Usage: seterrorhandler(errfunc)`. The engine dispatches caught errors through the slot, not through the global (`0x703b40`). The stock `BasicControls.xml` installs `_ERRORMESSAGE` before any addon, so play is unchanged. - The engine's surface allowlist now holds only `_G`. `scripts/api-coverage.sh` no longer excuses `__` names. Verified: - New tests show each of the eleven names absent from `_G` and that writing it changes nothing. On main, `__benilla_now = 0` rewound `GetTime` and every buff and cooldown. - The getter shapes and the error-handler slot are pinned. The surface gate fails when a name is re-added. - `scripts/gates.sh` and `scripts/smoke.sh` are green. - A live run read the clock, zone texts, PvP info and game time, then showed that writing the old names changes nothing. Built on macOS; no platform seam touched. Fixes #576 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
398 lines
14 KiB
Rust
398 lines
14 KiB
Rust
//! Stock `ZoneText.xml`, driven by hand as `crate::area::feed_zone_events` drives it: zone caches
|
|
//! pushed, the event fired, the clock ticked. A plain `ZONE_CHANGED` re-caches the zone name
|
|
//! silently (`ZoneText.xml:92`), so a later `ZONE_CHANGED_NEW_AREA` on that name never splashes.
|
|
|
|
use benilla_ui::script::{UiScript, ZoneTexts};
|
|
|
|
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"))
|
|
.unwrap()
|
|
}
|
|
|
|
fn text_of(s: &UiScript, fontstring: &str) -> String {
|
|
s.eval::<String>(&format!("return {fontstring}:GetText() or ''"))
|
|
.unwrap()
|
|
}
|
|
|
|
/// The zone caches the app pushes on an area change: the minimap line is the subzone, else the
|
|
/// zone; an empty PvP word or faction is nil.
|
|
fn area(zone: &str, sub: &str, pvp: &str, faction: &str) -> ZoneTexts {
|
|
let some = |t: &str| (!t.is_empty()).then(|| t.to_string());
|
|
ZoneTexts {
|
|
zone: zone.into(),
|
|
real_zone: zone.into(),
|
|
subzone: sub.into(),
|
|
minimap: if sub.is_empty() { zone } else { sub }.into(),
|
|
pvp_type: some(pvp),
|
|
pvp_faction: some(faction),
|
|
is_arena: false,
|
|
}
|
|
}
|
|
|
|
fn set_area(s: &mut UiScript, zone: &str, sub: &str, pvp: &str, faction: &str) {
|
|
s.set_zone_texts(area(zone, sub, pvp, faction));
|
|
}
|
|
|
|
fn harness() -> UiScript {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_screen_size(1024.0, 768.0);
|
|
// The splash formats its territory and autofollow lines straight out of GlobalStrings.
|
|
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
|
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
|
// `TEXT()`, which AutoFollowStatus_OnEvent puts its message through.
|
|
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
|
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
|
|
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
|
|
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
|
|
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
|
|
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
|
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
|
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
|
|
// The fading kit, its own `FrameXML.toc` entry.
|
|
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.
|
|
assert!(!visible(&s, "ZoneTextFrame"));
|
|
assert!(!visible(&s, "SubZoneTextFrame"));
|
|
|
|
set_area(&mut s, "Westfall", "", "friendly", "Alliance");
|
|
s.fire_event("ZONE_CHANGED_NEW_AREA", vec![]);
|
|
assert!(visible(&s, "ZoneTextFrame"), "zone splash shows");
|
|
assert_eq!(text_of(&s, "ZoneTextString"), "Westfall");
|
|
assert_eq!(text_of(&s, "PVPInfoTextString"), "Alliance Territory");
|
|
assert_eq!(text_of(&s, "SubZoneTextString"), "");
|
|
|
|
// The stock timeline: in 0.5 s, hold 1.0 s, out 2.0 s (`ZoneText.xml:5`).
|
|
s.tick(0.25);
|
|
let alpha: f32 = s.eval("return ZoneTextFrame:GetAlpha()").unwrap();
|
|
assert!((alpha - 0.5).abs() < 0.05, "mid-fade-in alpha, got {alpha}");
|
|
s.tick(0.5);
|
|
let alpha: f32 = s.eval("return ZoneTextFrame:GetAlpha()").unwrap();
|
|
assert!((alpha - 1.0).abs() < 0.01, "hold alpha, got {alpha}");
|
|
s.tick(3.0);
|
|
assert!(!visible(&s, "ZoneTextFrame"), "fade-out ends in Hide()");
|
|
}
|
|
|
|
#[test]
|
|
fn subzone_hop_shows_only_the_small_line() {
|
|
let _data = benilla_formats::wow_data_or_skip!();
|
|
let mut s = harness();
|
|
set_area(&mut s, "Elwynn Forest", "", "friendly", "Alliance");
|
|
s.fire_event("ZONE_CHANGED_NEW_AREA", vec![]);
|
|
s.tick(4.0); // let the login splash finish
|
|
assert!(!visible(&s, "ZoneTextFrame"));
|
|
|
|
set_area(&mut s, "Elwynn Forest", "Goldshire", "friendly", "Alliance");
|
|
s.fire_event("ZONE_CHANGED", vec![]);
|
|
assert!(visible(&s, "SubZoneTextFrame"), "subzone splash shows");
|
|
assert!(
|
|
!visible(&s, "ZoneTextFrame"),
|
|
"the big zone name stays hidden on a subzone hop"
|
|
);
|
|
assert_eq!(text_of(&s, "SubZoneTextString"), "Goldshire");
|
|
}
|
|
|
|
#[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(&mut s, "Elwynn Forest", "", "friendly", "Alliance");
|
|
s.fire_event("ZONE_CHANGED_NEW_AREA", vec![]);
|
|
s.tick(4.0);
|
|
assert!(!visible(&s, "ZoneTextFrame"));
|
|
|
|
set_area(
|
|
&mut s,
|
|
"Westfall",
|
|
"The Jansen Stead",
|
|
"friendly",
|
|
"Alliance",
|
|
);
|
|
s.fire_event("ZONE_CHANGED", vec![]);
|
|
assert!(
|
|
!visible(&s, "ZoneTextFrame"),
|
|
"plain ZONE_CHANGED never splashes the zone name"
|
|
);
|
|
s.fire_event("ZONE_CHANGED_NEW_AREA", vec![]);
|
|
assert!(
|
|
!visible(&s, "ZoneTextFrame"),
|
|
"NEW_AREA on the already-cached zone text must not re-splash"
|
|
);
|
|
|
|
set_area(&mut s, "Duskwood", "", "contested", "");
|
|
s.fire_event("ZONE_CHANGED_NEW_AREA", vec![]);
|
|
assert!(visible(&s, "ZoneTextFrame"));
|
|
assert_eq!(text_of(&s, "PVPInfoTextString"), "Contested Territory");
|
|
}
|
|
|
|
/// Northshire Abbey is an outdoor subzone of Elwynn (`AreaTable` row 24, parent 12), so entering
|
|
/// it is a plain `ZONE_CHANGED`; the territory line is `ZoneTextFrame`'s child, which stays hidden.
|
|
#[test]
|
|
fn abbey_grounds_subzone_hop_shows_no_territory_line() {
|
|
let _data = benilla_formats::wow_data_or_skip!();
|
|
let mut s = harness();
|
|
set_area(
|
|
&mut s,
|
|
"Elwynn Forest",
|
|
"Northshire Valley",
|
|
"friendly",
|
|
"Alliance",
|
|
);
|
|
s.fire_event("ZONE_CHANGED_NEW_AREA", vec![]);
|
|
s.tick(4.0); // both splashes fully faded
|
|
|
|
set_area(
|
|
&mut s,
|
|
"Elwynn Forest",
|
|
"Northshire Abbey",
|
|
"friendly",
|
|
"Alliance",
|
|
);
|
|
s.fire_event("ZONE_CHANGED", vec![]);
|
|
assert!(visible(&s, "SubZoneTextFrame"), "the subzone line splashes");
|
|
assert_eq!(text_of(&s, "SubZoneTextString"), "Northshire Abbey");
|
|
assert!(
|
|
!visible(&s, "ZoneTextFrame"),
|
|
"ZoneTextFrame stays hidden on a plain subzone hop — no territory line can show"
|
|
);
|
|
}
|
|
|
|
/// Indoors, the zone-name override skips when the whole-WMO name equals the subzone (`0x67e670`),
|
|
/// so the zone stays and the group's name becomes the subzone: no big splash, no territory line.
|
|
#[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(
|
|
&mut s,
|
|
"Elwynn Forest",
|
|
"Northshire Abbey",
|
|
"friendly",
|
|
"Alliance",
|
|
);
|
|
s.fire_event("ZONE_CHANGED_NEW_AREA", vec![]);
|
|
s.tick(4.0);
|
|
|
|
set_area(&mut s, "Elwynn Forest", "Main Hall", "friendly", "Alliance");
|
|
s.fire_event("ZONE_CHANGED_INDOORS", vec![]);
|
|
assert!(
|
|
!visible(&s, "ZoneTextFrame"),
|
|
"the zone text did not change — no big splash, so no territory line"
|
|
);
|
|
assert!(
|
|
visible(&s, "SubZoneTextFrame"),
|
|
"the room name splashes small"
|
|
);
|
|
assert_eq!(text_of(&s, "SubZoneTextString"), "Main Hall");
|
|
}
|
|
|
|
/// An inn's WMO name differs from the street's, so the override makes it the zone. Handlers run in
|
|
/// registration order (`0x702140` appends), so `SubZoneTextFrame`'s `SetZoneText(1)` writes last.
|
|
#[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(&mut s, "Elwynn Forest", "Goldshire", "friendly", "Alliance");
|
|
s.fire_event("ZONE_CHANGED_NEW_AREA", vec![]);
|
|
s.tick(4.0);
|
|
|
|
set_area(&mut s, "Lion's Pride Inn", "", "friendly", "Alliance");
|
|
s.fire_event("ZONE_CHANGED_INDOORS", vec![]);
|
|
assert!(visible(&s, "ZoneTextFrame"), "the inn name splashes big");
|
|
assert_eq!(text_of(&s, "ZoneTextString"), "Lion's Pride Inn");
|
|
assert_eq!(
|
|
text_of(&s, "PVPInfoTextString"),
|
|
"Alliance Territory",
|
|
"SubZoneTextFrame fires last (FIFO) — its SetZoneText(1) leaves the territory line set"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn indoor_exit_returns_the_subzone_line_alone() {
|
|
let _data = benilla_formats::wow_data_or_skip!();
|
|
let mut s = harness();
|
|
set_area(
|
|
&mut s,
|
|
"Elwynn Forest",
|
|
"Northshire Abbey",
|
|
"friendly",
|
|
"Alliance",
|
|
);
|
|
s.fire_event("ZONE_CHANGED_NEW_AREA", vec![]);
|
|
set_area(&mut s, "Elwynn Forest", "Main Hall", "friendly", "Alliance");
|
|
s.fire_event("ZONE_CHANGED_INDOORS", vec![]);
|
|
s.tick(4.0); // everything faded
|
|
|
|
set_area(
|
|
&mut s,
|
|
"Elwynn Forest",
|
|
"Northshire Abbey",
|
|
"friendly",
|
|
"Alliance",
|
|
);
|
|
s.fire_event("ZONE_CHANGED", vec![]);
|
|
assert!(visible(&s, "SubZoneTextFrame"));
|
|
assert_eq!(text_of(&s, "SubZoneTextString"), "Northshire Abbey");
|
|
assert!(
|
|
!visible(&s, "ZoneTextFrame"),
|
|
"nothing changed the zone text — cache-only, no splash"
|
|
);
|
|
}
|
|
|
|
#[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(&mut s, "Elwynn Forest", "Main Hall", "friendly", "Alliance");
|
|
s.fire_event("ZONE_CHANGED_INDOORS", vec![]);
|
|
s.tick(4.0);
|
|
|
|
set_area(
|
|
&mut s,
|
|
"Elwynn Forest",
|
|
"Library Wing",
|
|
"friendly",
|
|
"Alliance",
|
|
);
|
|
s.fire_event("ZONE_CHANGED_INDOORS", vec![]);
|
|
assert!(
|
|
!visible(&s, "ZoneTextFrame"),
|
|
"no zone-text change, no big splash"
|
|
);
|
|
assert!(visible(&s, "SubZoneTextFrame"));
|
|
assert_eq!(text_of(&s, "SubZoneTextString"), "Library Wing");
|
|
}
|
|
|
|
/// An FFA pit: `GetZonePVPInfo`'s isArena, the leaf area's flag `0x80`.
|
|
#[test]
|
|
fn arena_pit_shows_the_ffa_line() {
|
|
let _data = benilla_formats::wow_data_or_skip!();
|
|
let mut s = harness();
|
|
set_area(&mut s, "Stranglethorn Vale", "", "contested", "");
|
|
s.fire_event("ZONE_CHANGED_NEW_AREA", vec![]);
|
|
s.tick(4.0);
|
|
|
|
s.set_zone_texts(ZoneTexts {
|
|
is_arena: true,
|
|
..area("Stranglethorn Vale", "Gurubashi Arena", "contested", "")
|
|
});
|
|
s.fire_event("ZONE_CHANGED", vec![]);
|
|
assert!(visible(&s, "SubZoneTextFrame"));
|
|
assert_eq!(text_of(&s, "SubZoneTextString"), "Gurubashi Arena");
|
|
assert_eq!(text_of(&s, "PVPArenaTextString"), "PvP Area");
|
|
}
|
|
|
|
/// With PvP info showing, `SubZoneTextString`'s `TOP` anchors to `PVPInfoTextString`'s `BOTTOM`.
|
|
#[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(
|
|
&mut s,
|
|
"Stormwind City",
|
|
"Valley of Heroes",
|
|
"friendly",
|
|
"Alliance",
|
|
);
|
|
s.fire_event("ZONE_CHANGED_NEW_AREA", vec![]);
|
|
s.resolve();
|
|
let ok: bool = s
|
|
.eval(
|
|
"return SubZoneTextString:GetTop() == PVPInfoTextString:GetBottom() \
|
|
and PVPInfoTextString:GetTop() == ZoneTextString:GetBottom()",
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
ok,
|
|
"zone → territory → subzone, each seated at the previous line's bottom"
|
|
);
|
|
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
|
|
}
|
|
|
|
/// `AutoFollowStatus`, all of `ZoneText.lua`: END reuses the name BEGIN latched, so the app fires
|
|
/// END with no argument.
|
|
#[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"),
|
|
"hidden until a follow begins"
|
|
);
|
|
|
|
s.fire_event(
|
|
"AUTOFOLLOW_BEGIN",
|
|
vec![benilla_ui::script::ScriptValue::Str("Probeone".into())],
|
|
);
|
|
s.resolve();
|
|
assert!(visible(&s, "AutoFollowStatus"));
|
|
assert_eq!(text_of(&s, "AutoFollowStatusText"), "Following Probeone.");
|
|
let alpha: f32 = s.eval("return AutoFollowStatus:GetAlpha()").unwrap();
|
|
assert!((alpha - 1.0).abs() < 0.01, "full alpha while following");
|
|
|
|
// Only END arms the fade; there is no hold timer (`ZoneText.lua:19`).
|
|
s.tick(10.0);
|
|
assert!(visible(&s, "AutoFollowStatus"), "no fade while following");
|
|
let alpha: f32 = s.eval("return AutoFollowStatus:GetAlpha()").unwrap();
|
|
assert!((alpha - 1.0).abs() < 0.01, "still opaque after 10 s");
|
|
|
|
s.fire_event("AUTOFOLLOW_END", vec![]);
|
|
s.resolve();
|
|
assert_eq!(
|
|
text_of(&s, "AutoFollowStatusText"),
|
|
"You stop following Probeone.",
|
|
"END has no argument of its own — the name comes from what BEGIN latched"
|
|
);
|
|
// `AUTOFOLLOW_STATUS_FADETIME`: a linear 4 s fade.
|
|
s.tick(2.0);
|
|
let alpha: f32 = s.eval("return AutoFollowStatus:GetAlpha()").unwrap();
|
|
assert!((alpha - 0.5).abs() < 0.05, "mid-fade alpha, got {alpha}");
|
|
s.tick(2.5);
|
|
assert!(!visible(&s, "AutoFollowStatus"), "gone after the 4 s fade");
|
|
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
|
|
}
|
|
|
|
/// A switch of subject fires BEGIN alone, and the stock BEGIN arm clears `fadeTime` and restores
|
|
/// alpha (`ZoneText.lua:11`).
|
|
#[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",
|
|
vec![benilla_ui::script::ScriptValue::Str("Probeone".into())],
|
|
);
|
|
s.fire_event("AUTOFOLLOW_END", vec![]);
|
|
s.tick(3.0); // most of the way through the fade
|
|
let faded: f32 = s.eval("return AutoFollowStatus:GetAlpha()").unwrap();
|
|
assert!(faded < 0.4, "mid-fade, got {faded}");
|
|
|
|
s.fire_event(
|
|
"AUTOFOLLOW_BEGIN",
|
|
vec![benilla_ui::script::ScriptValue::Str("Probetwo".into())],
|
|
);
|
|
s.resolve();
|
|
assert_eq!(text_of(&s, "AutoFollowStatusText"), "Following Probetwo.");
|
|
let alpha: f32 = s.eval("return AutoFollowStatus:GetAlpha()").unwrap();
|
|
assert!(
|
|
(alpha - 1.0).abs() < 0.01,
|
|
"BEGIN restores alpha, got {alpha}"
|
|
);
|
|
s.tick(10.0);
|
|
assert!(
|
|
visible(&s, "AutoFollowStatus"),
|
|
"and clears the pending fade rather than letting it finish"
|
|
);
|
|
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
|
|
}
|