- 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>
768 lines
32 KiB
Rust
768 lines
32 KiB
Rust
//! 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);
|
||
}
|
||
}
|