the minimap and world map go stock, and the chat loses its white plate

- the minimap cluster and the battleground list are the reference's own `Minimap.xml` and `BattlefieldFrame.xml`, ours deleted, and the sixteen engine names the static probe found unbound are built, not stubbed: the battleground list and queue family (the as-group join refused client-side with no packet when the map's `MaxPlayers` is below the group), the bodyless `CMSG_BATTLEFIELD_STATUS` on world enter — benilla never sent it, so a queue joined before a reconnect was invisible — the 5.5556 yd `BATTLEFIELDS_CLOSED` leash, and the meeting-stone pair over the `0x295` five-way table with both asymmetries; the ping's lifetime is the stock Lua's now (held 5 s, faded 0.5 s), `GetPingPosition` answers two numbers always — our nil was a Lua error on every frame of the stock `Minimap_OnUpdate` — and the map-change drop the reference never had is gone (1974)
- the world map is the reference's own `WorldMapFrame.xml`, our transcription deleted; built for it: the arrow-frame family (an anonymous `Model` child at its 33.6 px footprint, drawn app-side as the minimap's own arrow sprite through one shared constant), the battleground position family over `MSG_BATTLEGROUND_PLAYER_POSITIONS` both ways (the reference's 5000 ms request throttle), and `GetPlayerMapPosition` resolving `raid1..raid40` where it knew only the party (B320); `SetupFullscreenScale` — the engine binding `0x48c270` the map's and the cinematic frame's OnShow call, which our map had stood in for with Lua — is bound to the carved law `min(0.75 · aspect, 1)` off the configured aspect, written through the frame's own `SetScale` (1.0 at 4:3 and wider, 0.9375 at 5:4); a hovered POI writes the map's own area label, not the tooltip; named gap: `WorldMapPing` draws nothing yet (1980, 1981)
- the tutorial frame is the reference's own `TutorialFrame.xml`, over a tutorial system benilla never had: two bit banks filled from `SMSG_TUTORIAL_FLAGS` — the fire-once bank `TriggerTutorial` reads and sets (never a send) and the acknowledged bank `FlagTutorial` writes and sends; there is no tutorial CVar in the binary (210 `CVar::Register` sites censused), so "disable tutorials" is every bit acknowledged; the sites this app can produce are wired (the world-enter Welcome cascade, the level-up arm, item pushes, the loot sparkle, the meeting-stone and battleground arms), the rest named; the loader learned `<TitleRegion>` — the stock frame's drag handle, a region that emits no quad; and the `TabardModel` widget kind arrives ahead of its window, five ints bounded by the `.rdata` table `{170, 17, 6, 17, 51}` and the constant 100 000 tabard cost (1976, 1977)
- the chat window drew as an opaque white plate: the chat-cache loader, gated on `InWorld` alone, restored into the boot VM 130 ms before the deferred entry load filled it, and its per-VM memo then blocked the restore that would have reached the real frames — the plate the stock Lua tints black stayed the XML's white; between the entry edge and the load there is now no VM in the world (the edge parks the boot VM, the load takes it back), so no in-world feed pushes into a VM with no interface; the perf pill reads `59 fps  7.0 ms  17.3 cpu` — the MAIN thread's milliseconds in full text and the process-wide sum dim, because the sum of every thread's CPU time had been read as a frame time (a raid read 17 on it at a solid 60 with the main thread at 7); and `WOW_PROBE_UI_ONE_TEX=1` is a pricing lever, never a look, for the draw-count ceiling a UI atlas would reach before one is built (1978, 1954)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-04 20:59:53 +07:00
co-authored by Claude Fable 5.1
parent edd6628329
commit 9bf64e1a6b
89 changed files with 6164 additions and 2107 deletions
@@ -0,0 +1,258 @@
//! The stock `BattlefieldFrame.xml` and `Minimap.xml`'s queue icon (decision 1974), driven
//! engine-only over the queue family's pushes: the icon and its tooltip across
//! `UPDATE_BATTLEFIELD_STATUS`, the confirm dialog, the list window across `BATTLEFIELDS_SHOW`
//! / `_CLOSED`, and the join button's request.
use benilla_ui::script::{BattlefieldListView, BattlefieldMapInfo, BattlefieldQueueSlot, UiScript};
use super::test_ui::load_ui as load_xml;
/// The stock pair with everything they call at load and on the events below — the durability
/// harness's shape (UIParent.xml for the panel kit and `UIPanelWindows`; StaticPopup for the
/// confirm dialog; UIDropDownMenu for the icon's menu; UIPanelTemplates for the buttons).
fn session() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The list verbs read the local player object at call time (1974) — seat one, as every
// in-world call has.
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Probefour".into()),
level: 60,
..Default::default()
}),
);
s.run("function PlaySound() end").unwrap();
// `FloatingChatFrame.lua`'s constant (l.9), which the queue icon's fade-in reads; the chat
// files are not part of this harness.
s.run("CHAT_FRAME_FADE_TIME = 0.15").unwrap();
for f in [
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\Localization.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\BasicControls.xml",
"UIParent.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"UiPanels.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\BattlefieldFrame.xml",
"Interface\\FrameXML\\Minimap.xml",
] {
load_xml(&s, f);
}
s.resolve();
s
}
fn slot(map_id: u32, name: &str, status: u32, instance: u32) -> BattlefieldQueueSlot {
BattlefieldQueueSlot {
map_id,
map_name: Some(name.into()),
status,
instance_id: instance,
min_level: 20,
max_level: 29,
port_expiration_ms: 75_000,
estimated_wait_ms: 90_000,
time_waited_ms: 12_000,
}
}
fn empty() -> BattlefieldQueueSlot {
BattlefieldQueueSlot {
map_name: Some("Eastern Kingdoms".into()),
..Default::default()
}
}
fn visible(s: &UiScript, frame: &str) -> bool {
s.eval::<bool>(&format!("return {frame}:IsVisible()"))
.unwrap()
}
/// The minimap's queue icon: hidden with no queue, shown by a queued slot with the reference's
/// three-line tooltip, the confirm dialog on a ready slot, hidden again when the queue clears.
#[test]
fn the_queue_icon_follows_the_slots_across_update_battlefield_status() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = session();
assert!(!visible(&s, "MiniMapBattlefieldFrame"), "no queue at load");
s.set_battlefield_queue(vec![slot(529, "Arathi Basin", 1, 0), empty(), empty()], 0);
s.fire_event("UPDATE_BATTLEFIELD_STATUS", vec![]);
assert!(
visible(&s, "MiniMapBattlefieldFrame"),
"a queued slot shows the icon"
);
let tooltip = s
.eval::<String>("return MiniMapBattlefieldFrame.tooltip")
.unwrap();
assert!(
tooltip.starts_with("You are in the queue for Arathi Basin\n"),
"{tooltip}"
);
// `SecondsToTime` leaves a trailing space after every unit — the reference's own text.
assert!(
tooltip.contains("Average wait time: 1 Min (Last 10 players)"),
"{tooltip}"
);
assert!(tooltip.contains("Time in queue: 12 Secs \n"), "{tooltip}");
assert!(
tooltip.ends_with("|cffffffff<Right Click> for PvP Options|r"),
"{tooltip}"
);
assert!(!visible(&s, "StaticPopup1"), "queued is not yet a question");
// Ready: the confirm dialog with the map name and the port countdown.
s.set_battlefield_queue(vec![slot(529, "Arathi Basin", 2, 3), empty(), empty()], 0);
s.fire_event("UPDATE_BATTLEFIELD_STATUS", vec![]);
assert!(
visible(&s, "StaticPopup1"),
"confirm raises CONFIRM_BATTLEFIELD_ENTRY"
);
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"You are now eligible to enter Arathi Basin 3, choose an action:"
);
let tooltip = s
.eval::<String>("return MiniMapBattlefieldFrame.tooltip")
.unwrap();
assert!(
tooltip.starts_with(
"You are eligible to enter Arathi Basin 3\nYou will be removed from the queue in 1 Min 15 Secs "
),
"{tooltip}"
);
// The dialog's Enter Battle is AcceptBattlefieldPort(slot, 1).
s.run("StaticPopup1Button1:Click()").unwrap();
assert_eq!(s.take_battlefield_port_requests(), vec![(1, true)]);
// The queue clears: the icon goes.
s.set_battlefield_queue(vec![empty(), empty(), empty()], 0);
s.fire_event("UPDATE_BATTLEFIELD_STATUS", vec![]);
assert!(
!visible(&s, "MiniMapBattlefieldFrame"),
"no queue hides the icon"
);
}
/// The list window: `BATTLEFIELDS_SHOW` opens it on the pushed list — the fake "First
/// Available" row, one row per instance, the description — the join button asks for the
/// selection, and `BATTLEFIELDS_CLOSED` hides it.
#[test]
fn the_list_window_opens_on_battlefields_show_and_joins_the_selection() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = session();
s.set_battlefield_list(BattlefieldListView {
instances: vec![4, 9],
bracket_min: 20,
bracket_max: 29,
info: Some(BattlefieldMapInfo {
name: "Arathi Basin".into(),
description: Some("The Arathi Basin is one of the main staging points.".into()),
min_level: 20,
max_level: 60,
field_16: -1,
field_17: 0.0,
field_18: 0.0,
}),
group_queue: true,
});
s.set_battlefield_queue(vec![empty(), empty(), empty()], 0);
assert!(!visible(&s, "BattlefieldFrame"));
s.fire_event("BATTLEFIELDS_SHOW", vec![]);
assert!(visible(&s, "BattlefieldFrame"), "the event shows the panel");
assert_eq!(
s.eval::<String>("return BattlefieldFrameFrameLabel:GetText()")
.unwrap(),
"Arathi Basin"
);
assert_eq!(
s.eval::<String>("return BattlefieldZone1:GetText()")
.unwrap(),
"First Available"
);
assert_eq!(
s.eval::<String>("return BattlefieldZone2:GetText()")
.unwrap(),
"Arathi Basin 4"
);
assert_eq!(
s.eval::<String>("return BattlefieldZone3:GetText()")
.unwrap(),
"Arathi Basin 9"
);
assert!(
!visible(&s, "BattlefieldZone4"),
"two instances, three rows"
);
assert_eq!(
s.eval::<String>("return BattlefieldFrameZoneDescription:GetText()")
.unwrap(),
"The Arathi Basin is one of the main staging points."
);
assert!(
visible(&s, "BattlefieldFrameGroupJoinButton"),
"the map allows group queues: the button shows"
);
assert!(
!s.eval::<bool>("return BattlefieldFrameGroupJoinButton:IsEnabled() ~= 0")
.unwrap(),
"…disabled while not leading a group"
);
// Join the default selection (row 1 = first available), then a specific instance.
s.run("BattlefieldFrameJoinButton:Click()").unwrap();
assert_eq!(s.take_battlefield_join_requests(), vec![(0, false)]);
assert!(!visible(&s, "BattlefieldFrame"), "joining hides the panel");
s.fire_event("BATTLEFIELDS_SHOW", vec![]);
s.run("BattlefieldZone3:Click()").unwrap();
assert_eq!(s.eval::<i64>("return GetSelectedBattlefield()").unwrap(), 2);
s.run("BattlefieldFrameJoinButton:Click()").unwrap();
assert_eq!(s.take_battlefield_join_requests(), vec![(9, false)]);
s.fire_event("BATTLEFIELDS_SHOW", vec![]);
assert!(visible(&s, "BattlefieldFrame"));
s.fire_event("BATTLEFIELDS_CLOSED", vec![]);
assert!(
!visible(&s, "BattlefieldFrame"),
"the leash's event hides it"
);
}
/// The queue icon's status pass marks the listed rows: a queued instance reads "(In Queue)"
/// beside its row, and the first-available row carries it when the queue names no instance.
#[test]
fn the_list_rows_carry_the_queue_status() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = session();
s.set_battlefield_list(BattlefieldListView {
instances: vec![4],
info: Some(BattlefieldMapInfo {
name: "Arathi Basin".into(),
..Default::default()
}),
..Default::default()
});
s.set_battlefield_queue(vec![slot(529, "Arathi Basin", 1, 4), empty(), empty()], 0);
s.fire_event("BATTLEFIELDS_SHOW", vec![]);
assert_eq!(
s.eval::<String>("return BattlefieldZone2Status:GetText()")
.unwrap(),
"(In Queue)"
);
assert_eq!(
s.eval::<String>("return BattlefieldZone1Status:GetText()")
.unwrap(),
""
);
}
@@ -79,7 +79,7 @@ fn shipped_xml() -> Vec<(String, String)> {
out.sort();
// A sanity floor for the walk, not a census: `assets/ui` retires file by file (1751), so
// the floor sits well under the count rather than one step above it (1956).
assert!(out.len() >= 15, "only {} xml files swept", out.len());
assert!(out.len() >= 10, "only {} xml files swept", out.len());
out
}
@@ -465,7 +465,7 @@ fn corpse_map_position_binding() {
.eval::<(f64, f64)>("return GetCorpseMapPosition()")
.unwrap();
assert_eq!((x, y), (0.0, 0.0), "no corpse ⇒ the (0,0) hide sentinel");
s.set_world_map_feed(None, None, 0.0, Some((0.25, 0.75)), Vec::new());
s.set_world_map_feed(None, None, 0.0, Some((0.25, 0.75)), Vec::new(), Vec::new());
let (x, y) = s
.eval::<(f64, f64)>("return GetCorpseMapPosition()")
.unwrap();
@@ -27,7 +27,9 @@ fn harness() -> UiScript {
"Interface\\FrameXML\\StaticPopup.xml",
"UIParent.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"MinimapCluster.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\BattlefieldFrame.xml",
"Interface\\FrameXML\\Minimap.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.
@@ -404,7 +406,9 @@ fn the_quest_tracker_stacks_below_the_durability_guy() {
"Interface\\FrameXML\\StaticPopup.xml",
"UIParent.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"MinimapCluster.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\BattlefieldFrame.xml",
"Interface\\FrameXML\\Minimap.xml",
"ScrollTemplates.xml",
"Interface\\FrameXML\\DurabilityFrame.xml",
"Interface\\FrameXML\\BasicControls.xml",
@@ -1328,8 +1328,51 @@ fn convert_entry(
//
// A pane with no name, or one no window claims, draws nothing (`SetModel` panes included:
// this engine holds their scene and renders no M2 into it). Nothing is stubbed white.
QuadContent::ModelPane { name } => {
QuadContent::ModelPane {
name,
model,
facing,
model_scale: _,
} => {
use crate::portrait::PortraitSource;
// The world-map arrow (decision 1980): the stock `WorldMapFrame.lua` /
// `Blizzard_BattlefieldMinimap.lua` create an anonymous `Model` child, `SetModel` it to
// the minimap arrow, and steer it with `SetFacing`/`SetPosition` every update. benilla
// draws that one file as the minimap's own arrow sprite — the same art the minimap
// ring draws (`crate::minimap::blips`), turned the same way (`-facing`: the M2's yaw
// is CCW-positive, the screen's is CW), filling the pane the engine sized to the
// reference's 33.6 px footprint times the arrow's `SetModelScale`. No 3D pane needs
// to exist for a two-triangle arrow.
if model.as_deref() == Some(benilla_ui::script::ARROW_MODEL) {
use crate::minimap::blips::{
PLAYER_ARROW_OFFSET_PX, PLAYER_ARROW_QUAD_PX, PLAYER_ARROW_TEXTURE,
};
let Some(handle) = assets
.as_mut()
.and_then(|a| a.sprite_texture(PLAYER_ARROW_TEXTURE, images))
else {
return;
};
// The M2's single quad sits off its origin by an authored offset that turns with
// the facing — the minimap's arrow applies the same one at its 33.6 px basis, and
// the pane the engine sized (`worldmap_arrow.rs`: the footprint times the
// `SetModelScale`) scales it here. `model_scale` is already in the rect.
let rotation = -facing;
let (sin, cos) = rotation.sin_cos();
let off = PLAYER_ARROW_OFFSET_PX * (rect.width() / PLAYER_ARROW_QUAD_PX);
let off = Vec2::new(off.x * cos - off.y * sin, off.x * sin + off.y * cos);
let rect = Rect::from_center_size(rect.center() + off, rect.size());
out.push(UiQuad {
rect,
z_key: eq.z,
texture: Some(handle),
color: [1.0, 1.0, 1.0, eq.alpha],
rotation,
clip,
..default()
});
return;
}
let Some(slot) = name.as_deref().and_then(crate::portrait::model_pane_booth) else {
return;
};
@@ -192,12 +192,9 @@ const KNOWN: &[Known] = &[
// entries here — twelve `MerchantItem<N>` plus `MerchantBuyBackItem` — saying ours took the
// mouse on the row itself where the reference splits each row into an inert container plus a
// `$parentItemButton`. That is exactly what this gate exists to notice going away.
Known {
frame: "WorldMapFrame",
flag: Flag::Mouse,
why: "our map body takes the mouse so a click on it cannot reach the world behind a \
FULLSCREEN_DIALOG window; the reference relies on WorldMapButton alone",
},
// The `WorldMapFrame` mouse entry RETIRED (1980): it said our map body took the mouse where
// the reference relies on `WorldMapButton` alone — true of our transcription, and the map is
// the reference's own file now.
// ── The faux scroll panes: a different WIDGET KIND, not a missing flag ─────────────────────
//
// The reference declares each of these `<ScrollFrame …inherits="FauxScrollFrameTemplate">` and
@@ -576,10 +573,11 @@ fn the_shipped_frames_carry_the_references_flags() {
// 1956 our SkillFrame.xml, 1958 our UnitPopup.xml and 1959 our FriendsFrame.xml (their
// frames are the reference's own now), which took the paired count from the low 400s to
// the high 150s; 1966 (TradeFrame), 1968 (GameTooltip), 1969 (DressUpFrame) and 1970
// (MailFrame) to the high 80s. The floor guards the pairing, not the census — it comes
// down with every window that migrates, and reaches zero with the last file of ours
// that declares a reference-named frame.
compared > 60,
// (MailFrame) to the high 80s, and 1980 (WorldMapFrame, whose 50-odd blip frames were
// most of what was left) to the high teens. The floor guards the pairing, not the
// census — it comes down with every window that migrates, and reaches zero with the
// last file of ours that declares a reference-named frame.
compared > 10,
"only {compared} frames compared — the pairing broke, and the sweep guards nothing"
);
assert!(
@@ -568,7 +568,17 @@ fn the_world_map_cannot_open_behind_the_menu_and_gives_its_slot_back() {
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"WorldMapFrame.xml",
// The stock map's OnShow/OnHide call `UpdateMicroButtons` unguarded (1980), which the
// micro menu defines over the action bar it sits in — the spellbook kit's cluster.
"Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
"MicroMenu.xml",
r"Interface\FrameXML\WorldMapFrame.xml",
]);
// Opens normally, and takes the full-screen slot.
@@ -619,7 +629,17 @@ fn nothing_opens_behind_the_world_map_and_escape_closes_it_first() {
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"WorldMapFrame.xml",
// The action-bar cluster the micro menu sits in — the stock map's OnShow/OnHide call
// `UpdateMicroButtons` unguarded (1980).
"Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
"MicroMenu.xml",
r"Interface\FrameXML\WorldMapFrame.xml",
"Interface\\FrameXML\\MerchantFrame.xml",
// The loot window is the reference's own since 1751 — see `test_ui::LOOT_UI` for what
// each of these buys; `PartyFrame`'s MAX_PARTY_MEMBERS is needed at LOAD time.
+60 -4
View File
@@ -151,10 +151,30 @@ fn ui_wanted(world: &World) -> bool {
#[derive(Resource, Default)]
pub(crate) struct PendingEntryUiLoad;
/// `OnEnter(InWorld)`: arm the deferred entry load. The load itself runs a few frames later —
/// see [`PendingEntryUiLoad`].
pub(crate) fn arm_entry_ui_load(mut commands: Commands) {
commands.insert_resource(PendingEntryUiLoad);
/// **The boot VM, parked for the deferral window** (decision 1978). Between the world-entry edge
/// and the deferred entry load there is no VM in the world at all: the boot VM waits here, out of
/// every feed's reach, and the load takes it back. Every feed takes the VM as an `Option` and
/// already returns on `None` — the glue phase's shape — so a feed keyed on a per-VM memo cannot
/// push into a VM that has no interface yet and then hold the real push back (the chat plate
/// that stayed white, and 1348's whole class). `ingame_ui_pending` stays for the feeds that
/// name it; with the VM parked it is belt and braces.
pub(crate) struct ParkedBootVm(UiScript);
/// `OnEnter(InWorld)`: arm the deferred entry load and park the boot VM. The load itself runs a
/// few frames later — see [`PendingEntryUiLoad`] and [`ParkedBootVm`].
pub(crate) fn arm_entry_ui_load(world: &mut World) {
world.insert_resource(PendingEntryUiLoad);
if let Some(vm) = world.remove_non_send_resource::<UiScript>() {
world.insert_non_send_resource(ParkedBootVm(vm));
}
}
/// The parked boot VM back into the world, if one is parked — the session end and the
/// left-before-the-load arm both want the VM where the tail expects it.
fn unpark_boot_vm(world: &mut World) {
if let Some(ParkedBootVm(vm)) = world.remove_non_send_resource::<ParkedBootVm>() {
world.insert_non_send_resource(vm);
}
}
/// **Is the in-game UI still owed for this world entry?** True from `OnEnter(InWorld)` until
@@ -200,6 +220,7 @@ pub(crate) fn run_pending_entry_load(world: &mut World) {
if !in_world {
// Left the world before the load ran — nothing to build a UI for.
world.remove_resource::<PendingEntryUiLoad>();
unpark_boot_vm(world);
return;
}
let covering = world
@@ -245,6 +266,9 @@ pub(crate) fn run_pending_entry_load(world: &mut World) {
/// capture boots straight into `InWorld`, so before that this would have run ahead of
/// [`benilla_assets::AssetSet::Open`] and loaded against no patch chain.
pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
// The VM the entry edge parked (1978); the live slot is the fallback for a caller that did
// not go through the arm.
unpark_boot_vm(world);
if !ui_wanted(world) {
return;
}
@@ -449,6 +473,9 @@ pub(crate) fn shutdown_ui_state(script: &mut UiScript, identity: Option<&(String
/// Exclusive rather than a `NonSendMut` system because it both drops and installs a `NonSend`, and
/// because the shutdown writes must be ordered against each other — see [`shutdown_ui_state`].
pub(crate) fn end_ui_session(world: &mut World) {
// A VM still parked (the load never ran) goes back into the world first, so the tail below
// runs against the same slot it always did (1978).
unpark_boot_vm(world);
// An armed-but-unrun entry load ([`PendingEntryUiLoad`]) means this session never built an
// in-game UI: there are no globals to save and no addon state to write, and running the
// shutdown tail against the boot VM would overwrite the real files with that emptiness.
@@ -701,3 +728,32 @@ pub(crate) fn is_emote_token_line(line: &str) -> bool {
.bytes()
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
}
#[cfg(test)]
mod tests {
use super::*;
/// The entry edge parks the boot VM (1978): between the arm and the load there is no VM in
/// the world for any feed to push into, and the session end puts a never-loaded one back.
#[test]
fn the_entry_edge_parks_the_boot_vm_and_the_session_end_returns_it() {
let mut world = World::new();
let boot = UiScript::new().unwrap();
let session = boot.session();
world.insert_non_send_resource(boot);
arm_entry_ui_load(&mut world);
assert!(
world.get_non_send_resource::<UiScript>().is_none(),
"no VM in the deferral window"
);
assert!(world.get_resource::<PendingEntryUiLoad>().is_some());
assert!(world.get_non_send_resource::<ParkedBootVm>().is_some());
// Left the world before the load ran: the VM comes back, untouched.
unpark_boot_vm(&mut world);
let vm = world
.get_non_send_resource::<UiScript>()
.expect("the parked VM is back");
assert_eq!(vm.session(), session, "the same VM, no session moved");
assert!(world.get_non_send_resource::<ParkedBootVm>().is_none());
}
}
+145 -21
View File
@@ -1,4 +1,4 @@
//! The shipped `MinimapCluster.xml` driven engine-only: the +/- zoom buttons must re-sync their
//! The stock `Minimap.xml` (1974) driven engine-only: the +/- zoom buttons must re-sync their
//! enabled state when the active zoom index switches (stepping inside/outside a WMO flips to the
//! other, independent level). Regression guard for the director-caught stale-button bug (2026-07-09):
//! `ZoomIn` greyed from an outdoor max-zoom stayed greyed indoors at the middle default level.
@@ -27,7 +27,11 @@ fn minimap_zoom_buttons_resync_when_switching_inside_and_outside() {
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, "MinimapCluster.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\BattlefieldFrame.xml");
load_xml(&s, "Interface\\FrameXML\\Minimap.xml");
// OnLoad seeds the +/- state from the live zoom. The CVar default is 3 — a middle level, so
// BOTH buttons start enabled (the old hardcoded `MinimapZoomOut:Disable()` assumed zoom 0).
@@ -92,7 +96,11 @@ fn tracking_frame_follows_get_tracking_texture_across_player_auras_changed() {
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, "MinimapCluster.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\BattlefieldFrame.xml");
load_xml(&s, "Interface\\FrameXML\\Minimap.xml");
let vis = |s: &UiScript| {
s.eval::<bool>("return MiniMapTrackingFrame:IsVisible()")
@@ -142,7 +150,11 @@ fn game_time_session(hour: u32, minute: u32) -> UiScript {
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, "MinimapCluster.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\BattlefieldFrame.xml");
load_xml(&s, "Interface\\FrameXML\\Minimap.xml");
load_xml(&s, "Interface\\FrameXML\\GameTime.xml");
s
}
@@ -255,7 +267,11 @@ fn a_click_on_the_minimap_parks_a_centre_relative_ping_request() {
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, "MinimapCluster.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\BattlefieldFrame.xml");
load_xml(&s, "Interface\\FrameXML\\Minimap.xml");
s.resolve();
let cx = s
@@ -269,14 +285,16 @@ fn a_click_on_the_minimap_parks_a_centre_relative_ping_request() {
// Nothing is parked until somebody clicks.
assert_eq!(s.take_minimap_ping_request(), None);
// A click 20 UI units right and 12 up of the centre — inside the 70-unit disc.
// A click 20 UI units right and 12 up of the centre — inside the 70-unit disc. The stock
// `Minimap_OnClick` adds its `CURSOR_OFFSET_X/Y` (−7, −9) to the cursor before subtracting
// the centre, so what it parks is the click less that offset.
s.mouse_button(cx + 20.0, cy + 12.0, "LeftButton", true);
s.mouse_button(cx + 20.0, cy + 12.0, "LeftButton", false);
let (dx, dy) = s
.take_minimap_ping_request()
.expect("OnMouseUp → Minimap_OnClick → PingLocation");
assert!((dx - 20.0).abs() < 0.01, "x right of centre: {dx}");
assert!((dy - 12.0).abs() < 0.01, "y UP from centre: {dy}");
assert!((dx - 13.0).abs() < 0.01, "x right of centre, less 7: {dx}");
assert!((dy - 3.0).abs() < 0.01, "y UP from centre, less 9: {dy}");
// Draining is a drain: the app must not see the same click twice.
assert_eq!(s.take_minimap_ping_request(), None);
@@ -286,11 +304,12 @@ fn a_click_on_the_minimap_parks_a_centre_relative_ping_request() {
assert_eq!(s.take_minimap_ping_request(), None, "off-widget is no ping");
}
/// `Minimap:GetPingPosition()` answers **nil** with no ping, and the live pair once the app
/// publishes one — never `(0, 0)` for "there isn't one", which a caller cannot tell from a ping
/// under its own feet.
/// `Minimap:GetPingPosition()` answers **two numbers always** (the reference recomputes them
/// from statics nothing clears — wow-re `minimap-ping-law.md`), and the stock
/// `Minimap_OnUpdate` leans on exactly that: while its 5 s timer runs it multiplies the answer
/// with no nil test. The pair is the app's last publish, `(0, 0)` before any.
#[test]
fn get_ping_position_is_nil_until_there_is_a_ping() {
fn get_ping_position_answers_two_numbers_always() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -301,14 +320,23 @@ fn get_ping_position_is_nil_until_there_is_a_ping() {
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, "MinimapCluster.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\BattlefieldFrame.xml");
load_xml(&s, "Interface\\FrameXML\\Minimap.xml");
assert_eq!(
s.eval::<Option<f32>>("return (Minimap:GetPingPosition())")
s.eval::<i64>("return select('#', Minimap:GetPingPosition())")
.unwrap(),
None
2
);
s.set_minimap_ping(Some((0.25, -0.125)));
assert_eq!(
s.eval::<f32>("return (Minimap:GetPingPosition())").unwrap(),
0.0,
"before any ping: a number, not nil"
);
s.set_minimap_ping((0.25, -0.125));
let x = s
.eval::<f32>("local x = Minimap:GetPingPosition(); return x")
.unwrap();
@@ -319,10 +347,106 @@ fn get_ping_position_is_nil_until_there_is_a_ping() {
(x - 0.25).abs() < 1e-6 && (y + 0.125).abs() < 1e-6,
"{x} {y}"
);
s.set_minimap_ping(None);
assert_eq!(
s.eval::<Option<f32>>("return (Minimap:GetPingPosition())")
.unwrap(),
None
// The stock lifetime, end to end: MINIMAP_PING shows the model frame and starts the 5 s
// timer; OnUpdate re-seats it from GetPingPosition every frame; past 5 s it "fades" and
// hides. The app's sprite follows this frame (`minimap/ping.rs`).
assert!(
!s.eval::<bool>("return MiniMapPing:IsVisible()").unwrap(),
"hidden until a ping"
);
s.fire_event(
"MINIMAP_PING",
vec![
benilla_ui::script::ScriptValue::Str("player".into()),
benilla_ui::script::ScriptValue::Number(0.25),
benilla_ui::script::ScriptValue::Number(-0.125),
],
);
assert!(s.eval::<bool>("return MiniMapPing:IsVisible()").unwrap());
s.tick(1.0);
assert!(
s.eval::<bool>("return MiniMapPing:IsVisible()").unwrap(),
"held at 1 s"
);
let cx = s
.eval::<f32>(
"local x = MiniMapPing:GetCenter(); local mx = Minimap:GetCenter(); return x - mx",
)
.unwrap();
assert!(
(cx - 0.25 * 140.0).abs() < 0.5,
"re-seated at the normalized offset times the width: {cx}"
);
s.tick(4.2);
s.tick(0.6);
assert!(
!s.eval::<bool>("return MiniMapPing:IsVisible()").unwrap(),
"5 s hold + 0.5 s fade, then hidden"
);
}
/// The meeting-stone icon (stock `Minimap.xml`, 1974): hidden until `IsInMeetingStoneQueue()`
/// answers across `MEETINGSTONE_CHANGED`, its hover the cached status text, hidden again when
/// the queue empties — and a click raises the leave-queue question.
#[test]
fn the_meeting_stone_icon_follows_the_queue_across_meetingstone_changed() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run("function GetMinimapZoneText() return '' end")
.unwrap();
s.run("function PlaySound() end").unwrap();
for f in [
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\Localization.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\BasicControls.xml",
"UIParent.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"UiPanels.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\BattlefieldFrame.xml",
"Interface\\FrameXML\\Minimap.xml",
] {
load_xml(&s, f);
}
s.resolve();
let vis = |s: &UiScript| {
s.eval::<bool>("return MiniMapMeetingStoneFrame:IsVisible()")
.unwrap()
};
assert!(!vis(&s), "hidden at load");
s.set_meeting_stone(1519, Some("Looking for more for Stormwind City".into()));
s.fire_event("MEETINGSTONE_CHANGED", vec![]);
assert!(vis(&s), "a queued area shows the icon");
s.run("this = MiniMapMeetingStoneFrame; MiniMapMeetingStoneFrame:GetScript('OnEnter')()")
.unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
"Looking for more for Stormwind City"
);
s.run("MiniMapMeetingStoneFrame:Click()").unwrap();
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"a click asks CONFIRM_LEAVE_QUEUE"
);
s.run("StaticPopup1Button1:Click()").unwrap();
assert_eq!(
s.take_meeting_stone_cancels(),
1,
"Accept is CancelMeetingStoneRequest()"
);
s.set_meeting_stone(0, Some("Looking for more for Unknown".into()));
s.fire_event("MEETINGSTONE_CHANGED", vec![]);
assert!(!vis(&s), "area 0 hides it again");
}
+6
View File
@@ -1023,6 +1023,12 @@ mod quest_tests;
#[cfg(test)]
mod quest_timer_tests;
#[cfg(test)]
mod battlefield_tests;
#[cfg(test)]
mod tutorial_tests;
#[cfg(test)]
mod durability_tests;
// `pub(crate)` for its `harness`/`push`/`row` helpers: `perf::hud`'s own test drives the readout
@@ -468,7 +468,7 @@ fn hovering_the_diet_icon_lists_what_the_pet_eats() {
let pane = quads
.iter()
.find(|q| {
matches!(&q.content, QuadContent::ModelPane { name: Some(n) } if n == "PetModelFrame")
matches!(&q.content, QuadContent::ModelPane { name: Some(n), .. } if n == "PetModelFrame")
})
.expect("the pet model pane is in the render list");
let pane_rect = pane.rect.expect("…with a resolved rect");
@@ -1567,78 +1567,6 @@ mod tests {
(1889; the producer gate carries the event). Gated since the addon became a reached \
LoadOnDemand row (1967).",
),
(
"Blizzard_BattlefieldMinimap.xml",
"CreateMiniWorldMapArrowFrame",
"the world map's arrow-frame family (Create/Position/Show/UpdateWorldMapArrowFrames), engine verbs the reference's WorldMapFrame.lua also calls — unbuilt; ours draws the map's arrows app-side, and they land with the world map's own migration. The addon is a reached LoadOnDemand row since 1972 (UIParent.xml's \
BattlefieldMinimap_LoadUI, which the stock WorldStateFrame.lua calls on a \
battleground's world states); its pane is a <Minimap>-kind widget this engine \
draws nothing into yet, so the addon loads, paints nothing, and these are the verbs \
its update would reach.",
),
(
"Blizzard_BattlefieldMinimap.xml",
"GetBattlefieldFlagPosition",
"the battlefield minimap's flag-position getter (0x4ac230, structural row only; the position family is uncarved). The addon is a reached LoadOnDemand row since 1972 (UIParent.xml's \
BattlefieldMinimap_LoadUI, which the stock WorldStateFrame.lua calls on a \
battleground's world states); its pane is a <Minimap>-kind widget this engine \
draws nothing into yet, so the addon loads, paints nothing, and these are the verbs \
its update would reach.",
),
(
"Blizzard_BattlefieldMinimap.xml",
"GetBattlefieldMapIconScale",
"the battlefield minimap's icon-scale getter (uncarved; reads the queue slot's Map.dbc row). The addon is a reached LoadOnDemand row since 1972 (UIParent.xml's \
BattlefieldMinimap_LoadUI, which the stock WorldStateFrame.lua calls on a \
battleground's world states); its pane is a <Minimap>-kind widget this engine \
draws nothing into yet, so the addon loads, paints nothing, and these are the verbs \
its update would reach.",
),
(
"Blizzard_BattlefieldMinimap.xml",
"GetBattlefieldPosition",
"the battlefield minimap's player-position getter (wow-re `bindings.md` 0x4abf90, a structural row — the position family is outside 195cd099's carve). The addon is a reached LoadOnDemand row since 1972 (UIParent.xml's \
BattlefieldMinimap_LoadUI, which the stock WorldStateFrame.lua calls on a \
battleground's world states); its pane is a <Minimap>-kind widget this engine \
draws nothing into yet, so the addon loads, paints nothing, and these are the verbs \
its update would reach.",
),
(
"Blizzard_BattlefieldMinimap.xml",
"PositionMiniWorldMapArrowFrame",
"as CreateMiniWorldMapArrowFrame — the arrow-frame family. The addon is a reached LoadOnDemand row since 1972 (UIParent.xml's \
BattlefieldMinimap_LoadUI, which the stock WorldStateFrame.lua calls on a \
battleground's world states); its pane is a <Minimap>-kind widget this engine \
draws nothing into yet, so the addon loads, paints nothing, and these are the verbs \
its update would reach.",
),
(
"Blizzard_BattlefieldMinimap.xml",
"ShowMiniWorldMapArrowFrame",
"as CreateMiniWorldMapArrowFrame — the arrow-frame family. The addon is a reached LoadOnDemand row since 1972 (UIParent.xml's \
BattlefieldMinimap_LoadUI, which the stock WorldStateFrame.lua calls on a \
battleground's world states); its pane is a <Minimap>-kind widget this engine \
draws nothing into yet, so the addon loads, paints nothing, and these are the verbs \
its update would reach.",
),
(
"Blizzard_BattlefieldMinimap.xml",
"UpdateWorldMapArrowFrames",
"as CreateMiniWorldMapArrowFrame — the arrow-frame family. The addon is a reached LoadOnDemand row since 1972 (UIParent.xml's \
BattlefieldMinimap_LoadUI, which the stock WorldStateFrame.lua calls on a \
battleground's world states); its pane is a <Minimap>-kind widget this engine \
draws nothing into yet, so the addon loads, paints nothing, and these are the verbs \
its update would reach.",
),
(
"Blizzard_BattlefieldMinimap.xml",
"WorldMap_GetPOITextureCoords",
"a stock WorldMapFrame.lua function; our WorldMapFrame.xml is still ours and defines its own POI atlas walk, so the name arrives with the world map's migration. The addon is a reached LoadOnDemand row since 1972 (UIParent.xml's \
BattlefieldMinimap_LoadUI, which the stock WorldStateFrame.lua calls on a \
battleground's world states); its pane is a <Minimap>-kind widget this engine \
draws nothing into yet, so the addon loads, paints nothing, and these are the verbs \
its update would reach.",
),
(
"StaticPopup.xml",
"ReplaceTradeEnchant",
@@ -2324,6 +2252,12 @@ mod tests {
// built; none is 1819-shaped, because no PAIR is split (a half-fired pair is the tell).
const UNPRODUCED: &[(&str, &str)] = &[
("BAG_OPEN", "ContainerFrame.lua"),
(
"CLOSE_WORLD_MAP",
"WorldMapFrame.lua — the engine-side close the reference fires when the map is \
shut from outside its own frame; benilla closes the map through the frame's own \
hide path only (1980)",
),
("DISPLAY_SIZE_CHANGED", "the four paperdoll files"),
(
"GMSURVEY_DISPLAY",
@@ -32,7 +32,7 @@ fn every_shipped_ui_xml_parses() {
assert!(
// A sanity floor for the walk, not a census: `assets/ui` retires file by file (1751),
// so the floor sits well under the count rather than one step above it (1956).
checked >= 15,
checked >= 10,
"only {checked} xml files swept — sweep broke"
);
}
@@ -430,8 +430,9 @@ fn every_shipped_texture_path_resolves_in_the_client_archives() {
}
// Never let the sweep pass by matching nothing — the shipped UI still names over a hundred
// textures (173 after 1971 retired the auction transcription, fewer again after 1973 the
// crafting pair; the count falls with every window that migrates, and the floor follows it).
assert!(refs.len() >= 80, "only {} texture paths swept", refs.len());
// crafting pair, 64 after 1980 the world map; the count falls with every window that
// migrates, and the floor follows it).
assert!(refs.len() >= 50, "only {} texture paths swept", refs.len());
// The shape half: a doubled separator is the Lua escaping written into XML, and it resolves to
// nothing. Checked without the client so a data-less machine still catches this exact class.
@@ -624,10 +625,10 @@ fn every_archive_path_a_shipped_lua_chunk_names_survives_its_own_escaping() {
}
}
}
// Never let the sweep pass by matching nothing (19 after 1971; the floor follows the census
// down as windows migrate).
// Never let the sweep pass by matching nothing (19 after 1971, 8 after 1980; the floor
// follows the census down as windows migrate).
assert!(
paths.len() >= 10,
paths.len() >= 5,
"only {} archive paths swept out of the shipped Lua",
paths.len()
);
@@ -845,7 +846,7 @@ fn no_shipped_script_sets_a_global_string_key_as_display_text() {
assert!(offenders.is_empty(), "{}", offenders.join("\n"));
// The sweep must never pass by finding nothing to sweep.
// The same walk floor as above (1956).
assert!(swept >= 15, "only {swept} xml files swept — sweep broke");
assert!(swept >= 10, "only {swept} xml files swept — sweep broke");
}
/// **The `$parentTextureFrame` idiom's contract, over the whole shipped UI**: a frame whose art is
@@ -1372,7 +1373,10 @@ fn a_cinematic_leaves_nothing_of_the_interface_on_screen() {
// The sweep is only worth anything if there was something to hide in the first place.
let before = names.iter().filter(|n| visible(&s, n)).count();
assert!(
before > 20, // 50 until 1938 took the three bar files (and their visible frames) stock
// 50 until 1938 took the three bar files stock, 20 until 1974 took the minimap cluster
// (its 22 named frames were most of what this sweep counted); the floor follows the
// census of OUR files down, and 12 is what is left — the glue and dev frames.
before > 10,
"only {before} frames visible before the cinematic — the sweep found no interface to \
hide, so it would pass no matter what the cascade did"
);
@@ -0,0 +1,135 @@
//! The stock `TutorialFrame.xml` (decision 1976) driven engine-only: an alert button per
//! `TUTORIAL_TRIGGER`, the window a click opens over the published id's strings, the
//! `FlagTutorial` it makes, the unticked checkbox's `ClearTutorials` on hide, and
//! `CINEMATIC_STOP` clicking the Welcome alert.
use benilla_ui::script::{ScriptValue, UiScript};
use super::test_ui::load_ui as load_xml;
fn session() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run("function PlaySound() end").unwrap();
for f in [
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\Localization.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\BasicControls.xml",
"UIParent.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"UiPanels.xml",
"Interface\\FrameXML\\GameTooltip.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\TutorialFrame.xml",
] {
load_xml(&s, f);
}
s.resolve();
s
}
fn visible(s: &UiScript, frame: &str) -> bool {
s.eval::<bool>(&format!("return {frame}:IsVisible()"))
.unwrap()
}
fn trigger(s: &mut UiScript, published: i64) {
s.fire_event("TUTORIAL_TRIGGER", vec![ScriptValue::Int(published)]);
}
/// A trigger queues an alert button; its click opens the window on the id's title and text and
/// acknowledges the id (0-based on the drain); a second trigger queues a second button.
#[test]
fn a_trigger_queues_an_alert_whose_click_opens_the_window_and_flags_it() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = session();
assert!(!visible(&s, "TutorialFrameAlertButton1"));
assert!(!visible(&s, "TutorialFrame"));
trigger(&mut s, 41);
assert!(
visible(&s, "TutorialFrameAlertButton1"),
"the alert button shows"
);
assert_eq!(
s.eval::<String>("return TutorialFrameAlertButton1.tooltip")
.unwrap(),
"Elite Quests"
);
trigger(&mut s, 42);
assert!(
visible(&s, "TutorialFrameAlertButton2"),
"a second id takes the next button"
);
s.run("TutorialFrameAlertButton1:Click()").unwrap();
assert!(visible(&s, "TutorialFrame"), "the click opens the window");
assert_eq!(
s.eval::<String>("return TutorialFrameTitle:GetText()")
.unwrap(),
"Elite Quests"
);
assert!(s
.eval::<String>("return TutorialFrameText:GetText()")
.unwrap()
.starts_with("You have accepted an elite quest"));
assert_eq!(
s.take_tutorial_flag_requests(),
vec![40],
"FlagTutorial(41) → the 0-based id"
);
assert!(
!visible(&s, "TutorialFrameAlertButton1"),
"the clicked alert is spent; the other stays"
);
assert!(visible(&s, "TutorialFrameAlertButton2"));
// Okay hides it; the checkbox is ticked, so nothing is cleared.
s.run("TutorialFrameOkayButton:Click()").unwrap();
assert!(!visible(&s, "TutorialFrame"));
assert_eq!(s.take_tutorial_clears(), 0);
}
/// Unticking "Display Tips" and closing calls `ClearTutorials` and drops every alert.
#[test]
fn an_unticked_checkbox_clears_the_tutorials_on_hide() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = session();
trigger(&mut s, 41);
trigger(&mut s, 42);
s.run("TutorialFrameAlertButton1:Click()").unwrap();
let _ = s.take_tutorial_flag_requests();
s.run("TutorialFrameCheckButton:SetChecked(nil) TutorialFrame:Hide()")
.unwrap();
assert_eq!(s.take_tutorial_clears(), 1, "ClearTutorials() on hide");
assert!(
!visible(&s, "TutorialFrameAlertButton2"),
"every alert dropped"
);
}
/// `CINEMATIC_STOP` clicks the Welcome alert (id 42) if it is queued — the first thing a new
/// character sees after the intro — and moves the window to the screen's centre.
#[test]
fn cinematic_stop_opens_the_welcome_window() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = session();
trigger(&mut s, 42);
trigger(&mut s, 1);
s.fire_event("CINEMATIC_STOP", vec![]);
assert!(visible(&s, "TutorialFrame"));
assert_eq!(
s.eval::<String>("return TutorialFrameTitle:GetText()")
.unwrap(),
"Welcome to World of Warcraft!"
);
assert_eq!(s.take_tutorial_flag_requests(), vec![41]);
assert!(
visible(&s, "TutorialFrameAlertButton2"),
"the Questgivers alert stays queued"
);
}
@@ -3,10 +3,13 @@
//! (`SMSG_GOSSIP_POI` → [`crate::poi_marker`]); the `AreaPOI.dbc` rows are 0203's deferred slice
//! and arrive through the same list, so these tests pin the *pool*, not the marker.
//!
//! Driven through the shipped `assets/ui/WorldMapFrame.xml` in a bare engine (no Bevy) — the
//! panel-test idiom: push host state, run the repaint, read the frames back.
//! Driven through the reference's own `WorldMapFrame.xml` off the player's chain (1980) in a bare
//! engine (no Bevy) — the panel-test idiom: push host state, run the repaint, read the frames back.
use benilla_ui::script::{UiScript, WorldMapLandmarkView};
use benilla_ui::script::{
BattlefieldFlagView, BattlefieldPositionView, QuadContent, UiScript, WorldMapLandmarkView,
ARROW_MODEL,
};
/// The map plus everything its OnLoad touches, in the shipped list's own order.
fn harness() -> UiScript {
@@ -26,11 +29,12 @@ fn harness() -> UiScript {
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml", // the map's continent/zone pickers initialize into it at OnLoad
"ScrollTemplates.xml",
// The blip templates, which WorldMapFrame.xml instantiates with inherits=. Not
// optional: an unknown template is a loader WARNING, not an error, so leaving this
// out loads clean and then hands every blip a sizeless, anchorless frame.
"WorldMapFrameTemplates.xml",
"WorldMapFrame.xml",
// The stock update walks MAX_PARTY_MEMBERS and MAX_RAID_MEMBERS, which the party and
// raid files own.
r"Interface\FrameXML\PartyMemberFrame.lua",
r"Interface\FrameXML\RaidFrame.lua",
// The reference's own map, which <Include>s its blip templates itself.
r"Interface\FrameXML\WorldMapFrame.xml",
] {
// `test_ui::load_ui`, not a local read: a manifest entry carrying a path separator is the
// REFERENCE's own file and must come off the player's chain, which
@@ -44,6 +48,14 @@ fn harness() -> UiScript {
s
}
/// The stock per-frame update, driven the way its `OnUpdate` script runs it: `this` is the map
/// button and the elapsed time its one argument. Everything on the sheet — the arrow, the party
/// and raid blips, the battleground teammates and flags, the corpse — is seated here.
fn update(s: &mut UiScript) {
s.run("this = WorldMapButton WorldMapButton_OnUpdate(0.1) this = nil")
.unwrap();
}
fn landmark(name: &str, icon: u32, uv: (f32, f32)) -> WorldMapLandmarkView {
WorldMapLandmarkView {
name: name.into(),
@@ -140,8 +152,9 @@ fn the_poi_pool_grows_and_parks_its_tail() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Hovering a POI names it. The description line only appears on landmarks that carry one — the
/// guard's directions never do, a battleground node's "In Conflict" would.
/// Hovering a POI names it in the map's area label; the description line beneath carries the
/// landmark's status only when it has one — the guard's directions never do, a battleground
/// node's "In Conflict" would — and is blanked otherwise (stock `WorldMapPOI_OnEnter`).
#[test]
fn hovering_a_poi_names_it_and_adds_a_status_line_only_when_there_is_one() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -151,14 +164,15 @@ fn hovering_a_poi_names_it_and_adds_a_status_line_only_when_there_is_one() {
s.run("this = WorldMapFramePOI1 this:GetScript(\"OnEnter\")() this = nil")
.unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
s.eval::<String>("return WorldMapFrameAreaLabel:GetText()")
.unwrap(),
"Lion's Pride Inn"
);
assert!(
s.eval::<bool>("return GameTooltipTextLeft2:GetText() == nil or GameTooltipTextLeft2:GetText() == \"\"")
assert_eq!(
s.eval::<String>("return WorldMapFrameAreaDescription:GetText()")
.unwrap(),
"no description → no second line"
"",
"no description → an empty description line"
);
let mut with_status = landmark("Stables", 6, (0.5, 0.5));
@@ -168,7 +182,7 @@ fn hovering_a_poi_names_it_and_adds_a_status_line_only_when_there_is_one() {
s.run("this = WorldMapFramePOI1 this:GetScript(\"OnEnter\")() this = nil")
.unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft2:GetText()")
s.eval::<String>("return WorldMapFrameAreaDescription:GetText()")
.unwrap(),
"In Conflict"
);
@@ -240,7 +254,7 @@ fn party_blips_sit_at_their_map_positions_and_hide_when_absent() {
};
// Nobody in the party: every slot answers the hide sentinel.
s.run("WorldMapParty_Update()").unwrap();
update(&mut s);
for i in 1..=4 {
assert!(!shown(&s, i), "slot {i} hides while the party is empty");
}
@@ -253,8 +267,9 @@ fn party_blips_sit_at_their_map_positions_and_hide_when_absent() {
0.0,
None,
vec![Some((0.25, 0.75)), None, Some((0.5, 0.125))],
Vec::new(),
);
s.run("WorldMapParty_Update()").unwrap();
update(&mut s);
let diag = s
.eval::<String>(
r#"return "p1="..tostring(GetPlayerMapPosition("party1")).." p3="..tostring(GetPlayerMapPosition("party3"))"#,
@@ -289,14 +304,162 @@ fn party_blips_sit_at_their_map_positions_and_hide_when_absent() {
);
// The party breaks up: the blips go with it.
s.set_world_map_feed(None, Some((0.5, 0.5)), 0.0, None, Vec::new());
s.run("WorldMapParty_Update()").unwrap();
s.set_world_map_feed(None, Some((0.5, 0.5)), 0.0, None, Vec::new(), Vec::new());
update(&mut s);
for i in 1..=4 {
assert!(!shown(&s, i), "slot {i} hides when the party is gone");
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The player arrow is the reference's own** (1980): `WorldMapFrame_OnLoad` creates it
/// (`CreateWorldMapArrowFrame(WorldMapFrame)`, an anonymous `Model` pane holding the minimap
/// arrow), and the update seats it at the player's UV on the detail sheet — the same point the
/// `WorldMapPlayer` mouseover button is seated at — turns it to the facing and shows it; off-map
/// hides it. The pane extracts as the minimap-arrow model the app draws as a sprite.
#[test]
fn the_player_arrow_is_the_stock_model_pane_seated_and_turned_by_the_update() {
let _data = benilla_formats::wow_data_or_skip!();
// The whole manifest, not the kit: the arrow's quad is read off the render list, which
// needs the map VISIBLE — and showing it is `ShowUIPanel`'s full-screen route through
// `UIParent` (see the furniture test below for the same setup and why the player exists).
let mut s = UiScript::new().unwrap();
s.set_screen_size(1600.0, 900.0);
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Probefour".into()),
level: 60,
..Default::default()
}),
);
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
s.resolve();
s.run("ShowUIPanel(WorldMapFrame)").unwrap();
s.resolve();
s.set_world_map_feed(None, Some((0.25, 0.75)), 1.25, None, Vec::new(), Vec::new());
update(&mut s);
s.resolve();
let arrow = |s: &mut UiScript| {
s.extract().into_iter().find(|q| {
matches!(&q.content, QuadContent::ModelPane { model: Some(m), .. } if m == ARROW_MODEL)
})
};
let pane = arrow(&mut s).expect("the arrow pane is in the render list");
assert!(
matches!(&pane.content, QuadContent::ModelPane { facing, .. } if (*facing - 1.25).abs() < 1e-6),
"turned to the facing: {:?}",
pane.content
);
let rect = pane.rect.expect("…with a resolved rect");
assert!(
rect.right > rect.left && rect.top > rect.bottom,
"sized: {rect:?}"
);
// The seat, read back in Lua: the arrow is anonymous, so it is found among the map's
// children by kind and compared against the mouseover button the update seats at the same
// point.
let (dx, dy) = s
.eval::<(f64, f64)>(
r#"local px, py = WorldMapPlayer:GetCenter()
for _, child in ipairs({ WorldMapFrame:GetChildren() }) do
if child:GetObjectType() == "Model" and child:IsShown() then
local ax, ay = child:GetCenter()
return ax - px, ay - py
end
end
return 1e9, 1e9"#,
)
.unwrap();
assert!(
dx.abs() < 0.5 && dy.abs() < 0.5,
"seated with WorldMapPlayer ({dx}, {dy})"
);
// Off the displayed map: the (0,0) sentinel hides it.
s.set_world_map_feed(None, None, 0.0, None, Vec::new(), Vec::new());
update(&mut s);
s.resolve();
assert!(arrow(&mut s).is_none(), "off-map hides the arrow");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Battleground teammates and the flag carrier** (1980) come through the position family the
/// stock update polls after the party arm: each teammate takes the next `WorldMapRaid` frame
/// past the raid's own, carrying its name for the tooltip; the carrier takes `WorldMapFlag1`
/// wearing the token's texture; the `(0, 0)` sentinel hides; and the empty push — leaving the
/// battleground — hides them all.
#[test]
fn battleground_teammates_and_the_flag_draw_from_the_position_family() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.set_world_map_feed(None, Some((0.5, 0.5)), 0.0, None, Vec::new(), Vec::new());
s.set_battlefield_positions(
vec![
BattlefieldPositionView {
uv: (0.25, 0.75),
name: Some("Probe".into()),
},
BattlefieldPositionView {
uv: (0.0, 0.0),
name: None,
},
],
Some(BattlefieldFlagView {
uv: (0.4, 0.6),
token: Some("HordeFlag".into()),
}),
1.0,
);
update(&mut s);
assert!(
s.eval::<bool>(r#"return WorldMapRaid1:IsShown() and WorldMapRaid1.name == "Probe""#)
.unwrap(),
"the placed teammate shows on the first raid frame with its name"
);
assert!(
!s.eval::<bool>("return WorldMapRaid2:IsShown()").unwrap(),
"the (0,0) teammate hides"
);
assert!(s.eval::<bool>("return WorldMapFlag1:IsShown()").unwrap());
assert_eq!(
s.eval::<String>("return WorldMapFlag1Texture:GetTexture()")
.unwrap(),
r"Interface\WorldStateFrame\HordeFlag"
);
assert!(!s.eval::<bool>("return WorldMapFlag2:IsShown()").unwrap());
let (w, h) = s
.eval::<(f64, f64)>(
"return WorldMapDetailFrame:GetWidth(), WorldMapDetailFrame:GetHeight()",
)
.unwrap();
let (sheet_left, sheet_top) = s
.eval::<(f64, f64)>("return WorldMapDetailFrame:GetLeft(), WorldMapDetailFrame:GetTop()")
.unwrap();
let (bx, by) = s
.eval::<(f64, f64)>("return WorldMapRaid1:GetCenter()")
.unwrap();
assert!(
(bx - (sheet_left + 0.25 * w)).abs() < 0.5,
"u scales by the sheet's width"
);
assert!(
(by - (sheet_top - 0.75 * h)).abs() < 0.5,
"v runs DOWN from the sheet's top"
);
s.set_battlefield_positions(Vec::new(), None, 1.0);
update(&mut s);
assert!(
!s.eval::<bool>("return WorldMapRaid1:IsShown() or WorldMapFlag1:IsShown()")
.unwrap(),
"leaving the battleground hides them"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Opening the map must not hide the map's own furniture.** (Director report: the world showed
/// through at the left and right of the sheet, where black bars had always been.)
///