Files
benilla-octo/crates/benilla-app/src/ui_script/mod.rs
T
Sam SungandClaude Opus 5.5 6fff0fa7a1 Hover leaves publish the world mouseover loss before the next OnEnter (#935)
Moving from the world onto a window, the world mouseover's loss reached the tooltip a frame after the window's OnEnter, so a gate kept the fade off any tooltip the world had not put up.

In 1.12.1 the hover walk runs the old frame's leave before the new frame's enter (`0x766229`, `0x766239`). The WorldFrame's leave (`0x483b10`) runs OnLeave, then publishes the empty mouseover, whose publisher fades GameTooltip whoever holds it (`0x492890` → `0x530ae0`). A nameplate's leave publishes first (`0x7cb8b0`); the Minimap's hides the tooltip (`0x4eb880`), and its probe drops a held blip on a miss (`0x4eb0c0`).

The VM now holds the published mouseover and runs these leaves in the walk, the hide and every mouse disable, freelook's plates included (`0x60f830`); the ownership gate is gone.

Tests: OnEnter no longer sees "mouseover", an addon tooltip fades on a change, blip and Minimap leaves hide it.

Fixes #901

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 03:28:42 +07:00

1112 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The UI-engine bridge: hosts [`benilla_ui::script::UiScript`] and feeds its
//! [`extract`](benilla_ui::script::UiScript::extract) output into the quad pass
//! ([`crate::ui_pass::UiQuads`]) every frame. The script side is WoW UI space (y-up, origin
//! bottom-left, a screen root 768 units tall at scale 1); [`seam_scale`] carries quads ×s out and
//! the mouse ÷s in, and extraction flips to y-down window px. The UI scale is `UIParent`'s own
//! frame scale inside the VM ([`UiScaleCvar`]), never part of the seam.
use bevy::prelude::*;
use benilla_ui::script::{ActionSlot, ScriptValue, UiScript, UnitState};
use crate::ui_unit::UnitFeed;
use benilla_world::schedule::WorldStage;
/// The addon folder: discovery, manifests, the enable file and the load walk.
pub(crate) mod addons;
mod content;
pub(crate) mod extract;
mod input;
#[cfg(test)]
mod layer_tests;
mod load_log;
mod manifest;
/// The stock FrameXML this client runs off the player's own patch chain; its header is the rule.
mod reference_ui;
/// What the host remembers per VM, so a seed or change-memo never outlives the VM it was for.
mod session;
/// The feed gate: a UI feed's input-side early-out, audited by `WOW_FEED_GATE_CHECK=1`.
pub(crate) mod gate;
pub(crate) use session::VmMemo;
// Not test-only: the addon harness loads the whole shipped interface under each addon.
pub(crate) use manifest::default_bindings;
pub(crate) use manifest::load_default_ui;
pub(crate) use manifest::load_ingame_ui;
#[cfg(test)]
pub(crate) use manifest::{load_stock_bindings, stock_bindings_file};
/// Whether the pointer is over any UI (the egui dev overlay or a player-UI frame), combined by
/// [`arbitrate_pointer_over_ui`]; gameplay reads it, so it is not the dev plugin's.
#[derive(Resource, Default)]
pub(crate) struct PointerOverUi(pub(crate) bool);
/// A synthetic pointer owns the mouse this frame (the drag probe's gesture), so
/// [`input::feed_ui_input`] skips its mouse half and never feeds the real cursor.
#[derive(Resource, Default)]
pub(crate) struct SyntheticPointer(pub(crate) bool);
/// A capture never reads the OS pointer: set for a whole `$WOW_CAPTURE`/`$WOW_CAPTURE_UI` run, so
/// its pixels never depend on where the real cursor rests.
#[derive(Resource, Default)]
pub(crate) struct CapturePointerPinned(pub(crate) bool);
/// The egui dev overlay's half of the pointer arbitration, written by `track_pointer_over_ui`;
/// defined here so a build without dev overlays still has the type.
#[derive(Resource, Default)]
pub(crate) struct EguiPointerOver(pub(crate) bool);
/// Whether the dev `I` inspector's world picking is armed; `player::control` and `target::click`
/// read it every frame, so it lives here, and the default (disarmed) is the player's.
#[derive(Resource, Default)]
pub(crate) struct InspectMode {
pub(crate) enabled: bool,
}
/// One frame's UI-pass phase split in μs, as the `[ui-cost]` line prints it; owned by the
/// producer so it exists whether or not its reader, `hover_log`, is compiled in.
#[derive(Resource, Default, Clone)]
pub(crate) struct UiFrameCost {
/// FontStrings the layout had the font engine shape this frame, and the first few by name.
pub(crate) measured: usize,
pub(crate) measured_texts: Vec<String>,
pub(crate) tick: u128,
pub(crate) resolve: u128,
pub(crate) measure: u128,
pub(crate) extract: u128,
pub(crate) convert: u128,
pub(crate) diff: u128,
pub(crate) quads: usize,
pub(crate) solves: u64,
/// Full layout-graph derivations this frame, each about 30× a solve; expected zero.
pub(crate) derives: u64,
pub(crate) skipped: bool,
/// Entries the splice re-converted this frame; 0 on a settled or full-conversion frame.
pub(crate) spliced: usize,
/// Entries the splice dropped (drawn last frame, not now); `[ui-cost] dropped=` prints it.
pub(crate) dropped: usize,
}
/// Whether [`UiFrameCost`] is wanted this run: `WOW_UI_COST=1` or the hover recorder.
#[derive(Resource, Default)]
pub(crate) struct UiCostWanted(pub(crate) bool);
/// The mouse-enabled player-UI frame under the cursor; world pick and camera look yield to it.
#[derive(Resource, Default)]
pub(crate) struct PlayerUiHover(pub(crate) Option<u32>);
/// Who owns this frame's keys (the client's `DAT_00cf4dc8 != 0` gate), written in [`UiInput`] and
/// read after it: a focused EditBox takes every key, a shown keyboard-enabled frame only the key
/// it ate. Neither releases a held key: only the window deactivate (`0x514490`, from `0x493058`)
/// and the world-enter cascade (`0x5144c0`) clear the direction bits, so W keeps running while
/// you type.
#[derive(Resource, Default)]
pub(crate) struct UiKeyboardCapture {
/// A focused EditBox eats every key (`0x77b35e` returns 1 on every path but alt-arrow).
pub(crate) typing: bool,
/// Keys a shown keyboard-enabled frame consumed this frame (the existence gate, `0x76b7d0`),
/// per key so the map eating `M` suppresses no other binding; raw codes, as read.
pub(crate) consumed: Vec<bevy::input::keyboard::KeyCode>,
/// The arrows fall through: the focused box is in alt-arrow mode and ALT is up, so the
/// reference declines them at `0x77b1c4` and `CGWorldFrame` runs their bindings.
pub(crate) arrows_fall_through: bool,
}
/// A left press the UI consumed without hitting a frame (a payload dropped on empty world), which
/// world click-pick and orbit-start yield to. Over a world object the reference keeps an item
/// payload and runs SELECT instead.
#[derive(Resource, Default)]
pub(crate) struct PlayerUiClickConsumed(pub(crate) bool);
/// Whether the cursor carries a payload, a `Send` mirror. A click on sky with one held never
/// deselects: the reference gates its nothing-leg `SetSelection(0,0)` on no payload (`0x492d30`).
#[derive(Resource, Default)]
pub(crate) struct CursorPayloadHeld(pub(crate) bool);
/// What [`extract::tick_script`] hands [`extract::paint_script`]; with `live` false (no VM or
/// window) the paint does nothing.
#[derive(Resource, Default)]
pub(crate) struct UiPassState {
pub(crate) live: bool,
/// The 768-virtual seam scale this frame was ticked under.
pub(crate) seam: f32,
pub(crate) dpi: f32,
/// The meter's first three phases, so one `[ui-cost]` row still describes one frame.
pub(crate) us_tick: u128,
pub(crate) us_resolve: u128,
pub(crate) us_measure: u128,
/// The layout counters before the tick, so the row's `solves` and `derives` cover both halves.
pub(crate) solves_before: u64,
pub(crate) derives_before: u64,
}
/// UI chrome under the pointer: [`PointerOverUi`] minus the nameplates, which take the mouse but
/// not the wheel. The reference has no such bit: a wheel notch walks the strata and falls through
/// any frame without `OnMouseWheel` to `CGWorldFrame`.
#[derive(Resource, Default)]
pub(crate) struct PointerOverUiPanel(pub(crate) bool);
/// The player-UI feed phase, after [`WorldStage::Net`] and ungated (`UnitFeed` is the gated
/// sub-phase): every push this frame's tick must see, as in the reference's frame, where packet
/// dispatch precedes every `FrameScript_SignalEvent` and both precede `OnUpdate`. Every VM holder
/// in `Update` is in this set or `.after(UiInput)`, as `game_plugins::schedule_tests` checks.
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct UiFeed;
/// The player-UI paint pass, after the camera so world-anchored widgets use this frame's view.
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct UiPaint;
/// The player-UI input pass (tick, hit-test, handlers), before [`WorldStage::Input`] so its
/// [`PlayerUiHover`] reaches `PointerOverUi` before `player::control` reads it.
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct UiInput;
/// The frame's atomic (`Instant`, `GetTime`) pair, the one base for mapping a store `Instant` onto
/// `GetTime`: `anchor`'s steps are the VM clock's deltas, so a mapped start is stable frame to
/// frame. Neither leg restarts with the VM: the reference's `GetTime` is `GetTickCount`, and stock
/// `Cooldown.lua:3` gates on `start > 0`, so a reset would hide every running cooldown's sweep.
#[derive(Resource)]
pub(crate) struct UiClock {
/// `Time<Real>::last_update()` at the tick that produced [`Self::ui_now`].
pub(crate) anchor: std::time::Instant,
/// The VM clock after that tick, in seconds since this process started.
pub(crate) ui_now: f64,
}
/// The pre-boot pair, replaced by [`lifecycle::seed_vm_clock`] at the first VM.
impl Default for UiClock {
fn default() -> Self {
Self {
anchor: std::time::Instant::now(),
ui_now: 0.0,
}
}
}
/// The FrameXML digest this process loads, which the corpus harness stamps on each report.
pub(crate) fn framexml_digest() -> String {
content::digest()
}
/// Run a Lua chunk and log a failure: the form for any run whose `Result` is not consumed.
pub(crate) fn run_or_warn(script: &benilla_ui::script::UiScript, chunk: &str) {
if let Err(e) = script.run(chunk) {
warn!("ui_script: chunk failed: {e}");
}
}
/// The reference's `uiScale` CVar, which the VM makes `UIParent`'s own scale
/// ([`UiScript::set_ui_scale`]): the screen root stays 768 units tall, so `uiScale = 768/screenH`
/// is pixel-perfect inside `UIParent`, and a frame outside it is not scaled. Tests pin the
/// `Default` 1.0.
#[derive(Resource)]
pub(crate) struct UiScaleCvar(pub(crate) f32);
impl Default for UiScaleCvar {
fn default() -> Self {
Self(1.0)
}
}
/// The shipped `uiScale`. Deviation: a flat 0.9, because 1.0 reads oversized. With `useUiScale`
/// off, its default (`0x8430c0`), the reference sets `max(768/H, 0.9)` above 768 px tall and 1.0
/// at or below (`0x492f70`, on a mode set and from `0x4908ad`), so the two agree from ~853 px up.
/// benilla never reads `useUiScale`: this CVar always applies to `UIParent` alone, as with it on.
/// A change goes through the CVar callback (`0x490770`: the narrow-aspect cap, the 0.64 floor);
/// the load and a display change through the display handler's ON leg (`0x492e90`: the cap, and
/// below 0.64 the automatic scale `0x492f70` instead of the floor).
pub(crate) const DEFAULT_UI_SCALE: f32 = 0.9;
/// The display the reference's automatic UI scale last ran for (`0x492f70`'s `[0xb4e304]` and
/// `[0xb4e308]`), which outlives every UI: the display handler skips a display it already ran for.
#[derive(Resource, Default)]
pub(crate) struct UiAutoScaleCache(pub(crate) Option<(u32, u32)>);
/// `WOW_UI_SCALE=` if set, clamped to the dial's range, else [`DEFAULT_UI_SCALE`].
fn default_ui_scale() -> f32 {
std::env::var("WOW_UI_SCALE")
.ok()
.and_then(|v| v.parse::<f32>().ok())
.map(|v| v.clamp(0.5, 1.5))
.unwrap_or(DEFAULT_UI_SCALE)
}
/// The seam scale `s`, window px per screen-root unit (`windowH/768`: the root is 768 units tall at
/// every aspect, `0x41ad10`), used at every crossing of the VM boundary; identity for a degenerate
/// window (h ≤ 0, before winit). The UI scale is not in it: it is `UIParent`'s, inside the VM.
pub(crate) fn seam_scale(window_h: f32) -> f32 {
if window_h > 0.0 {
window_h / 768.0
} else {
1.0
}
}
/// A window cursor (logical px, y-down from the top left) in the VM's root units, y up: what the
/// pointer feed hands the engine and `GetCursorPosition` answers (`0x48b820`, no scale divided out).
pub(crate) fn window_to_ui(window_h: f32, cursor: Vec2) -> Vec2 {
let s = seam_scale(window_h);
Vec2::new(cursor.x / s, (window_h - cursor.y) / s)
}
/// The Lua UI host (a `NonSend` resource: an mlua VM is `!Send`) and its per-frame passes.
pub(crate) struct UiScriptPlugin;
/// `uiScale`'s change callback: the dial's own `[0.5, 1.5]` clamp.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut scale: ResMut<UiScaleCvar>) {
if ev.is("uiScale") {
scale.0 = ev.num().clamp(0.5, 1.5);
}
}
/// `/console reloadUI`: the same deferred rebuild `ReloadUI()` and `/reload` queue.
fn console_reload_ui(world: &mut World, _args: &str) -> Vec<String> {
match world.get_non_send_resource_mut::<UiScript>() {
Some(mut script) => {
script.queue_session_request(benilla_ui::script::SessionRequest::ReloadUi);
Vec::new()
}
None => vec!["reloadUI: no interface to reload".to_string()],
}
}
impl Plugin for UiScriptPlugin {
fn build(&self, app: &mut App) {
use crate::console::ConsoleCommandApp;
app.add_observer(on_cvar);
app.console_command("reloadUI", "Reload the interface.", console_reload_ui);
// The quit root runs in `Last`: the close button's `AppExit` is written in `PostUpdate`.
crate::shutdown::on_app_exit(app, shutdown_on_exit.into_configs());
app.insert_resource(UiScaleCvar(default_ui_scale()))
.init_resource::<UiAutoScaleCache>()
.init_resource::<UiFrameCost>()
.init_resource::<crate::bindings::WheelNotches>()
.init_resource::<UiCostWanted>()
.init_resource::<PointerOverUi>()
.init_resource::<SyntheticPointer>()
.insert_resource(CapturePointerPinned(
std::env::var_os("WOW_CAPTURE").is_some()
|| std::env::var_os("WOW_CAPTURE_UI").is_some(),
))
.init_resource::<EguiPointerOver>()
.init_resource::<InspectMode>()
.init_resource::<PlayerUiHover>()
.init_resource::<UiKeyboardCapture>()
.init_resource::<PlayerUiClickConsumed>()
.init_resource::<CursorPayloadHeld>()
.init_resource::<UiClock>()
.init_resource::<AddOnIdentity>()
// After `AssetSet::Open`: the VM's first load is the chain's `GlobalStrings.lua`.
.add_systems(Startup, setup_script.after(benilla_assets::AssetSet::Open))
// The in-game UI loads on world entry, as the reference's does, once the loading cover
// has presented, so its ~0.5 s never stalls the cover's first frame.
.add_systems(
OnEnter(crate::char_select::ClientState::InWorld),
lifecycle::arm_entry_ui_load,
)
// The shutdown tail: events, then writes, in the reference's order.
.add_systems(
OnExit(crate::char_select::ClientState::InWorld),
end_ui_session,
)
// `init_` here and in `UiUnitPlugin`: either plugin may be built alone in a test.
.init_resource::<LeavingWorldArmed>()
.add_message::<WorldLeaveSweepMessage>()
.add_systems(Update, lifecycle::arm_leaving_world_on_self_create)
// A queued `ReloadUI()` runs in `PreUpdate`, a frame after its drain (the reference's
// deferral, `0x495590`) and before every `Update` seed or feed.
.init_resource::<ReloadUiPending>()
// Both are exclusive edges on the VM and must not interleave.
.add_systems(
PreUpdate,
(run_pending_reload, lifecycle::run_pending_entry_load).chain(),
)
// The three UI phases in order: feeds after the net drain, the tick, then the paint.
.configure_sets(
Update,
(
UiFeed.after(WorldStage::Net),
UiInput.before(WorldStage::Input),
UiPaint,
)
.chain(),
)
// In the feed phase, so an `OnUpdate` reads this frame's `GetFramerate()`.
.add_systems(Update, feed_framerate.in_set(UiFeed))
// The input pass hit-tests the rects the tick just resolved, and runs in-world only.
.init_resource::<UiPassState>()
.init_resource::<PointerOverUiPanel>()
.add_systems(
Update,
(
extract::tick_script,
input::feed_ui_input.in_set(crate::char_select::InWorldGated),
// Off the walk that just ran, for the pick and the arbiter after the pass.
crate::vplates::seed_plate_hover,
)
.chain()
.in_set(UiInput)
// So a key a focused box consumed never also fires a binding.
.before(crate::bindings::BindingSet),
)
// After the plate driver seats this frame's nameplates, and before the append lane
// fills the widget slot this pass parks.
.add_systems(
Update,
extract::paint_script
.in_set(UiPaint)
.after(crate::vplates::VPlateSet)
.before(crate::ui_pass::UiQuadAppend),
)
// After the hover is known and before gameplay reads `PointerOverUi`.
.add_systems(
Update,
arbitrate_pointer_over_ui
.after(UiInput)
.before(WorldStage::Input),
);
// A UI capture's synthetic unit snapshots, after the real feed so they win.
app.add_systems(
Update,
demo_unit_feed
.in_set(UiFeed)
.after(UnitFeed)
.run_if(capture_ui_active),
);
}
}
/// Whether a capture includes the player UI: world baselines stay UI-free unless
/// [`crate::run_mode::capture_ui_opted_in`], which the UI load and window size also read.
fn capture_ui_active(capture: Option<Res<crate::run_mode::CaptureMode>>) -> bool {
capture.is_some() && crate::run_mode::capture_ui_opted_in()
}
/// `PointerOverUi = egui dev overlay ∨ player-UI hover`; the dev half is optional.
fn arbitrate_pointer_over_ui(
egui: Option<Res<EguiPointerOver>>,
hover: Res<PlayerUiHover>,
plate_hover: Res<crate::vplates::PlateHover>,
mut over: ResMut<PointerOverUi>,
mut panel: ResMut<PointerOverUiPanel>,
) {
// No cinematic term: the hit test finds the full-screen, mouse-enabled `CinematicFrame`.
over.0 = egui.is_some_and(|e| e.0) || hover.0.is_some();
// Chrome excludes the plates, whose hover is this frame's walk's.
panel.0 = over.0 && plate_hover.0.is_none();
}
/// The session lifecycle: the VM's birth, identity, death and reload.
mod lifecycle;
pub(crate) use lifecycle::{
end_ui_session, ingame_ui_up, run_pending_reload, setup_script, AddOnIdentity, LeaveWorldSweep,
LeavingWorldArmed, PendingEntryUiLoad, ReloadUiPending, WorldLeaveSweepMessage,
};
// Test-only: other modules' tests consume these, and a plain re-export would warn unused.
#[cfg(test)]
pub(crate) use lifecycle::load_ingame_ui_on_world_entry;
use lifecycle::shutdown_on_exit;
#[cfg(test)]
pub(crate) use lifecycle::{
finish_ui_load, is_emote_token_line, seat_from_roster, shutdown_ui_state,
};
/// `GetFramerate()`'s host half: FPS on `Time<Real>`, smoothed by a one-second pole; how the
/// reference averages it is untraced.
fn feed_framerate(
script: Option<NonSendMut<UiScript>>,
time: Res<Time<bevy::time::Real>>,
mut smoothed: Local<f64>,
) {
let Some(mut script) = script else {
return;
};
let dt = time.delta_secs_f64();
if dt <= 0.0 {
return; // the first frame, or a paused one
}
let instant = 1.0 / dt;
// One-pole IIR with a 1 s time constant, frame-rate independent.
let alpha = (dt / 1.0).min(1.0);
*smoothed = if *smoothed <= 0.0 {
instant
} else {
*smoothed + alpha * (instant - *smoothed)
};
script.set_framerate(*smoothed);
}
/// Synthetic player and target snapshots for a server-less UI capture, and its first events once.
fn demo_unit_feed(script: Option<NonSendMut<UiScript>>, mut fired: Local<VmMemo<bool>>) {
/// The synthetic target's guid: a creature high part; only its distinctness matters.
const DEMO_TARGET_GUID: u64 = 0xF130_0000_0000_0001;
let Some(mut script) = script else {
return;
};
// Session-keyed: a fresh VM needs the one-shot events and bar seeds again.
let fired = fired.get(&script);
// Debug (`WOW_CAPTURE_REHOVER=1`): re-fire the world hover every frame, a flapping raycast.
if std::env::var("WOW_CAPTURE_REHOVER").as_deref() == Ok("1") {
script.world_tooltip_unit("mouseover");
}
script.set_unit(
"player",
Some(UnitState {
exists: true,
name: Some("Benilla".into()),
health: 72,
max_health: 100,
level: 12,
power_type: 0, // mana
power: 45,
max_power: 80,
dead: false,
reaction: 0, // own avatar: no reaction to itself
// Race/class so the ui-char capture's level line reads "Level 12 Night Elf Warrior".
race: Some("Night Elf".into()),
race_file: Some("NightElf".into()),
class: Some("Warrior".into()),
class_file: Some("WARRIOR".into()),
sex: 2,
is_player: true,
player_controlled: true,
..Default::default()
}),
);
script.set_unit(
"target",
Some(UnitState {
exists: true,
name: Some("Young Wolf".into()),
health: 30,
max_health: 50,
level: 3,
// A powerless beast (maxPower 0): exercises the power-bar-hide path in the capture.
power_type: 0,
power: 0,
max_power: 0,
dead: false,
reaction: 4, // neutral: yellow (UnitReactionColor[4])
// A beast: no race/class tokens (UnitRace/UnitClass report the absent shape).
race: None,
race_file: None,
class: None,
class_file: None,
sex: 0,
// The tooltip's level line reads "Level 3 Beast".
creature_type_name: Some("Beast".into()),
// `GetComboPoints` reports only points banked on the current target's guid.
guid: DEMO_TARGET_GUID,
..Default::default()
}),
);
// The combo dots need a rogue and banked points, seeded only for `ui-combopoints`.
if std::env::var("WOW_CAPTURE").as_deref() == Ok("ui-combopoints") {
script.set_player_req_state(benilla_ui::script::PlayerReqState {
level: 12,
class_id: 4, // rogue
..Default::default()
});
script.set_combo_points(4, DEMO_TARGET_GUID);
script.fire_event("PLAYER_COMBO_POINTS", vec![]);
}
if !*fired {
// Battle-stance page slots (actions 73..); slot 80 is a consumable stack of 5, since stock
// `ActionButton_UpdateCount` shows only a consumable's count (`ActionButton.lua:287`).
script.set_bonus_bar_offset(1);
// A macro on button 4; a capture has no character for `ui_macro::load_macros` to read.
script.set_macros(benilla_ui::script::MacroState {
account: vec![benilla_ui::script::MacroView {
name: "spawn".into(),
texture: Some("Interface\\Icons\\Ability_Racial_Cannibalize".into()),
body: ".spawn 16032".into(),
local_only: false,
}],
character: Vec::new(),
});
for (action, icon, kind, id, count) in [
(
73,
"Interface\\Icons\\Ability_SteelMelee",
0x00u8,
100u32,
0u32,
),
(74, "Interface\\Icons\\Ability_Rogue_Ambush", 0x00, 101, 0),
(
75,
"Interface\\Icons\\Ability_Warrior_BattleShout",
0x00,
102,
0,
),
(
76,
"Interface\\Icons\\Ability_Racial_Cannibalize",
0x40,
1,
0,
),
(80, "Interface\\Icons\\INV_Misc_Food_16", 0x80, 117, 5),
(84, "Interface\\Icons\\Spell_Holy_SealOfMight", 0x00, 103, 0),
// BottomLeft is actions 61..72, BottomRight 49..60; an empty multibar well hides.
(
61,
"Interface\\Icons\\Spell_Nature_Regenerate",
0x00,
104,
0,
),
(62, "Interface\\Icons\\Spell_Shadow_Curse", 0x00, 105, 0),
(
72,
"Interface\\Icons\\Spell_Frost_FrostBolt02",
0x00,
106,
0,
),
(49, "Interface\\Icons\\Spell_Fire_FlameBolt", 0x00, 107, 0),
(60, "Interface\\Icons\\Spell_Holy_Heal", 0x00, 108, 0),
] {
script.set_action(
action,
Some(ActionSlot {
texture: Some(icon.into()),
kind,
action: id,
count,
// The seed's only item slot is the food stack, which is consumable.
consumable: kind == 0x80,
}),
);
// Server-less a slot has no usable state; a live bar's resting state is usable.
script.set_action_state(
action,
Some(benilla_ui::script::ActionState {
usable: true,
..Default::default()
}),
);
}
// The stance bar: battle active (bonus offset 1), defensive uncastable, berserker cooling.
script.set_shapeshift_forms(vec![
benilla_ui::script::ShapeshiftFormView {
spell_id: 2457,
texture: Some("Interface\\Icons\\Ability_Warrior_OffensiveStance".into()),
name: "Battle Stance".into(),
active: true,
castable: true,
cooldown: None,
},
benilla_ui::script::ShapeshiftFormView {
spell_id: 71,
texture: Some("Interface\\Icons\\Ability_Warrior_DefensiveStance".into()),
name: "Defensive Stance".into(),
active: false,
castable: false,
cooldown: None,
},
benilla_ui::script::ShapeshiftFormView {
spell_id: 2458,
texture: Some("Interface\\Icons\\Ability_Racial_Avatar".into()),
name: "Berserker Stance".into(),
active: false,
castable: true,
cooldown: Some((900, 1500, true)),
},
]);
script.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
// 70% XP, set before the world-enter events so the bar's first update reads it.
script.set_player_xp(4200, 6000);
script.fire_world_enter();
// The bottom multibars ship off and a capture has no toggle byte, so raise them as the
// Options rows do; `WOW_DEMO_BOTTOM_BARS=0` leaves them down for the stance shelf art,
// which `ShapeshiftBar_UpdatePosition` hides under the bottom-left bar.
if std::env::var("WOW_DEMO_BOTTOM_BARS").as_deref() != Ok("0") {
let _ = script.run(
"SHOW_MULTI_ACTIONBAR_1 = 1 SHOW_MULTI_ACTIONBAR_2 = 1 \
if MultiActionBar_Update then MultiActionBar_Update() end",
);
}
script.fire_event("PLAYER_XP_UPDATE", vec![]);
for token in ["player", "target"] {
script.fire_event("UNIT_HEALTH", vec![ScriptValue::Str(token.into())]);
}
script.fire_event("PLAYER_TARGET_CHANGED", vec![]);
*fired = true;
}
}
/// A test font engine: every character `.0` wide, 12 tall per line, greedily wrapped.
#[cfg(test)]
pub(crate) struct FixedWidthFont(pub(crate) f32);
#[cfg(test)]
impl benilla_ui::script::TextMeasure for FixedWidthFont {
fn measure(&mut self, req: &benilla_ui::script::MeasureRequest) -> (f32, f32, f32) {
let natural = req.text.chars().count() as f32 * self.0;
match req.wrap_width {
Some(w) if w > 0.0 && natural > w => (w, 12.0 * (natural / w).ceil(), natural),
_ => (natural, 12.0, natural),
}
}
}
#[cfg(test)]
pub(crate) mod test_ui;
/// The chat loader's two login events (`0x498a60`): `UPDATE_CHAT_WINDOWS` once, then
/// `UPDATE_CHAT_COLOR` per registry entry.
#[cfg(test)]
pub(crate) fn fire_chat_login(s: &mut benilla_ui::script::UiScript) {
// `FCF_OnUpdate` reads `UIOptionsFrame:IsShown()` every frame; stand in a closed one.
s.run("if not UIOptionsFrame then UIOptionsFrame = CreateFrame('Frame') UIOptionsFrame:Hide() end")
.expect("the options stand-in");
s.fire_event("UPDATE_CHAT_WINDOWS", vec![]);
let renorm = |b: u8| f64::from(b as f32 * (1.0f32 / 255.0f32));
for entry in s.chat_colors() {
s.fire_event(
"UPDATE_CHAT_COLOR",
vec![
benilla_ui::script::ScriptValue::Str(entry.name),
benilla_ui::script::ScriptValue::Number(renorm(entry.rgb[0])),
benilla_ui::script::ScriptValue::Number(renorm(entry.rgb[1])),
benilla_ui::script::ScriptValue::Number(renorm(entry.rgb[2])),
],
);
}
}
/// The `benilla_formats::TokenContext::printf` seam: `SStrPrintf` over the template filler.
pub(crate) fn token_printf(template: &str, args: &[benilla_formats::TokenNumber]) -> String {
let args: Vec<_> = args
.iter()
.map(|n| match n {
benilla_formats::TokenNumber::Int(v) => benilla_ui::strings::Arg::D(*v),
benilla_formats::TokenNumber::Float(v) => benilla_ui::strings::Arg::F(*v),
})
.collect();
benilla_ui::strings::fill(template, &args)
}
/// [`test_ui::load_ui`] for a test module outside `ui_script`, such as `ui_action::feed_tests`.
#[cfg(test)]
pub(crate) fn load_ui_for_test(script: &benilla_ui::script::UiScript, entry: &str) -> usize {
test_ui::load_ui(script, entry)
}
#[cfg(test)]
mod cinematic_tests;
#[cfg(test)]
mod cast_tests;
#[cfg(test)]
mod mirror_timer_tests;
#[cfg(test)]
mod combat_text_tests;
#[cfg(test)]
mod combo_frame_tests;
#[cfg(test)]
mod unit_frame_tests;
#[cfg(test)]
mod unit_popup_tests;
#[cfg(test)]
mod chat_resize_tests;
#[cfg(test)]
mod dropdown_tests;
#[cfg(test)]
mod ui_scale_tests;
#[cfg(test)]
mod action_bar_tests;
#[cfg(test)]
mod exp_bar_tests;
#[cfg(test)]
mod multibar_stance_tests;
#[cfg(test)]
mod pet_bar_tests;
#[cfg(test)]
mod pet_frame_tests;
#[cfg(test)]
mod pet_stable_tests;
#[cfg(test)]
mod tot_frame_tests;
#[cfg(test)]
mod micro_menu_tests;
#[cfg(test)]
mod perf_bar_tests;
#[cfg(test)]
mod panel_tests;
#[cfg(test)]
mod fade_tests;
#[cfg(test)]
mod merchant_tests;
#[cfg(test)]
mod money_frame_tests;
#[cfg(test)]
mod faux_scroll_tests;
/// The stock UIPanelTemplates and OptionsFrameTemplates kit, driven the way an addon drives it.
#[cfg(test)]
mod panel_template_tests;
/// The return-shape gate: `reference/1.12-shapes.tsv` against what this client answers.
#[cfg(test)]
mod shape_gate;
/// The surface gate: the production load's globals and widget methods against the 1.12 tables.
#[cfg(test)]
mod surface_gate;
/// The event argument-shape gate: every fire site against `reference/1.12-events.tsv`.
#[cfg(test)]
mod event_shape_gate;
/// The verb-fired event gate, against `reference/1.12-verb-events.tsv`.
#[cfg(test)]
mod verb_event_gate;
/// The stock BasicControls.xml, which benilla never calls, entered from Lua as an addon does.
#[cfg(test)]
mod basic_controls_tests;
#[cfg(test)]
mod color_picker_tests;
/// `UIParent.xml`'s loose addon-facing helpers, such as `MouseIsOver`.
#[cfg(test)]
mod uiparent_tests;
#[cfg(test)]
mod trainer_tests;
#[cfg(test)]
mod bank_tests;
#[cfg(test)]
mod taxi_tests;
#[cfg(test)]
mod loot_tests;
#[cfg(test)]
mod group_loot_tests;
#[cfg(test)]
pub(crate) mod chat_tests;
/// The chat bubble's `UIMenu` kit driven as a menu: the rows' label and shortcut anchoring.
#[cfg(test)]
mod ui_menu_tests;
/// The chat tab's options menu end to end, over the whole dropdown and colour-picker stack.
#[cfg(test)]
mod chat_options_tests;
#[cfg(test)]
mod bag_tests;
#[cfg(test)]
mod resolve_bench;
#[cfg(test)]
mod tooltip_anchor_tests;
#[cfg(test)]
mod tooltip_compare_tests;
/// `GameTooltipTemplate` as an addon sees it, through `inherits=` and `CreateFrame`.
#[cfg(test)]
mod tooltip_template_tests;
#[cfg(test)]
mod escape_tests;
#[cfg(test)]
mod game_menu_addon_tests;
#[cfg(test)]
mod game_menu_tests;
#[cfg(test)]
mod macro_tests;
// `pub(crate)` for `harness`, `on_page` and `label`, which the bindings dispatch tests use.
#[cfg(test)]
pub(crate) mod keybindings_tests;
#[cfg(test)]
mod options_tests;
#[cfg(test)]
mod delete_item_tests;
#[cfg(test)]
mod instance_tests;
#[cfg(test)]
mod static_popup_tests;
#[cfg(test)]
mod binder_tests;
#[cfg(test)]
mod summon_tests;
#[cfg(test)]
mod talent_wipe_tests;
#[cfg(test)]
mod death_tests;
#[cfg(test)]
mod duel_tests;
#[cfg(test)]
mod enchant_confirm_tests;
/// The `toplevel`, mouse and `id` flags of the whole loaded tree against the reference.
#[cfg(test)]
mod frame_flag_gate;
#[cfg(test)]
mod friends_tests;
/// The four guild windows, over a Lua stand-in for the guild engine API.
#[cfg(test)]
mod guild_tests;
/// The GM help window and its ticket toast, over a pushed `GMTicketCategory.dbc` catalog.
#[cfg(test)]
mod help_frame_tests;
/// The guild-charter registrar and petition sheet, over a Lua stand-in for the charter API.
#[cfg(test)]
mod petition_tests;
/// The social window's fourth tab: the raid pane and its grid, over a pushed raid roster.
#[cfg(test)]
mod raid_tests;
#[cfg(test)]
mod quest_share_tests;
#[cfg(test)]
mod quest_tests;
#[cfg(test)]
mod quest_timer_tests;
#[cfg(test)]
mod battlefield_tests;
/// The battle map: the stock `Blizzard_BattlefieldMinimap` addon, demand-loaded as SHIFT-M does.
#[cfg(test)]
mod battlefield_minimap_tests;
#[cfg(test)]
mod tutorial_tests;
#[cfg(test)]
mod durability_tests;
// `pub(crate)` for `harness`, `push` and `row`, which `perf::hud`'s test uses.
#[cfg(test)]
pub(crate) mod world_state_tests;
#[cfg(test)]
mod screenshot_tests;
#[cfg(test)]
mod questlog_tests;
#[cfg(test)]
mod character_tests;
#[cfg(test)]
mod skills_frame_tests;
#[cfg(test)]
mod reputation_frame_tests;
#[cfg(test)]
mod honor_frame_tests;
#[cfg(test)]
mod pet_paperdoll_tests;
#[cfg(test)]
mod inspect_tests;
#[cfg(test)]
mod dressup_tests;
#[cfg(test)]
mod minimap_tests;
#[cfg(test)]
mod spellbook_tests;
#[cfg(test)]
mod buff_tests;
#[cfg(test)]
mod target_aura_tests;
#[cfg(test)]
mod zone_text_tests;
#[cfg(test)]
mod errors_tests;
#[cfg(test)]
mod shipped_xml_tests;
/// The stock Video Options window off the chain: open, Okay, Defaults and Cancel.
#[cfg(test)]
mod video_options_tests;
#[cfg(test)]
mod bottom_hud_tests;
#[cfg(test)]
mod bagnon_render_tests;
/// A hunter's Quiver addon publishes its global functions, end to end.
#[cfg(test)]
mod quiver_tests;
/// Teardown at the character screen and a genuine second load at the next login.
#[cfg(test)]
mod world_entry_tests;
/// The guild tabard designer: the stock `TabardFrame.xml` off the chain.
#[cfg(test)]
mod tabard_tests;
/// The stock world map: its POI pool, unit blips, player arrow and full-screen quads.
#[cfg(test)]
mod world_map_tests;
#[cfg(test)]
mod seam_scale_tests {
use super::{seam_scale, window_to_ui};
use bevy::math::Vec2;
#[test]
fn seam_scale_is_the_768_tall_root() {
// Identity at the design height, proportional elsewhere.
assert_eq!(seam_scale(768.0), 1.0);
assert_eq!(seam_scale(1536.0), 2.0);
// A degenerate (pre-winit) window is identity, never a division blow-up.
assert_eq!(seam_scale(0.0), 1.0);
}
/// `GetCursorPosition` answers root units whatever the dial: the bottom of a 1080-tall window
/// is 0 and its top 768, its middle column `768·a/2`.
#[test]
fn the_cursor_reaches_the_vm_in_root_units() {
let at = window_to_ui(1080.0, Vec2::new(960.0, 108.0));
assert!((at.x - 960.0 * 768.0 / 1080.0).abs() < 1e-3, "{at}");
assert!((at.y - 972.0 * 768.0 / 1080.0).abs() < 1e-3, "{at}");
assert_eq!(window_to_ui(1080.0, Vec2::new(0.0, 1080.0)), Vec2::ZERO);
}
}
#[cfg(test)]
mod pointer_arbiter_tests {
use super::*;
/// A world to run the arbiter in: its inputs and its two outputs, nothing else.
fn app() -> App {
let mut app = App::new();
app.init_resource::<PlayerUiHover>()
.init_resource::<crate::vplates::PlateHover>()
.init_resource::<PointerOverUi>()
.init_resource::<PointerOverUiPanel>()
.add_systems(Update, arbitrate_pointer_over_ui);
app
}
#[test]
fn a_hovered_plate_is_ui_but_not_chrome() {
let mut app = app();
let plate = app.world_mut().spawn_empty().id();
app.world_mut().resource_mut::<PlayerUiHover>().0 = Some(7);
app.world_mut()
.resource_mut::<crate::vplates::PlateHover>()
.0 = Some(plate);
app.update();
assert!(
app.world().resource::<PointerOverUi>().0,
"the plate takes the pointer"
);
assert!(
!app.world().resource::<PointerOverUiPanel>().0,
"…and the WHEEL looks straight through it"
);
}
/// The camera reads the raw flag (`0x7662c0` gives a mouse-down to exactly one frame, the
/// plate); the wheel reads chrome, walking past a frame that only takes the mouse.
#[test]
fn the_camera_yields_to_a_plate_and_the_wheel_does_not() {
let mut app = app();
let plate = app.world_mut().spawn_empty().id();
app.world_mut().resource_mut::<PlayerUiHover>().0 = Some(7);
app.world_mut()
.resource_mut::<crate::vplates::PlateHover>()
.0 = Some(plate);
app.update();
// `latch_world_mouse` reads this one: the press is not the world's, so no look starts.
assert!(
app.world().resource::<PointerOverUi>().0,
"the camera must yield the press to the plate"
);
// …and `bindings`' wheel branch reads this one.
assert!(
!app.world().resource::<PointerOverUiPanel>().0,
"the wheel must still reach the world over a plate"
);
}
#[test]
fn a_hovered_panel_is_both() {
let mut app = app();
app.world_mut().resource_mut::<PlayerUiHover>().0 = Some(7);
app.update();
assert!(app.world().resource::<PointerOverUi>().0);
assert!(app.world().resource::<PointerOverUiPanel>().0);
}
#[test]
fn no_hover_is_neither() {
let mut app = app();
app.update();
assert!(!app.world().resource::<PointerOverUi>().0);
assert!(!app.world().resource::<PointerOverUiPanel>().0);
}
}