diff --git a/crates/benilla-app/assets/ui/MinimapCluster.xml b/crates/benilla-app/assets/ui/MinimapCluster.xml
deleted file mode 100644
index 96ddb77c..00000000
--- a/crates/benilla-app/assets/ui/MinimapCluster.xml
+++ /dev/null
@@ -1,645 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- MinimapZoneTextButton_OnEnter();
-
-
- GameTooltip:Hide();
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ToggleMinimap();
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- this:RegisterEvent("PLAYER_AURAS_CHANGED");
-
-
- MiniMapTrackingFrame_OnEvent();
-
-
- if ( arg1 == "RightButton" ) then
- CancelTrackingBuff();
- end
-
-
- MiniMapTrackingFrame_OnEnter();
-
-
- GameTooltip:Hide();
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- this:RegisterEvent("UPDATE_PENDING_MAIL");
- this:SetFrameLevel(this:GetFrameLevel()+1)
-
-
- MiniMapMailFrame_OnEvent();
-
-
- MiniMapMailFrame_OnEnter();
-
-
- GameTooltip:Hide();
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Minimap_ZoomInClick();
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Minimap_ZoomOutClick();
-
-
-
-
-
-
-
-
-
-
-
-
-
- Minimap_OnClick();
-
-
-
-
-
-
- Minimap_Update();
- -- Seed the +/- state from the live zoom (default index 3 ⇒ both enabled) rather than
- -- hardcoding a zoom-0 "can't zoom out" — the client's default is 3, not the widest.
- Minimap_UpdateZoom();
- this:RegisterEvent("MINIMAP_ZONE_CHANGED");
- this:RegisterEvent("MINIMAP_UPDATE_ZOOM");
-
-
- if ( event == "MINIMAP_UPDATE_ZOOM" ) then
- Minimap_UpdateZoom();
- else
- Minimap_Update();
- end
-
-
-
-
diff --git a/crates/benilla-app/assets/ui/UiPanels.xml b/crates/benilla-app/assets/ui/UiPanels.xml
index b1a242b6..a8b69f06 100644
--- a/crates/benilla-app/assets/ui/UiPanels.xml
+++ b/crates/benilla-app/assets/ui/UiPanels.xml
@@ -70,6 +70,8 @@
-- arrived on both clients, yet no window). pushable=1 lets a pushable=0 panel (a merchant)
-- shove it to center, the reference's browse-while-trading arbitration.
UIPanelWindows["TradeFrame"] = { area = "left", pushable = 1 }
+ -- UIParent.lua l.38, verbatim — the battlemaster's instance list (decision 1974).
+ UIPanelWindows["BattlefieldFrame"] = { area = "left", pushable = 0 }
-- The trainer window (decision 0237 phase 3, TrainerFrame.xml): an NPC session exactly like
-- Gossip/Merchant above (area "left", pushable 0) — competes for/replaces them at the slot.
UIPanelWindows["BenillaTrainerFrame"] = { area = "left", pushable = 0 }
diff --git a/crates/benilla-app/assets/ui/WorldMapFrame.xml b/crates/benilla-app/assets/ui/WorldMapFrame.xml
deleted file mode 100644
index 20655b36..00000000
--- a/crates/benilla-app/assets/ui/WorldMapFrame.xml
+++ /dev/null
@@ -1,953 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- WorldMapMarker_ShowTooltip(this, this.name, this.description);
-
-
- GameTooltip:Hide();
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- WorldMapContinentsDropDown_OnLoad();
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- WorldMapZoneDropDown_OnLoad();
- this:RegisterEvent("WORLD_MAP_UPDATE");
-
-
- if ( this:IsVisible() ) then
- WorldMapContinentsDropDown_OnLoad();
- WorldMap_UpdateContinentDropDownText();
- WorldMapZoneDropDown_OnLoad();
- WorldMap_UpdateZoneDropDownText();
- end
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- WorldMapZoomOutButton_OnClick();
-
-
-
-
-
-
-
-
-
-
-
-
-
- HideUIPanel(WorldMapFrame);
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- this.unit = "player";
- this:SetFrameLevel(this:GetFrameLevel() + 1);
-
- WorldMapUnit_OnEnter()
- WorldMapTooltip:Hide()
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- WorldMapMarker_ShowTooltip(this, "|cffff2020Corpse|r")
-
-
- GameTooltip:Hide()
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- this:RegisterForClicks("LeftButtonUp", "RightButtonUp");
-
-
- WorldMapButton_OnClick(arg1);
-
-
- WorldMapButton_OnUpdate(arg1);
-
-
-
-
-
-
- this:RegisterEvent("WORLD_MAP_UPDATE");
- -- The hover area label inherits WorldMapTextFont (nominal 102). 33 was the
- -- director's taste call (0235) — which the one-to-one raster cap (the fold-back
- -- record; wow-re fontstring-overflow.md) now clamps to the byte law's 32, one
- -- unit under that call and closer to it than the 36 the atlas snap used to
- -- deliver. The real client draws this SetFont(33) at 32 too. Still a taste dial:
- -- any value ≤ 32 is honored exactly.
- WorldMapFrameAreaLabel:SetFont("Fonts\\FRIZQT__.TTF", 33);
- WorldMapFrame_Update();
-
-
-
- BlackoutWorld:Show();
- SetupFullscreenScale();
- SetMapToCurrentZone();
- WorldMapFrame_Update();
- if UpdateMicroButtons then UpdateMicroButtons() end;
- PlaySound("igQuestLogOpen");
- CloseDropDownMenus();
-
-
- BlackoutWorld:Hide();
- if UpdateMicroButtons then UpdateMicroButtons() end;
- PlaySound("igQuestLogClose");
-
-
- if ( WorldMapFrame:IsVisible() ) then
- WorldMapFrame_Update();
- end
-
-
-
-
-
-
-
diff --git a/crates/benilla-app/assets/ui/WorldMapFrameTemplates.xml b/crates/benilla-app/assets/ui/WorldMapFrameTemplates.xml
deleted file mode 100644
index 63ac7ca1..00000000
--- a/crates/benilla-app/assets/ui/WorldMapFrameTemplates.xml
+++ /dev/null
@@ -1,72 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
- this:SetFrameLevel(this:GetFrameLevel() + 1);
- this.unit = "party" .. this:GetID();
- getglobal(this:GetName() .. "Icon"):SetTexture("Interface\\WorldMap\\WorldMapPartyIcon");
-
- WorldMapUnit_OnEnter()
- WorldMapTooltip:Hide()
-
-
-
-
-
-
-
- this:SetFrameLevel(this:GetFrameLevel() + 1);
- getglobal(this:GetName() .. "Icon"):SetTexture("Interface\\WorldMap\\WorldMapPartyIcon");
-
-
-
-
diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc
index c03712b2..1cba6c08 100644
--- a/crates/benilla-app/assets/ui/benilla.toc
+++ b/crates/benilla-app/assets/ui/benilla.toc
@@ -441,10 +441,23 @@ Interface\FrameXML\BonusActionBarFrame.xml
# their OnShow/OnHide calls the `UpdateMicroButtons` this file defines (the panel frames
# themselves are read back through getglobal, so the reverse dependency is call-time).
MicroMenu.xml
-# The HUD minimap cluster (decision 0203 phase 1): the chrome around the engine-rendered
-# widget hole (`crate::minimap` fills it). After GameTooltip (its zone-text
-# hover uses the shared tooltip).
-MinimapCluster.xml
+# ══ THE BATTLEGROUND LIST + THE MINIMAP CLUSTER — the reference's own, off the player's chain
+# (1751; decision 1974) ══
+# BattlefieldFrame.xml + BattlefieldFrame.lua: the battlemaster's instance list (the "First
+# Available" row, the join and join-as-group buttons) over `crate::ui_battlefield`'s feed and
+# the engine's queue family (GetBattlefieldStatus & co.), and — defined here, consumed by the
+# minimap file below at LOAD time — the minimap queue icon's status pass and its right-click
+# dropdown. The reference's toc seats it right before Minimap.xml (l.29-30) for that reason.
+# After UIDropDownMenu.xml (the dropdown template) and UIPanelTemplates (the buttons, the faux
+# scroll frame).
+Interface\FrameXML\BattlefieldFrame.xml
+# Minimap.xml + Minimap.lua: the cluster around the engine-rendered widget hole
+# (`crate::minimap` fills it), the zone text, the zoom buttons, the tracking / mail / meeting
+# stone / battlefield icons, and `MiniMapPing` — a this engine draws no pixels for; the
+# app's ping sprite (`minimap/ping.rs`) follows that frame's own show/hide and alpha, so the
+# 5 s lifetime is this file's, as the reference's is. After GameTooltip (the zone-text hover).
+# 0203's transcription is gone.
+Interface\FrameXML\Minimap.xml
# ══ THE TIME-OF-DAY INDICATOR — the reference's own, off the player's chain (1751, window 5) ══
# `GameTimeFrame`, `GameTimeTexture`, and `GameTime_GetTime` / `GameTimeFrame_Update` /
# `_UpdateTooltip` / `GAMETIME_DAWN`/`_DUSK` out of `GameTime.lua`. Ours was a re-implementation of
@@ -528,13 +541,16 @@ OptionsFrameTemplates.xml
# entry and the ESC rung that cancel-clicks it. UIDropDownMenu.xml sits above too, but that
# direction is call-time only, so the two could sit either way round.
Interface\FrameXML\ColorPickerFrame.xml
-# The fullscreen world map (decision 0203 phase 2), over the worldmap host API
-# (`crate::ui_world_map` feeds it); the 'M' binding (ui_script/input.rs) calls its
-# ToggleWorldMap().
-# Its per-unit blip templates load FIRST: WorldMapFrame.xml instantiates WorldMapParty1..4 and
-# WorldMapRaid1..40 with inherits=, so the virtual Frames have to be registered before it.
-WorldMapFrameTemplates.xml
-WorldMapFrame.xml
+# ══ THE WORLD MAP — the reference's own, off the player's chain (1751, migrated 1980) ══
+# The fullscreen map over the worldmap host API (`crate::ui_world_map` feeds the player, corpse,
+# party and landmark positions; `crate::ui_battlefield_positions` the battleground teammates and
+# the flag carrier); the 'M' binding (ui_script/input.rs) calls its ToggleWorldMap(). The file
+# s its own blip templates (WorldMapFrameTemplates.xml) before the frames that inherit
+# them. Its player arrow is the Create/Position/Show/UpdateWorldMapArrowFrames family
+# (`script/worldmap_arrow.rs`), an anonymous Model pane the app draws as the minimap's arrow
+# sprite; its WorldMapPing is a MinimapPing.mdx Model pane this engine holds and draws nothing
+# into (named gap, 1980).
+Interface\FrameXML\WorldMapFrame.xml
# ══ THE CAST BAR — the reference's own, off the player's chain (1751) ══
# `CastingBarFrame` + its Border/Flash/Spark/Text children, driven by the SPELLCAST_* events
# `crate::ui_cast` fires (decision 0137 phase 1). Our `CastingBar.xml` was a transcription of this
@@ -1091,6 +1107,15 @@ Interface\FrameXML\HelpFrame.xml
# nothing else, and nothing needs it — the engine reaches it purely by firing CINEMATIC_START.
# Seated late because it is an outermost, screen-owning layer, like the two windows below it.
Interface\FrameXML\CinematicFrame.xml
+# ══ THE TUTORIAL FRAME — the reference's own, off the player's chain (1751; decision 1976) ══
+# TutorialFrame.xml + TutorialFrame.lua: the alert buttons `TUTORIAL_TRIGGER` queues, the window
+# a click opens over `TUTORIAL_TITLE`/`TUTORIAL`, the "Display Tips" checkbox whose unticked
+# hide calls ClearTutorials, and the CINEMATIC_STOP arm that clicks the Welcome alert when the
+# intro ends. Right after CinematicFrame.xml as the reference's toc has it (l.76-80); its
+# engine — the two flag banks, the fire-once trigger, the four verbs — is `crate::tutorial`.
+# Needs UIPanelTemplates (UICheckButtonTemplate), BasicControls (the dialog button textures),
+# GameTooltip, and UiPanels' SetButtonPulse, all far above.
+Interface\FrameXML\TutorialFrame.xml
# The game menu (decision 0674): the frame ESC opens. LAST on purpose — it is the outermost
# layer of the shell, it depends on everything it touches being loaded (UiPanels' popup engine
# + panel manager, MicroMenu's UpdateMicroButtons, BagFrame's Disable_BagButtons), and nothing
diff --git a/crates/benilla-app/src/char_select/mod.rs b/crates/benilla-app/src/char_select/mod.rs
index e2dd91e4..b00080bc 100644
--- a/crates/benilla-app/src/char_select/mod.rs
+++ b/crates/benilla-app/src/char_select/mod.rs
@@ -1068,6 +1068,7 @@ mod tests {
// One frame carrying both halves of the race, in the order the drain produces them.
app.world_mut().write_message(EnteredWorldMessage {
billing_time_rested: 0,
+ tutorial_flags: None,
});
app.world_mut()
.write_message(crate::net::DisconnectedMessage {
@@ -1109,6 +1110,7 @@ mod tests {
app.world_mut().write_message(EnteredWorldMessage {
billing_time_rested: 0,
+ tutorial_flags: None,
});
app.world_mut()
.write_message(crate::net::DisconnectedMessage {
diff --git a/crates/benilla-app/src/creature_anim.rs b/crates/benilla-app/src/creature_anim.rs
index e23d422e..48558784 100644
--- a/crates/benilla-app/src/creature_anim.rs
+++ b/crates/benilla-app/src/creature_anim.rs
@@ -803,7 +803,7 @@ pub(crate) use impact::{DefenseAnim, PendingImpacts, SwingFlush, SwingImpact, Sw
/// one-shots ([`EmoteAnim`]) and kit sounds ([`spell_visual::SpellKitSound`]).
mod blood;
mod env_damage;
-mod spell_visual;
+pub(crate) mod spell_visual;
use blood::{blood_spurts, load_blood_tables};
use env_damage::{hard_landing_dust, load_env_damage_table};
pub(crate) use env_damage::{EnvDamageTable, HardLanding, HARD_LANDING_DESCENT};
diff --git a/crates/benilla-app/src/creature_anim/spell_visual.rs b/crates/benilla-app/src/creature_anim/spell_visual.rs
index c4691bf7..9f4739eb 100644
--- a/crates/benilla-app/src/creature_anim/spell_visual.rs
+++ b/crates/benilla-app/src/creature_anim/spell_visual.rs
@@ -55,6 +55,25 @@ const LEVEL_UP_EFFECT: &str = "HARDCODED Unit Level Up";
/// 1185 → `Spells\DruidMorph_Impact_Base.mdx`, i.e. literally the druid-morph puff (wow-re
/// `mount-composition.md` Q4b, decision 0927).
const MOUNT_POOF_EFFECT: &str = "HARDCODED Mount Poof";
+/// The meeting-stone join — hardcoded index **0xc** (`0x861808`), spawned on the local player by
+/// the `SMSG 0x295` status-1 arm with the same `Effect_C` shape as the mount poof (wow-re
+/// `meeting-stone-status.md` §8; decision 1974).
+const MEETING_STONE_JOIN_EFFECT: &str = "HARDCODED Meeting Stone Join";
+
+/// The meeting-stone join visual for `entity`, or `None` without client data or the row —
+/// `0x61fae0(kind = 0xc, &guid, completion = 0x5fbf50)`: one-shot, non-persistent, the
+/// end-of-clip terminator, attached at `0x13` like every `HARDCODED` effect.
+pub(crate) fn meeting_stone_join_fx(visuals: &SpellVisuals, entity: Entity) -> Option {
+ let path = visuals.0.hardcoded_effect(MEETING_STONE_JOIN_EFFECT)?;
+ Some(SpellKitFx::Begin {
+ entity,
+ spell_id: 0,
+ persistent: false,
+ class: FxClass::Hold,
+ stage: FxStage::OneShot,
+ effects: vec![(HARDCODED_FX_ATTACH, path.to_string())],
+ })
+}
/// `SpellVisual.dbc` × `SpellVisualKit.dbc` — the stage-kit chain + each kit's anim/sound
/// (`crate::creature_anim::spell_visual` module docs). Optional like every other DBC-backed
@@ -1296,6 +1315,7 @@ pub(super) fn arm_loot_fx(
visuals: Option>,
mut fx: MessageWriter,
mut armed: Local,
+ mut tutorials: Option>,
) {
let full_sweep = visuals.as_ref().is_some_and(|v| v.is_changed());
let Some(path) = visuals.as_ref().and_then(|v| v.0.loot_art_path()) else {
@@ -1317,6 +1337,12 @@ pub(super) fn arm_loot_fx(
store.0.unit_is_dead() && store.0.unit_lootable()
};
if lootable && armed.insert(entity) {
+ // `OnDynamicFlagsChanged`'s rise (`0x60049b`): the Looting tutorial (1976).
+ if let Some(t) = tutorials.as_mut() {
+ t.write(crate::tutorial::TutorialEvent::trigger(
+ crate::tutorial::id::LOOTING,
+ ));
+ }
fx.write(SpellKitFx::Begin {
entity,
spell_id: LOOT_FX_KEY,
diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs
index c7e056f3..005aefb9 100644
--- a/crates/benilla-app/src/lib.rs
+++ b/crates/benilla-app/src/lib.rs
@@ -116,10 +116,13 @@ mod sound;
mod target;
mod textinput;
mod transport;
+mod tutorial;
mod ui_action;
mod ui_auction;
mod ui_aura;
mod ui_bank;
+mod ui_battlefield;
+mod ui_battlefield_positions;
mod ui_battlefield_score;
mod ui_bind_confirm;
mod ui_binder;
@@ -206,10 +209,13 @@ use sound::SoundPlugin;
use target::TargetPlugin;
use textinput::TextInputPlugin;
use transport::TransportPlugin;
+use tutorial::TutorialPlugin;
use ui_action::UiActionPlugin;
use ui_auction::UiAuctionPlugin;
use ui_aura::UiAuraPlugin;
use ui_bank::UiBankPlugin;
+use ui_battlefield::BattlefieldPlugin;
+use ui_battlefield_positions::BattlefieldPositionsPlugin;
use ui_battlefield_score::BattlefieldScorePlugin;
use ui_binder::UiBinderPlugin;
use ui_cast::UiCastPlugin;
@@ -644,6 +650,9 @@ pub fn run(build: BuildId) -> AppExit {
// boot clock, the area spirit healer, the battleground queue, the meeting stone.
.add_plugins(UiDialogVerbsPlugin)
.add_plugins(BattlefieldScorePlugin)
+ .add_plugins(BattlefieldPlugin)
+ .add_plugins(BattlefieldPositionsPlugin)
+ .add_plugins(TutorialPlugin)
// Being summoned (decision 1747): SMSG_SUMMON_REQUEST's latch, the CONFIRM_SUMMON dialog it
// raises, and the CMSG_SUMMON_RESPONSE its Accept sends. The binder's twin one line up — a
// server-asked question whose only wire answer is yes — and here for that reason.
diff --git a/crates/benilla-app/src/minimap/blips/mod.rs b/crates/benilla-app/src/minimap/blips/mod.rs
index 2dad0ead..377adf11 100644
--- a/crates/benilla-app/src/minimap/blips/mod.rs
+++ b/crates/benilla-app/src/minimap/blips/mod.rs
@@ -116,8 +116,12 @@ pub(super) const BLIP_BASIS_PX: f32 = 140.8;
/// quad rendered at 1280 px per model unit — 33.6 px at stock. Its authored centre sits
/// (+0.0004, +0.00135) model units off the frame origin: ≈(+0.5 right, 1.7 up) screen px,
/// rotating with the facing (model +y = screen-up at zero rotation).
-pub(super) const PLAYER_ARROW_QUAD_PX: f32 = 33.6;
-pub(super) const PLAYER_ARROW_OFFSET_PX: bevy::math::Vec2 = bevy::math::Vec2::new(0.51, -1.73);
+/// The player arrow's art — the minimap ring's and, since decision 1980, the world map's: the stock
+/// `WorldMapFrame.lua` `SetModel`s an anonymous pane to the same M2 and the app draws that pane as
+/// this sprite (`ui_script::extract`).
+pub(crate) const PLAYER_ARROW_TEXTURE: &str = "Interface\\Minimap\\MinimapArrow";
+pub(crate) const PLAYER_ARROW_QUAD_PX: f32 = 33.6;
+pub(crate) const PLAYER_ARROW_OFFSET_PX: bevy::math::Vec2 = bevy::math::Vec2::new(0.51, -1.73);
/// The rim arrow's on-screen quad: the `Rotating-MinimapArrow.m2` geometry is a stack of six
/// full-texture 0.0500-unit quads (measured from the real M2's 24 vertices, z 0/0.0145/0.0291),
/// and the frame renders at 768 px per model unit (modelScale 0.6 × 1280 px/unit):
diff --git a/crates/benilla-app/src/minimap/mod.rs b/crates/benilla-app/src/minimap/mod.rs
index 63879b66..a5c3213a 100644
--- a/crates/benilla-app/src/minimap/mod.rs
+++ b/crates/benilla-app/src/minimap/mod.rs
@@ -27,7 +27,7 @@
//! stem; [`blips`] — the phase-3 blip layer (AreaPOI landmark arrows, quest-giver dots, the
//! hover tooltip).
-mod blips;
+pub(crate) mod blips;
/// The party blip's position law, shared with the world map (report B320): one function decides
/// where a member is, so the two surfaces can never disagree about it.
pub(crate) use blips::party_member_pos;
@@ -315,7 +315,7 @@ fn setup_minimap(
Ok(translate) => {
info!("minimap: md5translate.trs — {} tiles", translate.len());
let mask = assets.mask_texture("Textures\\MinimapMask", &mut images);
- let arrow = assets.sprite_texture("Interface\\Minimap\\MinimapArrow", &mut images);
+ let arrow = assets.sprite_texture(blips::PLAYER_ARROW_TEXTURE, &mut images);
let poi = assets.sprite_texture("Interface\\Minimap\\POIIcons", &mut images);
let mut rim_arrows = blips::RimArrowArt::default();
for kind in blips::RimArrow::ALL {
@@ -471,7 +471,14 @@ fn emit_minimap(
// always spent in the frame it was made. Held across frames it would seat against geometry
// the player never clicked on; and a click made on a frame the map does not draw is simply
// not a ping (decision 1596).
- let click = script.and_then(|mut s| s.take_minimap_ping_request());
+ // The stock `MiniMapPing` frame's alpha is read beside it: the ping's lifetime is that
+ // frame's (`ping.rs`), so the sprite wants the frame as it stands after this tick.
+ let (click, ping_shown) = script.map_or((None, None), |mut s| {
+ (
+ s.take_minimap_ping_request(),
+ s.frame_effective_alpha(ping::PING_FRAME),
+ )
+ });
let (Some(slot), Some(assets), Some(map), Some(catalog)) =
(widget.0.as_ref(), assets, map, catalog)
else {
@@ -939,7 +946,7 @@ fn emit_minimap(
// This is also where a `Minimap:PingLocation` click is drained: the geometry it must resolve
// against is the geometry standing right here, this frame (decision 1596).
if let Some(ctx) = &blip_ctx {
- ping::emit_ping(ctx, &mut ping, click, map.0, &assets.ping, &mut quads);
+ ping::emit_ping(ctx, &mut ping, click, ping_shown, &assets.ping, &mut quads);
}
}
diff --git a/crates/benilla-app/src/minimap/ping.rs b/crates/benilla-app/src/minimap/ping.rs
index 67e4f0f2..76e804b9 100644
--- a/crates/benilla-app/src/minimap/ping.rs
+++ b/crates/benilla-app/src/minimap/ping.rs
@@ -29,14 +29,23 @@
//! offsets the byte-verified relay `0x4ee330` hands Lua (`(−dy·k, dx·k)`, `k = 1/(2·radius)` —
//! wow-re `party-group-wire.md` §TU-D). `Minimap:GetPingPosition()` reads the live value back.
//!
-//! ## Lifetime
+//! ## Lifetime — the stock `Minimap.lua`'s, not ours (1974)
//!
-//! 5 s hold, then the reference's 0.5 s "fade" — see [`PING_ALPHA_IS_A_POP`]. A map change drops
-//! it (the point is not here any more). **Nothing else clears it**, and in particular *not*
-//! proximity: the first version applied the client's 10-yd `d² < 100` auto-clear to the party
-//! ping, and that clear belongs to the **`SMSG_GOSSIP_POI` marker** — a different feature in a
-//! different slot (wow-re `party-group-wire.md` §TU-D corrects it explicitly; `MSG_MINIMAP_PING`
-//! has no C-side storage at all). Walking to your own ping used to delete it mid-hold.
+//! The reference splits the ping in two: the engine stores the world point in a pair of statics
+//! **nothing ever clears** (wow-re `minimap-ping-law.md` §3 — six instructions touch those cells
+//! and the only zeroing is a CRT initializer), and FrameXML owns everything visible — the
+//! `MiniMapPing` `` it shows on `MINIMAP_PING`, re-seats every frame from
+//! `GetPingPosition()`, holds 5 s (`MINIMAPPING_TIMER`), "fades" 0.5 s through a `SetAlpha(255·t)`
+//! that clamps to full until the last ~2 ms, and hides. Since 1751's swap that file runs here
+//! verbatim, and this engine's `` draws no pixels — so the sprite below is **that frame's
+//! renderer**: it draws while `MiniMapPing` is effectively visible, at its effective alpha, and
+//! keeps no clock of its own. The world point is likewise never cleared: `GetPingPosition()`
+//! answers two numbers always, which the stock `Minimap_OnUpdate` multiplies without a nil test.
+//!
+//! What is *not* a lifetime: proximity. The first version applied the client's 10-yd
+//! `d² < 100` auto-clear to the party ping, and that clear belongs to the **`SMSG_GOSSIP_POI`
+//! marker** — a different feature in a different slot (wow-re `party-group-wire.md` §TU-D
+//! corrects it explicitly). Walking to your own ping used to delete it mid-hold.
use bevy::prelude::*;
@@ -48,20 +57,12 @@ use crate::net::{ClientCommand, Guid, NetCommands, SelfPlayer};
use crate::player::Player;
use crate::ui_pass::{UiQuad, UiQuads};
-/// How long the ping holds at full strength — the reference's `MINIMAPPING_TIMER`.
-const PING_HOLD: f32 = 5.0;
-/// The tail after the hold — the reference's `MINIMAPPING_FADE_TIMER`.
-const PING_FADE: f32 = 0.5;
-
-/// **The reference's ping does not fade; it pops** — now byte-confirmed, not inferred from the Lua
-/// (wow-re `minimap-ping-law.md`). `Minimap_OnUpdate` writes `SetAlpha(255 * (t / 0.5))`, and
-/// `Frame:SetAlpha` (`0x774e90`) **clamps to [0,1]** before scaling to a 0–255 byte — so the
-/// nominal half-second ramp is at full alpha for 498 of its 500 ms and the whole visible fall is
-/// ~12 % of one frame at 60 fps. The real client holds bright for ~5 s and vanishes.
-///
-/// Reproduced as the clamp rather than as a constant 1.0, so the mechanism stays visible and the
-/// `false` branch is a real alternative (a smooth fade, which wow-re notes will visibly diverge).
-const PING_ALPHA_IS_A_POP: bool = true;
+/// The stock frame whose pixels this module draws: `Minimap.xml`'s ``.
+/// Its show/hide and alpha ARE the ping's lifetime (module doc). The reference's "fade" pops
+/// rather than fades — `Minimap_OnUpdate` writes `SetAlpha(255 · t/0.5)` and `Frame:SetAlpha`
+/// (`0x774e90`) clamps to [0,1] first, so the frame reads full alpha for 498 of the 500 ms — and
+/// that arrives here through the frame's effective alpha, with no constant of ours to keep it.
+pub(super) const PING_FRAME: &str = "MiniMapPing";
/// **The marker, byte-measured** (wow-re `system/ui/scratch/minimap-ping-law.md` §10, VERIFIED).
///
@@ -165,27 +166,27 @@ pub(super) struct PingArt {
pub(super) ping4: Option>,
}
-/// The live ping. One at a time — the reference keeps no list either.
+/// The stored ping — the reference's two statics. One, never cleared: the reference keeps no
+/// list, and no map tag either (a worldport mid-ping re-projects the old point against the new
+/// map's player position, which the stock Lua's disc test then hides — reproduced as is).
struct LivePing {
/// **The pin**: the WoW `(x, y)` this ping marks. The only stored position; the screen seat is
/// re-derived from it every frame.
world: (f32, f32),
- /// The map it was placed on. A map change drops the ping rather than re-projecting a point
- /// that does not exist here.
- map: u32,
- /// Seconds since it was seated.
- age: f32,
/// The pinger's guid, `0` = ourselves — resolved to the `MINIMAP_PING` event's unit token, and
/// the test for "this is ours, put it on the wire".
sender: u64,
}
/// The engine-owned ping state (decision 1596). Seated by a click (drained in the renderer, with
-/// that frame's geometry) or by a group member's `MSG_MINIMAP_PING`; aged, announced and expired
-/// by [`drive_minimap_ping`].
+/// that frame's geometry) or by a group member's `MSG_MINIMAP_PING`; announced by
+/// [`drive_minimap_ping`]; shown for exactly as long as the stock `MiniMapPing` frame is.
#[derive(Resource, Default)]
pub(crate) struct MinimapPing {
live: Option,
+ /// [`PING_FRAME`]'s effective alpha while it is effectively visible, as the renderer last read
+ /// it — `None` hidden. The model clock below runs only while this is `Some`.
+ shown: Option,
/// The model's own clock, in **milliseconds, accumulated only while a ping is shown** — which
/// is the reference's behaviour rather than a simplification of it. `SetSequence(0)` runs once
/// in the ref's `OnLoad` and stores an *anchor*; the sampler free-runs from it, and a Model
@@ -202,34 +203,10 @@ impl MinimapPing {
/// Seat a ping at a world point. Re-pinging replaces: the reference tolerates the same, and a
/// group echo of our own click lands on the spot we already drew (`Minimap_SetPing` twice on
/// one spot just restarts the timer).
- pub(crate) fn seat(&mut self, world: (f32, f32), map: u32, sender: u64) {
- self.live = Some(LivePing {
- world,
- map,
- age: 0.0,
- sender,
- });
+ pub(crate) fn seat(&mut self, world: (f32, f32), sender: u64) {
+ self.live = Some(LivePing { world, sender });
self.fresh = true;
}
-
- /// The ping's alpha at its current age, or `None` once it is over.
- fn alpha(&self) -> Option {
- let age = self.live.as_ref()?.age;
- if age <= PING_HOLD {
- return Some(1.0);
- }
- let tail = (PING_HOLD + PING_FADE - age) / PING_FADE;
- if tail <= 0.0 {
- None
- } else if PING_ALPHA_IS_A_POP {
- // The reference's `255 * tail`, clamped by SetAlpha's own 0..1 — full until the last
- // ~2 ms. Written as the clamp rather than as a constant 1.0 so the mechanism is
- // visible and the `false` branch is a real alternative, not a rewrite.
- Some((255.0 * tail).min(1.0))
- } else {
- Some(tail)
- }
- }
}
/// Convert a `Minimap:PingLocation(x, y)` click into the world point it names, drain-side.
@@ -254,8 +231,10 @@ fn click_to_world(ctx: &BlipCtx, ui: (f32, f32), seam: f32) -> Option<(f32, f32)
Some((ctx.wx + up_yd, ctx.wy - right_yd))
}
-/// Seat this frame's `Minimap:PingLocation` click and draw the live ping — both inside the
+/// Seat this frame's `Minimap:PingLocation` click and draw the ping — both inside the
/// renderer, against the geometry the player actually clicked on and the map actually drew at.
+/// `shown` is the stock [`PING_FRAME`]'s effective alpha this frame (`None` = hidden), read
+/// beside the click: the frame is the ping's lifetime (module doc), so nothing draws without it.
///
/// The seat happens here rather than in a system of its own precisely so there is no window in
/// which a click is held against a *stale* view scale: the first version parked the click for a
@@ -266,15 +245,16 @@ pub(super) fn emit_ping(
ctx: &BlipCtx,
ping: &mut MinimapPing,
click: Option<(f32, f32)>,
- map: u32,
+ shown: Option,
art: &PingArt,
quads: &mut UiQuads,
) {
if let Some(world) = click.and_then(|c| click_to_world(ctx, c, ctx.seam)) {
- ping.seat(world, map, 0);
+ ping.seat(world, 0);
}
- let Some(alpha) = ping.alpha() else { return };
+ ping.shown = shown;
+ let Some(alpha) = shown else { return };
let Some(live) = ping.live.as_ref() else {
return;
};
@@ -282,7 +262,8 @@ pub(super) fn emit_ping(
// The reference's Lua hides the marker outside the disc and keeps the ping alive
// (`Minimap_SetPing`'s else-branch is `MiniMapPing:Hide()`, not a clear) — so walking back
- // into range brings it back for the rest of its 5 s.
+ // into range brings it back for the rest of its 5 s. The stock file makes that test itself
+ // and hides the frame; this one is the same test in yards, for the frame it cannot see.
let d = (px - ctx.wx).hypot(py - ctx.wy);
if d >= ctx.radius_yd {
return;
@@ -329,7 +310,7 @@ pub(super) fn emit_ping(
);
}
-/// Age the ping, announce a fresh one, and expire it — everything that is *not* geometry.
+/// Announce a fresh ping and republish the position — everything that is *not* geometry.
///
/// Runs before the script tick so the `MINIMAP_PING` event and the position behind
/// `Minimap:GetPingPosition()` land in the same tick, and so an addon's handler sees a ping that
@@ -340,46 +321,21 @@ pub(super) fn drive_minimap_ping(
mut ping: ResMut,
time: Res,
player: Res,
- map: Option>,
widget: Res,
inside: Res,
group: Res,
self_q: Query<&Guid, With>,
commands: Res,
) {
- // Ageing and expiry run before the VM guard: a ping seated with no UI up (the wire can land
- // one across a world-enter) must still run out, rather than waiting to start its five seconds
- // whenever a VM next appears.
- if let Some(live) = ping.live.as_mut() {
- live.age += time.delta_secs();
- // The model clock runs only while a ping is up — see `shown_ms`. (The reference stops it
- // on `Hide()`, which also covers the off-disc case; we keep it running there, a difference
- // of a few tenths of a phase on a marker nobody can see.)
+ // The model clock runs only while the frame is shown — see `shown_ms`. (The reference's
+ // `Hide()` covers the off-disc case too; ours keeps running there, a difference of a few
+ // tenths of a phase on a marker nobody can see.)
+ if ping.shown.is_some() {
ping.shown_ms += time.delta_secs() * 1000.0;
}
- // A map change drops it — a DELIBERATE divergence, now that the law is known. wow-re
- // `minimap-ping-law.md` §3 is categorical: **nothing** engine-side ever clears the reference's
- // ping (six instructions touch those cells; the only zeroing is a CRT static initializer before
- // `WinMain`), so a real 1.12 client that teleports mid-ping keeps drawing a marker at a world
- // point that is now somewhere else entirely. That is a consequence of storing the ping in a
- // never-cleared global, not a behaviour anyone chose, and the 5 s timer hides it in practice.
- // We drop it instead: same observable in every case a player can produce, minus the stale
- // marker after a worldport.
- if let (Some(live), Some(map)) = (ping.live.as_ref(), map.as_ref()) {
- if live.map != map.0 {
- ping.live = None;
- }
- }
- if ping.alpha().is_none() {
- ping.live = None;
- }
- if ping.live.is_none() {
- ping.fresh = false;
- }
let Some(mut script) = script else { return };
let Some(live) = ping.live.as_ref() else {
- script.set_minimap_ping(None);
return;
};
@@ -402,7 +358,7 @@ pub(super) fn drive_minimap_ping(
);
let k = 1.0 / (2.0 * radius);
let norm = ((wow[1] - live.world.1) * k, (live.world.0 - wow[0]) * k);
- script.set_minimap_ping(Some(norm));
+ script.set_minimap_ping(norm);
if !std::mem::take(&mut ping.fresh) {
return;
@@ -523,7 +479,7 @@ mod tests {
fn the_marker_tracks_the_world_as_the_player_walks() {
let mut c = ctx();
let mut ping = MinimapPing::default();
- ping.seat((30.0, 0.0), 0, 0); // 30 yd north of the player
+ ping.seat((30.0, 0.0), 0); // 30 yd north of the player
let live = ping.live.as_ref().unwrap();
let before = c.offset([live.world.0, live.world.1, 0.0]);
assert!(before.y < 0.0, "north draws UP the screen: {before:?}");
@@ -542,29 +498,43 @@ mod tests {
/// storage to clear at all. Standing on your own ping must not delete it.
#[test]
fn reaching_the_ping_does_not_clear_it() {
+ let mut c = ctx();
let mut ping = MinimapPing::default();
- ping.seat((1.0, 1.0), 0, 0);
- assert!(ping.alpha().is_some());
- // Age it well inside the hold, standing right on top of the point.
- ping.live.as_mut().unwrap().age = 2.0;
- assert_eq!(ping.alpha(), Some(1.0), "a reached ping still holds");
+ let mut quads = UiQuads::default();
+ ping.seat((1.0, 1.0), 0);
+ // Walk onto the point: it still draws.
+ c.wx = 1.0;
+ c.wy = 1.0;
+ emit_ping(&c, &mut ping, None, Some(1.0), &art(), &mut quads);
+ assert!(!quads.overlays.is_empty(), "a reached ping still draws");
}
- /// The hold, then the reference's clamped tail, then gone.
+ /// **The lifetime is the stock frame's** (1974): the sprite draws while `MiniMapPing` is
+ /// shown, at the frame's alpha, and nothing here ages it. The model clock advances only
+ /// while the frame is shown.
#[test]
- fn the_ping_holds_five_seconds_and_pops() {
+ fn the_marker_draws_only_while_the_stock_frame_is_shown() {
+ let c = ctx();
let mut ping = MinimapPing::default();
- ping.seat((0.0, 0.0), 0, 0);
- for (age, want) in [(0.0, Some(1.0)), (4.9, Some(1.0)), (5.4, Some(1.0))] {
- ping.live.as_mut().unwrap().age = age;
- assert_eq!(ping.alpha(), want, "at {age}s");
- }
- // The last ~2 ms is the only part of the "fade" that is below full.
- ping.live.as_mut().unwrap().age = PING_HOLD + PING_FADE - 0.0005;
- let a = ping.alpha().expect("still alive");
- assert!(a > 0.0 && a < 1.0, "the pop's one dim frame: {a}");
- ping.live.as_mut().unwrap().age = PING_HOLD + PING_FADE;
- assert_eq!(ping.alpha(), None, "over");
+ let mut quads = UiQuads::default();
+ ping.seat((0.0, 0.0), 0);
+ emit_ping(&c, &mut ping, None, None, &art(), &mut quads);
+ assert!(quads.overlays.is_empty(), "hidden frame: nothing drawn");
+ assert!(ping.shown.is_none());
+ emit_ping(&c, &mut ping, None, Some(0.5), &art(), &mut quads);
+ assert!(!quads.overlays.is_empty(), "shown frame: drawn");
+ assert!(
+ quads
+ .overlays
+ .iter()
+ .all(|q| (q.color[3] - 0.5).abs() < 1e-6 || q.color[3] == 0.0),
+ "at the frame's alpha (the ring is transparent at phase 0)"
+ );
+ assert_eq!(ping.shown, Some(0.5));
+ assert!(
+ ping.live.is_some(),
+ "the world point outlives the frame's show"
+ );
}
fn art() -> PingArt {
@@ -588,11 +558,18 @@ mod tests {
let art = art();
// No ping: nothing drawn.
- emit_ping(&c, &mut ping, None, 0, &art, &mut quads);
+ emit_ping(&c, &mut ping, None, Some(1.0), &art, &mut quads);
assert!(quads.overlays.is_empty());
// A click 30 UI units up (north) at seam 1 seats a ping 30/px_per_yd yards north.
- emit_ping(&c, &mut ping, Some((0.0, 30.0)), 0, &art, &mut quads);
+ emit_ping(
+ &c,
+ &mut ping,
+ Some((0.0, 30.0)),
+ Some(1.0),
+ &art,
+ &mut quads,
+ );
assert_eq!(
quads.overlays.len(),
2,
@@ -618,9 +595,9 @@ mod tests {
quads.overlays.clear();
let mut far = ctx();
far.wx = -200.0;
- emit_ping(&far, &mut ping, None, 0, &art, &mut quads);
+ emit_ping(&far, &mut ping, None, Some(1.0), &art, &mut quads);
assert!(quads.overlays.is_empty(), "off the disc: hidden");
- emit_ping(&c, &mut ping, None, 0, &art, &mut quads);
+ emit_ping(&c, &mut ping, None, Some(1.0), &art, &mut quads);
assert_eq!(quads.overlays.len(), 2, "back in range: visible again");
}
@@ -635,10 +612,10 @@ mod tests {
let art = art();
let ring_at = |ms: f32| {
let mut ping = MinimapPing::default();
- ping.seat((0.0, 0.0), 0, 0);
+ ping.seat((0.0, 0.0), 0);
ping.shown_ms = ms;
let mut quads = UiQuads::default();
- emit_ping(&c, &mut ping, None, 0, &art, &mut quads);
+ emit_ping(&c, &mut ping, None, Some(1.0), &art, &mut quads);
// The ring is the third layer whenever it is visible at all.
quads
.overlays
@@ -701,9 +678,9 @@ mod tests {
#[test]
fn a_second_ping_resumes_the_animation_rather_than_restarting_it() {
let mut ping = MinimapPing::default();
- ping.seat((0.0, 0.0), 0, 0);
+ ping.seat((0.0, 0.0), 0);
ping.shown_ms = 1234.0;
- ping.seat((10.0, 10.0), 0, 0);
+ ping.seat((10.0, 10.0), 0);
assert!(
(ping.shown_ms - 1234.0).abs() < f32::EPSILON,
"the clock is the model's, not the ping's"
diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs
index d3ddeab6..ca083aad 100644
--- a/crates/benilla-app/src/net.rs
+++ b/crates/benilla-app/src/net.rs
@@ -1282,6 +1282,40 @@ pub(crate) enum ClientCommand {
},
/// `CancelMeetingStoneRequest()` — `CMSG 0x293`, empty.
MeetingStoneLeave,
+ /// The enter-world meeting-stone status query — `CMSG 0x296`, empty (decision 1974).
+ MeetingStoneStatusQuery,
+ // ── The tutorial system (decision 1976) ──
+ /// `FlagTutorial` / an auto-acknowledge site — `CMSG_TUTORIAL_FLAG`, the 0-based id.
+ TutorialFlag {
+ id: u32,
+ },
+ /// `ClearTutorials()` — `CMSG_TUTORIAL_CLEAR`, empty.
+ TutorialClear,
+ /// `ResetTutorials()` — `CMSG_TUTORIAL_RESET`, empty.
+ TutorialReset,
+ // ── The battleground list window (decision 1974) ──
+ /// `ShowBattlefieldList(index)` — `CMSG_BATTLEFIELD_LIST`: the queued slot's map.
+ BattlefieldList {
+ map_id: u32,
+ },
+ /// `JoinBattlefield` when the list came from a battlemaster — `CMSG_BATTLEMASTER_JOIN`.
+ BattlemasterJoin {
+ battlemaster: u64,
+ map_id: u32,
+ instance_id: u32,
+ as_group: bool,
+ },
+ /// `RequestBattlefieldPositions()` — `MSG_BATTLEGROUND_PLAYER_POSITIONS` outbound (empty;
+ /// decision 1980), throttled app-side to the reference's 5000 ms.
+ RequestBattlefieldPositions,
+ /// `JoinBattlefield` when the list arrived without one — `CMSG_BATTLEFIELD_JOIN`.
+ BattlefieldJoin {
+ map_id: u32,
+ instance_id: u32,
+ as_group: bool,
+ },
+ /// The world-enter status request — `CMSG_BATTLEFIELD_STATUS`, empty.
+ BattlefieldStatusRequest,
/// `ConfirmPetUnlearn()` — `CMSG_PET_UNLEARN` with the latched trainer guid.
PetUnlearn {
trainer: u64,
@@ -2291,6 +2325,9 @@ pub(crate) struct EnteredWorldMessage {
/// admitted the session — pushed into the script here because this is the only moment it ever
/// arrives (decision 1820).
pub(crate) billing_time_rested: u32,
+ /// The tutorial bank, if `SMSG_TUTORIAL_FLAGS` landed during the login handshake (1976);
+ /// otherwise it arrives in the world stream.
+ pub(crate) tutorial_flags: Option>,
}
/// The server asked us to play a cinematic (`SMSG_TRIGGER_CINEMATIC`) — a `CinematicSequences.dbc`
diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs
index 96919525..68c3daae 100644
--- a/crates/benilla-app/src/net/apply.rs
+++ b/crates/benilla-app/src/net/apply.rs
@@ -259,6 +259,9 @@ pub(super) fn apply_net_updates(
ResMut,
ResMut,
ResMut,
+ ResMut,
+ ResMut,
+ ResMut,
),
),
// The guard's directions marker (`SMSG_GOSSIP_POI`) and the map id it has to be
@@ -483,6 +486,9 @@ pub(super) fn apply_net_updates(
mut battlefield_queue,
mut meeting_stone,
mut battlefield_scoreboard,
+ mut battlefield,
+ mut tutorials,
+ mut battlefield_positions,
),
),
mut poi_marker,
@@ -571,10 +577,12 @@ pub(super) fn apply_net_updates(
self_guid: guid,
name,
billing_time_rested,
+ tutorial_flags,
} => session::connected(
guid,
name,
billing_time_rested,
+ tutorial_flags,
&mut self_guid,
&mut status,
&mut names,
@@ -962,9 +970,17 @@ pub(super) fn apply_net_updates(
}
SessionEvent::BattlefieldStatus(status) => battlefield_queue.apply(status),
SessionEvent::PvpLogData(data) => battlefield_scoreboard.apply(data),
+ SessionEvent::BattlefieldList(list) => battlefield.apply_list(list),
+ SessionEvent::GroupJoinedBattleground { result } => battlefield.apply_verdict(result),
+ SessionEvent::BattlegroundPlayer { guid, joined } => {
+ battlefield.apply_player(guid, joined);
+ }
SessionEvent::MeetingStoneSetQueue { area, status } => {
meeting_stone.apply(area, status);
}
+ SessionEvent::MeetingStoneNotice(notice) => meeting_stone.apply_notice(notice),
+ SessionEvent::TutorialFlags(bytes) => tutorials.apply_flags(&bytes),
+ SessionEvent::BattlefieldPositions(packet) => battlefield_positions.apply(packet),
SessionEvent::PlayerBound { binder: npc, area } => {
debug!("net: bound to area {area} by {npc:#x}");
crate::ui_binder::apply::bound(
@@ -1221,7 +1237,7 @@ pub(super) fn apply_net_updates(
// between people who are grouped, and a ping from another map would be dropped by the
// renderer's own map test anyway.
SessionEvent::MinimapPing { guid, x, y } => {
- ping.seat((x, y), current_map.as_ref().map_or(0, |m| m.0), guid);
+ ping.seat((x, y), guid);
}
SessionEvent::ReadyCheckAnswer { .. } => {}
// ── The duel family (decision 0633): the session mirror + the two DisplayError
@@ -1398,7 +1414,9 @@ pub(super) fn apply_net_updates(
SessionEvent::LootReleaseResponse { guid } => {
loot_release_response(guid, &mut loot, &mut loot_latch)
}
- SessionEvent::ItemPushResult(p) => item_push_result(p, &self_guid, &mut loot),
+ SessionEvent::ItemPushResult(p) => {
+ item_push_result(p, &self_guid, &mut loot, &mut tutorials)
+ }
// ── The group-loot roll family (decision 0591) — the GroupLootFrame feed ───────────
SessionEvent::LootStartRoll(p) => loot_start_roll(p, &mut loot_rolls),
SessionEvent::LootRoll(p) => loot_roll(p, &mut loot_rolls),
diff --git a/crates/benilla-app/src/net/apply/loot.rs b/crates/benilla-app/src/net/apply/loot.rs
index 68fb43f5..9f514b37 100644
--- a/crates/benilla-app/src/net/apply/loot.rs
+++ b/crates/benilla-app/src/net/apply/loot.rs
@@ -220,7 +220,12 @@ fn is_our_push(p: &ItemPushResult, self_guid: &SelfGuid) -> bool {
/// self check being the only thing that can stop a push here. The reference's
/// `CGGameUI::OnItemPush 0x491a60` emits both from this one packet: it returns early only on a guid
/// mismatch, and tests `showInChat` further down, after the `ITEM_PUSH` fire.
-pub(super) fn item_push_result(p: ItemPushResult, self_guid: &SelfGuid, loot: &mut LootState) {
+pub(super) fn item_push_result(
+ p: ItemPushResult,
+ self_guid: &SelfGuid,
+ loot: &mut LootState,
+ tutorials: &mut crate::tutorial::Tutorials,
+) {
// Solo loot never gets a MONEY_NOTIFY from this server (vmangos comments it out;
// the purse rides the ordinary COINAGE field flush) — so this push line is the
// one reliable "it landed" signal, surfaced as the "You receive loot/item" line.
@@ -231,6 +236,8 @@ pub(super) fn item_push_result(p: ItemPushResult, self_guid: &SelfGuid, loot: &m
if !is_our_push(&p, self_guid) {
return;
}
+ // The item-received handler's tutorial sites (1976): resolved on the tutorial feed.
+ tutorials.item_received(p.item_entry, p.bag_slot, p.item_slot);
loot.push_receive(&p);
}
@@ -470,7 +477,12 @@ mod tests {
// animates — it only loses its chat line, downstream in `drain_receives`.
assert!(is_our_push(&push(ME, false), &me));
let mut loot = LootState::default();
- item_push_result(push(ME, false), &me, &mut loot);
+ item_push_result(
+ push(ME, false),
+ &me,
+ &mut loot,
+ &mut crate::tutorial::Tutorials::default(),
+ );
assert_eq!(loot.pending_receive_count(), 1);
}
}
diff --git a/crates/benilla-app/src/net/apply/session.rs b/crates/benilla-app/src/net/apply/session.rs
index 4179694f..bb8ac963 100644
--- a/crates/benilla-app/src/net/apply/session.rs
+++ b/crates/benilla-app/src/net/apply/session.rs
@@ -101,10 +101,12 @@ pub(super) fn cinematic_triggered(
/// We are in the world (the IO thread's first in-world event): record our guid, flip the status,
/// and seed the name cache with our own name.
+#[allow(clippy::too_many_arguments)] // the login's whole hand-off
pub(super) fn connected(
guid: u64,
name: String,
billing_time_rested: u32,
+ tutorial_flags: Option>,
self_guid: &mut SelfGuid,
status: &mut NetStatus,
names: &mut NameCache,
@@ -118,6 +120,7 @@ pub(super) fn connected(
names.insert_player(guid, name, None);
entered_world.write(EnteredWorldMessage {
billing_time_rested,
+ tutorial_flags,
});
}
diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs
index 78ba37ca..9e9d1d2b 100644
--- a/crates/benilla-app/src/net/io.rs
+++ b/crates/benilla-app/src/net/io.rs
@@ -589,12 +589,14 @@ fn run(
session.set_active_mover(guid)?;
let billing_time_rested = session.billing_time_rested();
+ let tutorial_flags = session.take_tutorial_flags();
let (mut reader, writer) = session.into_split()?;
if events_tx
.send(SessionEvent::Connected {
self_guid: guid,
name,
billing_time_rested,
+ tutorial_flags,
})
.is_err()
{
@@ -1076,6 +1078,26 @@ fn writer_loop(
}
ClientCommand::LeaveBattlefield { map_id } => w.leave_battlefield(map_id),
ClientCommand::MeetingStoneLeave => w.meeting_stone_leave(),
+ ClientCommand::MeetingStoneStatusQuery => w.meeting_stone_status_query(),
+ ClientCommand::TutorialFlag { id } => w.tutorial_flag(id),
+ ClientCommand::TutorialClear => w.tutorial_clear(),
+ ClientCommand::TutorialReset => w.tutorial_reset(),
+ ClientCommand::BattlefieldList { map_id } => w.battlefield_list(map_id),
+ ClientCommand::RequestBattlefieldPositions => {
+ w.request_battlefield_positions()
+ }
+ ClientCommand::BattlemasterJoin {
+ battlemaster,
+ map_id,
+ instance_id,
+ as_group,
+ } => w.battlemaster_join(battlemaster, map_id, instance_id, as_group),
+ ClientCommand::BattlefieldJoin {
+ map_id,
+ instance_id,
+ as_group,
+ } => w.battlefield_join(map_id, instance_id, as_group),
+ ClientCommand::BattlefieldStatusRequest => w.battlefield_status(),
ClientCommand::PetUnlearn { trainer } => w.pet_unlearn(trainer),
ClientCommand::BankerActivate { guid } => w.banker_activate(guid),
ClientCommand::BuyBankSlot { guid } => w.buy_bank_slot(guid),
diff --git a/crates/benilla-app/src/perf/hud.rs b/crates/benilla-app/src/perf/hud.rs
index d3e2e4fe..0683e5fc 100644
--- a/crates/benilla-app/src/perf/hud.rs
+++ b/crates/benilla-app/src/perf/hud.rs
@@ -231,11 +231,19 @@ pub(super) fn pill_quads(
!matches!(&*cache, Some(c) if c.snap_at == hud.snap_at && c.win_w == win_w && c.top == top);
if stale {
let cpu = hud.snap.cpu.mean();
+ let main = hud.snap.main.mean();
let fps = hud.snap.fps();
- // One string, the dim run via markup: "59 fps 8.5 ms" — fps dim, cost in full text.
- let text = match cpu {
- Some(cpu) => format!("{Q_DIM_MARKUP}{fps:.0} fps|r {cpu:.1} ms"),
- None => format!("{Q_DIM_MARKUP}-- ms"),
+ // One string, the dim runs via markup: "59 fps 7.0 ms 17.3 cpu" — fps dim, the MAIN
+ // thread's ms in full text (the part of the frame the player feels), and the process-wide
+ // sum dim at the end (every thread, the number a CPU % agrees with — decision 1954: a
+ // raid read 17 on it at a solid 60 with the main thread at 7, and the sum was taken for
+ // a frame time by everyone who looked at it).
+ let text = match (main, cpu) {
+ (Some(main), Some(cpu)) => {
+ format!("{Q_DIM_MARKUP}{fps:.0} fps|r {main:.1} ms {Q_DIM_MARKUP}{cpu:.1} cpu|r")
+ }
+ (None, Some(cpu)) => format!("{Q_DIM_MARKUP}{fps:.0} fps|r {cpu:.1} cpu"),
+ _ => format!("{Q_DIM_MARKUP}-- ms"),
};
let center = Vec2::new(win_w * 0.5, 0.0); // measured first, then shifted under PILL_TOP
let mut e = atlas.lock();
diff --git a/crates/benilla-app/src/perf/stats.rs b/crates/benilla-app/src/perf/stats.rs
index 74b8d81f..6041e462 100644
--- a/crates/benilla-app/src/perf/stats.rs
+++ b/crates/benilla-app/src/perf/stats.rs
@@ -16,7 +16,7 @@ use std::collections::VecDeque;
use bevy::prelude::*;
use bevy::time::Real;
-use super::clock::process_cpu_secs;
+use super::clock::{main_thread_cpu_secs, process_cpu_secs};
/// Recent frames kept for the pill's windowed means (~5 s at 60 fps, ~2.5 s at 120).
pub(super) const SAMPLE_WINDOW: usize = 300;
@@ -70,6 +70,10 @@ pub(super) struct FrameStats {
/// currency (0736) and the meter the rail cannot fool (0717).
pub(super) cpu: Series,
prev_cpu_secs: Option,
+ /// The MAIN thread's CPU per frame — the part of the frame the player can feel: the wall
+ /// the schedule runs on. Sampled pinned (`sample_frame_time`'s `NonSendMarker`).
+ pub(super) main: Series,
+ prev_main_secs: Option,
}
impl Default for FrameStats {
@@ -77,7 +81,9 @@ impl Default for FrameStats {
Self {
wall: Series::new(SAMPLE_WINDOW),
cpu: Series::new(SAMPLE_WINDOW),
+ main: Series::new(SAMPLE_WINDOW),
prev_cpu_secs: None,
+ prev_main_secs: None,
}
}
}
@@ -102,6 +108,9 @@ impl FrameStats {
for &(wall, cpu) in frames {
self.wall.push(wall);
self.cpu.push(cpu);
+ // The fixture's main-thread share: a fixed fraction of the sum, so a test can tell
+ // the two apart without a second column.
+ self.main.push(cpu * 0.5);
t += dt;
}
t
@@ -109,7 +118,12 @@ impl FrameStats {
}
/// Sample both meters for this frame.
-pub(super) fn sample_frame_time(time: Res>, mut stats: ResMut) {
+pub(super) fn sample_frame_time(
+ // Pinned to the main thread: `main_thread_cpu_secs` reads the CALLING thread's clock.
+ _pin: bevy::ecs::system::NonSendMarker,
+ time: Res>,
+ mut stats: ResMut,
+) {
let wall_ms = time.delta_secs() * 1000.0;
stats.wall.push(wall_ms);
@@ -118,6 +132,11 @@ pub(super) fn sample_frame_time(time: Res>, mut stats: ResMut,
+ // The tutorial system's world-input sites (1976): Movement acknowledged + the 10 s
+ // Targeting popup on a movement input, Cameras acknowledged on a mouse-look.
+ crate::tutorial::InputHooks,
),
// Nested into one param to stay within Bevy's 16-element system-param tuple limit (see `mouse`).
speed_capsule: (
@@ -483,6 +486,13 @@ pub(super) fn control(
turn_right,
..
} = axes;
+ // The world-input tutorial sites (1976): a movement input, a mouse-look.
+ if fwd_axis != 0 || side_axis != 0 {
+ net.13.moved();
+ }
+ if mouselook {
+ net.13.mouselooked();
+ }
// **The mover's own** six speeds, read once here for the whole frame — the turn below and
// the run/backpedal selection further down. All six live on the driven unit's `CMovement`
// and nothing in the reference's applied-input path ever reads *our* speeds when the mover
diff --git a/crates/benilla-app/src/sound/message.rs b/crates/benilla-app/src/sound/message.rs
index ba45e7dc..4cac62a0 100644
--- a/crates/benilla-app/src/sound/message.rs
+++ b/crates/benilla-app/src/sound/message.rs
@@ -46,7 +46,12 @@ use super::{AudioListener, SoundConfig, SoundOutput};
/// the frame its line appears. Rows, not resolved sounds: the *reading* of `+0x08`/`+0x0c` belongs
/// here beside the players, not scattered across the windows that raise messages.
#[derive(Resource, Default)]
-pub(crate) struct MessageSounds(Vec<&'static MessageRecord>);
+pub(crate) struct MessageSounds {
+ records: Vec<&'static MessageRecord>,
+ /// Cues by `PlaySoundByName` name with no catalog row behind them — the tutorial popup
+ /// (`0x4b5390`'s `"TutorialPopup"`, 1976): played on the same 2D SFX path as a row's cue.
+ cues: Vec<&'static str>,
+}
impl MessageSounds {
/// Queue one displayed message's sound.
@@ -65,14 +70,19 @@ impl MessageSounds {
/// off the display. It is written down because the *ordering* is the surprising part, and a
/// future locale or a patched table could give it a tenant.
pub(crate) fn push(&mut self, record: &'static MessageRecord) {
- self.0.push(record);
+ self.records.push(record);
+ }
+
+ /// Queue a named cue with no message behind it.
+ pub(crate) fn push_cue(&mut self, name: &'static str) {
+ self.cues.push(name);
}
/// What is queued — for the tests that drive the producer
/// (`crate::ui_action::show_messages`) and read back what the drain would be handed.
#[cfg(test)]
pub(crate) fn queued(&self) -> &[&'static MessageRecord] {
- &self.0
+ &self.records
}
}
@@ -88,10 +98,11 @@ fn play_message_sounds(
config: Res,
listener: Res,
) {
- if queue.0.is_empty() {
+ if queue.records.is_empty() && queue.cues.is_empty() {
return;
}
- let records = std::mem::take(&mut queue.0);
+ let records = std::mem::take(&mut queue.records);
+ let cues = std::mem::take(&mut queue.cues);
// Drained even when there is nothing to play it with (headless, no client data), so the queue
// can never grow unbounded — `super::ui`'s posture exactly.
let (Some(mut kits), Some(assets)) = (kits, assets) else {
@@ -100,6 +111,20 @@ fn play_message_sounds(
// The reference reads the player's sex per play (`0x49676f` → `0x5ed5b0`), not at table-build
// time; a descriptor that has not arrived yet means no voice, not a guessed one.
let sex = self_q.iter().next().and_then(|s| s.0.unit_gender());
+ for name in cues {
+ if let Err(e) = kit::play_kit(
+ &mut kits,
+ &assets,
+ &mut out,
+ &config,
+ listener.pos,
+ KitRef::Name(name),
+ None,
+ SoundCategory::Sfx,
+ ) {
+ warn!("sound(message): cue {name:?}: {e:#}");
+ }
+ }
for record in records {
if record.type_tag == NO_SPEECH_TAG {
let Some(name) = record.sound else {
diff --git a/crates/benilla-app/src/tutorial.rs b/crates/benilla-app/src/tutorial.rs
new file mode 100644
index 00000000..bff5ebd0
--- /dev/null
+++ b/crates/benilla-app/src/tutorial.rs
@@ -0,0 +1,705 @@
+//! The tutorial system (decision 1976; wow-re `system/ui/scratch/tutorial-flags.md`): the two
+//! bit banks, the fire-once trigger, the acknowledge-and-send setter, the timers, the popup
+//! sound, and the trigger sites the app can produce — everything behind the stock
+//! `TutorialFrame.xml`.
+//!
+//! ## Two banks, not one (§1, §2)
+//!
+//! Both are filled byte for byte from the same `SMSG_TUTORIAL_FLAGS`, and until it lands no
+//! tutorial can fire. **Bank A** is the fire-once bank: [`Tutorials::trigger`] tests it, sets it,
+//! and raises `TUTORIAL_TRIGGER(id + 1)` — never sending. **Bank B** is the acknowledged bank:
+//! [`Tutorials::acknowledge`] (Lua's `FlagTutorial` and the C++ auto-acknowledge sites) tests it,
+//! sets the bit in BOTH banks, cancels the id's pending timer, and sends `CMSG_TUTORIAL_FLAG`.
+//! `ClearTutorials`/`ResetTutorials` write both banks and send. So A ⊇ B, and doing the thing
+//! (moving, chatting, adding a friend…) suppresses the tutorial about it, account-wide.
+//!
+//! ## The trigger (§3)
+//!
+//! `TriggerTutorial(id, delayMs)`: no bank → silent; bank-A bit set → silent; else the bit is
+//! set and, with a zero delay, the `TutorialPopup` cue plays and the event fires now; with any
+//! other delay (unsigned — the 10 s Targeting popup is the one site) a timer holds it. The
+//! reference bounds-checks nothing; ours refuses an id past the bank as a no-op.
+//!
+//! ## The sites (§4, §5)
+//!
+//! Fifty-one trigger sites and seven acknowledge sites exist in the reference. The ones this app
+//! can produce are wired — the self-descriptor edges here, the packets and drains at their own
+//! seams through [`TutorialEvent`] — and the record names the rest with their conditions.
+
+use std::time::{Duration, Instant};
+
+use bevy::prelude::*;
+
+use benilla_ui::script::{ScriptValue, UiScript};
+
+use crate::char_select::ClientState;
+use crate::net::{ClientCommand, EnteredWorldMessage, NetCommands, ObjectStore, SelfPlayer};
+use crate::player::Player;
+use crate::ui_script::UiInput;
+
+/// The popup cue `0x4b5390` plays on both legs (`0x846b68`), gated on `MasterSoundEffects` at
+/// the mixer like every SFX kit.
+const TUTORIAL_POPUP_SOUND: &str = "TutorialPopup";
+
+/// The internal (0-based) ids of the sites this app produces, named by their published title.
+pub(crate) mod id {
+ pub(crate) const QUESTGIVERS: u32 = 0x00;
+ pub(crate) const MOVEMENT: u32 = 0x01;
+ pub(crate) const CAMERAS: u32 = 0x02;
+ pub(crate) const TARGETING: u32 = 0x03;
+ pub(crate) const LOOTING: u32 = 0x06;
+ pub(crate) const BACKPACK: u32 = 0x07;
+ pub(crate) const TRAINERS: u32 = 0x0d;
+ pub(crate) const GROUPING: u32 = 0x11;
+ pub(crate) const VENDORS: u32 = 0x13;
+ pub(crate) const QUEST_LOG: u32 = 0x14;
+ pub(crate) const FRIENDS: u32 = 0x15;
+ pub(crate) const CHATTING: u32 = 0x16;
+ pub(crate) const EQUIPPABLE_ITEMS: u32 = 0x17;
+ pub(crate) const DEATH: u32 = 0x18;
+ pub(crate) const RESTED: u32 = 0x19;
+ pub(crate) const FATIGUE: u32 = 0x1a;
+ pub(crate) const SWIMMING: u32 = 0x1b;
+ pub(crate) const BREATH: u32 = 0x1c;
+ pub(crate) const RESTING: u32 = 0x1d;
+ pub(crate) const HEARTHSTONES: u32 = 0x1e;
+ pub(crate) const PVP_COMBAT: u32 = 0x1f;
+ pub(crate) const TRAVEL: u32 = 0x22;
+ pub(crate) const WELCOME: u32 = 0x29;
+ pub(crate) const RANGED_WEAPONS: u32 = 0x2b;
+ pub(crate) const RAID_GROUPS: u32 = 0x2d;
+ pub(crate) const MEETING_STONES: u32 = 0x2e;
+ pub(crate) const BATTLEGROUND_QUEUE: u32 = 0x2f;
+ pub(crate) const PORT_TO_BATTLEGROUND: u32 = 0x30;
+ pub(crate) const KEYRINGS: u32 = 0x31;
+}
+
+/// The level-gated arm of the `SMSG_LEVELUP_INFO` handler (§4): in the handler's own order,
+/// each fires when the new level is at least its threshold.
+const LEVEL_TRIGGERS: [(u32, u32); 8] = [
+ (3, 0x16), // Chatting
+ (4, 0x0d), // Trainers
+ (4, 0x02), // Cameras
+ (5, 0x26), // Groups
+ (7, 0x15), // Friends
+ (7, 0x25), // Professions
+ (8, 0x20), // Jumping
+ (10, 0x0c), // Learning Talents
+];
+
+/// The Targeting popup's delay at its one site (`0x514a8d mov edx,0x2710`).
+pub(crate) const TARGETING_DELAY_MS: u32 = 10_000;
+
+/// The Movement popup's silence window (`0x482ff7`: elapsed − stamp − 90 000 ≥ 0) — INFERRED as
+/// ninety seconds after world enter with no movement input (§4's row reads the stamp getter,
+/// not its meaning).
+const MOVEMENT_SILENCE: Duration = Duration::from_millis(90_000);
+
+/// A site's ask of the tutorial system — written from wherever the reference's site lives.
+#[derive(Message, Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum TutorialEvent {
+ /// `TriggerTutorial(id, delayMs)`: the fire-once leg.
+ Trigger { id: u32, delay_ms: u32 },
+ /// `SetTutorialFlag(id)` (`0x4b54c0`): the acknowledge-and-send leg.
+ Acknowledge { id: u32 },
+}
+
+impl TutorialEvent {
+ pub(crate) const fn trigger(id: u32) -> Self {
+ Self::Trigger { id, delay_ms: 0 }
+ }
+}
+
+/// A send the drains owe the wire.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum TutorialSend {
+ Flag(u32),
+ Clear,
+ Reset,
+}
+
+/// A bank: `bit = bytes[id >> 3] & (1 << (id & 7))` — the client's `word = id >> 5` on
+/// little-endian dwords, byte-wise.
+fn bank_bit(bank: &[u8], id: u32) -> Option {
+ bank.get((id >> 3) as usize)
+ .map(|b| b & (1 << (id & 7)) != 0)
+}
+
+fn set_bank_bit(bank: &mut [u8], id: u32) {
+ if let Some(b) = bank.get_mut((id >> 3) as usize) {
+ *b |= 1 << (id & 7);
+ }
+}
+
+/// The two banks, the timers, and what the frame owes the VM and the wire.
+#[derive(Resource, Default)]
+pub(crate) struct Tutorials {
+ /// Bank A (`0xb711b8`…): fire-once. `None` until the packet lands.
+ fire_once: Option>,
+ /// Bank B (`0xb711e4`…): acknowledged.
+ acknowledged: Option>,
+ /// The delayed triggers (`TUTORIALTIMER` nodes): `(id, due)`.
+ timers: Vec<(u32, Instant)>,
+ /// Published ids (`id + 1`) whose event and cue are owed this frame.
+ fired: Vec,
+ sends: Vec,
+ /// Bank B changed since the VM last saw it.
+ dirty: bool,
+ /// Item pushes for us since the last feed (`0x491a60`'s sites): `(entry, bag, slot)`.
+ pushes: Vec<(u32, u8, u32)>,
+}
+
+impl Tutorials {
+ /// `SMSG_TUTORIAL_FLAGS`: both banks copied from the payload.
+ pub(crate) fn apply_flags(&mut self, bytes: &[u8]) {
+ self.fire_once = Some(bytes.to_vec());
+ self.acknowledged = Some(bytes.to_vec());
+ self.dirty = true;
+ }
+
+ /// The bring-up `0x4b5330`: both banks zeroed if present (the bit count stays).
+ fn world_enter(&mut self) {
+ for bank in [&mut self.fire_once, &mut self.acknowledged]
+ .into_iter()
+ .flatten()
+ {
+ bank.iter_mut().for_each(|b| *b = 0);
+ }
+ self.dirty = true;
+ }
+
+ /// The teardown `0x4b5380`: every pending timer flushed.
+ fn world_leave(&mut self) {
+ self.timers.clear();
+ }
+
+ /// `TriggerTutorial(id, delayMs)` (§3).
+ pub(crate) fn trigger(&mut self, id: u32, delay_ms: u32, now: Instant) {
+ let Some(bank) = self.fire_once.as_mut() else {
+ return; // no bank yet: silent
+ };
+ match bank_bit(bank, id) {
+ Some(false) => set_bank_bit(bank, id),
+ _ => return, // already set — or past the bank, which the reference would overrun
+ }
+ if delay_ms == 0 {
+ self.fired.push(id + 1);
+ } else {
+ self.timers
+ .push((id, now + Duration::from_millis(u64::from(delay_ms))));
+ }
+ }
+
+ /// `SetTutorialFlag(id)` (`0x4b54c0`, §5): bank B's bit gates; both banks set; the id's timer
+ /// cancelled; the 0-based id sent. The reference dereferences an absent bank B; ours treats
+ /// that as a no-op.
+ pub(crate) fn acknowledge(&mut self, id: u32) {
+ let Some(bank_b) = self.acknowledged.as_mut() else {
+ return;
+ };
+ if bank_bit(bank_b, id) != Some(false) {
+ return;
+ }
+ self.timers.retain(|(t, _)| *t != id);
+ set_bank_bit(bank_b, id);
+ if let Some(bank_a) = self.fire_once.as_mut() {
+ set_bank_bit(bank_a, id);
+ }
+ self.sends.push(TutorialSend::Flag(id));
+ self.dirty = true;
+ }
+
+ /// `ClearTutorials()`: the timers flushed, every bit of both banks set, the empty clear sent.
+ fn clear(&mut self) {
+ self.timers.clear();
+ for bank in [&mut self.fire_once, &mut self.acknowledged]
+ .into_iter()
+ .flatten()
+ {
+ bank.iter_mut().for_each(|b| *b = 0xFF);
+ }
+ self.sends.push(TutorialSend::Clear);
+ self.dirty = true;
+ }
+
+ /// `ResetTutorials()`: every bit of both banks cleared — the timers NOT flushed — the empty
+ /// reset sent.
+ fn reset(&mut self) {
+ for bank in [&mut self.fire_once, &mut self.acknowledged]
+ .into_iter()
+ .flatten()
+ {
+ bank.iter_mut().for_each(|b| *b = 0);
+ }
+ self.sends.push(TutorialSend::Reset);
+ self.dirty = true;
+ }
+
+ /// `ProcessTutorialTimers`: every due node fires the same cue and event.
+ fn tick(&mut self, now: Instant) {
+ let mut due = Vec::new();
+ self.timers.retain(|&(id, at)| {
+ if at <= now {
+ due.push(id + 1);
+ false
+ } else {
+ true
+ }
+ });
+ self.fired.extend(due);
+ }
+
+ /// `SMSG_ITEM_PUSH_RESULT` for one of ours — the item-received handler's five sites, resolved
+ /// on the next feed with the item's template.
+ pub(crate) fn item_received(&mut self, entry: u32, bag: u8, slot: u32) {
+ self.pushes.push((entry, bag, slot));
+ }
+
+ #[cfg(test)]
+ fn bits(&self) -> (Option<&[u8]>, Option<&[u8]>) {
+ (self.fire_once.as_deref(), self.acknowledged.as_deref())
+ }
+}
+
+/// The self descriptor's last-seen fields, for the edge-triggered sites (§4): the level-up
+/// arm, the ghost flag, the resting flag, the PvP flag, the rest state.
+#[derive(Default)]
+struct SelfWatch {
+ level: Option,
+ player_flags: Option,
+ unit_flags: Option,
+ rest_state: Option,
+ swimming: Option,
+}
+
+const PLAYER_FLAGS_GHOST: u32 = 0x10;
+const PLAYER_FLAGS_RESTING: u32 = 0x20;
+const UNIT_FLAG_PVP_ATTACKABLE: u32 = 0x1000;
+
+/// The hearthstone's item entry (`0x1b24`).
+const HEARTHSTONE: u32 = 6948;
+const INVTYPE_RANGED: u32 = 15;
+const CLASS_HUNTER: u8 = 3;
+/// The keyring bag and its slot band (`edi == 0xff`, slot ∈ [0x51, 0x70]).
+const KEYRING_BAG: u8 = 0xff;
+const KEYRING_SLOTS: std::ops::RangeInclusive = 0x51..=0x70;
+
+/// Before the script tick: the sites' asks, the descriptor edges, the timers, the cue and the
+/// event, and the acknowledged bank's push to the VM.
+fn feed_tutorials(
+ script: Option>,
+ mut tutorials: ResMut,
+ mut asks: MessageReader,
+ self_q: Query<&ObjectStore, With>,
+ items: Res,
+ mut sounds: ResMut,
+) {
+ let now = Instant::now();
+ for ask in asks.read() {
+ match *ask {
+ TutorialEvent::Trigger { id, delay_ms } => tutorials.trigger(id, delay_ms, now),
+ TutorialEvent::Acknowledge { id } => tutorials.acknowledge(id),
+ }
+ }
+
+ // The item-received handler's sites, over the item's template: Backpack unconditionally;
+ // Equippable Items for an inventory type with an equip slot (INFERRED as any non-zero type —
+ // the reference's 23-bit mask is not carved bit by bit); Hearthstones by entry; Ranged
+ // Weapons for a ranged type on a non-hunter; Keyrings by the slot band.
+ let pushes = std::mem::take(&mut tutorials.pushes);
+ let class = self_q.single().ok().and_then(|s| s.0.unit_class());
+ for (entry, bag, slot) in pushes {
+ tutorials.trigger(id::BACKPACK, 0, now);
+ if let Some(info) = items.template_cached(entry) {
+ if info.inventory_type != 0 {
+ tutorials.trigger(id::EQUIPPABLE_ITEMS, 0, now);
+ }
+ if info.inventory_type == INVTYPE_RANGED && class != Some(CLASS_HUNTER) {
+ tutorials.trigger(id::RANGED_WEAPONS, 0, now);
+ }
+ }
+ if entry == HEARTHSTONE {
+ tutorials.trigger(id::HEARTHSTONES, 0, now);
+ }
+ if bag == KEYRING_BAG && KEYRING_SLOTS.contains(&slot) {
+ tutorials.trigger(id::KEYRINGS, 0, now);
+ }
+ }
+
+ tutorials.tick(now);
+
+ let Some(mut script) = script else {
+ return;
+ };
+ for published in std::mem::take(&mut tutorials.fired) {
+ sounds.push_cue(TUTORIAL_POPUP_SOUND);
+ script.fire_event(
+ "TUTORIAL_TRIGGER",
+ vec![ScriptValue::Int(i64::from(published))],
+ );
+ }
+ if std::mem::take(&mut tutorials.dirty) {
+ script.set_tutorial_bank(tutorials.acknowledged.clone());
+ }
+}
+
+/// The self descriptor's edge sites (§4): the `SMSG_LEVELUP_INFO` arm on the level rising, the
+/// ghost and resting flags, the PvP flag, the rested table's one live row, and the mover
+/// entering water — a change each, never the login descriptor. The memory is per world session:
+/// the bring-up resets it, so a re-login's first descriptor arms silently like the first one.
+fn watch_self(
+ self_q: Query<&ObjectStore, With>,
+ player: Res,
+ mut cascade: ResMut,
+ mut watch: Local,
+ mut asks: MessageWriter,
+) {
+ if std::mem::take(&mut cascade.reset_watch) {
+ *watch = SelfWatch::default();
+ }
+ // The descriptor edges — a change, never the login descriptor (the watchers run on
+ // field changes; the first sight arms silently).
+ if let Ok(store) = self_q.single() {
+ let level = store.0.unit_level();
+ let player_flags = store.0.player_flags();
+ let unit_flags = store.0.unit_flags();
+ let rest_state = store.0.player_rest_state();
+ if let (Some(prev), Some(new)) = (watch.level, level) {
+ if new > prev {
+ for (threshold, id) in LEVEL_TRIGGERS {
+ if new >= threshold {
+ asks.write(TutorialEvent::trigger(id));
+ }
+ }
+ }
+ }
+ if let Some(prev) = watch.player_flags {
+ let rose = |bit: u32| prev & bit == 0 && player_flags & bit != 0;
+ if rose(PLAYER_FLAGS_GHOST) {
+ asks.write(TutorialEvent::trigger(id::DEATH));
+ }
+ if rose(PLAYER_FLAGS_RESTING) {
+ asks.write(TutorialEvent::trigger(id::RESTING));
+ }
+ }
+ if let Some(prev) = watch.unit_flags {
+ if prev & UNIT_FLAG_PVP_ATTACKABLE == 0 && unit_flags & UNIT_FLAG_PVP_ATTACKABLE != 0 {
+ asks.write(TutorialEvent::trigger(id::PVP_COMBAT));
+ }
+ }
+ if let (Some(prev), Some(new)) = (watch.rest_state, rest_state) {
+ // The rested table: only rest state 1 names a tutorial (the other rows carry the
+ // sentinel), on the field's change.
+ if new != prev && new == 1 {
+ asks.write(TutorialEvent::trigger(id::RESTED));
+ }
+ }
+ watch.level = level;
+ watch.player_flags = Some(player_flags);
+ watch.unit_flags = Some(unit_flags);
+ watch.rest_state = rest_state;
+ }
+ if let Some(prev) = watch.swimming {
+ if !prev && player.swimming {
+ asks.write(TutorialEvent::trigger(id::SWIMMING));
+ }
+ }
+ watch.swimming = Some(player.swimming);
+}
+
+/// After the script tick: the four verbs' writes, then every send.
+fn drain_tutorials(
+ script: Option>,
+ mut tutorials: ResMut,
+ commands: Res,
+) {
+ if let Some(mut script) = script {
+ for id in script.take_tutorial_flag_requests() {
+ tutorials.acknowledge(id);
+ }
+ for _ in 0..script.take_tutorial_clears() {
+ tutorials.clear();
+ }
+ for _ in 0..script.take_tutorial_resets() {
+ tutorials.reset();
+ }
+ }
+ for send in std::mem::take(&mut tutorials.sends) {
+ let command = match send {
+ TutorialSend::Flag(id) => ClientCommand::TutorialFlag { id },
+ TutorialSend::Clear => ClientCommand::TutorialClear,
+ TutorialSend::Reset => ClientCommand::TutorialReset,
+ };
+ let _ = commands.0.send(command);
+ }
+}
+
+/// The world-enter bring-up: the banks zeroed, then the bank captured during the login handshake
+/// applied if there was one (the world stream's copy lands through `SessionEvent` otherwise).
+fn on_world_enter(
+ mut entered: MessageReader,
+ mut tutorials: ResMut,
+ mut cascade: ResMut,
+) {
+ for e in entered.read() {
+ tutorials.world_enter();
+ if let Some(bytes) = &e.tutorial_flags {
+ tutorials.apply_flags(bytes);
+ }
+ cascade.armed = true;
+ cascade.entered_at = Some(Instant::now());
+ cascade.moved = false;
+ cascade.reset_watch = true;
+ }
+}
+
+/// The enter-world cascade `0x4908c0`'s two unconditional triggers — Welcome (`0x490a48`) then
+/// Questgivers (`0x490a51`) — run once per world session when the local player object exists,
+/// which is after the bank has landed on a stock server; and the Movement popup's silence window.
+#[derive(Resource, Default)]
+pub(crate) struct WorldEnterCascade {
+ armed: bool,
+ entered_at: Option,
+ /// A movement input happened this session (the Movement auto-acknowledge site).
+ pub(crate) moved: bool,
+ /// The descriptor watcher's memory is owed a reset (a new world session).
+ reset_watch: bool,
+}
+
+fn run_world_enter_cascade(
+ mut cascade: ResMut,
+ mut tutorials: ResMut,
+ self_q: Query<(), With>,
+) {
+ let now = Instant::now();
+ if cascade.armed && !self_q.is_empty() {
+ cascade.armed = false;
+ tutorials.trigger(id::WELCOME, 0, now);
+ tutorials.trigger(id::QUESTGIVERS, 0, now);
+ }
+ if let Some(at) = cascade.entered_at {
+ if !cascade.moved && now.duration_since(at) >= MOVEMENT_SILENCE {
+ cascade.entered_at = None;
+ tutorials.trigger(id::MOVEMENT, 0, now);
+ }
+ }
+}
+
+/// The world-input sites (`0x514840`, §4/§5), bundled for the controller: a movement input
+/// acknowledges Movement and arms the 10 s Targeting popup; a mouse-look acknowledges Cameras.
+#[derive(bevy::ecs::system::SystemParam)]
+pub(crate) struct InputHooks<'w> {
+ /// Optional, so a controller test's partial app runs without the tutorial plugin.
+ asks: Option>,
+ cascade: Option>,
+}
+
+impl InputHooks<'_> {
+ /// A movement input this frame (`test ecx,0x1030` — INFERRED as the forward/side axes).
+ pub(crate) fn moved(&mut self) {
+ if let Some(c) = self.cascade.as_mut() {
+ c.moved = true;
+ }
+ if let Some(asks) = self.asks.as_mut() {
+ asks.write(TutorialEvent::Acknowledge { id: id::MOVEMENT });
+ asks.write(TutorialEvent::Trigger {
+ id: id::TARGETING,
+ delay_ms: TARGETING_DELAY_MS,
+ });
+ }
+ }
+
+ /// A mouse-look this frame (`[ebp+8] & 3`, the two buttons).
+ pub(crate) fn mouselooked(&mut self) {
+ if let Some(asks) = self.asks.as_mut() {
+ asks.write(TutorialEvent::Acknowledge { id: id::CAMERAS });
+ }
+ }
+}
+
+/// The window edges the reference triggers or acknowledges from inside its handlers: the
+/// trainer list (Trainers acknowledged, `0x4d74ae`), the vendor list (Vendors, `0x4fad32`), the
+/// taxi map (Travel, `0x4dbaa2`), and the group turning raid (Raid Groups, `0x4ba60d` — INFERRED
+/// as the raid-type edge; the row names only its non-zero argument).
+#[derive(Default)]
+struct WindowWatch {
+ trainer: Option,
+ vendor: Option,
+ taxi: bool,
+ raid: Option,
+}
+
+fn watch_windows(
+ trainer: Res,
+ merchant: Res,
+ taxi: Res,
+ group: Res,
+ mut watch: Local,
+ mut asks: MessageWriter,
+) {
+ if trainer.trainer.is_some() && trainer.trainer != watch.trainer {
+ asks.write(TutorialEvent::Acknowledge { id: id::TRAINERS });
+ }
+ watch.trainer = trainer.trainer;
+ if merchant.vendor.is_some() && merchant.vendor != watch.vendor {
+ asks.write(TutorialEvent::trigger(id::VENDORS));
+ }
+ watch.vendor = merchant.vendor;
+ let taxi_open = taxi.open.is_some();
+ if taxi_open && !watch.taxi {
+ asks.write(TutorialEvent::trigger(id::TRAVEL));
+ }
+ watch.taxi = taxi_open;
+ let raid = group.in_group && group.group_type == crate::ui_party::GROUPTYPE_RAID;
+ if raid && watch.raid == Some(false) {
+ asks.write(TutorialEvent::trigger(id::RAID_GROUPS));
+ }
+ watch.raid = Some(raid);
+}
+
+fn on_world_leave(mut tutorials: ResMut, mut cascade: ResMut) {
+ tutorials.world_leave();
+ cascade.armed = false;
+ cascade.entered_at = None;
+}
+
+pub(crate) struct TutorialPlugin;
+
+impl Plugin for TutorialPlugin {
+ fn build(&self, app: &mut App) {
+ app.init_resource::()
+ .init_resource::()
+ .add_message::()
+ .add_systems(
+ Update,
+ (
+ on_world_enter.before(run_world_enter_cascade),
+ run_world_enter_cascade.before(feed_tutorials),
+ watch_windows.before(feed_tutorials),
+ watch_self.after(on_world_enter).before(feed_tutorials),
+ feed_tutorials.before(UiInput),
+ drain_tutorials.after(UiInput),
+ ),
+ )
+ .add_systems(OnExit(ClientState::InWorld), on_world_leave);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn banked() -> Tutorials {
+ let mut t = Tutorials::default();
+ t.apply_flags(&[0u8; 32]);
+ t
+ }
+
+ /// No bank: silent. A clear bit: set in A only, the event owed, nothing sent. Twice: once.
+ #[test]
+ fn the_trigger_fires_once_per_bank_a_and_never_sends() {
+ let now = Instant::now();
+ let mut t = Tutorials::default();
+ t.trigger(id::WELCOME, 0, now);
+ assert!(t.fired.is_empty(), "no bank yet: silent");
+ let mut t = banked();
+ t.trigger(id::WELCOME, 0, now);
+ t.trigger(id::WELCOME, 0, now);
+ assert_eq!(t.fired, vec![id::WELCOME + 1], "published as id + 1, once");
+ let (a, b) = t.bits();
+ assert_eq!(bank_bit(a.unwrap(), id::WELCOME), Some(true));
+ assert_eq!(
+ bank_bit(b.unwrap(), id::WELCOME),
+ Some(false),
+ "bank B untouched"
+ );
+ assert!(t.sends.is_empty(), "the trigger sends nothing");
+ t.trigger(300, 0, now);
+ assert_eq!(t.fired.len(), 1, "past the bank: a no-op");
+ }
+
+ /// The delayed leg holds the id until due; an acknowledge cancels it.
+ #[test]
+ fn a_delayed_trigger_waits_and_an_acknowledge_cancels_it() {
+ let now = Instant::now();
+ let mut t = banked();
+ t.trigger(id::TARGETING, TARGETING_DELAY_MS, now);
+ assert!(t.fired.is_empty());
+ t.tick(now + Duration::from_millis(9_999));
+ assert!(t.fired.is_empty(), "not yet");
+ t.tick(now + Duration::from_millis(10_000));
+ assert_eq!(t.fired, vec![id::TARGETING + 1]);
+
+ let mut t = banked();
+ t.trigger(id::TARGETING, TARGETING_DELAY_MS, now);
+ t.acknowledge(id::TARGETING);
+ t.tick(now + Duration::from_millis(20_000));
+ assert!(t.fired.is_empty(), "the acknowledge cancelled the timer");
+ assert_eq!(t.sends, vec![TutorialSend::Flag(id::TARGETING)]);
+ }
+
+ /// The acknowledge: bank B gates, both banks set, one send; a second is silent; absent bank
+ /// B is a no-op.
+ #[test]
+ fn the_acknowledge_sets_both_banks_and_sends_once() {
+ let mut t = Tutorials::default();
+ t.acknowledge(id::CHATTING);
+ assert!(t.sends.is_empty(), "no bank: nothing");
+ let mut t = banked();
+ t.acknowledge(id::CHATTING);
+ t.acknowledge(id::CHATTING);
+ assert_eq!(t.sends, vec![TutorialSend::Flag(id::CHATTING)]);
+ let (a, b) = t.bits();
+ assert_eq!(bank_bit(a.unwrap(), id::CHATTING), Some(true));
+ assert_eq!(bank_bit(b.unwrap(), id::CHATTING), Some(true));
+ // Acknowledged first: the later trigger is silent (A ⊇ B).
+ t.trigger(id::CHATTING, 0, Instant::now());
+ assert!(t.fired.is_empty());
+ }
+
+ /// Clear sets every bit and flushes the timers; Reset clears every bit and keeps them.
+ #[test]
+ fn clear_and_reset_write_both_banks() {
+ let now = Instant::now();
+ let mut t = banked();
+ t.trigger(id::TARGETING, TARGETING_DELAY_MS, now);
+ t.clear();
+ assert!(t.timers.is_empty(), "clear flushes the timers");
+ let (a, b) = t.bits();
+ assert!(a.unwrap().iter().all(|&x| x == 0xFF) && b.unwrap().iter().all(|&x| x == 0xFF));
+ t.reset();
+ t.trigger(id::TARGETING, TARGETING_DELAY_MS, now);
+ assert_eq!(
+ t.timers.len(),
+ 1,
+ "a cleared bank lets the trigger through again"
+ );
+ t.reset();
+ assert_eq!(t.timers.len(), 1, "reset does NOT flush the timers");
+ let (a, b) = t.bits();
+ assert!(a.unwrap().iter().all(|&x| x == 0) && b.unwrap().iter().all(|&x| x == 0));
+ assert_eq!(
+ t.sends,
+ vec![
+ TutorialSend::Clear,
+ TutorialSend::Reset,
+ TutorialSend::Reset
+ ]
+ );
+ }
+
+ /// World enter zeroes present banks and keeps their size; the packet fills both.
+ #[test]
+ fn world_enter_zeroes_and_the_packet_fills() {
+ let mut t = Tutorials::default();
+ t.world_enter();
+ assert!(t.fire_once.is_none(), "no bank: nothing to zero");
+ t.apply_flags(&[0xFFu8; 4]);
+ t.world_enter();
+ let (a, b) = t.bits();
+ assert_eq!(a.unwrap(), &[0u8; 4]);
+ assert_eq!(b.unwrap(), &[0u8; 4]);
+ assert_eq!(bank_bit(a.unwrap(), 31), Some(false));
+ assert_eq!(bank_bit(a.unwrap(), 32), None, "past a 4-byte bank");
+ }
+}
diff --git a/crates/benilla-app/src/ui_action/errors.rs b/crates/benilla-app/src/ui_action/errors.rs
index 0b9b205d..823729bb 100644
--- a/crates/benilla-app/src/ui_action/errors.rs
+++ b/crates/benilla-app/src/ui_action/errors.rs
@@ -192,6 +192,12 @@ pub(crate) struct Shown {
}
impl Shown {
+ /// The resolved text — what the surface will show.
+ #[cfg(test)]
+ pub(crate) fn text(&self) -> &str {
+ &self.text
+ }
+
/// A **catalog** message, named by its GlobalStrings key — the reference's `DisplayError(id)`,
/// which is nearly every line benilla shows. One lookup answers both "where does it go" and
/// "what does it sound like".
@@ -232,6 +238,17 @@ pub(crate) fn keyed_line(script: &UiScript, key: &'static str) -> Option
(!text.is_empty()).then(|| Shown::keyed(key, text))
}
+/// [`keyed_line`] for a `%s`-shaped string: each argument fills the next `%s`, in order — the
+/// reference's `format`-with-pushed-arguments face of the same catalog rows (the battleground
+/// join verdict's map name, the joined-or-left player's name, 1974).
+pub(crate) fn keyed_line_s(script: &UiScript, key: &'static str, args: &[&str]) -> Option {
+ let mut text = script.lua().globals().get::(key).ok()?;
+ for arg in args {
+ text = text.replacen("%s", arg, 1);
+ }
+ (!text.is_empty()).then(|| Shown::keyed(key, text))
+}
+
/// **The one sink** for a resolved message line: put it on the surface its [`MsgKind`] names, and
/// queue the sound its catalog row names.
///
diff --git a/crates/benilla-app/src/ui_action/mod.rs b/crates/benilla-app/src/ui_action/mod.rs
index 28f72c2c..1aff412a 100644
--- a/crates/benilla-app/src/ui_action/mod.rs
+++ b/crates/benilla-app/src/ui_action/mod.rs
@@ -66,9 +66,9 @@ pub(crate) struct CooldownEvents;
pub(crate) use cast_send::{CastCommit, CastLadder};
pub(crate) use cast_target::AutoSelfCast;
pub(crate) use errors::{
- attack_actor_blocked, attack_actor_refusal, keyed_line, reagent_totem_refusal, show_messages,
- ui_error_text, CastErrors, CastFail, MessageSink, MountErrors, Shown, UiError, UiErrorKeys,
- UiErrorTexts,
+ attack_actor_blocked, attack_actor_refusal, keyed_line, keyed_line_s, reagent_totem_refusal,
+ show_messages, ui_error_text, CastErrors, CastFail, MessageSink, MountErrors, Shown, UiError,
+ UiErrorKeys, UiErrorTexts,
};
// `pub(crate)`: the requirement validator's mounted block is ONE gate in the reference
// (`0x6094f0` @ `0x609c6c`) sitting under the ONE cast entry `TryCast 0x6e4b60` — but benilla still
diff --git a/crates/benilla-app/src/ui_battlefield.rs b/crates/benilla-app/src/ui_battlefield.rs
new file mode 100644
index 00000000..70c25696
--- /dev/null
+++ b/crates/benilla-app/src/ui_battlefield.rs
@@ -0,0 +1,501 @@
+//! The battleground **list window**'s app half (decision 1974; wow-re
+//! `system/ui/scratch/battlefield-verb-family.md` §2.1, §4.1, §4.4, §4.5, §5.2, §7.1, §8): what
+//! the stock `BattlefieldFrame.lua` and the minimap's queue icon need from the wire, the clock and
+//! the Map.dbc rows. The verbs themselves are `benilla_ui::script::battlefield_queue`'s; the
+//! queue slots live in [`BattlefieldQueue`]; the scoreboard is [`crate::ui_battlefield_score`]'s.
+//!
+//! - **The list** (`SMSG_BATTLEFIELD_LIST`, §4.1) is the battlemaster's guid, the map, the bracket
+//! index and the instance ids. The handler derives the bracket's level pair off the map row
+//! (`bracket · span + minLevel`, max clamped at 60), fires `BATTLEFIELDS_SHOW`, and only THEN
+//! anchors the player's position — the leash below measures from it.
+//! - **The leash** (§7.1): a world tick fires `BATTLEFIELDS_CLOSED` once the player is strictly
+//! more than 5.5556 yd from where the list was opened, and zeroes the anchor first. The stock
+//! window hides itself on the event; there is no other closer.
+//! - **The queue view**: every frame the three slots go to the VM with their clock-shaped fields
+//! reduced — the port deadline, the raw estimated wait, the time waited — and the map names
+//! resolved (map 0 included: the reference looks a cleared slot's id up like any other).
+//! - **The join** (§5.2): `JoinBattlefield`'s instance and group flag come back from the VM; the
+//! group leg is refused client-side with message 442 when the map's `MaxPlayers` is below
+//! either the party count or the raid count, and the opcode is chosen by the cached
+//! battlemaster guid — `CMSG_BATTLEMASTER_JOIN` with it, `CMSG_BATTLEFIELD_JOIN` without.
+//! - **The three speaking handlers** (§4.4/§4.5) print and store nothing: the group verdict
+//! (deserters / joined `%s` / failed) and the joined-or-left player lines, the latter once the
+//! name cache answers — the reference's fallback callback, in the shape this app already uses.
+//! - **World enter** (§8) clears the list scalars and the selection (not the queue slots) and
+//! sends the bodyless `CMSG_BATTLEFIELD_STATUS`, which the server answers slot by slot.
+
+use std::time::Instant;
+
+use bevy::prelude::*;
+
+use benilla_assets::MapCatalogRes;
+use benilla_formats::MapCatalog;
+use benilla_protocol::messages::{BattlefieldList, BattlefieldStatus};
+use benilla_ui::script::{BattlefieldListView, BattlefieldMapInfo, BattlefieldQueueSlot, UiScript};
+
+use crate::names::NameCache;
+use crate::net::{ClientCommand, EnteredWorldMessage, NetCommands};
+use crate::player::Player;
+use crate::ui_dialog_verbs::BattlefieldQueue;
+use crate::ui_party::GroupState;
+use crate::ui_script::UiInput;
+
+/// The leash radius, squared: the CRT initialiser's `fld [0x806574]; fmul` of the `.rdata` f32
+/// `5.55555534362793` (§7.1).
+const LEASH_RADIUS_YD: f32 = 5.555_555_3;
+
+/// The leash's degenerate-anchor refusal (`fcomp [0x8029d4]`, `2.384185791015625e-07`): an
+/// anchor this close to the origin is "no list open", never a place to measure from.
+const DEGENERATE_ANCHOR: f32 = 2.384_185_8e-7;
+
+/// `SMSG_GROUP_JOINED_BATTLEGROUND`'s deserters sentinel (`cmp eax,-2`, §4.4).
+const GROUP_JOIN_DESERTERS: u32 = 0xFFFF_FFFE;
+
+/// The list window's state: the last list, the leash anchor, and what the speaking handlers
+/// still owe the screen.
+#[derive(Resource, Default)]
+pub(crate) struct Battlefield {
+ /// The last `SMSG_BATTLEFIELD_LIST` — the battlemaster, the map, the bracket, the ids.
+ list: Option,
+ /// `BATTLEFIELDS_SHOW` owed for a list that just landed.
+ show: bool,
+ /// The list view needs re-pushing (a new list, or the world-enter reset).
+ dirty: bool,
+ /// Where the list was opened (`[0xb6e870..78]`), the leash's origin; `None` once it fired.
+ anchor: Option,
+ /// `SMSG_GROUP_JOINED_BATTLEGROUND` results awaiting their line.
+ verdicts: Vec,
+ /// Joined (`true`) / left guids awaiting a name.
+ players: Vec<(u64, bool)>,
+}
+
+impl Battlefield {
+ /// `SessionEvent::BattlefieldList` — replaces the list; the event and the anchor follow on
+ /// the next feed, in that order.
+ pub(crate) fn apply_list(&mut self, list: BattlefieldList) {
+ self.list = Some(list);
+ self.show = true;
+ self.dirty = true;
+ }
+
+ /// `SessionEvent::GroupJoinedBattleground` — one line, no state.
+ pub(crate) fn apply_verdict(&mut self, result: u32) {
+ self.verdicts.push(result);
+ }
+
+ /// `SessionEvent::BattlegroundPlayer` — one line once the name resolves, no state.
+ pub(crate) fn apply_player(&mut self, guid: u64, joined: bool) {
+ self.players.push((guid, joined));
+ }
+
+ /// The world-enter reset (§8): the list scalars and the anchor go; the queue slots do not.
+ fn clear_session(&mut self) {
+ self.list = None;
+ self.show = false;
+ self.dirty = true;
+ self.anchor = None;
+ self.verdicts.clear();
+ self.players.clear();
+ }
+
+ /// The listed map — `[0xb6eba4]`, which is 0 (a real Map.dbc row) with nothing listed.
+ fn map_id(&self) -> u32 {
+ self.list.as_ref().map_or(0, |l| l.map_id)
+ }
+}
+
+/// The VM's view of the list: the ids, the bracket pair the handler derived, the map row's
+/// `GetBattlefieldInfo` half, and the group-queue flag — every one off `[0xb6eba4]`'s row,
+/// whatever that id is.
+fn list_view(
+ state: &Battlefield,
+ catalog: Option<&MapCatalog>,
+ faction: Option<&str>,
+) -> BattlefieldListView {
+ let map_id = state.map_id();
+ let row = catalog.and_then(|c| c.battleground(map_id));
+ let (bracket_min, bracket_max) = state
+ .list
+ .as_ref()
+ .zip(row)
+ .map_or((0, 0), |(l, r)| r.bracket_levels(l.bracket));
+ let info = catalog
+ .and_then(|c| c.name(map_id))
+ .zip(row)
+ .map(|(name, r)| BattlefieldMapInfo {
+ name: name.to_string(),
+ // The faction-group index (§3.4): 0 for the mask-bit-4 side, 1 for bit 2 — Horde
+ // and Alliance in the emulator's naming; the shipped rows carry one text in both.
+ description: match faction {
+ Some("Horde") => Some(r.descriptions[0].clone()),
+ Some("Alliance") => Some(r.descriptions[1].clone()),
+ _ => None,
+ },
+ min_level: r.min_level,
+ max_level: r.max_level,
+ field_16: r.field_16,
+ field_17: r.field_17,
+ field_18: r.field_18,
+ });
+ BattlefieldListView {
+ instances: state
+ .list
+ .as_ref()
+ .map_or_else(Vec::new, |l| l.instances.clone()),
+ bracket_min,
+ bracket_max,
+ info,
+ group_queue: row.is_some_and(|r| r.group_queue != 0),
+ }
+}
+
+/// One slot's VM view at `now`: the three clock-shaped getters reduced (§3.3), the map name
+/// resolved, the bracket pair derived (§4.2).
+fn slot_view(
+ slot: Option<&(BattlefieldStatus, Instant)>,
+ catalog: Option<&MapCatalog>,
+ now: Instant,
+) -> BattlefieldQueueSlot {
+ let ms = |d: std::time::Duration| d.as_millis().min(u128::from(u32::MAX)) as u32;
+ let map_id = slot.map_or(0, |(s, _)| s.map_id);
+ let mut view = BattlefieldQueueSlot {
+ map_id,
+ map_name: catalog.and_then(|c| c.name(map_id)).map(str::to_string),
+ ..Default::default()
+ };
+ let Some((status, at)) = slot else {
+ return view;
+ };
+ view.status = status.status;
+ view.instance_id = status.instance_id;
+ if let Some(row) = catalog.and_then(|c| c.battleground(map_id)) {
+ (view.min_level, view.max_level) = row.bracket_levels(status.bracket);
+ }
+ // Status 2: `[slot+0x14] = Δ ? now + Δ : 0`; read as `deadline − now`, 0 once past.
+ if let Some(delta) = status.time_ms.filter(|&d| d != 0) {
+ let deadline = *at + std::time::Duration::from_millis(u64::from(delta));
+ view.port_expiration_ms = ms(deadline.saturating_duration_since(now));
+ }
+ // Status 1: the raw estimate, and `[slot+0x1c] = now − Δ` read back as `now − stamp`.
+ if let Some((estimate, waited)) = status.queued {
+ view.estimated_wait_ms = estimate;
+ let stamp = *at - std::time::Duration::from_millis(u64::from(waited));
+ view.time_waited_ms = ms(now.saturating_duration_since(stamp));
+ }
+ view
+}
+
+/// The as-group refusal (§5.2): the map's `MaxPlayers` must be at least the party count (the
+/// populated party slots, 0..4 — the members besides us) and the raid roster count. Which
+/// members the reference's raid roster counts is INFERRED here as the wire list's members in a
+/// raid and none in a party; the party count is VERIFIED.
+fn group_fits(catalog: Option<&MapCatalog>, map_id: u32, group: Option<&GroupState>) -> bool {
+ let max = catalog
+ .and_then(|c| c.battleground(map_id))
+ .map_or(0, |r| r.max_players) as usize;
+ let Some(group) = group else {
+ return true;
+ };
+ let party = group.party_slots().count();
+ let raid = if group.group_type == 1 {
+ group.members.len()
+ } else {
+ 0
+ };
+ max >= party && max >= raid
+}
+
+/// Every frame, before the dialog feed fires `UPDATE_BATTLEFIELD_STATUS` and the score feed
+/// fires `UPDATE_BATTLEFIELD_SCORE`: the list (when it changed), the queue slots (always), the
+/// `BATTLEFIELDS_SHOW` event with its anchor, the leash, and the speaking handlers' lines.
+#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
+fn feed_battlefield(
+ script: Option>,
+ mut state: ResMut,
+ queue: Res,
+ maps: Option>,
+ player: Res,
+ mut names: ResMut,
+ commands: Res,
+ mut sink: crate::ui_action::MessageSink,
+) {
+ let Some(mut script) = script else {
+ return;
+ };
+ let catalog = maps.as_deref().map(|m| &m.0);
+ let now = Instant::now();
+
+ if std::mem::take(&mut state.dirty) {
+ // The description's side is the local player's faction group (`0x5efe00(player)`, §3.4)
+ // — the same answer `UnitFactionGroup("player")` gives, read where it already lives.
+ let faction = script
+ .eval::>(r#"return (UnitFactionGroup("player"))"#)
+ .ok()
+ .flatten();
+ let view = list_view(&state, catalog, faction.as_deref());
+ script.set_battlefield_list(view);
+ }
+
+ let slots = queue
+ .slots()
+ .iter()
+ .map(|s| slot_view(s.as_ref(), catalog, now))
+ .collect();
+ script.set_battlefield_queue(slots, queue.instance_expiration_ms(now));
+
+ if std::mem::take(&mut state.show) {
+ script.fire_event("BATTLEFIELDS_SHOW", vec![]);
+ // §4.1 step 7: the anchor is written AFTER the event, from the live player position.
+ state.anchor = Some(player.pos);
+ }
+
+ // §7.1: strictly farther than the leash from where the list opened → the event, anchor first.
+ if let Some(anchor) = state.anchor {
+ if anchor.length_squared() > DEGENERATE_ANCHOR
+ && player.pos.distance_squared(anchor) > LEASH_RADIUS_YD * LEASH_RADIUS_YD
+ {
+ state.anchor = None;
+ script.fire_event("BATTLEFIELDS_CLOSED", vec![]);
+ }
+ }
+
+ let mut lines = Vec::new();
+ for result in std::mem::take(&mut state.verdicts) {
+ let line = if result == GROUP_JOIN_DESERTERS {
+ crate::ui_action::keyed_line(&script, "ERR_GROUP_JOIN_BATTLEGROUND_DESERTERS")
+ } else if let Some(name) = catalog.and_then(|c| c.name(result)) {
+ crate::ui_action::keyed_line_s(&script, "ERR_GROUP_JOIN_BATTLEGROUND_S", &[name])
+ } else {
+ crate::ui_action::keyed_line(&script, "ERR_GROUP_JOIN_BATTLEGROUND_FAIL")
+ };
+ lines.extend(line);
+ }
+ let pending = std::mem::take(&mut state.players);
+ for (guid, joined) in pending {
+ let Some(name) = names.resolve(guid, &commands).map(str::to_string) else {
+ state.players.push((guid, joined));
+ continue;
+ };
+ let line = if joined {
+ crate::ui_action::keyed_line_s(&script, "ERR_BG_PLAYER_JOINED_SS", &[&name, &name])
+ } else {
+ crate::ui_action::keyed_line_s(&script, "ERR_BG_PLAYER_LEFT_S", &[&name])
+ };
+ lines.extend(line);
+ }
+ if !lines.is_empty() {
+ crate::ui_action::show_messages(&mut script, &mut sink, "ui_battlefield", lines);
+ }
+}
+
+/// After the script tick: `JoinBattlefield`'s sends (with the group refusal and the opcode
+/// choice) and `ShowBattlefieldList`'s.
+fn drain_battlefield(
+ script: Option>,
+ state: Res,
+ maps: Option>,
+ group: Option>,
+ commands: Res,
+ mut sink: crate::ui_action::MessageSink,
+) {
+ let Some(mut script) = script else {
+ return;
+ };
+ let catalog = maps.as_deref().map(|m| &m.0);
+ let map_id = state.map_id();
+ for (instance_id, as_group) in script.take_battlefield_join_requests() {
+ if as_group && !group_fits(catalog, map_id, group.as_deref()) {
+ if let Some(line) =
+ crate::ui_action::keyed_line(&script, "ERR_GROUP_JOIN_BATTLEGROUND_TOO_MANY")
+ {
+ crate::ui_action::show_messages(&mut script, &mut sink, "ui_battlefield", [line]);
+ }
+ continue;
+ }
+ let battlemaster = state.list.as_ref().map_or(0, |l| l.battlemaster);
+ let command = if battlemaster != 0 {
+ ClientCommand::BattlemasterJoin {
+ battlemaster,
+ map_id,
+ instance_id,
+ as_group,
+ }
+ } else {
+ ClientCommand::BattlefieldJoin {
+ map_id,
+ instance_id,
+ as_group,
+ }
+ };
+ let _ = commands.0.send(command);
+ }
+ for map_id in script.take_battlefield_list_requests() {
+ let _ = commands.0.send(ClientCommand::BattlefieldList { map_id });
+ }
+}
+
+/// The world-enter reset (§8): the list scalars, the selection and the anchor cleared, the
+/// queue slots kept, and the bodyless status request sent.
+fn reset_on_world_enter(
+ mut entered: MessageReader,
+ mut state: ResMut,
+ script: Option>,
+ commands: Res,
+) {
+ if entered.read().next().is_none() {
+ return;
+ }
+ state.clear_session();
+ if let Some(mut script) = script {
+ script.reset_battlefield_selection();
+ }
+ let _ = commands.0.send(ClientCommand::BattlefieldStatusRequest);
+}
+
+pub(crate) struct BattlefieldPlugin;
+
+impl Plugin for BattlefieldPlugin {
+ fn build(&self, app: &mut App) {
+ app.init_resource::().add_systems(
+ Update,
+ (
+ reset_on_world_enter.before(feed_battlefield),
+ feed_battlefield
+ .before(crate::ui_battlefield_score::feed_battlefield_score)
+ .before(crate::ui_dialog_verbs::feed_dialog_verbs)
+ .before(UiInput),
+ drain_battlefield.after(UiInput),
+ ),
+ );
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn status(slot: u32, map_id: u32, status: u32) -> BattlefieldStatus {
+ BattlefieldStatus {
+ slot,
+ map_id,
+ bracket: 1,
+ instance_id: 7,
+ status,
+ time_ms: None,
+ in_progress: None,
+ queued: None,
+ }
+ }
+
+ /// A queued slot's view: the raw estimate, the time waited growing with the clock, the
+ /// port deadline zero; a confirm slot's deadline counting down and stopping at zero.
+ #[test]
+ fn the_slot_view_reduces_the_clocks() {
+ let at = Instant::now();
+ let mut queued = status(0, 489, 1);
+ queued.queued = Some((30_000, 5_000));
+ let later = at + std::time::Duration::from_millis(2_000);
+ let v = slot_view(Some(&(queued, at)), None, later);
+ assert_eq!((v.map_id, v.status, v.instance_id), (489, 1, 7));
+ assert_eq!(v.estimated_wait_ms, 30_000, "raw, no clock");
+ assert_eq!(v.time_waited_ms, 7_000, "the wire's 5 s plus the 2 s since");
+ assert_eq!(v.port_expiration_ms, 0);
+ assert_eq!((v.min_level, v.max_level), (0, 0), "no catalog: no bracket");
+ assert!(v.map_name.is_none());
+
+ let mut confirm = status(1, 529, 2);
+ confirm.time_ms = Some(60_000);
+ let v = slot_view(Some(&(confirm.clone(), at)), None, later);
+ assert_eq!(v.port_expiration_ms, 58_000);
+ let past = at + std::time::Duration::from_millis(61_000);
+ let v = slot_view(Some(&(confirm, at)), None, past);
+ assert_eq!(
+ v.port_expiration_ms, 0,
+ "a past deadline reads 0, never negative"
+ );
+
+ let v = slot_view(None, None, later);
+ assert_eq!(
+ (v.map_id, v.status),
+ (0, 0),
+ "an empty slot: map 0, status none"
+ );
+ }
+
+ /// The group refusal: the party count and the raid count against `MaxPlayers`; no group
+ /// always fits.
+ #[test]
+ fn the_group_gate_reads_both_counts() {
+ let data = benilla_formats::wow_data_or_skip!();
+ let mut chain = benilla_formats::open_chain(&data).expect("open chain");
+ let catalog = benilla_formats::load_map_catalog(&mut chain).expect("Map.dbc");
+ assert!(group_fits(Some(&catalog), 489, None));
+ let member = |guid: u64| benilla_protocol::messages::GroupMemberEntry {
+ name: format!("m{guid}"),
+ guid,
+ status: 1,
+ flags: 0,
+ };
+ let mut group = GroupState {
+ in_group: true,
+ ..Default::default()
+ };
+ group.members = (1..=4).map(member).collect();
+ assert!(
+ group_fits(Some(&catalog), 489, Some(&group)),
+ "a full party fits WSG's 10"
+ );
+ group.group_type = 1;
+ group.members = (1..=12).map(member).collect();
+ assert!(
+ !group_fits(Some(&catalog), 489, Some(&group)),
+ "a 12-member raid outruns WSG's 10"
+ );
+ assert!(
+ group_fits(Some(&catalog), 30, Some(&group)),
+ "and fits AV's 40"
+ );
+ }
+
+ /// The list view with nothing listed reads map 0's row — the reference resolves
+ /// `[0xb6eba4] = 0` like any other id — and a listed map's bracket pair off its bracket.
+ #[test]
+ fn the_list_view_resolves_the_listed_map_or_map_zero() {
+ let data = benilla_formats::wow_data_or_skip!();
+ let mut chain = benilla_formats::open_chain(&data).expect("open chain");
+ let catalog = benilla_formats::load_map_catalog(&mut chain).expect("Map.dbc");
+ let mut state = Battlefield::default();
+ let v = list_view(&state, Some(&catalog), Some("Horde"));
+ assert!(v.instances.is_empty());
+ assert_eq!(
+ v.info.as_ref().map(|i| i.name.as_str()),
+ Some("Eastern Kingdoms")
+ );
+ assert_eq!((v.bracket_min, v.bracket_max), (0, 0));
+ state.apply_list(BattlefieldList {
+ battlemaster: 0x10,
+ map_id: 529,
+ bracket: 2,
+ instances: vec![4, 9],
+ });
+ let v = list_view(&state, Some(&catalog), Some("Alliance"));
+ assert_eq!(v.instances, vec![4, 9]);
+ assert_eq!((v.bracket_min, v.bracket_max), (40, 49));
+ let info = v.info.expect("Arathi Basin's row");
+ assert_eq!(info.name, "Arathi Basin");
+ assert!(info
+ .description
+ .as_deref()
+ .is_some_and(|d| d.starts_with("The Arathi Basin")));
+ assert_eq!(
+ (info.min_level, info.max_level, info.field_16),
+ (20, 60, -1)
+ );
+ assert!(v.group_queue);
+ let v = list_view(&state, Some(&catalog), None);
+ assert!(
+ v.info.is_some_and(|i| i.description.is_none()),
+ "the -1 faction leg: no description"
+ );
+ }
+}
diff --git a/crates/benilla-app/src/ui_battlefield_positions.rs b/crates/benilla-app/src/ui_battlefield_positions.rs
new file mode 100644
index 00000000..78ce02e0
--- /dev/null
+++ b/crates/benilla-app/src/ui_battlefield_positions.rs
@@ -0,0 +1,262 @@
+//! The battleground **teammate and flag positions** on the world map (decision 1980; wow-re
+//! `worldmap-arrow-and-positions.md` §3): `MSG_BATTLEGROUND_PLAYER_POSITIONS` both ways, resolved
+//! into the engine's `GetBattlefieldPosition` / `GetBattlefieldFlagPosition` /
+//! `GetBattlefieldMapIconScale` backing the stock `WorldMapFrame.lua` and
+//! `Blizzard_BattlefieldMinimap.lua` poll every update.
+//!
+//! **The reference's law, and where each half lives.** The packet carries the teammates outside
+//! the requester's own group as raw world floats, plus the friendly flag carrier. The handler
+//! (`0x4aad40`) keeps them as sent; the *getter* (`0x4abf90`) does the work per call: it skips
+//! the player itself, the four party slots and the raid roster (all of which the map already
+//! draws through `GetPlayerMapPosition`), prefers the live object's position when the guid is
+//! streamed to us, and normalizes through the world-map projection under the **active queue
+//! slot's map** — which is why the getter answers `(0, 0)` off that map. Every one of those is an
+//! app-side fact (the group, the entity index, the projection, the catalog), so the app resolves
+//! the whole list here each frame and the engine holds only the finished view.
+//!
+//! **The name** is the name cache's answer (a `CMSG_NAME_QUERY` goes out for a guid it has not
+//! seen), `nil` until it lands — and when it lands the reference fires `WORLD_MAP_NAME_UPDATE`,
+//! which `WorldMapFrame_OnEvent` turns into a repaint; that is produced here, once per landing.
+//!
+//! **The request** (`RequestBattlefieldPositions()`) is the reference's own 5000 ms throttle
+//! (`0x4ac0f0`, the `[0xb6ec00]` stamp) and goes out only with an active slot — outside a
+//! battleground the list is cleared instead, so a stale roster never draws on the next map.
+
+use std::time::{Duration, Instant};
+
+use bevy::prelude::*;
+
+use benilla_assets::coords::bevy_to_wow;
+use benilla_assets::MapCatalogRes;
+use benilla_protocol::messages::{BattlefieldPosition, BattlefieldPositions as PositionsPacket};
+use benilla_ui::script::{BattlefieldFlagView, BattlefieldPositionView, UiScript};
+
+use crate::names::NameCache;
+use crate::net::{ClientCommand, EnteredWorldMessage, GuidIndex, NetCommands, NetEntity, SelfGuid};
+use crate::ui_dialog_verbs::BattlefieldQueue;
+use crate::ui_party::GroupState;
+use crate::ui_script::UiInput;
+use crate::ui_world_map::{project_on_displayed, WorldMapUiData};
+
+/// The reference's request throttle: `RequestBattlefieldPositions` sends at most once per 5000 ms.
+pub(crate) const REQUEST_THROTTLE: Duration = Duration::from_millis(5000);
+
+/// The last positions packet and the request stamp.
+#[derive(Resource, Default)]
+pub(crate) struct BattlefieldPositions {
+ /// The last `MSG_BATTLEGROUND_PLAYER_POSITIONS`, as sent; `None` outside a battleground.
+ packet: Option,
+ /// When the last request went out — the throttle's stamp.
+ last_request: Option,
+ /// Guids whose name the cache has not answered yet; a landing fires `WORLD_MAP_NAME_UPDATE`.
+ pending_names: Vec,
+}
+
+impl BattlefieldPositions {
+ /// `SessionEvent::BattlefieldPositions` — replaces the list.
+ pub(crate) fn apply(&mut self, packet: PositionsPacket) {
+ self.packet = Some(packet);
+ }
+
+ fn clear(&mut self) {
+ self.packet = None;
+ self.pending_names.clear();
+ }
+
+ /// The throttle: `true` (and the stamp moves) when a request may go out now.
+ fn request_due(&mut self, now: Instant) -> bool {
+ let due = self
+ .last_request
+ .is_none_or(|t| now.saturating_duration_since(t) >= REQUEST_THROTTLE);
+ if due {
+ self.last_request = Some(now);
+ }
+ due
+ }
+}
+
+/// The getter's position source (`0x4abf90`): the live object's when the guid is streamed to us,
+/// else the packet's floats. Both in wow space `(x, y)`.
+fn live_or_packet(
+ p: &BattlefieldPosition,
+ guids: &GuidIndex,
+ unit_pos: &Query<&GlobalTransform, With>,
+) -> (f32, f32) {
+ match guids.0.get(&p.guid).and_then(|e| unit_pos.get(*e).ok()) {
+ Some(tf) => {
+ let w = bevy_to_wow(tf.translation());
+ (w[0], w[1])
+ }
+ None => (p.x, p.y),
+ }
+}
+
+/// The flag texture token the local faction selects: the friendly carrier is carrying the OTHER
+/// side's flag, so an Alliance viewer sees `HordeFlag` and a Horde viewer `AllianceFlag`.
+fn flag_token(faction: Option<&str>) -> Option<&'static str> {
+ match faction {
+ Some("Alliance") => Some("HordeFlag"),
+ Some("Horde") => Some("AllianceFlag"),
+ _ => None,
+ }
+}
+
+#[allow(clippy::too_many_arguments)]
+fn feed_battlefield_positions(
+ script: Option>,
+ mut state: ResMut,
+ queue: Res,
+ data: Option>,
+ maps: Option>,
+ me: Res,
+ group: Res,
+ guids: Res,
+ unit_pos: Query<&GlobalTransform, With>,
+ mut names: ResMut,
+ commands: Res,
+ // What the empty push last carried (`(has_list, icon_scale bits)`): with no list the engine
+ // is told once per change, not per frame; with one, every frame — the positions move.
+ mut last_empty: Local>>,
+) {
+ let Some(mut script) = script else {
+ return;
+ };
+ let active = queue.active_map();
+ // `GetBattlefieldMapIconScale`: the active slot's `Map.dbc` row, map 0's with no slot.
+ let icon_scale = maps
+ .as_deref()
+ .and_then(|m| m.0.battleground(active.unwrap_or(0)))
+ .map_or(1.0, |b| b.minimap_icon_scale);
+ let BattlefieldPositions {
+ packet,
+ pending_names,
+ ..
+ } = &mut *state;
+ let (Some(map), Some(packet)) = (active, packet.as_ref()) else {
+ let key = Some((false, icon_scale.to_bits()));
+ let last = last_empty.get(&script);
+ if *last != key {
+ *last = key;
+ script.set_battlefield_positions(Vec::new(), None, icon_scale);
+ }
+ return;
+ };
+ *last_empty.get(&script) = Some((true, icon_scale.to_bits()));
+
+ let selection = script.world_map_selection();
+ let project = |x: f32, y: f32| {
+ data.as_deref()
+ .and_then(|d| project_on_displayed(d, selection, map, x, y))
+ .unwrap_or((0.0, 0.0))
+ };
+ let mut name_landed = false;
+ let mut players = Vec::with_capacity(packet.players.len());
+ for p in &packet.players {
+ if me.0 == Some(p.guid) || group.members.iter().any(|m| m.guid == p.guid) {
+ continue;
+ }
+ let (x, y) = live_or_packet(p, &guids, &unit_pos);
+ let name = names.resolve(p.guid, &commands).map(str::to_string);
+ match name {
+ None => {
+ if !pending_names.contains(&p.guid) {
+ pending_names.push(p.guid);
+ }
+ }
+ Some(_) => {
+ if let Some(i) = pending_names.iter().position(|g| *g == p.guid) {
+ pending_names.swap_remove(i);
+ name_landed = true;
+ }
+ }
+ }
+ players.push(BattlefieldPositionView {
+ uv: project(x, y),
+ name,
+ });
+ }
+ let flag = packet.carrier.as_ref().map(|c| {
+ let (x, y) = live_or_packet(c, &guids, &unit_pos);
+ let faction = script
+ .eval::>(r#"return (UnitFactionGroup("player"))"#)
+ .ok()
+ .flatten();
+ BattlefieldFlagView {
+ uv: project(x, y),
+ token: flag_token(faction.as_deref()).map(str::to_string),
+ }
+ });
+ script.set_battlefield_positions(players, flag, icon_scale);
+ if name_landed {
+ script.fire_event("WORLD_MAP_NAME_UPDATE", Vec::new());
+ }
+}
+
+fn drain_battlefield_positions(
+ script: Option>,
+ mut state: ResMut,
+ queue: Res,
+ commands: Res,
+) {
+ let Some(mut script) = script else {
+ return;
+ };
+ if script.take_battlefield_position_requests() == 0 {
+ return;
+ }
+ if queue.active_map().is_none() {
+ state.clear();
+ return;
+ }
+ if state.request_due(Instant::now()) {
+ let _ = commands.0.send(ClientCommand::RequestBattlefieldPositions);
+ }
+}
+
+fn reset_on_world_enter(
+ mut entered: MessageReader,
+ mut state: ResMut,
+) {
+ if entered.read().next().is_none() {
+ return;
+ }
+ state.clear();
+ state.last_request = None;
+}
+
+pub(crate) struct BattlefieldPositionsPlugin;
+
+impl Plugin for BattlefieldPositionsPlugin {
+ fn build(&self, app: &mut App) {
+ app.init_resource::().add_systems(
+ Update,
+ (
+ reset_on_world_enter.before(feed_battlefield_positions),
+ feed_battlefield_positions.before(UiInput),
+ drain_battlefield_positions.after(UiInput),
+ ),
+ );
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn the_request_throttle_is_the_references_five_seconds() {
+ let mut s = BattlefieldPositions::default();
+ let t0 = Instant::now();
+ assert!(s.request_due(t0), "the first request goes out");
+ assert!(!s.request_due(t0 + Duration::from_millis(4999)));
+ assert!(s.request_due(t0 + REQUEST_THROTTLE));
+ assert!(!s.request_due(t0 + REQUEST_THROTTLE + Duration::from_millis(1)));
+ }
+
+ #[test]
+ fn the_flag_token_is_the_other_sides() {
+ assert_eq!(flag_token(Some("Alliance")), Some("HordeFlag"));
+ assert_eq!(flag_token(Some("Horde")), Some("AllianceFlag"));
+ assert_eq!(flag_token(None), None);
+ }
+}
diff --git a/crates/benilla-app/src/ui_battlefield_score.rs b/crates/benilla-app/src/ui_battlefield_score.rs
index 79306022..2bbf16fa 100644
--- a/crates/benilla-app/src/ui_battlefield_score.rs
+++ b/crates/benilla-app/src/ui_battlefield_score.rs
@@ -105,7 +105,7 @@ fn resolve_board(
Some(rows)
}
-fn feed_battlefield_score(
+pub(crate) fn feed_battlefield_score(
script: Option>,
board: Res,
mut queue: ResMut,
diff --git a/crates/benilla-app/src/ui_chat/input/mod.rs b/crates/benilla-app/src/ui_chat/input/mod.rs
index 43a4f860..e80907e9 100644
--- a/crates/benilla-app/src/ui_chat/input/mod.rs
+++ b/crates/benilla-app/src/ui_chat/input/mod.rs
@@ -103,9 +103,6 @@ pub(super) struct ChatProbes<'w, 's> {
/// "leader" on this wire is a guid. Here rather than as a 17th drain parameter for the
/// reason this struct exists at all — the drain is at Bevy's 16-param ceiling.
self_guid: Res<'w, crate::net::SelfGuid>,
- /// The map `/partytest ping` stamps its synthetic ping with — a ping carries no map on the
- /// wire either, so "the one we are standing on" is the client's answer in both paths.
- map: Option>,
}
/// Everything the drain **queues into another subsystem's one setter** rather than applying itself,
@@ -218,7 +215,6 @@ pub(super) fn drain_chat_input(
guids,
kinds,
self_guid,
- map,
} = &probes;
let Some(mut script) = script else {
return;
@@ -573,9 +569,7 @@ pub(super) fn drain_chat_input(
let w = benilla_assets::coords::bevy_to_wow(tf.translation());
// +x is north, −y is east (0203's north-up mapping).
let at = (w[0] + 24.75, w[1] - 24.75);
- chat_out
- .ping
- .seat(at, map.as_ref().map_or(0, |m| m.0), 0xF001);
+ chat_out.ping.seat(at, 0xF001);
format!(
"partytest: Alice pinged ({:.0}, {:.0}) — 35 yd NE, party1, 5 s",
at.0, at.1
@@ -1479,6 +1473,7 @@ pub(super) fn drain_addon_chat_sends(
script: Option>,
commands: Res,
mut chat_log: ResMut,
+ mut tutorials: Option>,
) {
let Some(mut script) = script else {
return;
@@ -1495,6 +1490,19 @@ pub(super) fn drain_addon_chat_sends(
));
continue;
};
+ // `SendChatMessage`'s acknowledge (`0x49f5fc`, 1976): the twelve social wire types —
+ // party, raid, guild, officer, whisper, channel, AFK/DND, raid leader/warning, the two
+ // battleground types — say, yell and emote are not among them.
+ if !matches!(
+ kind,
+ super::edit::SendType::Say | super::edit::SendType::Yell | super::edit::SendType::Emote
+ ) {
+ if let Some(t) = tutorials.as_mut() {
+ t.write(crate::tutorial::TutorialEvent::Acknowledge {
+ id: crate::tutorial::id::CHATTING,
+ });
+ }
+ }
let cmd = ClientCommand::Chat {
kind: kind.wire(),
target: send.target,
diff --git a/crates/benilla-app/src/ui_chat/settings.rs b/crates/benilla-app/src/ui_chat/settings.rs
index b39baed1..3b4781f7 100644
--- a/crates/benilla-app/src/ui_chat/settings.rs
+++ b/crates/benilla-app/src/ui_chat/settings.rs
@@ -569,7 +569,17 @@ pub(super) fn plugin(app: &mut App) {
Update,
(load_chat_looks, watch_chat_looks)
.chain()
- .run_if(in_state(crate::char_select::ClientState::InWorld)),
+ .run_if(in_state(crate::char_select::ClientState::InWorld))
+ // …and never before the in-game UI exists (1348's law, the unit feed's own
+ // words): the restore fires `UPDATE_CHAT_WINDOWS` once and latches a per-VM
+ // memo, and the VM that is live between the world-entry edge and the deferred
+ // entry load is the SAME object the load then fills — so a restore made in
+ // that window reaches no chat frame and its memo blocks the one that would.
+ // Every plate texture stayed at the XML's white, alpha 1 (director report,
+ // 2026-09-04).
+ .run_if(bevy::ecs::schedule::common_conditions::not(
+ crate::ui_script::ingame_ui_pending,
+ )),
)
.add_systems(
Update,
diff --git a/crates/benilla-app/src/ui_dialog_verbs.rs b/crates/benilla-app/src/ui_dialog_verbs.rs
index 3ff0973a..5993b112 100644
--- a/crates/benilla-app/src/ui_dialog_verbs.rs
+++ b/crates/benilla-app/src/ui_dialog_verbs.rs
@@ -20,17 +20,31 @@
//! * **Battlefield queue** — `SMSG_BATTLEFIELD_STATUS` fills one of three slots and fires
//! `UPDATE_BATTLEFIELD_STATUS`; `AcceptBattlefieldPort(index, accept)` sends the slot's map id
//! with the answer as one byte.
-//! * **Meeting stone** — `SMSG 0x295 {areaId, status}` stores the queued area and fires
-//! `MEETINGSTONE_CHANGED`; `CancelMeetingStoneRequest()` sends `0x293` unless in a party led by
-//! someone else (`ERR_MEETING_STONE_NOT_LEADER`). The five status messages the reference prints
-//! are not built: the carve lists the five strings but not which status byte picks which.
+//! * **Meeting stone** (wow-re `meeting-stone-status.md`, 1974) — two globals: the queued area
+//! (`[0xb72038]`) and the cached status text (`[0xb7203c]`). `SMSG 0x295 {areaId, status}`
+//! latches the old area, stores the new one unconditionally, prints one of five chat lines by
+//! the status byte (with the two asymmetries §8 records: status 0 names the OLD area and is
+//! silent when it has no row; status 1 is skipped entirely when the area did not change, names
+//! the NEW one with an `UNKNOWN` fallback, and plays the `HARDCODED Meeting Stone Join` visual
+//! on the player), then — on EVERY path, an out-of-range status included — rebuilds the text
+//! (`MEETINGSTONE_TOOLTIP` over the area's name or `UNKNOWN`, into a 256-byte buffer) and fires
+//! `MEETINGSTONE_CHANGED`. World enter resets the text to the bare `UNKNOWN` and sends the empty
+//! `CMSG 0x296` once per world session; world leave drops the text to none.
+//! `CancelMeetingStoneRequest()` sends `0x293` unless in a party led by someone else
+//! (`ERR_MEETING_STONE_NOT_LEADER`). The four display-only replies (`0x297/0x298/0x299/0x2BB`)
+//! are chat lines with no state; the status-1 arm also triggers the Meeting Stones tutorial
+//! (`crate::tutorial`, 1976).
use std::time::Instant;
-use benilla_protocol::messages::BattlefieldStatus;
+use benilla_protocol::messages::{BattlefieldStatus, MeetingStoneNotice};
use benilla_ui::script::{ScriptValue, UiScript};
use bevy::prelude::*;
+use crate::area::AreaTableRes;
+use crate::creature_anim::spell_visual::{meeting_stone_join_fx, SpellKitFx, SpellVisuals};
+use crate::names::NameCache;
+
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfGuid, SelfPlayer};
use crate::ui_party::GroupState;
use crate::ui_script::UiInput;
@@ -130,46 +144,97 @@ impl AreaSpiritHealer {
}
}
-/// The three battleground queue slots (`0xb6e9d0`, stride `0x20`).
+/// The three battleground queue slots (`0xb6e9d0`, stride `0x20`), each with the moment its
+/// status landed — the clock every stamp in the slot is relative to.
#[derive(Resource, Default)]
pub(crate) struct BattlefieldQueue {
- slots: [Option; 3],
+ slots: [Option<(BattlefieldStatus, Instant)>; 3],
changed: bool,
- /// The slot the player is IN (`[0x8457cc]`, the status-3 arm), and the instance's clocks
- /// (`[0xb6ebb8]`/`[0xb6ebbc]`, §4.2): the run-time stamp `now − Δ₂` and the expiration
- /// `now + Δ₁`. Any other status for that slot resets both (1972).
+ /// The slot the player is IN (`[0x8457cc]`, the status-3 arm) and its map.
active: Option<(usize, u32)>,
+ /// The instance's two clocks (`[0xb6ebbc]`/`[0xb6ebb8]`, wow-re `battlefield-verb-family.md`
+ /// §4.2): the run-time stamp `now − Δ₂` and the expiration `now + Δ₁`, set by a status-3
+ /// message and zeroed by ANY non-clearing message of another status — whatever slot it is
+ /// about (§10's anomaly 4, reproduced: 1972 zeroed them only for the active slot; 1974
+ /// corrects it to the handler's unconditional clear).
run_started: Option,
+ instance_expiration: Option,
/// The status-3 arm rebuilds the scoreboard and fires `UPDATE_BATTLEFIELD_SCORE` before
/// `UPDATE_BATTLEFIELD_STATUS` (§4.2's ordering) — the score feed reads this first.
score_dirty: bool,
+ /// The handler's two tutorial arms (`0x2f` on queued, `0x30` on confirm; 1976), owed to the
+ /// tutorial system on the next feed.
+ tutorials: Vec,
}
impl BattlefieldQueue {
- /// `SMSG_BATTLEFIELD_STATUS`: an out-of-range slot aborts the handler; a zero map clears.
+ /// `SMSG_BATTLEFIELD_STATUS` (§4.2): an out-of-range slot abandons the message; a zero map
+ /// takes the clear arm (the slot emptied, the instance clocks zeroed only when this was the
+ /// active slot — and the active index itself left alone, as the handler leaves `[0x8457cc]`);
+ /// status 3 stamps the instance clocks and names the slot active; every other status zeroes
+ /// the instance clocks unconditionally and un-names the slot if it was the active one.
pub(crate) fn apply(&mut self, status: BattlefieldStatus) {
+ self.apply_at(status, Instant::now());
+ }
+
+ fn apply_at(&mut self, status: BattlefieldStatus, now: Instant) {
let index = status.slot as usize;
let Some(slot) = self.slots.get_mut(index) else {
return;
};
+ if status.map_id == 0 {
+ if self.active.is_some_and(|(i, _)| i == index) {
+ self.run_started = None;
+ self.instance_expiration = None;
+ }
+ *slot = None;
+ self.changed = true;
+ return;
+ }
+ match status.status {
+ 1 => self.tutorials.push(crate::tutorial::id::BATTLEGROUND_QUEUE),
+ 2 => self
+ .tutorials
+ .push(crate::tutorial::id::PORT_TO_BATTLEGROUND),
+ _ => {}
+ }
match status.in_progress {
- Some((_, elapsed_ms)) if status.map_id != 0 => {
+ Some((expires_ms, elapsed_ms)) => {
self.active = Some((index, status.map_id));
- self.run_started =
- Some(Instant::now() - std::time::Duration::from_millis(u64::from(elapsed_ms)));
+ self.instance_expiration = (expires_ms != 0)
+ .then(|| now + std::time::Duration::from_millis(u64::from(expires_ms)));
+ self.run_started = (elapsed_ms != 0)
+ .then(|| now - std::time::Duration::from_millis(u64::from(elapsed_ms)));
self.score_dirty = true;
}
- _ => {
+ None => {
+ self.run_started = None;
+ self.instance_expiration = None;
if self.active.is_some_and(|(i, _)| i == index) {
self.active = None;
- self.run_started = None;
}
}
}
- *slot = (status.map_id != 0).then_some(status);
+ *slot = Some((status, now));
self.changed = true;
}
+ /// The three slots with the instant each status landed — the queue verbs' view builder
+ /// (`crate::ui_battlefield`) reduces their stamps against `now`.
+ pub(crate) fn slots(&self) -> &[Option<(BattlefieldStatus, Instant)>; 3] {
+ &self.slots
+ }
+
+ /// `GetBattlefieldInstanceExpiration()`: `deadline − now` in ms, 0 when unset or past
+ /// (`[0xb6ebb8]`, the `jns` guard).
+ pub(crate) fn instance_expiration_ms(&self, now: Instant) -> u32 {
+ self.instance_expiration.map_or(0, |d| {
+ d.saturating_duration_since(now)
+ .as_millis()
+ .min(u128::from(u32::MAX)) as u32
+ })
+ }
+
/// The map of the battleground the player is in — `LeaveBattlefield`'s payload; `None` = 0.
pub(crate) fn active_map(&self) -> Option {
self.active.map(|(_, map)| map)
@@ -194,25 +259,259 @@ impl BattlefieldQueue {
self.slots
.get(usize::from(index).checked_sub(1)?)
.and_then(|s| s.as_ref())
- .map(|s| s.map_id)
+ .map(|(s, _)| s.map_id)
}
}
-/// The meeting-stone queue (`[0xb72038]`).
+/// The cached status text's three states (`[0xb7203c]`): none from process start and after world
+/// leave; the bare localized `UNKNOWN` from world enter until the server's `0x295` lands; a
+/// built line after that. The two localized halves are resolved against the VM at push time.
+#[derive(Clone, Debug, PartialEq, Eq, Default)]
+enum StoneText {
+ #[default]
+ None,
+ Unknown,
+ Built(String),
+}
+
+/// `SStrPrintf`'s buffer at the rebuild: 256 bytes, so 255 of text.
+const STONE_TEXT_BYTES: usize = 255;
+
+/// The meeting-stone queue: the two globals and what the wire still owes the screen.
#[derive(Resource, Default)]
pub(crate) struct MeetingStone {
+ /// `[0xb72038]` — the queued area id, `0` = none.
pub(crate) area: u32,
- pub(crate) status: Option,
- changed: bool,
+ text: StoneText,
+ /// `0x295` arrivals since the last feed: `(the area BEFORE the store, status)`; the new area
+ /// is already in `area` (the handler stores it before it switches).
+ updates: Vec<(u32, u8)>,
+ notices: Vec,
+ /// `0x299` guids whose name has not resolved yet.
+ pending_members: Vec,
+ /// The VM's copy of the two globals is stale.
+ dirty: bool,
}
impl MeetingStone {
- /// `SMSG 0x295`: the area is stored unconditionally; the status picks the (unbuilt) message.
+ /// `SMSG 0x295`: the area is stored unconditionally; the line, the rebuild and the event
+ /// follow on the next feed, with the VM.
pub(crate) fn apply(&mut self, area: u32, status: u8) {
+ let old = self.area;
self.area = area;
- self.status = Some(status);
- self.changed = true;
+ self.updates.push((old, status));
}
+
+ /// One of the four display-only replies.
+ pub(crate) fn apply_notice(&mut self, notice: MeetingStoneNotice) {
+ self.notices.push(notice);
+ }
+
+ /// The enter-world bring-up (`0x4c9f40`): the text becomes the bare `UNKNOWN`; the area is
+ /// untouched (the server's reply resets it).
+ fn enter_world(&mut self) {
+ self.text = StoneText::Unknown;
+ self.dirty = true;
+ }
+
+ /// The leave-world sweep (`0x4c9f80`): the text goes, the area stays.
+ fn leave_world(&mut self) {
+ self.text = StoneText::None;
+ self.dirty = true;
+ }
+}
+
+/// A `GlobalStrings` value as the client's `GetText` reads it: the string, or `""` when the Lua
+/// global is missing (`0x882748`, the shared empty-string constant — never NULL).
+fn global_text(script: &UiScript, key: &str) -> String {
+ script
+ .lua()
+ .globals()
+ .get::(key)
+ .unwrap_or_default()
+}
+
+/// The area's localized name, or `None` where the reference's three-part AreaTable resolve fails.
+fn area_name(areas: Option<&AreaTableRes>, id: u32) -> Option<&str> {
+ areas.and_then(|a| a.0.name(id))
+}
+
+/// The status-text rebuild `0x4ca070`: `MEETINGSTONE_TOOLTIP` (or `""` when missing) over the
+/// queued area's name (or `UNKNOWN`), printed into a 256-byte buffer.
+fn build_stone_text(script: &UiScript, areas: Option<&AreaTableRes>, area: u32) -> String {
+ let name = area_name(areas, area)
+ .map(str::to_string)
+ .unwrap_or_else(|| global_text(script, "UNKNOWN"));
+ let mut text = global_text(script, "MEETINGSTONE_TOOLTIP").replacen("%s", &name, 1);
+ if text.len() > STONE_TEXT_BYTES {
+ let mut cut = STONE_TEXT_BYTES;
+ while !text.is_char_boundary(cut) {
+ cut -= 1;
+ }
+ text.truncate(cut);
+ }
+ text
+}
+
+/// The `0x295` handler's five-way table (§8), as the line it prints for `(old, new, status)` —
+/// `None` where the reference prints nothing: status 0 with no row for the OLD area, status 1
+/// with an unchanged area, and any status past 4.
+fn stone_line(
+ script: &UiScript,
+ areas: Option<&AreaTableRes>,
+ old: u32,
+ new: u32,
+ status: u8,
+) -> Option {
+ match status {
+ 0 => {
+ let name = area_name(areas, old)?;
+ crate::ui_action::keyed_line_s(script, "ERR_MEETING_STONE_LEFT_QUEUE_S", &[name])
+ }
+ 1 => {
+ if new == old {
+ return None;
+ }
+ let name = area_name(areas, new)
+ .map(str::to_string)
+ .unwrap_or_else(|| global_text(script, "UNKNOWN"));
+ crate::ui_action::keyed_line_s(script, "ERR_MEETING_STONE_IN_QUEUE_S", &[&name])
+ }
+ 2 => crate::ui_action::keyed_line(script, "ERR_MEETING_STONE_OTHER_MEMBER_LEFT"),
+ 3 => crate::ui_action::keyed_line(script, "ERR_MEETING_STONE_PARTY_KICKED_FROM_QUEUE"),
+ 4 => crate::ui_action::keyed_line(script, "ERR_MEETING_STONE_MEMBER_STILL_IN_QUEUE"),
+ _ => None,
+ }
+}
+
+/// The inputs the meeting-stone feed reads beside the VM and its own state — bundled because the
+/// feed sits at Bevy's parameter ceiling otherwise.
+#[derive(bevy::ecs::system::SystemParam)]
+pub(crate) struct MeetingStoneInputs<'w, 's> {
+ areas: Option>,
+ names: ResMut<'w, NameCache>,
+ commands: Res<'w, NetCommands>,
+ visuals: Option>,
+ fx: MessageWriter<'w, SpellKitFx>,
+ self_q: Query<'w, 's, Entity, With>,
+ tutorials: Option>,
+}
+
+/// The meeting stone's feed: the `0x295` lines, the rebuild and `MEETINGSTONE_CHANGED` per
+/// arrival; the four display replies; the two globals pushed when they moved.
+fn feed_meeting_stone(
+ script: Option>,
+ mut stone: ResMut,
+ mut inputs: MeetingStoneInputs,
+ mut sink: crate::ui_action::MessageSink,
+) {
+ let Some(mut script) = script else {
+ return;
+ };
+ let areas = inputs.areas.as_deref();
+ let mut lines = Vec::new();
+
+ for (old, status) in std::mem::take(&mut stone.updates) {
+ let new = stone.area;
+ lines.extend(stone_line(&script, areas, old, new, status));
+ if status == 1 && new != old {
+ // The status-1 arm's extra block: `Effect_C` kind `0xc` on the local player, then
+ // the Meeting Stones tutorial (`0x4ca363`, 1976).
+ if let (Some(visuals), Ok(entity)) = (inputs.visuals.as_deref(), inputs.self_q.single())
+ {
+ if let Some(fx) = meeting_stone_join_fx(visuals, entity) {
+ inputs.fx.write(fx);
+ }
+ }
+ if let Some(t) = inputs.tutorials.as_mut() {
+ t.write(crate::tutorial::TutorialEvent::trigger(
+ crate::tutorial::id::MEETING_STONES,
+ ));
+ }
+ }
+ // Every path — the silent legs and an out-of-range status included — rebuilds and fires.
+ // The two globals reach the VM BEFORE the event: the stock handler's first act is
+ // `IsInMeetingStoneQueue()`, which has to see the area this packet stored.
+ let text = build_stone_text(&script, areas, new);
+ stone.text = StoneText::Built(text.clone());
+ stone.dirty = false;
+ script.set_meeting_stone(new, Some(text));
+ script.fire_event("MEETINGSTONE_CHANGED", vec![]);
+ }
+
+ for notice in std::mem::take(&mut stone.notices) {
+ match notice {
+ MeetingStoneNotice::Success => {
+ lines.extend(crate::ui_action::keyed_line(
+ &script,
+ "ERR_MEETING_STONE_SUCCESS",
+ ));
+ }
+ MeetingStoneNotice::InProgress => {
+ lines.extend(crate::ui_action::keyed_line(
+ &script,
+ "ERR_MEETING_STONE_IN_PROGRESS",
+ ));
+ }
+ MeetingStoneNotice::MemberAdded { guid } => stone.pending_members.push(guid),
+ MeetingStoneNotice::JoinFailed { code } => {
+ let key = match code {
+ 1 => "ERR_MEETING_STONE_MUST_BE_LEADER",
+ 2 => "ERR_MEETING_STONE_GROUP_FULL",
+ 3 => "ERR_MEETING_STONE_NO_RAID_GROUP",
+ _ => continue,
+ };
+ lines.extend(crate::ui_action::keyed_line(&script, key));
+ }
+ }
+ }
+ // `0x299`'s name-cache callback: the line when the name lands, nothing until then.
+ let pending = std::mem::take(&mut stone.pending_members);
+ for guid in pending {
+ match inputs
+ .names
+ .resolve(guid, &inputs.commands)
+ .map(str::to_string)
+ {
+ Some(name) => lines.extend(crate::ui_action::keyed_line_s(
+ &script,
+ "ERR_MEETING_STONE_MEMBER_ADDED_S",
+ &[&name],
+ )),
+ None => stone.pending_members.push(guid),
+ }
+ }
+ if !lines.is_empty() {
+ crate::ui_action::show_messages(&mut script, &mut sink, "ui_dialog_verbs", lines);
+ }
+
+ if std::mem::take(&mut stone.dirty) {
+ let text = match &stone.text {
+ StoneText::None => None,
+ StoneText::Unknown => Some(global_text(&script, "UNKNOWN")),
+ StoneText::Built(t) => Some(t.clone()),
+ };
+ script.set_meeting_stone(stone.area, text);
+ }
+}
+
+/// The enter-world bring-up's meeting-stone leg: the text reset, then the empty `CMSG 0x296`
+/// — once per world session, which is what the reference's run-once byte amounts to.
+fn meeting_stone_enter_world(
+ mut entered: MessageReader,
+ mut stone: ResMut,
+ commands: Res,
+) {
+ if entered.read().next().is_none() {
+ return;
+ }
+ stone.enter_world();
+ let _ = commands.0.send(ClientCommand::MeetingStoneStatusQuery);
+}
+
+/// The leave-world sweep's leg: the text dropped, the area kept.
+fn meeting_stone_leave_world(mut stone: ResMut) {
+ stone.leave_world();
}
pub(crate) fn feed_dialog_verbs(
@@ -221,8 +520,8 @@ pub(crate) fn feed_dialog_verbs(
mut boot: ResMut,
mut spirit: ResMut,
mut queue: ResMut,
- mut stone: ResMut,
mut sink: crate::ui_action::MessageSink,
+ mut tutorials: Option>,
) {
let Some(mut script) = script else {
return;
@@ -256,12 +555,14 @@ pub(crate) fn feed_dialog_verbs(
script.fire_event("AREA_SPIRIT_HEALER_IN_RANGE", vec![]);
}
+ for id in std::mem::take(&mut queue.tutorials) {
+ if let Some(t) = tutorials.as_mut() {
+ t.write(crate::tutorial::TutorialEvent::trigger(id));
+ }
+ }
if std::mem::take(&mut queue.changed) {
script.fire_event("UPDATE_BATTLEFIELD_STATUS", vec![]);
}
- if std::mem::take(&mut stone.changed) {
- script.fire_event("MEETINGSTONE_CHANGED", vec![]);
- }
}
/// The pet trainer's confirm and the spirit healer's accept — the two drains over a latch.
@@ -372,9 +673,15 @@ impl Plugin for UiDialogVerbsPlugin {
(
close_npc_session_out_of_range::.before(feed_dialog_verbs),
feed_dialog_verbs.before(UiInput),
+ meeting_stone_enter_world.before(feed_meeting_stone),
+ feed_meeting_stone.before(UiInput),
drain_latch_verbs.after(UiInput),
drain_queue_verbs.after(UiInput),
),
+ )
+ .add_systems(
+ OnExit(crate::char_select::ClientState::InWorld),
+ meeting_stone_leave_world,
);
}
}
@@ -454,7 +761,7 @@ mod tests {
slot,
map_id,
bracket: 0,
- unknown: 0,
+ instance_id: 0,
status: 2,
time_ms: Some(0),
in_progress: None,
@@ -473,6 +780,149 @@ mod tests {
assert_eq!(q.map_id(2), None, "a zero map clears the slot");
}
+ /// The instance clocks (§4.2): stamped by status 3, zeroed by any other status of ANY slot,
+ /// and by a clear of the active slot only — which leaves the active index alone.
+ #[test]
+ fn the_instance_clocks_follow_the_status_handler() {
+ let mut q = BattlefieldQueue::default();
+ let now = Instant::now();
+ let mut active = BattlefieldStatus {
+ slot: 0,
+ map_id: 489,
+ bracket: 0,
+ instance_id: 3,
+ status: 3,
+ time_ms: None,
+ in_progress: Some((90_000, 30_000)),
+ queued: None,
+ };
+ q.apply_at(active.clone(), now);
+ assert_eq!(q.active_map(), Some(489));
+ assert_eq!(q.instance_expiration_ms(now), 90_000);
+ assert_eq!(q.run_time_ms(now), 30_000);
+ assert!(q.take_score_dirty());
+ // A QUEUED update for slot 2 zeroes both clocks — the handler's unconditional arm.
+ let mut queued = active.clone();
+ queued.slot = 1;
+ queued.map_id = 529;
+ queued.status = 1;
+ queued.in_progress = None;
+ queued.queued = Some((60_000, 5_000));
+ q.apply_at(queued, now);
+ assert_eq!(
+ q.active_map(),
+ Some(489),
+ "another slot's status leaves the active index"
+ );
+ assert_eq!(q.instance_expiration_ms(now), 0);
+ assert_eq!(q.run_time_ms(now), 0);
+ assert_eq!(q.map_id(2), Some(529));
+ // Re-arm, then clear the ACTIVE slot: the clocks go, the index stays (`[0x8457cc]`).
+ q.apply_at(active.clone(), now);
+ active.map_id = 0;
+ q.apply_at(active, now);
+ assert_eq!(q.instance_expiration_ms(now), 0);
+ assert_eq!(q.map_id(1), None);
+ assert_eq!(
+ q.active_map(),
+ Some(489),
+ "the clear arm never resets the active index"
+ );
+ }
+
+ /// `0x295`'s store is unconditional and the old id is latched first; the feed reads both.
+ #[test]
+ fn the_stone_latches_the_old_area_before_storing_the_new() {
+ let mut stone = MeetingStone::default();
+ stone.apply(1519, 1);
+ stone.apply(1519, 1);
+ stone.apply(0, 0);
+ stone.apply(12, 9);
+ assert_eq!(stone.area, 12);
+ assert_eq!(stone.updates, vec![(0, 1), (1519, 1), (1519, 0), (0, 9)]);
+ stone.enter_world();
+ assert_eq!(stone.text, StoneText::Unknown);
+ assert_eq!(
+ stone.area, 12,
+ "the enter-world reset leaves the area alone"
+ );
+ stone.leave_world();
+ assert_eq!(stone.text, StoneText::None);
+ }
+
+ /// The five-way table with its two asymmetries, and the rebuild's fallbacks and buffer.
+ #[test]
+ fn the_status_table_and_the_rebuild_follow_the_handler() {
+ let data = benilla_formats::wow_data_or_skip!();
+ let mut chain = benilla_formats::open_chain(&data).expect("open chain");
+ let areas =
+ AreaTableRes(benilla_formats::load_area_table_catalog(&mut chain).expect("AreaTable"));
+ let s = UiScript::new().unwrap();
+ s.run(
+ r#"MEETINGSTONE_TOOLTIP = "Looking for more for %s" UNKNOWN = "Unknown"
+ ERR_MEETING_STONE_LEFT_QUEUE_S = "You are no longer queued for %s."
+ ERR_MEETING_STONE_IN_QUEUE_S = "You are now in the queue to join a party for %s."
+ ERR_MEETING_STONE_OTHER_MEMBER_LEFT = "left"
+ ERR_MEETING_STONE_PARTY_KICKED_FROM_QUEUE = "kicked"
+ ERR_MEETING_STONE_MEMBER_STILL_IN_QUEUE = "still""#,
+ )
+ .unwrap();
+ let a = Some(&areas);
+ let text = |l: Option| l.map(|l| l.text().to_string());
+ // Status 0 names the OLD area — and is silent when it has no row.
+ assert_eq!(
+ text(stone_line(&s, a, 1519, 0, 0)).as_deref(),
+ Some("You are no longer queued for Stormwind City.")
+ );
+ assert_eq!(
+ text(stone_line(&s, a, 0, 0, 0)),
+ None,
+ "no row for area 0: silent"
+ );
+ assert_eq!(text(stone_line(&s, a, 999_999, 0, 0)), None);
+ // Status 1 names the NEW area, falls back to UNKNOWN, and is skipped when unchanged.
+ assert_eq!(
+ text(stone_line(&s, a, 0, 1519, 1)).as_deref(),
+ Some("You are now in the queue to join a party for Stormwind City.")
+ );
+ assert_eq!(
+ text(stone_line(&s, a, 0, 999_999, 1)).as_deref(),
+ Some("You are now in the queue to join a party for Unknown.")
+ );
+ assert_eq!(
+ text(stone_line(&s, a, 1519, 1519, 1)),
+ None,
+ "unchanged: skipped"
+ );
+ assert_eq!(text(stone_line(&s, a, 0, 0, 2)).as_deref(), Some("left"));
+ assert_eq!(text(stone_line(&s, a, 0, 0, 3)).as_deref(), Some("kicked"));
+ assert_eq!(text(stone_line(&s, a, 0, 0, 4)).as_deref(), Some("still"));
+ assert_eq!(
+ text(stone_line(&s, a, 0, 0, 5)),
+ None,
+ "past the table: nothing"
+ );
+ // The rebuild.
+ assert_eq!(
+ build_stone_text(&s, a, 1519),
+ "Looking for more for Stormwind City"
+ );
+ assert_eq!(build_stone_text(&s, a, 0), "Looking for more for Unknown");
+ s.run("MEETINGSTONE_TOOLTIP = string.rep('x', 300) .. '%s'")
+ .unwrap();
+ assert_eq!(
+ build_stone_text(&s, a, 1519).len(),
+ STONE_TEXT_BYTES,
+ "the 256-byte buffer"
+ );
+ s.run("MEETINGSTONE_TOOLTIP = nil").unwrap();
+ assert_eq!(
+ build_stone_text(&s, a, 1519),
+ "",
+ "a missing template is GetText's empty string, not the unreachable %s fallback"
+ );
+ }
+
#[test]
fn the_pet_question_latches_and_closes_like_the_talent_wipe() {
let mut pet = PetUnlearnState::default();
diff --git a/crates/benilla-app/src/ui_mirror.rs b/crates/benilla-app/src/ui_mirror.rs
index dfe4a72b..8a7a1ab5 100644
--- a/crates/benilla-app/src/ui_mirror.rs
+++ b/crates/benilla-app/src/ui_mirror.rs
@@ -114,6 +114,7 @@ fn feed_mirror_timers(
script: Option>,
mut feed: ResMut,
spells: Option>,
+ mut tutorials: Option>,
) {
let Some(mut script) = script else {
// No VM (a capture/headless run): drop the edges rather than let them pile up unbounded.
@@ -135,6 +136,26 @@ fn feed_mirror_timers(
let Some(kind) = MirrorTimerKind::from_wire(raw) else {
continue;
};
+ // The handler's two tutorial arms (`0x5e7ab0` type 0, `0x5e7acd` type 1; 1976).
+ if matches!(edge, MirrorTimerEdge::Start(_)) {
+ match kind {
+ MirrorTimerKind::Fatigue => {
+ if let Some(t) = tutorials.as_mut() {
+ t.write(crate::tutorial::TutorialEvent::trigger(
+ crate::tutorial::id::FATIGUE,
+ ));
+ }
+ }
+ MirrorTimerKind::Breath => {
+ if let Some(t) = tutorials.as_mut() {
+ t.write(crate::tutorial::TutorialEvent::trigger(
+ crate::tutorial::id::BREATH,
+ ));
+ }
+ }
+ _ => {}
+ }
+ }
let name = ScriptValue::Str(script_name(kind).into());
let (event, args): (&str, Vec) = match edge {
MirrorTimerEdge::Start(start) => (
diff --git a/crates/benilla-app/src/ui_party/feed.rs b/crates/benilla-app/src/ui_party/feed.rs
index bcd7b145..9e4e5212 100644
--- a/crates/benilla-app/src/ui_party/feed.rs
+++ b/crates/benilla-app/src/ui_party/feed.rs
@@ -833,6 +833,7 @@ pub(super) fn drain_party(
mut names: ResMut,
self_q: Query<&Guid, With>,
commands: Res,
+ mut tutorials: Option>,
) {
let Some(mut script) = script else {
return;
@@ -859,6 +860,11 @@ pub(super) fn drain_party(
let _ = commands.0.send(ClientCommand::GroupLeave);
}
PartyRequest::InviteName(name) => {
+ if let Some(t) = tutorials.as_mut() {
+ t.write(crate::tutorial::TutorialEvent::Acknowledge {
+ id: crate::tutorial::id::GROUPING,
+ });
+ }
let _ = commands.0.send(ClientCommand::GroupInvite { name });
}
PartyRequest::InviteUnit(token) => {
@@ -873,6 +879,11 @@ pub(super) fn drain_party(
member_for_token(&group, &token).map(|m| m.name.clone())
};
if let Some(name) = name {
+ if let Some(t) = tutorials.as_mut() {
+ t.write(crate::tutorial::TutorialEvent::Acknowledge {
+ id: crate::tutorial::id::GROUPING,
+ });
+ }
let _ = commands.0.send(ClientCommand::GroupInvite { name });
}
}
diff --git a/crates/benilla-app/src/ui_pass.rs b/crates/benilla-app/src/ui_pass.rs
index ef3697b3..d2c933a5 100644
--- a/crates/benilla-app/src/ui_pass.rs
+++ b/crates/benilla-app/src/ui_pass.rs
@@ -542,6 +542,15 @@ pub(crate) fn project_overlay(
/// The accept REGION: exactly the viewport, **inclusive**. `[0xb4b2bc]` is the WorldFrame, and its
/// region rect is mirrored ÷G44/÷G48 into the compare fields (`0x483970`), so the aspect factors
/// cancel exactly and the test is against the raw screen box.
+/// `WOW_PROBE_UI_ONE_TEX=1` — a PRICING lever, never a look: the run split ignores texture
+/// identity, so runs break on state flags alone and every run draws with its first quad's
+/// texture. What it measures is the draw-count ceiling a UI texture atlas would reach (the
+/// module doc's "standard fix"), before that atlas is built.
+fn one_texture_probe() -> bool {
+ static ON: std::sync::OnceLock = std::sync::OnceLock::new();
+ *ON.get_or_init(|| std::env::var_os("WOW_PROBE_UI_ONE_TEX").is_some())
+}
+
fn accepts(p: Vec2, viewport: Vec2) -> bool {
(0.0..=viewport.x).contains(&p.x) && (0.0..=viewport.y).contains(&p.y)
}
@@ -1197,7 +1206,7 @@ fn rebuild_ui_mesh(
};
let texture = q.texture.clone().unwrap_or_else(|| white.0.clone());
let same_run = runs.last().is_some_and(|r| {
- r.texture == texture
+ (r.texture == texture || one_texture_probe())
&& r.additive == q.additive
&& r.circular == q.circular
&& r.desaturated == q.desaturated
diff --git a/crates/benilla-app/src/ui_quest.rs b/crates/benilla-app/src/ui_quest.rs
index aeb711ee..6618e873 100644
--- a/crates/benilla-app/src/ui_quest.rs
+++ b/crates/benilla-app/src/ui_quest.rs
@@ -668,6 +668,7 @@ fn drain_quest(
commands: Res,
index: Res,
stores: Query<&ObjectStore>,
+ mut tutorials: Option>,
) {
let Some(mut script) = script else {
return;
@@ -735,6 +736,12 @@ fn drain_quest(
let _ = commands
.0
.send(ClientCommand::QuestgiverAccept { npc, quest });
+ // `AcceptQuest`'s tail (`0x5013df`): the Quest Log tutorial (1976).
+ if let Some(t) = tutorials.as_mut() {
+ t.write(crate::tutorial::TutorialEvent::trigger(
+ crate::tutorial::id::QUEST_LOG,
+ ));
+ }
// `Script::AcceptQuest` (`0x501380`) closes the window on the CLICK, not on any
// answer: send `0x189` (`0x5eac10`), then `0x501130(0,0)` — the same clear the
// refusal handlers call, firing `QUEST_FINISHED`. Ours used to leave the panel
diff --git a/crates/benilla-app/src/ui_script/battlefield_tests.rs b/crates/benilla-app/src/ui_script/battlefield_tests.rs
new file mode 100644
index 00000000..bb29292a
--- /dev/null
+++ b/crates/benilla-app/src/ui_script/battlefield_tests.rs
@@ -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::(&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::("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 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::("return StaticPopup1Text:GetText()")
+ .unwrap(),
+ "You are now eligible to enter Arathi Basin 3, choose an action:"
+ );
+ let tooltip = s
+ .eval::("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::("return BattlefieldFrameFrameLabel:GetText()")
+ .unwrap(),
+ "Arathi Basin"
+ );
+ assert_eq!(
+ s.eval::("return BattlefieldZone1:GetText()")
+ .unwrap(),
+ "First Available"
+ );
+ assert_eq!(
+ s.eval::("return BattlefieldZone2:GetText()")
+ .unwrap(),
+ "Arathi Basin 4"
+ );
+ assert_eq!(
+ s.eval::("return BattlefieldZone3:GetText()")
+ .unwrap(),
+ "Arathi Basin 9"
+ );
+ assert!(
+ !visible(&s, "BattlefieldZone4"),
+ "two instances, three rows"
+ );
+ assert_eq!(
+ s.eval::("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::("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::("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::("return BattlefieldZone2Status:GetText()")
+ .unwrap(),
+ "(In Queue)"
+ );
+ assert_eq!(
+ s.eval::("return BattlefieldZone1Status:GetText()")
+ .unwrap(),
+ ""
+ );
+}
diff --git a/crates/benilla-app/src/ui_script/bottom_hud_tests.rs b/crates/benilla-app/src/ui_script/bottom_hud_tests.rs
index 5489ddfb..1e17fc2b 100644
--- a/crates/benilla-app/src/ui_script/bottom_hud_tests.rs
+++ b/crates/benilla-app/src/ui_script/bottom_hud_tests.rs
@@ -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
}
diff --git a/crates/benilla-app/src/ui_script/death_tests.rs b/crates/benilla-app/src/ui_script/death_tests.rs
index 51c7710a..6a6fefc3 100644
--- a/crates/benilla-app/src/ui_script/death_tests.rs
+++ b/crates/benilla-app/src/ui_script/death_tests.rs
@@ -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();
diff --git a/crates/benilla-app/src/ui_script/durability_tests.rs b/crates/benilla-app/src/ui_script/durability_tests.rs
index af2b5cd7..3e7afa4f 100644
--- a/crates/benilla-app/src/ui_script/durability_tests.rs
+++ b/crates/benilla-app/src/ui_script/durability_tests.rs
@@ -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",
diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs
index 37c21cb6..b7de99cd 100644
--- a/crates/benilla-app/src/ui_script/extract/mod.rs
+++ b/crates/benilla-app/src/ui_script/extract/mod.rs
@@ -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;
};
diff --git a/crates/benilla-app/src/ui_script/frame_flag_gate.rs b/crates/benilla-app/src/ui_script/frame_flag_gate.rs
index 3101cf88..4f6d5158 100644
--- a/crates/benilla-app/src/ui_script/frame_flag_gate.rs
+++ b/crates/benilla-app/src/ui_script/frame_flag_gate.rs
@@ -192,12 +192,9 @@ const KNOWN: &[Known] = &[
// entries here — twelve `MerchantItem` 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 `` 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!(
diff --git a/crates/benilla-app/src/ui_script/game_menu_tests.rs b/crates/benilla-app/src/ui_script/game_menu_tests.rs
index c3630af4..73761fc1 100644
--- a/crates/benilla-app/src/ui_script/game_menu_tests.rs
+++ b/crates/benilla-app/src/ui_script/game_menu_tests.rs
@@ -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.
diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs
index 57e20667..9bc68271 100644
--- a/crates/benilla-app/src/ui_script/lifecycle.rs
+++ b/crates/benilla-app/src/ui_script/lifecycle.rs
@@ -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::() {
+ 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::() {
+ 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::();
+ 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::().is_none(),
+ "no VM in the deferral window"
+ );
+ assert!(world.get_resource::().is_some());
+ assert!(world.get_non_send_resource::().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::()
+ .expect("the parked VM is back");
+ assert_eq!(vm.session(), session, "the same VM, no session moved");
+ assert!(world.get_non_send_resource::().is_none());
+ }
+}
diff --git a/crates/benilla-app/src/ui_script/minimap_tests.rs b/crates/benilla-app/src/ui_script/minimap_tests.rs
index 29eba1b7..410aa077 100644
--- a/crates/benilla-app/src/ui_script/minimap_tests.rs
+++ b/crates/benilla-app/src/ui_script/minimap_tests.rs
@@ -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::("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::>("return (Minimap:GetPingPosition())")
+ s.eval::("return select('#', Minimap:GetPingPosition())")
.unwrap(),
- None
+ 2
);
- s.set_minimap_ping(Some((0.25, -0.125)));
+ assert_eq!(
+ s.eval::("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::("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::>("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::("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::("return MiniMapPing:IsVisible()").unwrap());
+ s.tick(1.0);
+ assert!(
+ s.eval::("return MiniMapPing:IsVisible()").unwrap(),
+ "held at 1 s"
+ );
+ let cx = s
+ .eval::(
+ "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::("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::("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::("return GameTooltipTextLeft1:GetText()")
+ .unwrap(),
+ "Looking for more for Stormwind City"
+ );
+ s.run("MiniMapMeetingStoneFrame:Click()").unwrap();
+ assert!(
+ s.eval::("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");
+}
diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs
index a6933a95..30651592 100644
--- a/crates/benilla-app/src/ui_script/mod.rs
+++ b/crates/benilla-app/src/ui_script/mod.rs
@@ -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
diff --git a/crates/benilla-app/src/ui_script/pet_paperdoll_tests.rs b/crates/benilla-app/src/ui_script/pet_paperdoll_tests.rs
index 0dab35d2..d68e1112 100644
--- a/crates/benilla-app/src/ui_script/pet_paperdoll_tests.rs
+++ b/crates/benilla-app/src/ui_script/pet_paperdoll_tests.rs
@@ -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");
diff --git a/crates/benilla-app/src/ui_script/reference_ui.rs b/crates/benilla-app/src/ui_script/reference_ui.rs
index a74e6d2d..745f52ca 100644
--- a/crates/benilla-app/src/ui_script/reference_ui.rs
+++ b/crates/benilla-app/src/ui_script/reference_ui.rs
@@ -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 -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 -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 -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 -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 -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 -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 -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 -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",
diff --git a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs
index e9bdcbc7..c55caddf 100644
--- a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs
+++ b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs
@@ -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"
);
diff --git a/crates/benilla-app/src/ui_script/tutorial_tests.rs b/crates/benilla-app/src/ui_script/tutorial_tests.rs
new file mode 100644
index 00000000..eedaf1bf
--- /dev/null
+++ b/crates/benilla-app/src/ui_script/tutorial_tests.rs
@@ -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::(&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::("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::("return TutorialFrameTitle:GetText()")
+ .unwrap(),
+ "Elite Quests"
+ );
+ assert!(s
+ .eval::("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::("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"
+ );
+}
diff --git a/crates/benilla-app/src/ui_script/world_map_tests.rs b/crates/benilla-app/src/ui_script/world_map_tests.rs
index 0d63e773..0eefc5b3 100644
--- a/crates/benilla-app/src/ui_script/world_map_tests.rs
+++ b/crates/benilla-app/src/ui_script/world_map_tests.rs
@@ -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 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::("return GameTooltipTextLeft1:GetText()")
+ s.eval::("return WorldMapFrameAreaLabel:GetText()")
.unwrap(),
"Lion's Pride Inn"
);
- assert!(
- s.eval::("return GameTooltipTextLeft2:GetText() == nil or GameTooltipTextLeft2:GetText() == \"\"")
+ assert_eq!(
+ s.eval::("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::("return GameTooltipTextLeft2:GetText()")
+ s.eval::("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::(
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::(r#"return WorldMapRaid1:IsShown() and WorldMapRaid1.name == "Probe""#)
+ .unwrap(),
+ "the placed teammate shows on the first raid frame with its name"
+ );
+ assert!(
+ !s.eval::("return WorldMapRaid2:IsShown()").unwrap(),
+ "the (0,0) teammate hides"
+ );
+ assert!(s.eval::("return WorldMapFlag1:IsShown()").unwrap());
+ assert_eq!(
+ s.eval::("return WorldMapFlag1Texture:GetTexture()")
+ .unwrap(),
+ r"Interface\WorldStateFrame\HordeFlag"
+ );
+ assert!(!s.eval::("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::