Files
benilla-octo/crates/benilla-app/src/ui_script/mod.rs
T
SamandClaude Fable 5 d99ad8bc7d the dressing room opens, and the pet gets its paper doll
- ctrl-click an item to try it on: the dressing room dresses your double, and shift-click posts the item link to chat
- the pet tab lands in the character window: paper doll stats, family and diet, the right-click menu, and dropping food on the pet feeds it
- water-only spells refuse on dry land: aquatic form gates to swimming, client-side like the real one
- the macro icon picker's white squares get their art back, and option rows explain themselves on hover

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

768 lines
32 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;
mod manifest;
// The manifest's loaders read as `ui_script::…` at every call site, including the tests' `super::`.
#[cfg(test)]
pub(crate) use manifest::load_default_ui;
pub(crate) use manifest::{load_font_registry, load_ingame_ui};
/// 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;
/// The frame's atomic (`Instant`, `GetTime`) clock pair — the ONE lawful base for mapping a
/// store-side `Instant` onto the VM's `GetTime` clock (`CooldownInfo::ui_triple` and kin).
///
/// Written at the single `script.tick` site ([`extract::drive_script`]): `ui_now` is the value the
/// VM clock just advanced to, `anchor` is `Time<Real>`'s own `last_update()` — the exact instant
/// whose frame-to-frame differences ARE the deltas the VM clock accumulates. Because both legs
/// advance in lockstep by construction, a conversion `ui_now - (anchor - start)` yields the SAME
/// number every frame for one fixed `start` — the frame-stability 0375's absolute-start triples
/// require. Converting through `Instant::now()` sampled inside a feed system instead (the pre-fix
/// shape) re-measures the tick→feed scheduling gap every frame and wobbles the derived start by
/// that jitter (±12 ms observed live), turning every running cooldown into a per-frame "changed"
/// triple — the diff churn 0375 existed to kill.
#[derive(Resource)]
pub(crate) struct UiClock {
/// The `Instant` leg: `Time<Real>::last_update()` at the tick that produced [`Self::ui_now`].
pub(crate) anchor: std::time::Instant,
/// The `GetTime` leg: the VM clock's value after that tick (seconds).
pub(crate) ui_now: f64,
}
impl Default for UiClock {
fn default() -> Self {
Self {
anchor: std::time::Instant::now(),
ui_now: 0.0,
}
}
}
/// 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).
pub(crate) 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>()
.init_resource::<UiClock>()
.init_resource::<IngameUiLoaded>()
// 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))
// The in-game UI materializes on entering the world, not at boot (1051) — the
// reference's own seam. Only the font registry loads at `Startup`.
.add_systems(
OnEnter(crate::char_select::ClientState::InWorld),
load_ingame_ui_on_world_entry,
)
// `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)
// The binding dispatch (0997) runs in this same set, after the feed: it
// reads the capture gate the feed just wrote, so a key a focused box
// consumed this frame never also fires a binding.
.before(crate::bindings::BindingSet)
.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);
// ONLY the font-object registry at boot (1051). The glyph atlas bakes once, on the first
// `Update`, from `script.font_objects()` — and our native glue screens share that one atlas, so
// the registry has to exist before the login screen or in-game text loses its outlined variants
// and its registry-declared sizes for the whole session. The other 55 files are in-game UI and
// load at world entry ([`load_ingame_ui`]); the reference splits at exactly this seam, with
// GlueXML carrying its own `GlueFonts.xml`.
if ui_wanted(world) {
// 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_font_registry(&script);
}
world.insert_non_send_resource(script);
}
/// Does this run want the player UI at all? Captures stay pristine — their baselines regression-test
/// the WORLD render — unless `WOW_CAPTURE_UI=1` opts the UI in.
fn ui_wanted(world: &World) -> bool {
!world.contains_resource::<crate::capture::CaptureMode>()
|| std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1")
}
/// Has the in-game UI been materialized this process? The load is **once per process**, not once
/// per world entry: nothing tears the frame tree down on `OnExit(InWorld)` yet, so a re-entry after
/// `/logout` must not stack a second copy of every window.
///
/// The reference *does* tear down and rebuild (`ClientDestroyGame 0x401ee0` ↔ `0x401570`, and its
/// `ReloadUI` is verified end to end doing exactly that). Matching it is the follow-on; this latch
/// is what makes the move safe without it, and is the thing to delete when the teardown lands.
#[derive(Resource, Default)]
pub(crate) struct IngameUiLoaded(pub(crate) bool);
/// `OnEnter(InWorld)`: materialize the in-game UI, once.
///
/// Safe on the state edge only because 1038 moved the initial transition after `PostStartup` — a
/// capture boots straight into `InWorld`, so before that this would have run ahead of
/// [`crate::assets::AssetSet::Open`] and loaded against no patch chain.
fn load_ingame_ui_on_world_entry(world: &mut World) {
if world.resource::<IngameUiLoaded>().0 || !ui_wanted(world) {
return;
}
let Some(script) = world.remove_non_send_resource::<UiScript>() else {
warn!("ui_script: entering the world with no VM — the in-game UI will not load");
return;
};
let _ = load_ingame_ui(&script);
world.insert_non_send_resource(script);
world.resource_mut::<IngameUiLoaded>().0 = true;
}
/// 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'_')
}
/// 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 pet_bar_tests;
#[cfg(test)]
mod pet_frame_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 macro_tests;
#[cfg(test)]
mod keybindings_tests;
#[cfg(test)]
mod options_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 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;
#[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);
}
}