Files
benilla-octo/crates/benilla/src/ui_script/mod.rs
T
SamandClaude Fable 5 b82cc3dd4f the loading screen warms every shader, and locked chests say LOCKED
- the loading screen now compiles every shader the world can reach: sky, water, spell shards and model variants stop hitching the first time they appear
- a locked chest's cursor carries the LOCKED word like the real client, and the lock verdict never leaks into the sent packet
- enchanting asks before it acts: a bind-on-enchant item gets its confirm popup, and so does replacing an enchant that's already there
- a long gossip menu scrolls like the reference with rows grown to their wrapped text, and music already playing is not restarted when the server re-sends it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 18:54:16 +07:00

947 lines
46 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`] (the Lua VM + widget arena +
//! layout, all engine-free) and feeds its [`extract`](benilla_ui::script::UiScript::extract) output
//! into the quad pass ([`crate::ui_pass::UiQuads`]) every frame. This is the seam decision 0068
//! draws between the engine-free crate and the app: everything above it is data + Lua; everything
//! below it is Bevy.
//!
//! Coordinates: the script/layout side is WoW UI space — **y-up**, origin bottom-left, in the
//! client's 768-virtual units (decision 0582): `set_screen_size` feeds a screen `768/uiScale`
//! units tall every frame, and [`seam_scale`] carries quads ×s out / mouse ÷s in. The quad pass
//! wants y-down window px, so extraction also flips through the window height.
//!
//! Benilla's own **unit frames** (`assets/ui/UnitFrames.xml` — a template + the player and target
//! instances) load at startup by default through [`benilla_ui::loader`] — the decision 0068 slice-1
//! game-shell: real FrameXML+Lua rendering unit health/power/level/name through the whole chain
//! (snapshot → `Unit*` bindings → Lua → event → StatusBars+text → quad pass). Since captures run
//! server-less (no real game state), `WOW_CAPTURE_UI=1` also feeds synthetic `"player"`/`"target"`
//! snapshots ([`demo_unit_feed`]) so the frames populate on screen. `FontString` regions draw
//! through [`crate::ui_text::layout_text_quads`] against the glyph atlas
//! [`crate::ui_text::UiFontAtlas`] builds at startup (decision 0068 §2).
use bevy::prelude::*;
use benilla_ui::script::{ActionSlot, ScriptValue, UiScript, UnitState};
use crate::assets::LockRecover;
use crate::debug_panel::EguiPointerOver;
use crate::schedule::WorldStage;
use crate::ui_unit::UnitFeed;
mod extract;
mod input;
/// Is the pointer over *any* UI this frame — the egui dev overlay OR a mouse-enabled player-UI
/// frame? The single source of truth for "the mouse is talking to the UI, not the world",
/// combined by [`arbitrate_pointer_over_ui`] from both contributors (dev overlay =
/// [`crate::debug_panel::EguiPointerOver`]; player UI = [`PlayerUiHover`]). Gameplay reads it so
/// a drag doesn't start mouse-look; the inspector reads it so a pick doesn't fire behind an
/// overlaid frame. Owned HERE, not by the dev plugin (decision 0026): gameplay's read must
/// survive a build without the dev overlays, so the combiner treats the egui half as optional.
#[derive(Resource, Default)]
pub(crate) struct PointerOverUi(pub(crate) bool);
/// The frame the cursor is currently over (a mouse-enabled, visible player-UI frame), or `None`.
/// Written by [`feed_ui_input`], read by the pointer arbiter below so world-pick
/// and camera-look yield to the UI (decision 0026's single-source `PointerOverUi`).
#[derive(Resource, Default)]
pub(crate) struct PlayerUiHover(pub(crate) Option<u32>);
/// Set each frame by [`feed_ui_input`] to [`UiScript::has_keyboard_focus`]: true while an EditBox owns
/// keyboard focus and is eating every key. The gameplay/dev keyboard readers (movement, the Z sheath,
/// the HUD/panel/inspect toggles, chat-open, target-clear) gate on it so a key typed into a focused box
/// never also drives the world — the app-side twin of the client's `DAT_00cf4dc8 != 0` gate (RF-0082
/// §1). One mechanism, written once here; every reader is ordered after `UiInput` so it sees this
/// frame's value, not last frame's.
#[derive(Resource, Default)]
pub(crate) struct UiKeyboardCapture(pub(crate) bool);
/// Set each frame by [`feed_ui_input`]: true for a LEFT press this frame that hit no frame but was
/// consumed by the UI — the world-drop of a held cursor payload over EMPTY world (0216 §3,
/// narrowed by decision 0571: over a world object the reference keeps an item payload and runs
/// SELECT, so the press is NOT consumed there): world click-pick and camera orbit-start must
/// yield exactly as they do for a hovered click.
#[derive(Resource, Default)]
pub(crate) struct PlayerUiClickConsumed(pub(crate) bool);
/// Set each frame by [`feed_ui_input`] (after the mouse feed, so a same-frame pickup counts):
/// whether the cursor currently carries a payload ([`UiScript::cursor_payload`]) — a `Send`
/// mirror for world-click consumers that shouldn't take the `NonSend` VM just to ask. Read by
/// [`crate::target`]'s select router: a click on NOTHING (sky) with a payload held never
/// deselects (the reference's nothing-leg `SetSelection(0,0)` is no-payload-gated — `0x492d30`'s
/// local flag test; the terrain leg is not — decisions 0571 + 0574).
#[derive(Resource, Default)]
pub(crate) struct CursorPayloadHeld(pub(crate) bool);
/// The player-UI input pass — hit-testing + handler firing. Ordered before [`WorldStage::Input`] so
/// the [`PlayerUiHover`] it produces is folded into `PointerOverUi` before `player::control` reads it.
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct UiInput;
/// Run a Lua chunk, logging (never discarding) a failure. App systems drive the VM with fire-and-
/// forget chunks; `let _ = script.run(…)` swallows the error — the chat header machine died
/// mid-chunk on a missing `EditBox:SetTextColor` every single frame and nothing ever said so
/// (the /w caret bug). A chunk failure is always an app or engine defect; this is the mandatory
/// form for any run whose `Result` isn't otherwise 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 — the user dial on TOP of the 768-virtual base (decision 0584):
/// px-per-UI-unit multiplies by it, so the VM's virtual screen is `768/uiScale` units tall — the
/// same law that makes the reference's `uiScale = 768/screenH` its known pixel-perfect setting.
/// Folded into the one seam scale by [`seam_scale`]; at `1.0` (this `Default`, what every test
/// pins) the pipeline is bit-identical to the pre-dial 0582 behavior. The app inserts
/// [`default_ui_scale`] instead — the director's taste default, `WOW_UI_SCALE=` per-run override —
/// until a real cvar system subsumes this dial (0582's named residual).
#[derive(Resource)]
pub(crate) struct UiScaleCvar(pub(crate) f32);
impl Default for UiScaleCvar {
fn default() -> Self {
Self(1.0)
}
}
/// The shipped default (the reference's slider spans 0.64..1.0; 1.0 read oversized to the
/// director's eye — the taste call this dial exists for).
const DEFAULT_UI_SCALE: f32 = 0.9;
/// The app's `uiScale`: `WOW_UI_SCALE=` if set (clamped to the plausible dial range), else
/// [`DEFAULT_UI_SCALE`] — the env override makes taste iteration a relaunch, not a rebuild.
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` (decision 0582 + the 0584 dial): window px per UI unit —
/// `windowH/768 × uiScale`. Every crossing of the VM boundary uses this one number: extraction
/// ×s out, mouse ÷s in, measures at ×s answered ÷s, the atlas bakes at ×s. A degenerate window
/// (h ≤ 0, the pre-winit frame) is identity.
pub(crate) fn seam_scale(window_h: f32, ui_scale: f32) -> f32 {
if window_h > 0.0 {
window_h / 768.0 * ui_scale
} else {
1.0
}
}
/// Adds the Lua UI host as a `NonSend` resource (an mlua VM is `!Send`) and the per-frame
/// tick → resolve → extract pipeline into [`UiQuads`], plus the input pass ([`feed_ui_input`]).
pub(crate) struct UiScriptPlugin;
impl Plugin for UiScriptPlugin {
fn build(&self, app: &mut App) {
app.insert_resource(UiScaleCvar(default_ui_scale()))
// The UI pass publishes its per-frame phase split here every frame the cost meter or
// the hover recorder is armed; the producer owns the resource so any minimal app that
// runs `drive_script` (the extract tests) has it.
.init_resource::<crate::hover_log::UiFrameCost>()
.init_resource::<PointerOverUi>()
.init_resource::<PlayerUiHover>()
.init_resource::<UiKeyboardCapture>()
.init_resource::<PlayerUiClickConsumed>()
.init_resource::<CursorPayloadHeld>()
// After `AssetSet::Open` so the patch chain exists at boot: the VM's first load is
// the real `GlobalStrings.lua` (the reference's own FrameXML order), which the
// cast-fail display (0427) resolves its messages from.
.add_systems(Startup, setup_script.after(crate::assets::AssetSet::Open))
// `drive_script` resolves layout; `feed_ui_input` hit-tests against those rects, so they
// chain (also required because both take the single `NonSend` VM). The pair runs before
// `WorldStage::Input` so the hover result reaches the pointer arbiter in time. The input
// pass is in-world only (decision 0193): the character-select glue screen owns the
// pointer + keyboard there (its exit edge resets the latches this pass normally drives).
.add_systems(
Update,
(
extract::drive_script,
input::feed_ui_input.run_if(in_state(crate::char_select::ClientState::InWorld)),
)
.chain()
.in_set(UiInput)
.before(WorldStage::Input),
)
// Combine the two pointer contributions (dev overlay + player UI) into the single
// `PointerOverUi` source of truth, after the hover is known and before gameplay reads it.
.add_systems(
Update,
arbitrate_pointer_over_ui
.after(UiInput)
.before(WorldStage::Input),
);
// `WOW_CAPTURE_UI=1` on a capture: override the "player" token with a synthetic snapshot so
// the unit frames are populated in a server-less capture. Ordered after the real feed (so it
// wins) and before the VM ticks/dispatches this frame. Never active outside capture mode —
// synthetic data must not reach a real run.
app.add_systems(
Update,
demo_unit_feed
.after(UnitFeed)
.before(UiInput)
.run_if(capture_ui_active),
);
}
}
/// Should this CAPTURE include the player UI (+ the synthetic unit snapshot)? The visual harness's
/// baselines must stay UI-free (they regression-test the WORLD render), so captures skip the UI
/// unless `WOW_CAPTURE_UI=1` opts in. Normal runs always load the UI and never take synthetic data.
fn capture_ui_active(capture: Option<Res<crate::capture::CaptureMode>>) -> bool {
capture.is_some() && std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1")
}
/// The pointer arbiter (decision 0026): `PointerOverUi = egui dev overlay ∨ player-UI hover`. Runs
/// regardless of whether the UI VM exists (if it's absent, [`PlayerUiHover`] stays `None` and this is
/// just the egui bit) — and regardless of the DEV overlays existing (their half is `Option`, so a
/// player build without the debug-panel plugin arbitrates on the player UI alone).
fn arbitrate_pointer_over_ui(
egui: Option<Res<EguiPointerOver>>,
hover: Res<PlayerUiHover>,
mut over: ResMut<PointerOverUi>,
) {
over.0 = egui.is_some_and(|e| e.0) || hover.0.is_some();
}
fn setup_script(world: &mut World) {
let script = match UiScript::new() {
Ok(s) => s,
Err(e) => {
error!("ui_script: VM init failed: {e}");
return;
}
};
load_global_strings(world, &script);
load_emote_tokens(world, &script);
// The unit frames are the default player UI — always loaded in a normal run. Captures stay
// pristine (world-render baselines) unless WOW_CAPTURE_UI=1 opts the UI in.
let capturing = world.contains_resource::<crate::capture::CaptureMode>();
if !capturing || std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1") {
// Errors are already logged per file as they happen; the returned list is the test-side
// assertion (`shipped_xml_tests`), not a second reporting channel.
let _ = load_default_ui(&script);
}
world.insert_non_send_resource(script);
}
/// Execute the real `Interface\FrameXML\GlobalStrings.lua` off the patch chain into the VM —
/// the reference boots FrameXML with exactly this file FIRST, and it is the source of every
/// localized string global the UI reads (the cast-fail display's whole message set, 0427).
/// Loaded before our own `assets/ui` files, matching the reference order. Failures are LOUD:
/// a silently missing GlobalStrings once suppressed every red error line (the 0427 fold's
/// absent-key face is faithful data suppression — but only when the file actually loaded).
fn load_global_strings(world: &mut World, script: &UiScript) {
let Some(assets) = world.get_resource::<crate::assets::WorldAssets>() else {
warn!("ui_script: no patch chain — GlobalStrings absent, error lines will be empty");
return;
};
let bytes = {
let mut chain = assets.chain.lock_recover();
chain.read_file("Interface\\FrameXML\\GlobalStrings.lua")
};
let src = match bytes {
Ok(b) => String::from_utf8_lossy(&b).into_owned(),
Err(e) => {
error!("ui_script: GlobalStrings.lua read failed — error lines will be empty: {e:#}");
return;
}
};
if let Err(e) = script.run(&src) {
error!("ui_script: GlobalStrings.lua failed to run: {e}");
return;
}
// The sentinel: the exact lookup the cast-fail drain performs. If this misses, every
// message would silently vanish — turn that failure mode into a diagnosable line. Presence
// only, not the enUS text: a non-enUS install is still a loaded GlobalStrings.
let sentinel: Option<String> = script.lua().globals().get("SPELL_FAILED_NO_AMMO").ok();
match sentinel {
Some(s) if !s.is_empty() => info!("ui_script: GlobalStrings loaded"),
other => {
error!("ui_script: GlobalStrings sentinel missing ({other:?}) — error lines broken")
}
}
}
/// Execute the reference's own **emote token table** into the VM (`EMOTE87_TOKEN = "SIT"`, …) —
/// the second half of the emote slash grammar (decision 0881). The *aliases* are in
/// `GlobalStrings.lua` above (`EMOTE87_CMD1 = "/sit"`), but the alias → `EmotesText.Name` mapping
/// lives in `ChatFrame.lua`: the reference's chat **code**, which benilla replaces in Rust. So we
/// take that file's **data** and none of its code — only whole lines matching
/// `EMOTE<digits>_TOKEN = "<UPPER>";` ([`is_emote_token_line`]) are executed, and the file's ~2400
/// lines of frame logic never run. Reading the shipped table beats transcribing 170 tokens into
/// Rust: a transcription can be wrong, and a hand-kept alias list is exactly what left 61 real
/// commands (`/lol`, `/hi`, `/ty`, …) unresolvable before 0881.
fn load_emote_tokens(world: &mut World, script: &UiScript) {
let Some(assets) = world.get_resource::<crate::assets::WorldAssets>() else {
return; // already WARNed by load_global_strings
};
let bytes = {
let mut chain = assets.chain.lock_recover();
chain.read_file("Interface\\FrameXML\\ChatFrame.lua")
};
let src = match bytes {
Ok(b) => String::from_utf8_lossy(&b).into_owned(),
Err(e) => {
error!("ui_script: ChatFrame.lua read failed — emote commands will be dead: {e:#}");
return;
}
};
let table: Vec<&str> = src
.lines()
.map(str::trim)
.filter(|l| is_emote_token_line(l))
.collect();
let count = table.len();
if let Err(e) = script.run(&table.join("\n")) {
error!("ui_script: emote token table failed to run: {e}");
return;
}
// The sentinel is the command this whole seam exists for: EMOTE87 is `/sit`.
let sentinel: Option<String> = script.lua().globals().get("EMOTE87_TOKEN").ok();
match sentinel.as_deref() {
Some("SIT") => info!("ui_script: {count} emote tokens loaded"),
other => error!(
"ui_script: emote token sentinel is {other:?}, not \"SIT\" ({count} lines) — \
emote slash commands are broken"
),
}
}
/// Is this line one of `ChatFrame.lua`'s emote-token assignments — `EMOTE<digits>_TOKEN = "<NAME>";`
/// with `NAME` in `[A-Z0-9_]`? The whole-line shape is the filter that makes running the matched
/// lines equivalent to reading data (no calls, no expressions, no side effects).
pub(crate) fn is_emote_token_line(line: &str) -> bool {
let Some(rest) = line.strip_prefix("EMOTE") else {
return false;
};
let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len();
let Some(rest) = rest[digits..].strip_prefix("_TOKEN = \"") else {
return false;
};
let Some(name) = rest.strip_suffix("\";") else {
return false;
};
digits > 0
&& !name.is_empty()
&& name
.bytes()
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
}
/// Load benilla's own default UI (`assets/ui/*.xml` — the unit frames and the action bar) through
/// the engine-free loader, resolving any `<Include>`/`<Script file=>` references against the
/// crate's `assets/ui` dir. This is our content (MIT/Apache), committed and read from source —
/// `CARGO_MANIFEST_DIR` points at the `benilla` crate. Textures (`Interface\…`) still resolve at
/// render through the MPQ `sprite_texture` path; the loader only needs the XML/Lua text.
///
/// Returns every loader error, tagged `"<file>: <error>"` — the app ignores the value (each is
/// already logged as it happens) and [`shipped_xml_tests`] asserts it empty. The manifest is an
/// inline array no other test walks, so before that assertion a broken entry — a bad file name, a
/// frame that collides with a later window's, a template referenced before its definer — reached a
/// real run with nothing but a log line. Capture runs cannot cover it either: they skip this
/// function entirely unless `WOW_CAPTURE_UI=1`.
fn load_default_ui(script: &UiScript) -> Vec<String> {
let mut failures = Vec::new();
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
// Provider for FrameXML/Lua references: try the path as given and by basename (Blizzard-style
// backslash paths, dir-relative), resolved against our own assets/ui dir.
let provider = |req: &str| -> Option<String> {
let norm = req.replace('\\', "/");
let base = norm.rsplit('/').next().unwrap_or(&norm);
std::fs::read_to_string(dir.join(&norm))
.or_else(|_| std::fs::read_to_string(dir.join(base)))
.ok()
};
for file in [
// The named virtual Font objects (decision 0084) — must load first so any FontString's
// `inherits="GameFontNormal"` resolves against a registered object.
"Fonts.xml",
// The UIPanel slot manager (decision 0084 §2): script-only, no frames of its own — must load
// before any panel frame (Gossip/Merchant today) so ShowUIPanel/HideUIPanel already exist
// when those frames' OnLoad/OnEvent reference them.
"UiPanels.xml",
// The managed bottom-stack positions (decision 0272): script-only —
// UIPARENT_MANAGED_FRAME_POSITIONS + UIParent_ManageFramePositions, the ref
// UIParent.lua mechanism that re-anchors the cast bar / chat over the bottom bars.
// Loaded early so the stance bar's OnShow/OnHide can call it; applied once by the
// post-load bootstrap below.
"UIParent.xml",
// The tooltip: a hover window the bag/merchant/loot/unit frames drive. Loaded before them so
// `GameTooltip` + its verbs exist when their OnEnter handlers fire (runtime, so order
// is not strictly required — but it keeps the dependency legible).
"GameTooltip.xml",
// The dropdown kit (0203 phase 2's closing sub-slice, widened for 0434 phase 5):
// UIDropDownMenuTemplate + DropDownList1/2 + the UIDropDownMenu_* API — before its
// first customers (the unit popups below, the world map's pickers, the trainer filter).
"UIDropDownMenu.xml",
// The unit right-click popups (0434 phase 5): UnitPopupMenus/Buttons + UnitPopup_ShowMenu
// — after the dropdown kit it drives, before the unit/party frames whose DropDown
// children's OnLoad initialize into it.
"UnitPopup.xml",
// The chat-link click router (SetItemRef): item links → ItemRefTooltip:SetHyperlink; a
// player name → left-click whisper / right-click FRIEND dropdown. After GameTooltip.xml
// (GameTooltip_OnLoad) AND the dropdown kit + UnitPopup — its FriendsDropDown inherits
// UIDropDownMenuTemplate and opens a UnitPopup FRIEND menu.
"ItemRef.xml",
"UnitFrames.xml",
// The combo-point dots (decision 0869) — anchored to BenillaTargetFrame, so after
// UnitFrames.xml; its fade chain needs UiPanels.xml's UIFrameFade above.
"ComboFrame.xml",
// The center-screen scrolling combat text (decision 0578) — the Blizzard_CombatText
// transcription; consumes COMBAT_TEXT_UPDATE + the regen/health/power events, needs
// only Fonts.xml (CombatTextFont) + UIParent before it.
"CombatText.xml",
// The party member frames + the PARTY_INVITE popup + its event driver (decision 0434
// phase 2): a StaticPopup registry entry, so after UiPanels.xml.
"PartyFrame.xml",
// The death arc's dialogs + event driver (decision 0308): registry entries on the shared
// StaticPopup engine, so after UiPanels.xml.
"DeathFrame.xml",
// The two duel dialogs + their event driver (decision 0633): registry entries on the same
// StaticPopup engine, and DUEL_OUTOFBOUNDS leans on its per-tick countdown branch.
"DuelFrame.xml",
// The two enchant-apply confirms + their event driver (decision 0928): the same StaticPopup
// registry again. Not CraftFrame's — the gate that raises them is the generic item-target
// bind, reached by a poison or a sharpening stone with no profession window open.
"EnchantConfirm.xml",
// The shared CooldownFrame_SetTimer (the ref's Cooldown.lua file split) — before its
// consumers (ActionBar's buttons, the multibars, the stance bar, BagFrame's slots).
"Cooldown.xml",
"ActionBar.xml",
// The two always-on bottom multibars (actions 61-72 / 49-60): pure XML/Lua over
// ActionBar.xml's shared button template + handler set — loads right after it
// (inherits BenillaActionButtonTemplate, anchors to BenillaActionButton1, and its
// buttons join the BENILLA_ACTION_BUTTONS roster ActionBar.xml declares).
"MultiBars.xml",
// The stance/shapeshift bar: hidden until `crate::ui_shapeshift`'s feed pushes a
// non-empty form list (a formless class never shows it). After Cooldown.xml
// (CooldownFrame_SetTimer) + ActionBar.xml (BENILLA_FALLBACK_ICON, the BenillaActionBar
// anchor target).
"StanceBar.xml",
// The micro-button row in the bar's right-hand recess (the ref's own file split, its TOC
// loads MainMenuBarMicroButtons right after MainMenuBar). After ActionBar.xml: it anchors
// into BenillaActionBarArtFrame by name and seats itself with that file's
// BenillaActionBarArt_SeatAbove. It must come BEFORE the panels it toggles, because each of
// 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
// <Minimap> widget hole (`crate::minimap` fills it). After GameTooltip (its zone-text
// hover uses the shared tooltip).
"MinimapCluster.xml",
// The zone-entry splash (decision 0287): ZoneTextFrame/SubZoneTextFrame + the inlined
// FadingFrame kit, driven by the ZONE_CHANGED family `crate::area` fires. Needs only
// Fonts (ZoneTextFont/SubZoneTextFont) + UIParent; kept by the minimap cluster — the
// other consumer of the zone-text host globals.
"ZoneText.xml",
// The faux-scroll kit (decisions 0247/0250/0251): BenillaScrollBarTemplate (the draggable
// Slider scroll bar) + BenillaFauxScrollFrameTemplate + the BenillaFauxScrollFrame_* API —
// before its first customer (the trainer list) below.
"ScrollTemplates.xml",
// The fullscreen world map (decision 0203 phase 2), over the worldmap host API
// (`crate::ui_world_map` feeds it); the 'M' binding below calls its ToggleWorldMap().
"WorldMapFrame.xml",
// The cast bar (decision 0137 phase 1) — the extracted 1.12 CastingBarFrame, driven by
// the SPELLCAST_* events `crate::ui_cast` fires. Only needs Fonts before it.
"CastingBar.xml",
// The mirror timers (decision 0874): the breath/fatigue bars at top-center, driven by the
// MIRROR_TIMER_* events `crate::ui_mirror` fires. Needs only Fonts (GameFontHighlight)
// before it; kept beside the cast bar, whose border art it shares.
"MirrorTimer.xml",
// The player buff/debuff bar (decisions 0255/0257 — the player aura arc): the HUD row of
// aura icons under the minimap, driven by the UnitAura feed (`crate::ui_aura`). Needs Fonts
// (its FontStrings) and ActionBar's `BENILLA_FALLBACK_ICON` global (both already loaded).
"BuffFrame.xml",
// The durability alert (the "armor guy"): under the minimap beside BuffFrame, driven by
// UPDATE_INVENTORY_ALERTS off the same inventory push the character window reads. Needs
// MinimapCluster (its anchor) — loaded above.
"DurabilityFrame.xml",
"ErrorsFrame.xml",
"BagFrame.xml",
// The stack-split spinner (decision 0216 §6/slice 2): loaded beside BagFrame, whose slot
// click handler (`BenillaBagSlot_OnClick`) calls `OpenStackSplitFrame`. Order between the
// two doesn't matter for correctness (Lua globals resolve at call time, and neither file's
// XML `inherits=` reaches into the other's templates) — kept adjacent for legibility.
"StackSplit.xml",
// The character window (decision 0208 phase 1a): the C-key paper doll. Loaded after BagFrame
// (needs nothing from it, but keeps player-state windows grouped) and before the NPC session
// windows (Gossip/Merchant/Quest*) — it depends only on Fonts/UiPanels/GameTooltip, all
// already loaded above.
"CharacterFrame.xml",
// The Skills tab (decision 0437 phase 4): a CharacterFrame child page, but a separate
// top-level file (SkillFrame.xml's own header comment — this engine has no cross-file
// `parent=` attachment, so it's positioned by name via `<Anchors relativeTo=
// "BenillaCharacterFrame">` instead). Loaded immediately after CharacterFrame.xml so it
// paints ON TOP of it (later file = higher z) and so `BenillaCharacterFrame` already exists
// for that anchor to resolve against.
"SkillFrame.xml",
// The inspect window (decision 0631): another player's paper doll. Beside the character
// window it mirrors — it needs the same three (Fonts/UiPanels/GameTooltip) plus
// `CharacterFrameTabButtonTemplate` from UiPanels.xml, and nothing from CharacterFrame.xml
// itself (its slot template, handlers, and booth slot are all its own — the two windows
// share only the reference's art).
"InspectFrame.xml",
// The spellbook window (decision 0216 §8, slice 5): the P-key window, the spell SOURCE
// for the cursor-payload arc. Loaded right after CharacterFrame (the same "player-state
// windows grouped" posture, same Fonts/UiPanels/GameTooltip-only dependency set); the
// 'P' binding below calls its bare ToggleSpellBook(BOOKTYPE_SPELL).
"SpellBookFrame.xml",
// The talent window (decision 0304 §4): the N-key window, the class talent grid over the
// `benilla-ui::script::talent` engine seam. Loaded right after SpellBookFrame (the same
// "player-state windows grouped" posture; needs Fonts/UiPanels/GameTooltip plus
// ScrollTemplates.xml — its real ScrollFrame's scrollbar reuses BenillaScrollBarTemplate —
// all already loaded above). The 'N' binding (ui_script/input.rs) calls its bare
// ToggleTalentFrame().
"TalentFrame.xml",
// The social window (decision 0668): the O-key friends/ignore/who window over the social
// API (`benilla-ui/src/script/social.rs`) + `crate::ui_social`'s feed/drain. Needs
// UiPanels (the panel manager, `CharacterFrameTabButtonTemplate`, and the StaticPopup
// engine its two name-entry dialogs register into), ScrollTemplates (three faux lists),
// GameTooltip (the newbie tips) and UIDropDownMenu (the who list's variable column) —
// all loaded above.
"FriendsFrame.xml",
"GossipFrame.xml",
"MerchantFrame.xml",
// The mail window (decision 0544 P1/P2): the mailbox inbox, open-letter, and send tab over
// the Era mail API (`benilla-ui/src/script/mail.rs`) + `crate::ui_mail`'s feed/drain. Loaded
// right after MerchantFrame because it REUSES that file's global BenillaMoney_Set/_Clear
// coin helpers (postage/enclosed/COD displays) — they must be defined first.
"MailFrame.xml",
// The player-to-player trade window (decision 0592 P1): the two-sided offer over the trade
// API (`benilla-ui/src/script/trade.rs`) + `crate::ui_trade`'s feed/drain. Loaded after
// MerchantFrame because it REUSES that file's global BenillaMoney_Set coin helpers (the two
// gold displays) — they must be defined first.
"TradeFrame.xml",
// The item-text reader: letters (mail-made permanent copies) and, later, books/plaques —
// the ItemTextGet* seam (`benilla-ui/src/script/item_text.rs`) over `crate::ui_item_text`'s
// feed/drain. Loaded right after MailFrame (the same arc; needs only Fonts/UiPanels, both
// loaded above).
"ItemTextFrame.xml",
// The trainer window (decision 0237 phase 3): the read-only class/profession trainer list.
// Loaded right after MerchantFrame per the director's brief (kept adjacent even though this
// v1 uses plain-text costs, not MerchantFrame's coin helpers) and before the other NPC
// session windows below.
"TrainerFrame.xml",
// The bank window (decision 0604 phase 4): the vault, purchase ladder, and the 6 bank-bag
// popouts, over the C_Container feed already carrying container -1/5..10 (ui_items.rs) +
// the bank Lua surface (benilla-ui/src/script/bank.rs) + crate::ui_bank's feed/drain.
// Loaded AFTER MerchantFrame.xml (reuses its BenillaMoney_* coin helpers for the purchase
// row + purse) and AFTER BagFrame.xml, already above (its BenillaBagSlotTemplate/
// BenillaBagWindowTemplate + the BenillaBagSlot_*/BenillaBagFrame_* function family are
// reused verbatim — the 24 generic slots and the 6 bank-bag popouts are, respectively, a
// container-slot grid and ordinary container windows, exactly like the bag arc's own).
// Kept in the "NPC session windows grouped" cluster beside Trainer/Taxi.
"BankFrame.xml",
// The taxi-map window (decision 0484 phase 2): the flight-master session, driven by the
// taxi engine seam (`benilla-ui/src/script/taxi.rs`) over `crate::ui_taxi`'s feed/drain.
// Loaded right after TrainerFrame per the same "NPC session windows grouped" posture — it
// needs only Fonts/UiPanels/GameTooltip (SetTooltipMoney), both already loaded above.
"TaxiFrame.xml",
// The crafting window (decision 0437 phase 2): NOT an NPC session (opens off your own cast of
// an effect-47 opener spell, TradeSkillFrame.xml's own header comment) — loaded right after
// TrainerFrame per the same "player-state/profession window" grouping and because it depends
// on nothing TrainerFrame doesn't already need (Fonts/UiPanels/GameTooltip/ScrollTemplates,
// all loaded above).
"TradeSkillFrame.xml",
// The Enchanting window (decision 0437 phase 3): the exact twin of TradeSkillFrame above —
// same "opens off your own cast, not an NPC session" shape (CraftFrame.xml's own header
// comment) — loaded right after it so `BuildColoredListString` (a guarded GLOBAL
// TradeSkillFrame.xml defines) already exists when CraftFrame.xml reuses it without
// redefining (CraftFrame.xml's own header comment, deviation 2).
"CraftFrame.xml",
"LootFrame.xml",
// The group-loot roll popups (decision 0591): the Need/Greed/Pass dialogs, a sibling of
// LootFrame.xml (loaded right after it) over the `benilla-ui::script::loot_roll` seam
// (`crate::ui_loot_roll`'s feed/drain). NOT a UIPanel — four `frameStrata="DIALOG"` toplevel
// popups with their own hardcoded anchors — so it needs only Fonts (GameFontNormalSmall) +
// GameTooltip (the item/PASS/NEED/GREED hovers), both already loaded above.
"GroupLootFrame.xml",
// The questgiver window (decision 0088): four sub-panels over the Era quest API. Loaded after
// MerchantFrame (the BenillaMoney coin helpers live there) and GossipFrame (shared parchment
// art conventions); its title/panel-button labels use the auto-centred ButtonText slot.
"QuestFrame.xml",
// The quest log window (decision 0088 arc, the quest-log slice): durable player state, not an
// NPC session — loaded after MerchantFrame (the BenillaMoney coin helpers it reuses) and
// alongside QuestFrame (same arc); the 'L' binding below calls its ToggleQuestLog().
"QuestLogFrame.xml",
"ChatFrame.xml",
// The game menu (decision 0674): the frame ESC opens, plus the CAMP/QUIT dialogs and their
// event driver. 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 depends on it: the ESC
// chain and the micro button both reach it by name at call time.
"GameMenuFrame.xml",
] {
let entry = dir.join(file);
let text = match std::fs::read_to_string(&entry) {
Ok(t) => t,
Err(e) => {
error!("ui_script: reading {}: {e}", entry.display());
continue;
}
};
let doc = match benilla_ui::framexml::parse(&text) {
Ok(d) => d,
Err(e) => {
error!("ui_script: parsing {file}: {e}");
continue;
}
};
let report = benilla_ui::loader::load(script, &doc, &provider);
for w in &report.warnings {
warn!("ui_script({file}): {w}");
}
for e in &report.errors {
error!("ui_script({file}): {e}");
failures.push(format!("{file}: {e}"));
}
info!(
"ui_script: {file} loaded ({} frames materialized)",
report.frames
);
}
// The managed positions' startup pass (decision 0272): the ref applies
// UIPARENT_MANAGED_FRAME_POSITIONS once at load, then re-fires from the bottom bars'
// OnShow/OnHide. Every frame the table names now exists, so this is that load-time
// application; the stance bar's show/hide handles the rest at runtime.
if let Err(e) = script.run("UIParent_ManageFramePositions()") {
error!("ui_script: managed-positions bootstrap: {e}");
failures.push(format!("managed-positions bootstrap: {e}"));
}
failures
}
/// Feed synthetic `"player"`/`"target"` snapshots each frame (overriding the real feed, which finds
/// no avatar in a server-less capture) and fire the initial events once — proving the full chain
/// end-to-end on a screenshot: snapshot → `Unit*` bindings → Lua `OnEvent` → bars + text, for both
/// unit frames (the target one included, so captures regression-test its show-on-target path).
fn demo_unit_feed(script: Option<NonSendMut<UiScript>>, mut fired: Local<bool>) {
/// The synthetic target's guid — a creature-family high part, so nothing mistakes it for a
/// player. Only its *distinctness* matters.
const DEMO_TARGET_GUID: u64 = 0xF130_0000_0000_0001;
let Some(mut script) = script else {
return;
};
// TEMP DEBUG (WOW_CAPTURE_REHOVER=1): re-fire the world hover EVERY frame — the live
// flapping-raycast simulation for the under-sized-plate investigation.
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,
..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 → the name plate reads 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,
// A beast type word so the mouseover/target tooltip's level line reads
// "Level 3 Beast" in captures.
creature_type_name: Some("Beast".into()),
// A real guid: the combo seed below banks its points against exactly this unit, and
// `GetComboPoints` refuses to report points banked on anything but the CURRENT target
// (decision 0875). Also stops the demo player and target reading as the same unit,
// which two default zeros would.
guid: DEMO_TARGET_GUID,
..Default::default()
}),
);
// The combo dots (`ComboFrame`) need a class that can SEE them and points banked on the
// selected unit — seeded only for their own scenario, because the demo player is a warrior
// everywhere else (ui-char's level line reads "Level 12 Night Elf Warrior" off that) and a
// warrior authentically lights no dot. Run it with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=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 {
// A few synthetic bar slots so captures show the action bar populated (battle-stance
// page: actions 73.. — the page a real warrior login lands on). Spread across the 12 wells
// (buttons 1,2,3,8,12) so the ui-actionbar capture shows icons seated left-to-right. Slot
// 80 is an ITEM-kind action (decision 0216 §7) with a synthetic count of 5, so the capture
// also proves the Count fontstring wires up (`GetActionCount` reads the engine's own
// pushed count directly — no live server template needed in a capture).
script.set_bonus_bar_offset(1);
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,
),
(80, "Interface\\Icons\\INV_Misc_Food_16", 0x80, 117, 5),
(84, "Interface\\Icons\\Spell_Holy_SealOfMight", 0x00, 103, 0),
// The always-on multibars (MultiBars.xml): BottomLeft = actions 61..72, BottomRight
// = 49..60. A few occupied wells on each so the capture shows both rows seated
// (empty multibar wells hide — the ref's own default — so without these the rows
// would be invisible).
(
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 stance bar (StanceBar.xml): the synthetic warrior's three stances, battle active —
// matches the bonus offset 1 above (battle stance page). Defensive shows the not-castable
// grey; berserker a running cooldown swipe.
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![]);
// XP partway into the level (70%) so the MainMenuBar XP bar renders a purple partial fill.
// Set before PLAYER_ENTERING_WORLD so the bar's first Update reads it.
script.set_player_xp(4200, 6000);
script.fire_event("PLAYER_ENTERING_WORLD", vec![]);
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;
}
}
#[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 action_bar_tests;
#[cfg(test)]
mod multibar_stance_tests;
#[cfg(test)]
mod micro_menu_tests;
#[cfg(test)]
mod perf_bar_tests;
#[cfg(test)]
mod panel_tests;
#[cfg(test)]
mod merchant_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)]
mod chat_tests;
#[cfg(test)]
mod bag_tests;
#[cfg(test)]
mod resolve_bench;
#[cfg(test)]
mod tooltip_anchor_tests;
#[cfg(test)]
mod tooltip_compare_tests;
#[cfg(test)]
mod escape_tests;
#[cfg(test)]
mod game_menu_tests;
#[cfg(test)]
mod delete_item_tests;
#[cfg(test)]
mod static_popup_tests;
#[cfg(test)]
mod death_tests;
#[cfg(test)]
mod duel_tests;
#[cfg(test)]
mod enchant_confirm_tests;
#[cfg(test)]
mod friends_tests;
#[cfg(test)]
mod quest_tests;
#[cfg(test)]
mod durability_tests;
#[cfg(test)]
mod questlog_tests;
#[cfg(test)]
mod character_tests;
#[cfg(test)]
mod inspect_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;
#[cfg(test)]
mod seam_scale_tests {
use super::seam_scale;
#[test]
fn seam_scale_is_the_768_base_times_the_dial() {
// The 0582 base: identity at the design height, proportional elsewhere.
assert_eq!(seam_scale(768.0, 1.0), 1.0);
assert_eq!(seam_scale(1536.0, 1.0), 2.0);
// The 0584 dial multiplies it.
assert_eq!(seam_scale(768.0, 0.9), 0.9);
// The reference's pixel-perfect setting: uiScale = 768/screenH → 1 px per UI unit.
assert!((seam_scale(1080.0, 768.0 / 1080.0) - 1.0).abs() < 1e-6);
// A degenerate (pre-winit) window is identity, never a division blow-up.
assert_eq!(seam_scale(0.0, 0.9), 1.0);
}
}