Files
benilla-octo/crates/benilla-app/src/ui_script/mod.rs
T
SamandClaude Fable 5.1 fed2c96ec8 the mailbox takes typing again, and events carry their real arguments
- the mail window's send tab takes typing again: the subject, body and money boxes were taking no input while the recipient box swallowed every key, and a sent letter clears its subject and item icon instead of putting them straight back (2145)
- seven events fire the way the reference does: UNIT_DYNAMIC_FLAGS fires at all, swapping two identical stacks between bank slots fires PLAYERBANKSLOTS_CHANGED twice, the pet sheet's training points repaint, and ITEM_LOCK_CHANGED loses its invented arguments (2140)
- nine widget methods 1.12 addons probe for are in: ShaguTweaks stops recolouring textures the reference leaves alone, pfUI and _Nameplates stop raising on a tooltip's GetAnchorType, and 63 AceEvent addons get RegisterAllEvents (2142)
- six post-1.12 methods leave the widget surface (SetSize, SetShown, HasFocus, SetFormattedText and two tooltip verbs), so an addon reaching for a later client's API finds what a real 1.12 client gives it (2142, 2146)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 20:52:50 +07:00

1175 lines
52 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.
//!
//! The **unit frames** (the reference's own `Interface\FrameXML\{PlayerFrame,PartyFrame,
//! TargetFrame,PetFrame}.xml`, off the player's chain since 1751 — our `UnitFrames.xml`
//! transcription is retired) load at startup 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::ui_unit::UnitFeed;
use benilla_world::schedule::WorldStage;
/// The addon folder: discovery, manifests, the enable file, and the load walk. `pub(crate)`
/// because the AddOns screens read the same folder without a VM (decision 1196).
pub(crate) mod addons;
mod content;
pub(crate) mod extract;
mod input;
mod manifest;
/// The reference FrameXML this client EXECUTES off the player's own patch chain instead of
/// transcribing it — the rule, the list, and the licensing reason are that module's header.
mod reference_ui;
/// What the host remembers about the VM, and how it forgets — the one mechanism that stops a seed
/// or a change-memo outliving the VM it was written against (decision 1290).
mod session;
/// The feed gate (decision 1439): the input-side early-out for a per-frame UI feed, its
/// [`gate::Watch`] counter memory, and the `WOW_FEED_GATE_CHECK=1` audit that catches a gate
/// missing an input.
pub(crate) mod gate;
pub(crate) use session::VmMemo;
// The manifest's loaders read as `ui_script::…` at every call site, including the tests' `super::`.
// `load_default_ui` is no longer test-only: the addon harness (1188 phase 6) loads the whole
// shipped interface under each surveyed addon, because roughly half of what an addon calls is
// FrameXML's Lua rather than the engine's (decision 1190).
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 =
/// [`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);
/// **A synthetic pointer owns the mouse this frame** — set by the headless drag probe
/// ([`crate::capture::ProbeDragPlugin`]) while it drives a gesture through the real pointer path.
///
/// [`input::feed_ui_input`] skips its whole mouse half while this is set, and that is the ONLY
/// thing it does. Without it a scripted gesture cannot exist: the OS cursor is wherever the
/// director left it (usually outside a backgrounded probe window), so every frame between the
/// synthetic press and the synthetic release would feed the real position — dragging the frame to
/// the wrong place at best, and at worst calling `pointer_left_window`, which disarms the very
/// gesture the probe just armed. The keyboard half is untouched: a probe driving the mouse has no
/// business swallowing keys.
#[derive(Resource, Default)]
pub(crate) struct SyntheticPointer(pub(crate) bool);
/// **A capture never reads the OS pointer** — set for the whole life of a `$WOW_CAPTURE` /
/// `$WOW_CAPTURE_UI` run, and never cleared.
///
/// [`input::feed_ui_input`] treats this exactly like [`SyntheticPointer`]: the mouse half of the
/// pass is skipped entirely, touching nothing. The keyboard half is untouched.
///
/// **Why it is not just `SyntheticPointer`.** They are different statements. That one means "a
/// probe is driving a gesture *right now*", and the drag probe clears it when its gesture ends
/// (`capture::probes::act`) — so borrowing it would re-expose the real cursor for the rest of the
/// run, which is the opposite of the guarantee wanted here.
///
/// **Why it exists at all.** The mouse feed reads `window.cursor_position()`, so a UI capture's
/// pixels depended on where the person at the keyboard had left their mouse: a cursor resting over
/// the window arms a hover and a tooltip that a cursor an inch to the left does not. The old
/// defence was an assumption written in a comment — *"probes park the cursor outside"* — with
/// nothing enforcing it. On 2026-08-26 a `ui-tooltip` A/B came back with an MAE of 5.275 against
/// 0.020 for every other UI scenario; that particular anomaly turned out to be a rebuilt-between-
/// legs mistake, and three attempts to reproduce a cursor-driven one all failed, because
/// `CGWarpMouseCursorPosition` does not synthesise the events winit needs. So the hazard was left
/// on the record as **suspected, unproven** — and this closes it by construction instead, which
/// costs nothing and does not require ever winning that argument. A capture that cannot read the
/// pointer cannot be perturbed by it.
#[derive(Resource, Default)]
pub(crate) struct CapturePointerPinned(pub(crate) bool);
/// The egui dev overlay's half of the pointer arbitration, written each egui pass by the debug
/// panel's `track_pointer_over_ui`. **Defined here, with the arbiter that reads it** (decision
/// 1174 finishing 0026): the type has to exist in a build with no dev overlays compiled in, and
/// the arbiter takes it as `Option<Res<…>>` so its absence simply means "nothing is hovering the
/// dev UI" — which is the player-faithful answer. The writer lives in `debug_panel`; a dev
/// module writing an always-present fact is the allowed direction.
#[derive(Resource, Default)]
pub(crate) struct EguiPointerOver(pub(crate) bool);
/// Whether mouseover **world picking** is armed — the dev-chord `I` inspector's mode, toggled by
/// `debug_panel::inspect`.
///
/// The second of the two resources 0026 named as needing "a player-safe home", and here for the
/// same reason as [`EguiPointerOver`]: `player::control` and `target::click` read it every frame
/// to decide whether a left-click is the inspector's or the game's, and those reads must compile
/// and behave in a build with no inspector. The [`Default`] — **disarmed** — *is* the player
/// behaviour, so a player build's readers take the ordinary branch with nothing to switch them.
#[derive(Resource, Default)]
pub(crate) struct InspectMode {
pub(crate) enabled: bool,
}
/// One frame's UI-pass phase split, in μs — written by [`extract::drive_script`] under the same
/// marks the `[ui-cost]` line prints. **Owned by the producer** (decision 1174): the split is a
/// fact this pass publishes about itself, so it must exist whether or not the recorder that reads
/// it (`hover_log`) is compiled in. Its consumers are instruments; its writer is not.
#[derive(Resource, Default, Clone)]
pub(crate) struct UiFrameCost {
/// How many FontStrings the layout asked the font engine to shape this frame, and the first
/// few by name — a steady hover that keeps asking is the churn the recorder exists to catch.
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,
/// How many times this frame's resolve DERIVED the layout graph from scratch (decision 1388's
/// `layout_derivations`). The law is zero, and it is here because it was not: the recorder was
/// built for the hover-cost symptom and reported `solves` — the cheap term — while a
/// derivation, ~30× more expensive and paid on the same frames, was invisible to it
/// (decision 1625).
pub(crate) derives: u64,
pub(crate) skipped: bool,
/// How many entries the per-entry splice re-converted this frame — `0` on a settled or
/// full-conversion frame. Nonzero is the proof the splice path fired (the equivalence tests
/// and the live `[ui-cost] spliced=` field both read it).
pub(crate) spliced: usize,
/// How many entries the splice *dropped* — drew last frame and does not draw now. A frame
/// that only closes something has `spliced == 0` and still rode the splice, so this is the
/// other half of "did the splice path fire" (decision 1638). Not a CSV column: the recorder's
/// row is the phase timings, and `[ui-cost] dropped=` already carries it inline.
pub(crate) dropped: usize,
}
/// Does anything want [`UiFrameCost`] filled in this run? Measuring the split costs a clock read
/// per phase plus the churn strings, so the pass only pays when asked.
///
/// `WOW_UI_COST=1` (this module's own `[ui-cost]` line) arms it inline; the hover recorder arms it
/// by setting this — the direction that keeps the pass free of the instrument's name, and the
/// reason the flag is a resource rather than an env read here (decision 1174). Default `false` is
/// the player answer.
#[derive(Resource, Default)]
pub(crate) struct UiCostWanted(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 {
/// True while a focused EditBox is eating every key.
pub(crate) typing: bool,
/// **The four arrow keys are exempt this frame** — the focused box is in alt-arrow mode
/// (`ignoreArrows` / `SetAltArrowKeyMode`) and ALT is not held, so the reference's own key
/// handler declines LEFT/UP/RIGHT/DOWN at `0x77b1c4` and the strata walk carries them down to
/// `CGWorldFrame`, which runs their bindings. That is what lets you turn while the chat box
/// has focus (wow-re `ignorearrows-alt-arrow-gate.md`, §5 VERIFIED).
///
/// It is a whole-frame flag rather than a per-key one because both of its terms are:
/// there is at most one focused box, and ALT is read off the same modifier mirror. Only the
/// four arrows may use it — every other key a focused box still swallows, since the
/// reference's handler returns 1 on every other path (`0x77b35e`).
pub(crate) arrows_fall_through: 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.
///
/// **Both legs run on the PROCESS's clock and neither restarts with the VM** (decision 2116). A
/// rebuilt VM (any logout/login, any `ReloadUI`) is handed the running clock by
/// [`lifecycle::seed_vm_clock`], which writes this pair in the same breath — the reference's
/// `GetTime` is `KERNEL32!GetTickCount`, an OS clock, and stock `Cooldown.lua` gates on
/// `start > 0`, so a clock that went back to zero at the character screen pushed every already-
/// running cooldown into the past and hid its sweep.
#[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 clock's value after that tick (seconds since this process started).
pub(crate) ui_now: f64,
}
/// The pre-boot pair, replaced by [`lifecycle::seed_vm_clock`] the moment the first VM exists —
/// `init_resource` needs it, nothing else should build one.
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.
/// The FrameXML digest of the interface this process loads — see [`content::digest`]. Re-exported
/// so the corpus harness can stamp every report with the tree it measured.
pub(crate) fn framexml_digest() -> String {
content::digest()
}
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).
///
/// **It also happens to be what the reference itself lands on above ~853 px tall**, which we did
/// not know when it was chosen (wow-re `system/ui/scratch/modelframe-uiscale-law.md`, out of the
/// autocast-shine thread): `0x492f70` computes `max((H > 768) ? 768/H : 1.0, 0.9)` and is called
/// **both** on a mode set and from the `useUiScale`-OFF leg (`0x4908ad`) — and OFF is the shipped
/// default (`0x8430c0` = `"0"`). So the reference is 1.0 at 768 and below, 0.96 at 1280×800, and
/// **0.9 at 1080p and 1440p**. Our flat 0.9 therefore agrees with it on every modern window and
/// diverges only *below* ~853 px tall, where the reference is nearer 1.0. Left alone deliberately:
/// this constant is a director taste call, and the divergence is confined to window heights we
/// do not ship against. The reference's own law is written here so the next session inherits it
/// rather than re-deriving it.
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) {
// **The quit root of the shutdown tail, on the exit edge** (decision 1528). It was
// `.add_systems(Update, shutdown_on_exit)` below, which cannot see the `AppExit` a player
// produces — the close button's is written in `PostUpdate` — so quitting from in-world
// wrote no saved variables, no per-addon files and no `AddOns.txt` at all. `Last` is after
// every announcement; [`crate::shutdown`] is where that argument lives.
crate::shutdown::on_app_exit(app, shutdown_on_exit.into_configs());
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::<UiFrameCost>()
.init_resource::<UiCostWanted>()
.init_resource::<PointerOverUi>()
.init_resource::<SyntheticPointer>()
// Not `init_resource`: the value IS the answer, read once from the env at build.
.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` 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(benilla_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`. The entry edge
// ARMS the load; it runs a few frames later, once the loading cover has actually
// presented — the ~0.5 s burst must never stall the frame whose render would first
// show the cover, or what covers it is the frozen character screen (0962's frame
// accounting; see [`lifecycle::PendingEntryUiLoad`]).
.add_systems(
OnEnter(crate::char_select::ClientState::InWorld),
lifecycle::arm_entry_ui_load,
)
// The whole shutdown tail — events then writes, in the reference's order. See
// [`shutdown_ui_state`]; the two edges here are its five roots as far as our session
// has them. (The quit root is registered at the top of this function, through
// [`crate::shutdown::on_app_exit`] — it is a `Last` system, not an `Update` one.)
.add_systems(
OnExit(crate::char_select::ClientState::InWorld),
end_ui_session,
)
// A queued `ReloadUI()` runs in `PreUpdate` — one whole frame after the drain that
// queued it (the reference's own deferral, `0x495590`), and BEFORE every `Update`
// system, so no per-VM seed or feed can run against the dying VM in the reload frame
// and then leave the new one unseeded until the next. In `Update` the exclusive
// system would float: the scheduler could place it between `sync_cvars` and
// `save_config`, discarding a dirty CVar edit, or after `seed_bindings`, giving one
// whole frame with an empty binding table. See [`run_pending_reload`].
.init_resource::<ReloadUiPending>()
// The armed entry load shares the reload's slot, chained after it: both are
// exclusive edges on the same VM, and a reload must not interleave a pending entry
// load.
.add_systems(
PreUpdate,
(run_pending_reload, lifecycle::run_pending_entry_load).chain(),
)
// `GetFramerate()`'s host half (decision 1195), before the VM ticks so an `OnUpdate`
// handler reads this frame's number rather than the previous one's.
.add_systems(Update, feed_framerate.before(UiInput))
// `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 it is opted in — [`crate::run_mode::capture_ui_opted_in`], the SAME predicate the UI load and the
/// window sizing use, because the three disagreeing is how `ui-unitframes` ends up photographing
/// empty frames. Normal runs always load the UI and never take synthetic data.
fn capture_ui_active(capture: Option<Res<crate::run_mode::CaptureMode>>) -> bool {
capture.is_some() && crate::run_mode::capture_ui_opted_in()
}
/// 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>,
) {
// A cinematic used to need a third term here, and no longer does — the deletion is the point
// of decision 1734. `CinematicFrame` is `setAllPoints` + `enableMouse="true"`, so while it is
// up it is the only mouse target the hit test can reach, because `UIParent:Hide()` has taken
// every other frame out of it. The hit test now arrives at that on its own, so
// [`PlayerUiHover`] carries it and the special case is gone.
over.0 = egui.is_some_and(|e| e.0) || hover.0.is_some();
}
/// The session lifecycle — the VM's birth, identity, death, and the reload. Every edge function
/// lives there; this module keeps the per-frame bridge. See its header.
mod lifecycle;
pub(crate) use lifecycle::{
end_ui_session, ingame_ui_pending, run_pending_reload, setup_script, AddOnIdentity,
PendingEntryUiLoad, ReloadUiPending,
};
// Consumed only from other modules' test code (the emote-table checks, the harness's UI-init
// tail, the quit-once pin) — a plain re-export would warn unused in a non-test build.
#[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: push a **smoothed** frames-per-second into the VM each frame
/// (decision 1195).
///
/// Smoothed, not `1.0 / delta`, for the reason the reference smooths it too: 71 corpus addons put
/// this number on a panel, and a raw per-frame reciprocal reads as a flickering three-digit mess
/// that nobody can take a value off. The pole is a one-second time constant — fast enough that a
/// real stall is visible within a frame or two, slow enough to read.
///
/// `Time<Real>` deliberately, the same choice `drive_script` documents: this is a *frame rate*, and
/// a virtual clock that clamps its delta would report a rate the machine is not achieving.
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, and any paused one — nothing to average in
}
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);
}
/// 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<VmMemo<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;
};
// Session-keyed like every other seed (1290): the one-shot below is `PLAYER_ENTERING_WORLD`
// and the bar seeds, which a fresh VM needs again.
let fired = fired.get(&script);
// 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,
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 → 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). It must also seed
// the `IsConsumableAction` GATE, or the count paints nothing: 0926 put a gate in front of
// it that this seed never fed, so the capture quietly stopped showing the "5" it exists to
// prove (found while fixing decision 1301).
script.set_bonus_bar_offset(1);
// One MACRO slot too (button 4): a GM-style macro that casts nothing, so the capture
// shows the macro-name line under the icon and the icon full-colour — B340's shape
// (decision 1636). The table is seeded here because a capture has no character identity
// for `ui_macro::load_macros` to read a file for; nothing overwrites it.
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),
// The always-on multibars (stock MultiActionBars.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 seed's only ITEM slot is the food stack, which is consumable.
consumable: kind == 0x80,
}),
);
// The state feed has nothing to feed server-less (no `PlayerActions`), and a slot
// with no pushed state answers `IsUsableAction` nil — the 0.4 grey on every icon,
// which the `ui-actionbar` baseline wore for as long as it existed. Stand in for it
// as this feed stands in for the unit feed: 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 (stock BonusActionBarFrame.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![]);
// The two bottom multibars are player options and ship OFF since 1500, and a capture runs
// with no server behind it — so the login seed reads a zero toggle byte and neither bar
// comes up. Raise them the way the Options rows do, or the 61../49.. wells seeded above
// draw nowhere and `ui-actionbar` loses two rows it exists to show. This is the DEMO's
// choice about what to photograph, not a default: `MultiActionBar_Update` is the same
// function the row calls, so nothing here is a private door into the bars. Guarded because
// this feed also runs with `WOW_CAPTURE_UI` unset, where no interface has been loaded.
// `WOW_DEMO_BOTTOM_BARS=0` leaves them down — the shipped default a player logs in to
// (1500), and the only state in which the stance SHELF draws at all: the manage pass
// hides the shelf art whenever the bottom-left bar is up, so a capture of the shelf
// (decision 2000's hairline lived on it) needs the bars where the player has them.
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 fixed-advance stand-in font engine for tests — every character `.0` wide, one line tall,
/// greedily wrapped. The *numbers* the real engine produces are pinned against the client's own
/// fonts by `ui_text::atlas::metrics_tests`; a fixture wants arithmetic a reader can do in their
/// head, and wants a measure to arrive **in the tick that asked** exactly as the app's does.
#[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 (wow-re chat-cache-grammar.md §8; `ui_chat::settings`):
/// `UPDATE_CHAT_WINDOWS` once, then `UPDATE_CHAT_COLOR` for every registry entry. The reference's
/// `FloatingChatFrame_Update` docks, hides and colours the windows on the first and
/// `ChatFrame_OnEvent` fills `ChatTypeInfo` from the second, so a test VM that loads the
/// reference's chat files needs both before the windows look or route like a client's.
#[cfg(test)]
pub(crate) fn fire_chat_login(s: &mut benilla_ui::script::UiScript) {
// `FCF_OnUpdate` reads `UIOptionsFrame:IsShown()` every frame — the reference's options
// window, which OptionsFrame.xml aliases and a chat test VM does not load. A hidden stand-in
// is what a closed options window answers.
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::text` seam (decision 2045): resolve a `GlobalStrings` key
/// out of the VM and fill its `%d` holes through the one shared filler.
///
/// The split it implements is deliberate. `benilla-formats` walks the spell's columns and knows
/// which key and which numbers a `$d` or `$s` token wants; it has no business knowing about a
/// script VM or carrying a printf-family formatter, and it depends on neither crate that has one.
/// So it names the key, and this renders it.
pub(crate) fn token_text(
script: &benilla_ui::script::UiScript,
) -> impl Fn(&str, &[i64]) -> Option<String> + '_ {
|key: &str, args: &[i64]| {
let template = benilla_ui::strings::global(script.lua(), key)?;
let args: Vec<_> = args
.iter()
.map(|n| benilla_ui::strings::Arg::D(*n))
.collect();
Some(benilla_ui::strings::fill(&template, &args))
}
}
/// [`test_ui::load_ui`] for a test module OUTSIDE `ui_script` — `ui_action::feed_tests` drives the
/// real `UIErrorsFrame` end to end and needs the same both-stores reader everything else uses.
#[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 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 reference's shared widget kit (UIPanelTemplates.xml + OptionsFrameTemplates.xml), driven
/// through `CreateFrame`'s fourth argument the way an addon drives it — decision 1203's queue.
#[cfg(test)]
mod panel_template_tests;
/// The RETURN-SHAPE gate (decision 1842): `reference/1.12-shapes.tsv` against what this client
/// actually answers. `reference_surface` gates names; nothing gated shapes, and that gap produced
/// six decisions in two days.
#[cfg(test)]
mod shape_gate;
/// The event ARGUMENT-shape gate (decision 2140) — the same question one API over: the two event
/// gates in `reference_ui` compare names, and nothing compared what a fire site pushes.
#[cfg(test)]
mod event_shape_gate;
/// The reference's BasicControls.xml — TEXT/message/_ERRORMESSAGE and the ScriptErrors dialog,
/// none of which benilla itself calls: every test enters from Lua the way an addon does.
#[cfg(test)]
mod basic_controls_tests;
#[cfg(test)]
mod color_picker_tests;
/// `UIParent.xml`'s loose addon-facing helpers (`MouseIsOver` and kin) — the panel and ESC halves
/// of that file live in `panel_tests` / `escape_tests`.
#[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)]
mod chat_tests;
/// The chat bubble's `UIMenu` kit driven as a menu — the rows' label/shortcut anchoring
/// (decision 1996), kept apart from `chat_tests` because it is the kit under test, not the window.
#[cfg(test)]
mod ui_menu_tests;
/// The chat tab's options menu, end to end (decision 1589 / B246) — its own file because it needs
/// the whole dropdown + colour-picker stack under `ChatFrame.xml`, where `chat_tests` deliberately
/// runs on the window alone.
#[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 — the corpus's most-wanted template, driven through
/// `inherits=` and `CreateFrame` the way the 27 addons that name it do.
#[cfg(test)]
mod tooltip_template_tests;
#[cfg(test)]
mod escape_tests;
#[cfg(test)]
mod game_menu_tests;
#[cfg(test)]
mod macro_tests;
// `pub(crate)` for its `harness`/`on_page` alone: the bindings dispatch tests drive the real
// Keybindings page through the real input systems, and building the page twice would let the two
// copies drift.
#[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 shared reference-geometry diff (decision 0675) every transcribed window's test calls.
#[cfg(test)]
mod framexml_diff;
/// Its FLAG twin (decision 1739): the whole-tree sweep for `toplevel`/mouse/`id` against the
/// reference, read off the loaded engine rather than off our XML.
#[cfg(test)]
mod frame_flag_gate;
#[cfg(test)]
mod friends_tests;
/// The four guild windows — the social window's third tab and its three satellites (decision
/// 1257). Its own module rather than more of `friends_tests` because it stands the whole guild
/// engine API in for in Lua before the XML loads, which that file must not.
#[cfg(test)]
mod guild_tests;
/// The GM help window and its ticket toast (decision 1673). Its own module because every test in
/// it pushes a `GMTicketCategory.dbc` catalog and drives the ticket wire's own event vocabulary,
/// which none of the neighbouring windows share.
#[cfg(test)]
mod help_frame_tests;
/// The two guild-charter windows — the registrar and the petition sheet (decision 1672). Its own
/// module for `guild_tests`' reason: it stands the charter engine API in for in Lua before the XML
/// loads, so what is under test is the window and not `script::petition`'s plumbing.
#[cfg(test)]
mod petition_tests;
/// The social window's fourth tab — the raid pane and its grid (decision 1549). Its own module for
/// `guild_tests`' reason: every test in it pushes a RAID roster first, which a file about the
/// friends list must not be in the business of.
#[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;
#[cfg(test)]
mod tutorial_tests;
#[cfg(test)]
mod durability_tests;
// `pub(crate)` for its `harness`/`push`/`row` helpers: `perf::hud`'s own test drives the readout
// through them rather than keeping a second copy of the XML-loading boilerplate.
#[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;
#[cfg(test)]
mod bottom_hud_tests;
#[cfg(test)]
mod bagnon_render_tests;
/// Bug B267 end to end: a hunter's Quiver publishes its global functions (see the file header for
/// the three walls that stopped it).
#[cfg(test)]
mod quiver_tests;
/// The UI's per-world-entry lifecycle: teardown at the character screen, a genuine second load at
/// the next login (decision 1290).
#[cfg(test)]
mod world_entry_tests;
/// The guild tabard designer — the stock `TabardFrame.xml` off the chain (decision 1977).
#[cfg(test)]
mod tabard_tests;
/// The world map's POI pool — the guard's directions marker today, the AreaPOI landmarks later.
#[cfg(test)]
mod world_map_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);
}
}