Merge upstream main (samwhosung/benilla c2fc3cf3) into the graphics line
Conflicts: capture fixtures/scenarios (both sides appended: kept both), options_tests row counts (upstream 80->81 rows: ours 100->101 and 99->100), docs/MAP.md regenerated with scripts/genmap.sh.
This commit is contained in:
@@ -1692,9 +1692,10 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `UI_Init 0x48fbf0` loads the addons at `0x4900a3` (each `ADDON_LOADED` at `0x51f5ad`) and
|
||||
/// fires `VARIABLES_LOADED` at `0x4900b2`; `PLAYER_LOGIN` comes after, on a fresh login from
|
||||
/// the player's create (`0x5deb60` → `0x4908c0`), on a `/reload` from `0x490168`.
|
||||
/// `UI_Init 0x48fbf0` loads the addons at `0x4900a3` (each `ADDON_LOADED` at `0x51f5ad`),
|
||||
/// fires `VARIABLES_LOADED` at `0x4900b2` and arms `PLAYER_LOGIN` (`0x49011d`), which the
|
||||
/// world-enter cascade fires: on a fresh login from the player's create (`0x5deb60` →
|
||||
/// `0x4908c0`), on a `/reload` from `0x490168`.
|
||||
#[test]
|
||||
fn the_ui_init_events_fire_in_the_reference_order() {
|
||||
let _l = crate::local_state::test_env::ENV_LOCK
|
||||
@@ -1728,6 +1729,12 @@ mod tests {
|
||||
let failures = load_third_party(&mut script, None, &[], true);
|
||||
assert!(failures.is_empty(), "load errors: {failures:?}");
|
||||
crate::ui_script::finish_ui_load(&mut script);
|
||||
assert_eq!(
|
||||
event_log(&script),
|
||||
vec!["ADDON_LOADED:EventProbe", "VARIABLES_LOADED:nil"],
|
||||
"the load only arms PLAYER_LOGIN"
|
||||
);
|
||||
script.fire_world_enter();
|
||||
|
||||
assert_eq!(
|
||||
event_log(&script),
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use benilla_ui::script::{
|
||||
BattlefieldFlagView, BattlefieldPositionView, QuadContent, UiScript, UnitState,
|
||||
WorldMapContinentView, WorldMapLandmarkView, WorldMapOverlayView, WorldMapZoneView,
|
||||
WorldMapContinentView, WorldMapLandmarkSource, WorldMapOverlayView, WorldMapZoneView,
|
||||
WorldStateUiView, ARROW_MODEL,
|
||||
};
|
||||
|
||||
@@ -411,14 +411,17 @@ fn the_poi_pool_grows_from_the_landmarks_and_parks_its_tail() {
|
||||
open(&mut s);
|
||||
assert_eq!(s.eval::<i64>("return NUM_BATTLEFIELDMAP_POIS").unwrap(), 0);
|
||||
|
||||
let landmark = |name: &str, icon: u32, uv: (f32, f32)| WorldMapLandmarkView {
|
||||
// A world position here is already map UV, so a landmark source sits where it says.
|
||||
s.set_world_loc_projector(Box::new(|_, _, x, y| Some((x, y))));
|
||||
let landmark = |name: &str, icon: u32, uv: (f32, f32)| WorldMapLandmarkSource {
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
texture_index: icon,
|
||||
uv,
|
||||
map: 0,
|
||||
pos: uv,
|
||||
icons: [Some(icon); 3],
|
||||
};
|
||||
// Icon 6 is `ICON_POI_REDFLAG`, icon 9 the second row's first cell of the 8×8 `POIIcons` atlas.
|
||||
s.set_world_map_landmarks(vec![
|
||||
s.set_world_map_landmark_sources(vec![
|
||||
landmark("Silverwing Flag", 6, (0.25, 0.5)),
|
||||
landmark("Warsong Flag", 9, (0.75, 0.25)),
|
||||
]);
|
||||
@@ -466,7 +469,7 @@ fn the_poi_pool_grows_from_the_landmarks_and_parks_its_tail() {
|
||||
"DEFAULT_POI_ICON_SIZE × GetBattlefieldMapIconScale()"
|
||||
);
|
||||
|
||||
s.set_world_map_landmarks(vec![landmark("Warsong Flag", 9, (0.75, 0.25))]);
|
||||
s.set_world_map_landmark_sources(vec![landmark("Warsong Flag", 9, (0.75, 0.25))]);
|
||||
s.tick(0.0);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return NUM_BATTLEFIELDMAP_POIS").unwrap(),
|
||||
|
||||
@@ -6,7 +6,7 @@ use benilla_ui::script::{ExtractedQuad, QuadContent, UiScript};
|
||||
use super::test_ui::load_ui as load_xml;
|
||||
|
||||
/// The chat stack, fonts first so `inherits="ChatFontNormal"` resolves.
|
||||
fn chat_frame() -> UiScript {
|
||||
pub(crate) fn chat_frame() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
|
||||
@@ -425,7 +425,11 @@ pub(super) fn tick_script(
|
||||
mut last_seam: Local<f32>,
|
||||
// The `scale_factor` measures were last answered under; 0 until the first frame.
|
||||
mut last_dpi: Local<f32>,
|
||||
ui_scale: Res<super::UiScaleCvar>,
|
||||
// The dial, `UIParent`'s scale in the VM, and the automatic scale's last display.
|
||||
(ui_scale, mut auto_cache): (
|
||||
Res<super::UiScaleCvar>,
|
||||
Option<ResMut<super::UiAutoScaleCache>>,
|
||||
),
|
||||
mut ui_cost: ResMut<super::UiFrameCost>,
|
||||
mut pass: ResMut<super::UiPassState>,
|
||||
) {
|
||||
@@ -442,9 +446,9 @@ pub(super) fn tick_script(
|
||||
};
|
||||
let (w, h) = (window.width(), window.height());
|
||||
// The client's UI space is 768 units tall at every aspect (`f(screenH) = 768`, `0x41ad10`; the
|
||||
// caret's `H_px/192`, `0x77b8c0`), `768/uiScale` with the dial. The VM lives in it: quads
|
||||
// scale ×s out, and the mouse and the measures ÷s in.
|
||||
let s = super::seam_scale(h, ui_scale.0);
|
||||
// caret's `H_px/192`, `0x77b8c0`), the dial being `UIParent`'s scale inside it. The VM lives in
|
||||
// it: quads scale ×s out, and the mouse and the measures ÷s in.
|
||||
let s = super::seam_scale(h);
|
||||
// A moved seam scale or DPI stales every text metric the engine caches: integer-stepped
|
||||
// advances do not rescale, and the size snap moves a string's width by several percent,
|
||||
// enough for the ellipsis to eat fitting text. A monitor hop moves the DPI alone.
|
||||
@@ -486,8 +490,17 @@ pub(super) fn tick_script(
|
||||
{
|
||||
let _span = bevy::log::info_span!("ui_script: tick").entered();
|
||||
let resized = script.set_screen_size(w / s, if h > 0.0 { h / s } else { 768.0 });
|
||||
// A resize re-runs what the interface computed from the old screen size.
|
||||
// A display change re-applies the UI scale (`0x492e90`), whose narrow-aspect cap follows
|
||||
// the window; a moved dial applies once, as its CVar callback does (`0x490770`).
|
||||
if resized {
|
||||
let display = (w.round() as u32, h.round() as u32);
|
||||
let mut none = None;
|
||||
let cache = auto_cache.as_deref_mut().map_or(&mut none, |c| &mut c.0);
|
||||
script.apply_ui_scale(display, cache);
|
||||
}
|
||||
let rescaled = script.set_ui_scale(ui_scale.0);
|
||||
// Either re-runs what the interface computed from the old `GetScreenHeight()`.
|
||||
if resized || rescaled {
|
||||
super::manifest::on_screen_resized(&script);
|
||||
}
|
||||
script.tick(time.delta_secs());
|
||||
@@ -1713,9 +1726,10 @@ mod clip_plumb_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// At dial 0.5 the VM sees a 2048×1536 screen, and every window-px rect halves.
|
||||
/// The dial is `UIParent`'s scale, so a ScrollFrame with no parent keeps its px rect and clip
|
||||
/// at dial 0.5: the root stays 768 units tall.
|
||||
#[test]
|
||||
fn the_uiscale_dial_scales_the_extracted_rects() {
|
||||
fn the_uiscale_dial_leaves_a_parentless_frame_alone() {
|
||||
let mut app = app_with_scrolled_marker();
|
||||
app.insert_resource(crate::ui_script::UiScaleCvar(0.5));
|
||||
app.update();
|
||||
@@ -1726,12 +1740,10 @@ mod clip_plumb_tests {
|
||||
.find(|q| q.color[0] == 1.0 && q.color[1] == 0.0 && q.color[2] == 0.0)
|
||||
.expect("the colored marker quad extracted");
|
||||
|
||||
// WoW space: top 1536 − 100 = 1436, bottom 1236, right 300; ×0.5 is y-up px 618..718,
|
||||
// and y-down through the 768 window the top is 50 and the bottom 150.
|
||||
assert_eq!(
|
||||
marker.clip,
|
||||
Some(Rect::new(0.0, 50.0, 150.0, 150.0)),
|
||||
"dial 0.5 halves the px rect and re-hangs the frame from the taller virtual top"
|
||||
Some(Rect::new(0.0, 100.0, 300.0, 300.0)),
|
||||
"dial 0.5 moves nothing outside UIParent"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,10 +40,11 @@ pub(super) struct TextHost<'a> {
|
||||
/// The focused editbox's text UI, unfiltered: [`emit`] matches it to this quad by `target`.
|
||||
pub ebox: Option<&'a EditBoxTextUi>,
|
||||
pub screen_h: f32,
|
||||
/// The seam scale `windowH/768 × uiScale`, for the unit-space inputs; rects arrive in px.
|
||||
/// The seam scale `windowH/768`, for the root-unit inputs; rects arrive in px.
|
||||
pub scale: f32,
|
||||
/// The frame's effective scale, already in the rect: glyph size and shadow offset ride it, as
|
||||
/// the 1.12 client's text rides `SetScale`; the editbox x-offsets arrive in screen UI units.
|
||||
/// the 1.12 client's text rides `SetScale` and the UI scale; the editbox x-offsets arrive in
|
||||
/// root units.
|
||||
pub font_scale: f32,
|
||||
/// Captures pin the caret on for deterministic pixels; live, the engine's blink decides.
|
||||
pub caret_pinned: bool,
|
||||
@@ -387,7 +388,7 @@ mod tests {
|
||||
assert_eq!(band_clip_slack(Some(2.0)), 3.0);
|
||||
}
|
||||
|
||||
/// `1.0546875` is the seam scale of a 1600×900 window at uiScale 0.9.
|
||||
/// `1.0546875` is a 1600×900 window's seam times the 0.9 UI scale.
|
||||
#[test]
|
||||
fn a_shadow_offset_is_a_whole_pixel_and_never_vanishes() {
|
||||
assert_eq!(shadow_offset_px(1.0), 1.0);
|
||||
|
||||
@@ -94,8 +94,6 @@ pub(super) fn feed_ui_input(
|
||||
),
|
||||
// The characters the active layout makes, which name a key for a keyboard frame.
|
||||
layout: Res<crate::bindings::LayoutNames>,
|
||||
// The uiScale dial folded into the seam scale.
|
||||
ui_scale: Res<super::UiScaleCvar>,
|
||||
) {
|
||||
let (keyboard, keys, capture, clipboard) = (&mut kbd.0, &kbd.1, &mut kbd.2, &mut kbd.3);
|
||||
let world_pick = pointer.world_pick();
|
||||
@@ -139,10 +137,8 @@ pub(super) fn feed_ui_input(
|
||||
.or_else(crate::target::hover_probe_point)
|
||||
.filter(|_| !synthetic)
|
||||
{
|
||||
// The window cursor is logical px, y-down from the top left; the UI is y-up in 768-high
|
||||
// units under uiScale: flip through the window height, then undo the extract seam's scale.
|
||||
let s = super::seam_scale(window.height(), ui_scale.0);
|
||||
let (x, y) = (cursor.x / s, (window.height() - cursor.y) / s);
|
||||
// The window cursor in the root's y-up 768-high units, as `GetCursorPosition` answers it.
|
||||
let Vec2 { x, y } = super::window_to_ui(window.height(), cursor);
|
||||
// `WOW_HIT_COST=1` meters this call's per-frame hit-test rebuild, one line a second.
|
||||
static HIT_COST: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
let metering =
|
||||
|
||||
@@ -492,16 +492,19 @@ fn toggle_inspect_reopens_after_a_close_and_raises_like_the_reference() {
|
||||
s.eval::<bool>("return InspectFrame:IsVisible()").unwrap(),
|
||||
"the window still shows — Show() runs before OnShow raises"
|
||||
);
|
||||
// Two raises: `InspectFrame` and `InspectPaperDollFrame` each run their own `OnShow` under
|
||||
// their own pcall, the first dying at the portrait's gate, the second at `UnitLevel`'s.
|
||||
// Two raises: `InspectPaperDollFrame` and `InspectFrame` each run their own `OnShow` under
|
||||
// their own pcall, the page first, since the show cascade notifies children before their
|
||||
// parent (`0x76aed5`, then `0x76aef5`): the page dies at `UnitLevel`'s gate, the window at
|
||||
// the portrait's.
|
||||
let errors = s.errors();
|
||||
assert_eq!(errors.len(), 2, "one raise per OnShow handler: {errors:?}");
|
||||
assert!(
|
||||
errors[0].contains(r#"Usage: SetPortraitTexture(texture, "unit")"#),
|
||||
"the portrait's gate, before the name line is ever reached: {errors:?}"
|
||||
errors[0].contains(r#"Usage: UnitLevel("unit")"#),
|
||||
"the paper doll's own handler, dying at its first getter: {errors:?}"
|
||||
);
|
||||
assert!(
|
||||
errors[1].contains(r#"Usage: UnitLevel("unit")"#),
|
||||
"and the paper doll's own handler, dying at its first getter: {errors:?}"
|
||||
errors[1].contains(r#"Usage: SetPortraitTexture(texture, "unit")"#),
|
||||
"then the window's, at the portrait's gate, before the name line is ever reached: \
|
||||
{errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -230,26 +230,22 @@ pub(crate) fn run_pending_entry_load(world: &mut World) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Seat the VM's screen size and text measurer for the load, under the current window and
|
||||
/// `uiScale`: the load-edge half of [`super::extract::tick_script`]'s per-frame pair. A fresh VM
|
||||
/// answers 1024×768 until then, and a value computed once at load keeps it: stock
|
||||
/// `WorldMapFrame_OnLoad` sizes the full-screen `BlackoutWorld` from `GetScreenWidth()` and
|
||||
/// `GetScreenHeight()` and never again (`WorldMapFrame.lua:19-28`). With no atlas yet (it bakes on
|
||||
/// the first `Update`) the per-frame pass seats the measurer.
|
||||
/// Seat the VM's screen size and text measurer for the load, under the current window: the
|
||||
/// load-edge half of [`super::extract::tick_script`]'s per-frame pair. A fresh VM answers 1024×768
|
||||
/// until then, and a value computed once at load keeps it: stock `WorldMapFrame_OnLoad` sizes the
|
||||
/// full-screen `BlackoutWorld` from `GetScreenWidth()` and `GetScreenHeight()` and never again
|
||||
/// (`WorldMapFrame.lua:19-28`), which answer the root while `UIParent` is still at scale 1. With no
|
||||
/// atlas yet (it bakes on the first `Update`) the per-frame pass seats the measurer.
|
||||
fn seat_raster_seam_for_load(world: &mut World, script: &mut UiScript) {
|
||||
let ui_scale = world
|
||||
.get_resource::<super::UiScaleCvar>()
|
||||
.map_or(1.0, |c| c.0);
|
||||
let (w, h) = {
|
||||
let mut q = world.query_filtered::<&Window, With<bevy::window::PrimaryWindow>>();
|
||||
q.single(world)
|
||||
.map_or((0.0, 0.0), |win| (win.width(), win.height()))
|
||||
};
|
||||
let s = super::seam_scale(h, ui_scale);
|
||||
let s = super::seam_scale(h);
|
||||
if w > 0.0 && h > 0.0 {
|
||||
// The VM lives in 768-tall virtual space, so logical size over the seam scale is its
|
||||
// screen. No `UIParent_ManageFramePositions` here, unlike `tick_script`: nothing is laid
|
||||
// out yet.
|
||||
// The VM lives in a 768-tall root, so logical size over the seam scale is its screen. No
|
||||
// `UIParent_ManageFramePositions` here, unlike `tick_script`: nothing is laid out yet.
|
||||
script.set_screen_size(w / s, h / s);
|
||||
}
|
||||
let Some(atlas) = world.get_resource::<crate::ui_text::UiFontAtlas>() else {
|
||||
@@ -258,12 +254,29 @@ fn seat_raster_seam_for_load(world: &mut World, script: &mut UiScript) {
|
||||
super::extract::seat_text_measurer(script, atlas, s);
|
||||
}
|
||||
|
||||
/// Which `"player"` a UI load runs its files under, by the edge that runs it.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum LoadSeat {
|
||||
/// World entry: `UI_Init` runs before `CMSG_PLAYER_LOGIN` goes out (the load at `0x46c236`,
|
||||
/// the send in a later step at `0x46c272`), so no player object exists and the roster's
|
||||
/// record stands in ([`seat_from_roster`]).
|
||||
Roster,
|
||||
/// `/reload`: the player object stays in the world through the teardown and the load, so the
|
||||
/// live one is seated ([`crate::ui_unit::live_player_seat`]), the roster's if none exists.
|
||||
Live,
|
||||
}
|
||||
|
||||
/// Load the in-game UI for this session onto a VM built for it, once per world entry. The
|
||||
/// reference builds the UI in `UI_Init` (`0x48fbf0`, re-making the Lua state at `0x48fe97`) and
|
||||
/// destroys it at `0x490bd0`, so every login runs every addon's file scope afresh. Safe on the
|
||||
/// state edge because the initial transition runs after `PostStartup`: a capture booting straight
|
||||
/// into `InWorld` still loads after [`benilla_assets::AssetSet::Open`].
|
||||
pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
|
||||
load_ingame_ui_seated(world, LoadSeat::Roster);
|
||||
}
|
||||
|
||||
/// [`load_ingame_ui_on_world_entry`] under the `"player"` its edge has.
|
||||
fn load_ingame_ui_seated(world: &mut World, seat: LoadSeat) {
|
||||
// The parked VM goes back into the world, where the CVar fold reads it; a reload has none
|
||||
// parked and uses the live one.
|
||||
unpark_boot_vm(world);
|
||||
@@ -322,19 +335,31 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
|
||||
}
|
||||
// The player too, before the addon walk. The record first: the reference's
|
||||
// `UnitName`/`UnitRace`/`UnitClass`/`UnitSex` read only a copy of the char-enum row made at the
|
||||
// Enter World commit (`0x5abd9e`) and never cleared, and each rebuilt VM is told once. The
|
||||
// snapshot stands in for the descriptor until that streams in.
|
||||
// Enter World commit (`0x5abd9e`) and never cleared, and each rebuilt VM is told once. Then
|
||||
// the unit: the live player on a `/reload`, else the roster's stand-in until the descriptor
|
||||
// streams in.
|
||||
if let Some(record) = world
|
||||
.get_resource::<crate::char_select::Roster>()
|
||||
.and_then(record_from_roster)
|
||||
{
|
||||
script.set_player_record(record);
|
||||
}
|
||||
if let Some(seat) = world
|
||||
.get_resource::<crate::char_select::Roster>()
|
||||
.and_then(seat_from_roster)
|
||||
match (seat == LoadSeat::Live)
|
||||
.then(|| live_player_seat(world))
|
||||
.flatten()
|
||||
{
|
||||
script.set_unit("player", Some(seat));
|
||||
Some(live) => {
|
||||
script.set_unit("player", Some(live.state));
|
||||
script.set_player_xp(live.xp.0, live.xp.1);
|
||||
}
|
||||
None => {
|
||||
if let Some(seat) = world
|
||||
.get_resource::<crate::char_select::Roster>()
|
||||
.and_then(seat_from_roster)
|
||||
{
|
||||
script.set_unit("player", Some(seat));
|
||||
}
|
||||
}
|
||||
}
|
||||
// The addon version gate, read from the persisted base the fold above just made current, so a
|
||||
// character-screen "Load out of date AddOns" click counts. A bare test world is check on.
|
||||
@@ -358,6 +383,9 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
|
||||
// text width, `UIPanelTemplates.xml:370-373`), which answers 0 with no measurer.
|
||||
seat_raster_seam_for_load(world, &mut script);
|
||||
// Read out of the world first: the bracket below borrows `world` for the chat-cache restore.
|
||||
let ui_scale = world
|
||||
.get_resource::<super::UiScaleCvar>()
|
||||
.map_or(1.0, |c| c.0);
|
||||
let zoom = {
|
||||
let z = world.resource::<crate::minimap::MinimapZoom>();
|
||||
(z.outdoor, z.inside)
|
||||
@@ -392,6 +420,21 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
|
||||
|script| crate::vplates::seat_legacy_settings(script, &legacy_plates),
|
||||
|script| {
|
||||
crate::ui_chat::restore_chat_looks(world, script);
|
||||
// The UI scale lands on `UIParent` once the files, the addons and
|
||||
// `VARIABLES_LOADED` are done, after the chat reader (`0x4900d6`) and before the
|
||||
// `PLAYER_LOGIN` arm (`0x49011d`), through the display handler (`0x49010e` →
|
||||
// `0x492e90`). So every `OnLoad` sees `UIParent` at scale 1 and the root's size.
|
||||
let display = {
|
||||
let mut q =
|
||||
world.query_filtered::<&Window, With<bevy::window::PrimaryWindow>>();
|
||||
q.single(world).map_or((0, 0), |w| {
|
||||
(w.width().round() as u32, w.height().round() as u32)
|
||||
})
|
||||
};
|
||||
let mut none = None;
|
||||
let mut cache = world.get_resource_mut::<super::UiAutoScaleCache>();
|
||||
let cache = cache.as_deref_mut().map_or(&mut none, |c| &mut c.0);
|
||||
script.seat_ui_scale(ui_scale, display, cache);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -418,6 +461,17 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The live player's seat off the world, through the unit feed's own builder; `None` before our
|
||||
/// own create. A world without the feed's resources (a bare test world) has none to read.
|
||||
fn live_player_seat(world: &mut World) -> Option<crate::ui_unit::LivePlayerSeat> {
|
||||
world
|
||||
.run_system_cached(crate::ui_unit::live_player_seat)
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("ui_script: no live player to seat for the load ({e}); the roster stands in");
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
/// The wire's gender byte (0 male, 1 female) on `UnitSex`'s 2/3 scale, as `ui_unit::snapshot`
|
||||
/// maps the descriptor's; the record and the seat share it.
|
||||
fn roster_sex(gender: u8) -> u8 {
|
||||
@@ -486,14 +540,53 @@ pub(crate) fn seat_from_roster(
|
||||
pub(crate) struct AddOnIdentity(pub(crate) Option<(String, String)>);
|
||||
|
||||
/// The world latch, the reference's `[0xb4b424]`: set at `0x4908ce` in the world-enter cascade
|
||||
/// `0x4908c0` and cleared at `0x490a8d` as `PLAYER_LEAVING_WORLD` fires (`0x490b4d`, its one
|
||||
/// site), so the first departure in a world fires and the rest are no-ops until the next entry.
|
||||
/// Ours has two producers, [`shutdown_ui_state`]'s tail and `ui_unit`'s worldport fire. A
|
||||
/// `0x4908c0` and cleared at `0x490a8d` by the leave-world sweep `0x490a80`, which then fires
|
||||
/// `PLAYER_LEAVING_WORLD` (`0x490b4d`, its one site), so the first departure in a world sweeps
|
||||
/// and the rest are no-ops until the next entry. Ours is spent through [`LeaveWorldSweep`] or
|
||||
/// [`run_leave_world_sweep`], by [`shutdown_ui_state`]'s roots and `ui_unit`'s worldport fire. A
|
||||
/// cross-map worldport spends it without leaving `InWorld`, so a quit on its loading screen
|
||||
/// fires nothing; a `/reload` fires, and its rebuild re-arms.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct LeavingWorldArmed(bool);
|
||||
|
||||
/// The leave-world sweep `0x490a80` ran past its latch: a cross-map worldport (the local
|
||||
/// player's destructor, `0x5dd543`), a `/reload` (`0x490c20`) or leaving the world. Its legs
|
||||
/// past the clear read this, as the meeting stone's text drop (`0x490b2f` → `0x4c9f80`) does; a
|
||||
/// same-map teleport destroys nothing and never sweeps.
|
||||
#[derive(Message)]
|
||||
pub(crate) struct WorldLeaveSweepMessage;
|
||||
|
||||
/// The latch and the sweep it gates, for a system: spending one writes the other.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
pub(crate) struct LeaveWorldSweep<'w> {
|
||||
armed: ResMut<'w, LeavingWorldArmed>,
|
||||
sweeps: MessageWriter<'w, WorldLeaveSweepMessage>,
|
||||
}
|
||||
|
||||
impl LeaveWorldSweep<'_> {
|
||||
/// Run the sweep: `true`, and [`WorldLeaveSweepMessage`] written, at most once per world.
|
||||
pub(crate) fn run(&mut self) -> bool {
|
||||
let swept = self.armed.spend();
|
||||
if swept {
|
||||
self.sweeps.write(WorldLeaveSweepMessage);
|
||||
}
|
||||
swept
|
||||
}
|
||||
}
|
||||
|
||||
/// [`LeaveWorldSweep::run`] for an exclusive edge; no latch is no world to leave.
|
||||
pub(crate) fn run_leave_world_sweep(world: &mut World) -> bool {
|
||||
let swept = world
|
||||
.get_resource_mut::<LeavingWorldArmed>()
|
||||
.is_some_and(|mut l| l.spend());
|
||||
if swept {
|
||||
if let Some(mut sweeps) = world.get_resource_mut::<Messages<WorldLeaveSweepMessage>>() {
|
||||
sweeps.write(WorldLeaveSweepMessage);
|
||||
}
|
||||
}
|
||||
swept
|
||||
}
|
||||
|
||||
impl LeavingWorldArmed {
|
||||
/// A world began. Idempotent, like the reference's `mov byte [0xb4b424],1`.
|
||||
pub(crate) fn arm(&mut self) {
|
||||
@@ -501,7 +594,7 @@ impl LeavingWorldArmed {
|
||||
}
|
||||
|
||||
/// Take the one-shot: `true` at most once per world, `0x490a8d`'s clear folded in.
|
||||
pub(crate) fn spend(&mut self) -> bool {
|
||||
fn spend(&mut self) -> bool {
|
||||
std::mem::take(&mut self.0)
|
||||
}
|
||||
|
||||
@@ -566,9 +659,7 @@ pub(crate) fn end_ui_session(world: &mut World) {
|
||||
.get_resource::<AddOnIdentity>()
|
||||
.and_then(|id| id.0.clone());
|
||||
// Spent at the root: of this edge and the worldport's, the first to a departure owns it.
|
||||
let leaving_world = world
|
||||
.get_resource_mut::<LeavingWorldArmed>()
|
||||
.is_some_and(|mut l| l.spend());
|
||||
let leaving_world = run_leave_world_sweep(world);
|
||||
if !ui_never_loaded {
|
||||
if let Some(mut script) = world.get_non_send_resource_mut::<UiScript>() {
|
||||
shutdown_ui_state(&mut script, identity.as_ref(), leaving_world);
|
||||
@@ -611,11 +702,13 @@ pub(crate) fn end_ui_session(world: &mut World) {
|
||||
pub(crate) struct ReloadUiPending(pub(crate) bool);
|
||||
|
||||
/// Run a pending `ReloadUI()`: the reference's teardown and rebuild (`0x495664 call 0x490bd0`,
|
||||
/// `0x495669 call 0x48fbf0`), for us [`end_ui_session`] then [`load_ingame_ui_on_world_entry`]
|
||||
/// without leaving the world, so a `DisableAddOn` staged in the dying VM reaches `AddOns.txt`
|
||||
/// before the rebuild reads it. `PLAYER_ENTERING_WORLD` then refires from [`crate::ui_unit`]'s
|
||||
/// feed on the new VM. In-world only, as the reference's gate (`0x494a50(0xa)`) is; elsewhere the
|
||||
/// request is dropped.
|
||||
/// `0x495669 call 0x48fbf0`), for us [`end_ui_session`] then the load without leaving the world,
|
||||
/// so a `DisableAddOn` staged in the dying VM reaches `AddOns.txt` before the rebuild reads it.
|
||||
/// The load runs under the live player, which never left. `UI_Init` then enters the world-enter
|
||||
/// cascade itself (`0x490168`), firing the `PLAYER_LOGIN` it armed and `PLAYER_ENTERING_WORLD`;
|
||||
/// ours fire from [`crate::ui_unit`]'s feed on the new VM's first `Update`, once it has re-seated
|
||||
/// the player. In-world only, as the reference's gate (`0x494a50(0xa)`) is; elsewhere the request
|
||||
/// is dropped.
|
||||
pub(crate) fn run_pending_reload(world: &mut World) {
|
||||
if !std::mem::take(&mut world.resource_mut::<ReloadUiPending>().0) {
|
||||
return;
|
||||
@@ -630,7 +723,18 @@ pub(crate) fn run_pending_reload(world: &mut World) {
|
||||
}
|
||||
info!("ui_script: ReloadUI — ending the UI session and building a new one");
|
||||
end_ui_session(world);
|
||||
load_ingame_ui_on_world_entry(world);
|
||||
load_ingame_ui_seated(world, LoadSeat::Live);
|
||||
// `UI_Init` ends by re-entering the world-enter cascade (`0x490168`), its sends included, but
|
||||
// only with the active player set (`0x490166 je`): our own player's entity exists. Not
|
||||
// `SetActiveMover`, which only the player's create runs.
|
||||
let seated = world
|
||||
.query_filtered::<(), With<crate::net::SelfPlayer>>()
|
||||
.iter(world)
|
||||
.next()
|
||||
.is_some();
|
||||
if seated {
|
||||
world.write_message(crate::net::WorldEnterCascadeMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/// `AppExit`, the quit roots, read as a message because a quit from in-world never leaves
|
||||
@@ -640,7 +744,7 @@ pub(crate) fn shutdown_on_exit(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
id: Res<AddOnIdentity>,
|
||||
pending_entry: Option<Res<PendingEntryUiLoad>>,
|
||||
mut armed: ResMut<LeavingWorldArmed>,
|
||||
mut sweep: LeaveWorldSweep,
|
||||
mut exits: MessageReader<AppExit>,
|
||||
) {
|
||||
if exits.read().next().is_none() {
|
||||
@@ -650,7 +754,7 @@ pub(crate) fn shutdown_on_exit(
|
||||
if pending_entry.is_some() {
|
||||
return;
|
||||
}
|
||||
let leaving_world = armed.spend();
|
||||
let leaving_world = sweep.run();
|
||||
if let Some(mut script) = script {
|
||||
shutdown_ui_state(&mut script, id.0.as_ref(), leaving_world);
|
||||
}
|
||||
@@ -662,13 +766,13 @@ pub(crate) fn shutdown_on_exit(
|
||||
/// saved file and fires `VARIABLES_LOADED` (`0x4900b2` → `0x4913b0`), and arms `PLAYER_LOGIN`
|
||||
/// (`[0xb4e260]`, set at `0x49011d`). The world-enter cascade `0x4908c0` fires `PLAYER_LOGIN` if
|
||||
/// armed (read at `0x49094b`, fired at `0x490959`, disarmed at `0x49095e`), then
|
||||
/// `PLAYER_ENTERING_WORLD` (`0x49096a`).
|
||||
/// `PLAYER_ENTERING_WORLD` (`0x49096a`): [`UiScript::fire_world_enter`].
|
||||
///
|
||||
/// On a `/reload`, `UI_Init` enters the cascade itself (`0x490168`). On a fresh login that call is
|
||||
/// skipped (`0x490166 je`, the active-player GUID still 0) and the cascade runs from the local
|
||||
/// player's own create (`0x5deb60 call 0x4908c0`). This fires `PLAYER_LOGIN` at the end of the
|
||||
/// load on both, the `/reload` shape; `PLAYER_ENTERING_WORLD` fires from [`crate::ui_unit`] once
|
||||
/// the self descriptor lands.
|
||||
/// player's own create (`0x5deb60 call 0x4908c0`). Either way the player object exists when it
|
||||
/// runs, so this only arms, and [`crate::ui_unit`]'s feed fires both once it has seated the
|
||||
/// player.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn finish_ui_load(script: &mut UiScript) {
|
||||
finish_ui_load_with(script, |_| {}, |_| {});
|
||||
@@ -676,10 +780,10 @@ pub(crate) fn finish_ui_load(script: &mut UiScript) {
|
||||
|
||||
/// [`finish_ui_load`] with its two host seams. `host_settings` runs between the saved-variables
|
||||
/// chunk and `VARIABLES_LOADED`, carrying over a setting `config.toml` held before it moved into
|
||||
/// the saved variables. `between`
|
||||
/// is the chat-cache restore's `UPDATE_CHAT_WINDOWS` + `UPDATE_CHAT_COLOR` burst, after
|
||||
/// `VARIABLES_LOADED` and before `PLAYER_LOGIN`, where the reference registers its reader
|
||||
/// (`0x4900d6` → `0x498a20`). On a fresh login the reference's cache (`0x5afe50`) defers that
|
||||
/// the saved variables. `between` runs after `VARIABLES_LOADED` and before the `PLAYER_LOGIN` arm
|
||||
/// (so before the event too): the chat-cache restore's `UPDATE_CHAT_WINDOWS` +
|
||||
/// `UPDATE_CHAT_COLOR` burst, where the reference registers its reader (`0x4900d6` → `0x498a20`),
|
||||
/// then the UI scale (`0x49010e`). On a fresh login the reference's cache (`0x5afe50`) defers that
|
||||
/// reader until the server's account data reconciles (`0x5afa42`/`0x5afd01`), after `UI_Init` has
|
||||
/// returned. Either way a `VARIABLES_LOADED` handler sees the boot chat colours, not the file's.
|
||||
pub(crate) fn finish_ui_load_with(
|
||||
@@ -688,11 +792,12 @@ pub(crate) fn finish_ui_load_with(
|
||||
between: impl FnOnce(&mut UiScript),
|
||||
) {
|
||||
// Still the load edge, so still bounded: re-armed so the saved variables and every
|
||||
// `PLAYER_LOGIN` handler get the full allowance; the entry edge disarms it after.
|
||||
// `VARIABLES_LOADED` handler get the full allowance; the entry edge disarms it after. The
|
||||
// world-enter events fire later, unbounded, as every event the feeds fire is.
|
||||
script.set_instruction_budget(addons::LOAD_INSTRUCTION_BUDGET);
|
||||
crate::ui_saved::load_saved_variables(script, host_settings);
|
||||
between(script);
|
||||
script.fire_event("PLAYER_LOGIN", vec![]);
|
||||
script.arm_player_login();
|
||||
}
|
||||
|
||||
/// Run the patch chain's `Interface\FrameXML\GlobalStrings.lua`, the first file in the reference's
|
||||
@@ -842,6 +947,44 @@ mod tests {
|
||||
assert_eq!(*world.resource::<VPlateMode>(), on, "a reload keeps them");
|
||||
}
|
||||
|
||||
/// A `ReloadUI()` runs the leave-world sweep (`0x490c20`, past the armed latch) and then
|
||||
/// re-enters the world-enter cascade (`0x490168`), whose readers re-send the time, mail,
|
||||
/// battlefield and meeting-stone queries, only with the active player set (`0x490166 je`).
|
||||
#[test]
|
||||
fn a_reload_sweeps_and_reenters_the_world_enter_cascade_once_the_player_exists() {
|
||||
use crate::net::WorldEnterCascadeMessage;
|
||||
let reload = |seated: bool| {
|
||||
let mut world = World::new();
|
||||
world.insert_resource(State::new(crate::char_select::ClientState::InWorld));
|
||||
world.insert_resource(crate::run_mode::CaptureMode);
|
||||
world.init_resource::<Messages<WorldEnterCascadeMessage>>();
|
||||
world.init_resource::<Messages<WorldLeaveSweepMessage>>();
|
||||
world.init_resource::<LeavingWorldArmed>();
|
||||
world.resource_mut::<LeavingWorldArmed>().arm();
|
||||
if seated {
|
||||
world.spawn(crate::net::SelfPlayer);
|
||||
}
|
||||
world.insert_resource(ReloadUiPending(true));
|
||||
run_pending_reload(&mut world);
|
||||
assert!(!world.resource::<ReloadUiPending>().0, "the reload ran");
|
||||
let sweeps = world
|
||||
.resource_mut::<Messages<WorldLeaveSweepMessage>>()
|
||||
.drain()
|
||||
.count();
|
||||
let cascades = world
|
||||
.resource_mut::<Messages<WorldEnterCascadeMessage>>()
|
||||
.drain()
|
||||
.count();
|
||||
(sweeps, cascades)
|
||||
};
|
||||
assert_eq!(reload(true), (1, 1), "in the world");
|
||||
assert_eq!(
|
||||
reload(false).1,
|
||||
0,
|
||||
"no cascade before our own player's create"
|
||||
);
|
||||
}
|
||||
|
||||
/// The reference re-makes its Lua state inside `UI_Init` (`0x48fe97`).
|
||||
#[test]
|
||||
fn the_world_entry_builds_its_own_vm_rather_than_adopting_the_glue_phases() {
|
||||
|
||||
@@ -68,13 +68,13 @@ pub(super) fn apply_buff_durations(script: &UiScript) -> Result<(), String> {
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// What a screen-size change re-runs, from `extract::tick_script`'s resize arm: anchors follow the
|
||||
/// screen, but not a size or seat computed from the old one. The managed pass re-wraps the open
|
||||
/// bags, whose columns `updateContainerFrameAnchors` lays out by `GetScreenHeight()`.
|
||||
/// What a screen-size or UI-scale change re-runs, from `extract::tick_script`: anchors follow the
|
||||
/// screen, but not a size or seat computed from the old `GetScreenHeight()`. The managed pass
|
||||
/// re-wraps the open bags, whose columns `updateContainerFrameAnchors` lays out by it.
|
||||
/// Deviation: [`FULLSCREEN_QUADS_RESEAT`] re-sizes the full-screen quads, which the stock files
|
||||
/// size only in their OnLoads, because a resize or `uiScale` change here keeps the loaded UI and
|
||||
/// the blackout would stop short of the new screen. Host-side, not in the layer: it adapts the
|
||||
/// engine's resizable window, and changes nothing the stock UI does at a fixed size.
|
||||
/// size only in their OnLoads, because a resize here keeps the loaded UI and the blackout would
|
||||
/// stop short of the new screen. Host-side, not in the layer: it adapts the engine's resizable
|
||||
/// window, and changes nothing the stock UI does at a fixed size.
|
||||
pub(super) fn on_screen_resized(script: &UiScript) {
|
||||
let _ = script.run("if UIParent_ManageFramePositions then UIParent_ManageFramePositions() end");
|
||||
if let Err(e) = script.run(FULLSCREEN_QUADS_RESEAT) {
|
||||
@@ -85,11 +85,14 @@ pub(super) fn on_screen_resized(script: &UiScript) {
|
||||
/// The sizing arithmetic of `WorldMapFrame_OnLoad` (`WorldMapFrame.lua:19-28`) and
|
||||
/// `CinematicFrame_OnLoad` (`CinematicFrame.lua:6-21`), each existence-guarded: the glue screens
|
||||
/// have neither. Below 4:3 the bars go back to `CinematicFrame.xml`'s declared 1024 x 128, where
|
||||
/// the stock OnLoad leaves them.
|
||||
/// the stock OnLoad leaves them. The OnLoads read the screen root's size, `UIParent` being still at
|
||||
/// scale 1 then, and their frames hang off the root, so this re-reads the root:
|
||||
/// `GetScreenWidth()` times `UIParent`'s scale.
|
||||
const FULLSCREEN_QUADS_RESEAT: &str = r#"
|
||||
local rootScale = UIParent and UIParent:GetEffectiveScale() or 1
|
||||
if BlackoutWorld then
|
||||
local width = GetScreenWidth()
|
||||
local height = GetScreenHeight()
|
||||
local width = GetScreenWidth() * rootScale
|
||||
local height = GetScreenHeight() * rootScale
|
||||
if ( width / height < 4 / 3 ) then
|
||||
width = width * 1.25
|
||||
height = height * 1.25
|
||||
@@ -98,8 +101,8 @@ if BlackoutWorld then
|
||||
BlackoutWorld:SetHeight( height )
|
||||
end
|
||||
if UpperBlackBar and LowerBlackBar then
|
||||
local width = GetScreenWidth()
|
||||
local height = GetScreenHeight()
|
||||
local width = GetScreenWidth() * rootScale
|
||||
local height = GetScreenHeight() * rootScale
|
||||
local barWidth, blackBarHeight = 1024, 128
|
||||
if ( width / height > 4 / 3 ) then
|
||||
local desiredHeight = width / 2
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! The UI-engine bridge: hosts [`benilla_ui::script::UiScript`] and feeds its
|
||||
//! [`extract`](benilla_ui::script::UiScript::extract) output into the quad pass
|
||||
//! ([`crate::ui_pass::UiQuads`]) every frame. The script side is WoW UI space (y-up, origin
|
||||
//! bottom-left, a screen `768/uiScale` units tall); [`seam_scale`] carries quads ×s out and the
|
||||
//! mouse ÷s in, and extraction flips to y-down window px.
|
||||
//! bottom-left, a screen root 768 units tall at scale 1); [`seam_scale`] carries quads ×s out and
|
||||
//! the mouse ÷s in, and extraction flips to y-down window px. The UI scale is `UIParent`'s own
|
||||
//! frame scale inside the VM ([`UiScaleCvar`]), never part of the seam.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -199,8 +200,10 @@ pub(crate) fn run_or_warn(script: &benilla_ui::script::UiScript, chunk: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The reference's `uiScale` CVar: the VM's screen is `768/uiScale` units tall, so
|
||||
/// `uiScale = 768/screenH` is pixel-perfect. Tests pin the `Default` 1.0.
|
||||
/// The reference's `uiScale` CVar, which the VM makes `UIParent`'s own scale
|
||||
/// ([`UiScript::set_ui_scale`]): the screen root stays 768 units tall, so `uiScale = 768/screenH`
|
||||
/// is pixel-perfect inside `UIParent`, and a frame outside it is not scaled. Tests pin the
|
||||
/// `Default` 1.0.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct UiScaleCvar(pub(crate) f32);
|
||||
|
||||
@@ -213,8 +216,17 @@ impl Default for UiScaleCvar {
|
||||
/// The shipped `uiScale`. Deviation: a flat 0.9, because 1.0 reads oversized. With `useUiScale`
|
||||
/// off, its default (`0x8430c0`), the reference sets `max(768/H, 0.9)` above 768 px tall and 1.0
|
||||
/// at or below (`0x492f70`, on a mode set and from `0x4908ad`), so the two agree from ~853 px up.
|
||||
/// benilla never reads `useUiScale`: this CVar always applies to `UIParent` alone, as with it on.
|
||||
/// A change goes through the CVar callback (`0x490770`: the narrow-aspect cap, the 0.64 floor);
|
||||
/// the load and a display change through the display handler's ON leg (`0x492e90`: the cap, and
|
||||
/// below 0.64 the automatic scale `0x492f70` instead of the floor).
|
||||
pub(crate) const DEFAULT_UI_SCALE: f32 = 0.9;
|
||||
|
||||
/// The display the reference's automatic UI scale last ran for (`0x492f70`'s `[0xb4e304]` and
|
||||
/// `[0xb4e308]`), which outlives every UI: the display handler skips a display it already ran for.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct UiAutoScaleCache(pub(crate) Option<(u32, u32)>);
|
||||
|
||||
/// `WOW_UI_SCALE=` if set, clamped to the dial's range, else [`DEFAULT_UI_SCALE`].
|
||||
fn default_ui_scale() -> f32 {
|
||||
std::env::var("WOW_UI_SCALE")
|
||||
@@ -224,16 +236,24 @@ fn default_ui_scale() -> f32 {
|
||||
.unwrap_or(DEFAULT_UI_SCALE)
|
||||
}
|
||||
|
||||
/// The seam scale `s`, window px per UI unit (`windowH/768 × uiScale`), used at every crossing of
|
||||
/// the VM boundary; identity for a degenerate window (h ≤ 0, before winit).
|
||||
pub(crate) fn seam_scale(window_h: f32, ui_scale: f32) -> f32 {
|
||||
/// The seam scale `s`, window px per screen-root unit (`windowH/768`: the root is 768 units tall at
|
||||
/// every aspect, `0x41ad10`), used at every crossing of the VM boundary; identity for a degenerate
|
||||
/// window (h ≤ 0, before winit). The UI scale is not in it: it is `UIParent`'s, inside the VM.
|
||||
pub(crate) fn seam_scale(window_h: f32) -> f32 {
|
||||
if window_h > 0.0 {
|
||||
window_h / 768.0 * ui_scale
|
||||
window_h / 768.0
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
/// A window cursor (logical px, y-down from the top left) in the VM's root units, y up: what the
|
||||
/// pointer feed hands the engine and `GetCursorPosition` answers (`0x48b820`, no scale divided out).
|
||||
pub(crate) fn window_to_ui(window_h: f32, cursor: Vec2) -> Vec2 {
|
||||
let s = seam_scale(window_h);
|
||||
Vec2::new(cursor.x / s, (window_h - cursor.y) / s)
|
||||
}
|
||||
|
||||
/// The Lua UI host (a `NonSend` resource: an mlua VM is `!Send`) and its per-frame passes.
|
||||
pub(crate) struct UiScriptPlugin;
|
||||
|
||||
@@ -263,6 +283,7 @@ impl Plugin for UiScriptPlugin {
|
||||
// The quit root runs in `Last`: the close button's `AppExit` is written in `PostUpdate`.
|
||||
crate::shutdown::on_app_exit(app, shutdown_on_exit.into_configs());
|
||||
app.insert_resource(UiScaleCvar(default_ui_scale()))
|
||||
.init_resource::<UiAutoScaleCache>()
|
||||
.init_resource::<UiFrameCost>()
|
||||
.init_resource::<crate::bindings::WheelNotches>()
|
||||
.init_resource::<UiCostWanted>()
|
||||
@@ -295,6 +316,7 @@ impl Plugin for UiScriptPlugin {
|
||||
)
|
||||
// `init_` here and in `UiUnitPlugin`: either plugin may be built alone in a test.
|
||||
.init_resource::<LeavingWorldArmed>()
|
||||
.add_message::<WorldLeaveSweepMessage>()
|
||||
.add_systems(Update, lifecycle::arm_leaving_world_on_self_create)
|
||||
// A queued `ReloadUI()` runs in `PreUpdate`, a frame after its drain (the reference's
|
||||
// deferral, `0x495590`) and before every `Update` seed or feed.
|
||||
@@ -381,8 +403,8 @@ fn arbitrate_pointer_over_ui(
|
||||
/// The session lifecycle: the VM's birth, identity, death and reload.
|
||||
mod lifecycle;
|
||||
pub(crate) use lifecycle::{
|
||||
end_ui_session, ingame_ui_up, run_pending_reload, setup_script, AddOnIdentity,
|
||||
LeavingWorldArmed, PendingEntryUiLoad, ReloadUiPending,
|
||||
end_ui_session, ingame_ui_up, run_pending_reload, setup_script, AddOnIdentity, LeaveWorldSweep,
|
||||
LeavingWorldArmed, PendingEntryUiLoad, ReloadUiPending, WorldLeaveSweepMessage,
|
||||
};
|
||||
// Test-only: other modules' tests consume these, and a plain re-export would warn unused.
|
||||
#[cfg(test)]
|
||||
@@ -599,9 +621,9 @@ fn demo_unit_feed(script: Option<NonSendMut<UiScript>>, mut fired: Local<VmMemo<
|
||||
},
|
||||
]);
|
||||
script.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
|
||||
// 70% XP, set before `PLAYER_ENTERING_WORLD` so the bar's first update reads it.
|
||||
// 70% XP, set before the world-enter events so the bar's first update reads it.
|
||||
script.set_player_xp(4200, 6000);
|
||||
script.fire_event("PLAYER_ENTERING_WORLD", vec![]);
|
||||
script.fire_world_enter();
|
||||
// The bottom multibars ship off and a capture has no toggle byte, so raise them as the
|
||||
// Options rows do; `WOW_DEMO_BOTTOM_BARS=0` leaves them down for the stance shelf art,
|
||||
// which `ShapeshiftBar_UpdatePosition` hides under the bottom-left bar.
|
||||
@@ -705,6 +727,9 @@ mod chat_resize_tests;
|
||||
#[cfg(test)]
|
||||
mod dropdown_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod ui_scale_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod action_bar_tests;
|
||||
|
||||
@@ -794,7 +819,7 @@ mod loot_tests;
|
||||
mod group_loot_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod chat_tests;
|
||||
pub(crate) mod chat_tests;
|
||||
|
||||
/// The chat bubble's `UIMenu` kit driven as a menu: the rows' label and shortcut anchoring.
|
||||
#[cfg(test)]
|
||||
@@ -985,19 +1010,26 @@ mod world_map_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod seam_scale_tests {
|
||||
use super::seam_scale;
|
||||
use super::{seam_scale, window_to_ui};
|
||||
use bevy::math::Vec2;
|
||||
|
||||
#[test]
|
||||
fn seam_scale_is_the_768_base_times_the_dial() {
|
||||
fn seam_scale_is_the_768_tall_root() {
|
||||
// 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 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);
|
||||
assert_eq!(seam_scale(768.0), 1.0);
|
||||
assert_eq!(seam_scale(1536.0), 2.0);
|
||||
// A degenerate (pre-winit) window is identity, never a division blow-up.
|
||||
assert_eq!(seam_scale(0.0, 0.9), 1.0);
|
||||
assert_eq!(seam_scale(0.0), 1.0);
|
||||
}
|
||||
|
||||
/// `GetCursorPosition` answers root units whatever the dial: the bottom of a 1080-tall window
|
||||
/// is 0 and its top 768, its middle column `768·a/2`.
|
||||
#[test]
|
||||
fn the_cursor_reaches_the_vm_in_root_units() {
|
||||
let at = window_to_ui(1080.0, Vec2::new(960.0, 108.0));
|
||||
assert!((at.x - 960.0 * 768.0 / 1080.0).abs() < 1e-3, "{at}");
|
||||
assert!((at.y - 972.0 * 768.0 / 1080.0).abs() < 1e-3, "{at}");
|
||||
assert_eq!(window_to_ui(1080.0, Vec2::new(0.0, 1080.0)), Vec2::ZERO);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1346,14 +1346,17 @@ fn the_nameplates_page_toggles_the_unit_name_cvars() {
|
||||
"{row} defaults unchecked"
|
||||
);
|
||||
}
|
||||
// The name rows at the reference's registered defaults: player "1", NPC and own "0".
|
||||
assert!(
|
||||
s.eval::<bool>(
|
||||
"return BenillaOptionsFrameContainerBodyNameplatesRowPlayerNamesCheck:GetChecked()"
|
||||
)
|
||||
.unwrap(),
|
||||
"Player Names defaults checked"
|
||||
);
|
||||
// The name rows at the reference's registered defaults: player, guild and titles "1", NPC and
|
||||
// own "0".
|
||||
for row in ["RowPlayerNames", "RowGuildNames", "RowPlayerTitles"] {
|
||||
assert!(
|
||||
s.eval::<bool>(&format!(
|
||||
"return BenillaOptionsFrameContainerBodyNameplates{row}Check:GetChecked()"
|
||||
))
|
||||
.unwrap(),
|
||||
"{row} defaults checked"
|
||||
);
|
||||
}
|
||||
for row in ["RowNpcNames", "RowOwnName"] {
|
||||
assert!(
|
||||
!s.eval::<bool>(&format!(
|
||||
@@ -2915,11 +2918,11 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
// 83 rows less the three untipped below; four of the 80 carry a `BENILLA_` key, and a dropdown
|
||||
// 84 rows less the three untipped below; four of the 81 carry a `BENILLA_` key, and a dropdown
|
||||
// row is checked on the key it wears at rest.
|
||||
// MONKEY (graphics programme): the Advanced Graphics page's tipped rows (lighting, water,
|
||||
// lava, fog, daylight, Graphics Preset, Render Distance, ...) add 20. Upstream 80 -> 100.
|
||||
assert_eq!(checked, 100, "every tipped row carries a live key");
|
||||
// lava, fog, daylight, Graphics Preset, Render Distance, ...) add 20. Upstream 81 -> 101.
|
||||
assert_eq!(checked, 101, "every tipped row carries a live key");
|
||||
assert_eq!(
|
||||
untipped,
|
||||
vec![
|
||||
@@ -3018,9 +3021,9 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
|
||||
s.errors()
|
||||
);
|
||||
}
|
||||
// Every tipped row: the same 80 the key census counts.
|
||||
// MONKEY (graphics programme): the Advanced Graphics page's described rows add 19. 80 -> 99.
|
||||
assert_eq!(raised, 99, "every row but Auto Loot raises a description");
|
||||
// Every tipped row: the same 81 the key census counts.
|
||||
// MONKEY (graphics programme): the Advanced Graphics page's described rows add 19. 81 -> 100.
|
||||
assert_eq!(raised, 100, "every row but Auto Loot raises a description");
|
||||
}
|
||||
|
||||
/// 1.12's AdvancedOptionsCombatText box as saved-global rows: a click writes the global, never a
|
||||
@@ -4680,13 +4683,6 @@ fn without_a_seated_measurer_the_same_fit_reads_zero() {
|
||||
// nothing and reads back nil, so its box would offer a setting benilla does not have. A CVar is
|
||||
// registered only once something reads it.
|
||||
const UNBACKED_REFERENCE_CVARS: &[(&str, &str)] = &[
|
||||
(
|
||||
"UnitNamePlayerPVPTitle",
|
||||
"the PvP rank prefix on the overhead name line — slot a4 of `0x608f50`, bit `0x20` of the \
|
||||
same mask, resolved by `0x609370` through the `PVP_RANK_%d_%d` GlobalStrings key. Blocked \
|
||||
one step further back than its guild twin: the rank byte streams, but the key's second \
|
||||
index is a FACTION SIDE that `ui_unit` does not resolve for an arbitrary player yet",
|
||||
),
|
||||
// No slider CVar may land here: `UIOptionsFrame_Load` hands `GetCVar` to `Slider:SetValue`
|
||||
// (`UIOptionsFrame.lua:271-275`), which raises on nil (`0x790980`) and stops the stock
|
||||
// window's load. A check button's `GetCVar(x) == "1"` is nil-safe.
|
||||
@@ -4990,7 +4986,48 @@ fn show_target_damage_greys_its_own_pair_and_the_floating_text_master_leaves_it_
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The reference's rules: Player Guild Names is dead while player names are off
|
||||
/// The titles box is the guild row's twin: checked and live at the registered defaults (`"1"`),
|
||||
/// and a click survives closing and reopening the panel because the CVar is its store.
|
||||
#[test]
|
||||
fn the_titles_box_sticks_across_a_panel_reopen() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
let mut s = harness_on(audio_harness());
|
||||
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
|
||||
s.run("BenillaOptionsFrameCategoryListRowNameplates:Click()")
|
||||
.unwrap();
|
||||
let checked = |s: &mut UiScript| -> bool {
|
||||
s.eval::<bool>(
|
||||
"return BenillaOptionsFrameContainerBodyNameplatesRowPlayerTitlesCheck:GetChecked()",
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let cvar = |s: &mut UiScript| -> Option<String> {
|
||||
s.eval::<Option<String>>(r#"return GetCVar("UnitNamePlayerPVPTitle")"#)
|
||||
.unwrap()
|
||||
};
|
||||
assert!(checked(&mut s), "the registered default is \"1\"");
|
||||
assert_eq!(cvar(&mut s).as_deref(), Some("1"));
|
||||
|
||||
// A click writes the CVar; the box and the mirror agree.
|
||||
s.run("BenillaOptionsFrameContainerBodyNameplatesRowPlayerTitlesCheck:Click()")
|
||||
.unwrap();
|
||||
assert!(!checked(&mut s));
|
||||
assert_eq!(cvar(&mut s).as_deref(), Some("0"));
|
||||
|
||||
// Closed and reopened, the row re-reads the CVar instead of a staged value.
|
||||
s.run("HideUIPanel(BenillaOptionsFrame) ShowUIPanel(BenillaOptionsFrame)")
|
||||
.unwrap();
|
||||
s.run("BenillaOptionsFrameCategoryListRowNameplates:Click()")
|
||||
.unwrap();
|
||||
assert!(!checked(&mut s), "the click stuck");
|
||||
s.run("BenillaOptionsFrameContainerBodyNameplatesRowPlayerTitlesCheck:Click()")
|
||||
.unwrap();
|
||||
assert!(checked(&mut s));
|
||||
assert_eq!(cvar(&mut s).as_deref(), Some("1"));
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The reference's rules: Player Guild Names and Player Titles are dead while player names are off
|
||||
/// (`UIOptionsFrame.lua:703-709`), Auto-Follow Speed while the style is Never (`:717-721`).
|
||||
#[test]
|
||||
fn the_guild_line_greys_with_player_names_and_the_follow_speed_with_the_style() {
|
||||
@@ -4998,22 +5035,28 @@ fn the_guild_line_greys_with_player_names_and_the_follow_speed_with_the_style()
|
||||
let mut s = harness_on(audio_harness());
|
||||
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
|
||||
|
||||
// Nameplates: `UnitNamePlayer` ships "1", so the child arrives live.
|
||||
// Nameplates: `UnitNamePlayer` ships "1", so both children arrive live.
|
||||
s.run("BenillaOptionsFrameCategoryListRowNameplates:Click()")
|
||||
.unwrap();
|
||||
let guild_on = |s: &mut UiScript| -> bool {
|
||||
s.eval::<bool>(
|
||||
"return BenillaOptionsFrameContainerBodyNameplatesRowGuildNamesCheck:IsEnabled() ~= 0",
|
||||
)
|
||||
let child_on = |s: &mut UiScript, row: &str| -> bool {
|
||||
s.eval::<bool>(&format!(
|
||||
"return BenillaOptionsFrameContainerBodyNameplates{row}Check:IsEnabled() ~= 0"
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
assert!(guild_on(&mut s));
|
||||
for row in ["RowGuildNames", "RowPlayerTitles"] {
|
||||
assert!(child_on(&mut s, row), "{row} arrives live");
|
||||
}
|
||||
s.run("BenillaOptionsFrameContainerBodyNameplatesRowPlayerNamesCheck:Click()")
|
||||
.unwrap();
|
||||
assert!(!guild_on(&mut s), "no names, no guild line to gate");
|
||||
for row in ["RowGuildNames", "RowPlayerTitles"] {
|
||||
assert!(!child_on(&mut s, row), "no names, no {row} to gate");
|
||||
}
|
||||
s.run("BenillaOptionsFrameContainerBodyNameplatesRowPlayerNamesCheck:Click()")
|
||||
.unwrap();
|
||||
assert!(guild_on(&mut s));
|
||||
for row in ["RowGuildNames", "RowPlayerTitles"] {
|
||||
assert!(child_on(&mut s, row));
|
||||
}
|
||||
|
||||
// Controls: the style ships "1" (Smart), so the slider arrives live; "0" is Never.
|
||||
s.run("BenillaOptionsFrameCategoryListRowControls:Click()")
|
||||
|
||||
@@ -1693,3 +1693,115 @@ fn the_stock_title_width_cap_bites_and_is_sticky_per_row_button() {
|
||||
"the capped box survives the row going untagged (the ref's own 275 reset never fires)"
|
||||
);
|
||||
}
|
||||
|
||||
// ── The group-mates count ──
|
||||
|
||||
/// A party member held with `ids` in their quest-log window, named `name`.
|
||||
fn mate_on(guid: u64, name: &str, ids: &[u32]) -> benilla_ui::script::UnitState {
|
||||
let mut quest_log = [[0; 3]; 20];
|
||||
for (slot, &id) in quest_log.iter_mut().zip(ids) {
|
||||
*slot = [id, 0, 0];
|
||||
}
|
||||
benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
has_object: true,
|
||||
is_player: true,
|
||||
guid,
|
||||
name: Some(name.into()),
|
||||
quest_log,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Each quest row counts the party members on it as `[n]` (`QuestLogFrame.lua:168-183`), through
|
||||
/// `IsUnitOnQuest(questIndex, "partyN")`; the row tooltip names them (`:515-541`), and a mate's
|
||||
/// `UNIT_QUEST_LOG_CHANGED` recounts (`:69-72`).
|
||||
#[test]
|
||||
fn quest_rows_count_the_party_members_on_each_quest() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // our scroll kits
|
||||
|
||||
s.set_quest_log(eight_entries());
|
||||
s.set_party(party(2));
|
||||
s.set_unit("party1", Some(mate_on(0x300, "Mate0", &[1, 3])));
|
||||
s.set_unit("party2", Some(mate_on(0x301, "Mate1", &[1])));
|
||||
s.run("ToggleQuestLog()").unwrap();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
|
||||
let mates = |s: &mut UiScript, row: u32| {
|
||||
s.eval::<String>(&format!(
|
||||
"return QuestLogTitle{row}GroupMates:GetText() or ''"
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(mates(&mut s, 1), "[2]", "both mates are on quest 1");
|
||||
assert_eq!(mates(&mut s, 2), "", "nobody is on quest 2");
|
||||
assert_eq!(mates(&mut s, 3), "[1]", "party1 is on quest 3");
|
||||
|
||||
// The row tooltip, as `QuestLogTitleButton_OnEnter` builds it for row 1, then row 2.
|
||||
let line = |s: &mut UiScript, n: u32| {
|
||||
s.eval::<String>(&format!("return GameTooltipTextLeft{n}:GetText() or ''"))
|
||||
.unwrap()
|
||||
};
|
||||
s.run("this = QuestLogTitle1 QuestLog_UpdatePartyInfoTooltip()")
|
||||
.unwrap();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert_eq!(line(&mut s, 1), "Quest 1");
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"return GameTooltipTextLeft2:GetText() == \
|
||||
HIGHLIGHT_FONT_COLOR_CODE..PARTY_QUEST_STATUS_ON..FONT_COLOR_CODE_CLOSE"
|
||||
)
|
||||
.unwrap());
|
||||
assert!(line(&mut s, 3).contains("Mate0"), "{}", line(&mut s, 3));
|
||||
assert!(line(&mut s, 4).contains("Mate1"), "{}", line(&mut s, 4));
|
||||
s.run("this = QuestLogTitle2 QuestLog_UpdatePartyInfoTooltip()")
|
||||
.unwrap();
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"return GameTooltipTextLeft2:GetText() == \
|
||||
HIGHLIGHT_FONT_COLOR_CODE..PARTY_QUEST_STATUS_NONE..FONT_COLOR_CODE_CLOSE"
|
||||
)
|
||||
.unwrap());
|
||||
|
||||
// party2 takes quest 3: their quest-log event recounts the open log.
|
||||
s.set_unit("party2", Some(mate_on(0x301, "Mate1", &[1, 3])));
|
||||
s.fire_event(
|
||||
"UNIT_QUEST_LOG_CHANGED",
|
||||
vec![benilla_ui::script::ScriptValue::Str("party2".into())],
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert_eq!(mates(&mut s, 3), "[2]");
|
||||
|
||||
// Out of range, a mate counts for nothing, whatever their last window held (`0x468460` finds
|
||||
// no object).
|
||||
s.set_unit(
|
||||
"party1",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
has_object: false,
|
||||
..mate_on(0x300, "Mate0", &[1, 3])
|
||||
}),
|
||||
);
|
||||
s.run("QuestLog_Update()").unwrap();
|
||||
assert_eq!(mates(&mut s, 1), "[1]");
|
||||
assert_eq!(mates(&mut s, 3), "[1]");
|
||||
}
|
||||
|
||||
@@ -1937,12 +1937,6 @@ mod tests {
|
||||
),
|
||||
("SYSMSG", "UIErrorsFrame.lua"),
|
||||
("UNIT_DEFENSE", "PetPaperDollFrame.lua"),
|
||||
(
|
||||
"UNIT_QUEST_LOG_CHANGED",
|
||||
"QuestLogFrame.lua — a party member's quest-log fields changing (the reference \
|
||||
fires it off the unit's PLAYER_QUEST_LOG_* descriptor updates); benilla's unit \
|
||||
feed does not derive it yet",
|
||||
),
|
||||
(
|
||||
"UNIT_MODEL_CHANGED",
|
||||
"four files — the paperdoll model refresh",
|
||||
|
||||
@@ -501,7 +501,7 @@ pub(crate) const GOSSIP_UI: &[&str] = &[
|
||||
/// lacks what the app loads at setup (the master-loot menu reads `GROUP` and `GIVE_LOOT`),
|
||||
/// `ItemButtonTemplate.xml` because a missing template only warns, and `PartyFrame.xml` because
|
||||
/// `LootFrame.lua:217` reads `MAX_PARTY_MEMBERS` at load. Needs client data.
|
||||
pub(super) const LOOT_UI: &[&str] = &[
|
||||
pub(crate) const LOOT_UI: &[&str] = &[
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"Interface\\FrameXML\\Fonts.xml",
|
||||
"Interface\\FrameXML\\BasicControls.xml", // `TEXT`, which UnitPopup.lua reads at file scope
|
||||
@@ -660,6 +660,8 @@ pub(super) const BAG_UI: &[&str] = &[
|
||||
"Interface\\FrameXML\\UIDropDownMenu.xml",
|
||||
r"Interface\FrameXML\UIPanelTemplates.lua",
|
||||
r"Interface\FrameXML\UIPanelTemplates.xml",
|
||||
// `CharacterFrame.xml`'s tab template, for the hidden parent `PaperDollFrame` needs below.
|
||||
r"Interface\FrameXML\CharacterFrameTemplates.xml",
|
||||
// The dialog engine, after the `UIPanelCloseButton` it inherits.
|
||||
r"Interface\FrameXML\StaticPopup.xml",
|
||||
// `ContainerFrameItemButton_OnClick` hides `StackSplitFrame` on every plain click
|
||||
@@ -683,7 +685,9 @@ pub(super) const BAG_UI: &[&str] = &[
|
||||
"Interface\\FrameXML\\ItemButtonTemplate.xml",
|
||||
// `BagSlotButtonTemplate` inherits `PaperDollItemSlotButtonTemplate`, and its OnLoad
|
||||
// (`PaperDollItemSlotButton_OnLoad`) gives each bag button its inventory-slot id, 20..23.
|
||||
// `CharacterFrame.xml` stays out: a missing `parent=` only warns.
|
||||
// Its hidden parent `CharacterFrame` comes first: a parentless `PaperDollFrame` would be
|
||||
// visible at the end of its load and run its OnShow, which reaches for the Honor tab.
|
||||
"Interface\\FrameXML\\CharacterFrame.xml",
|
||||
"Interface\\FrameXML\\PaperDollFrame.xml",
|
||||
// The stock bag bar, with `BagSlotButtonTemplate` and `KEYRING_CONTAINER`.
|
||||
"Interface\\FrameXML\\MainMenuBarBagButtons.xml",
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
//! The UI scale through the real UI pass: `uiScale` is `UIParent`'s own frame scale (`0x494550`
|
||||
//! calls `0x76ac10` on the frame named `"UIParent"`), and the screen root under it keeps scale 1
|
||||
//! and is 768 units tall, so a frame outside `UIParent` is not scaled by it.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
|
||||
use benilla_ui::script::UiScript;
|
||||
|
||||
use super::extract::{paint_script, tick_script};
|
||||
use crate::portrait::PortraitImages;
|
||||
use crate::ui_pass::{UiQuad, UiQuads};
|
||||
|
||||
/// A `UIParent` filling the root with a red 100-unit square inside it, 10 units in from its top
|
||||
/// left, and a green 100-unit square at the root's bottom left with no parent.
|
||||
const TREE: &str = r#"
|
||||
local ui = CreateFrame("Frame", "UIParent")
|
||||
ui:SetPoint("BOTTOMLEFT", 0, 0)
|
||||
ui:SetPoint("TOPRIGHT", 0, 0)
|
||||
local inside = CreateFrame("Frame", "Inside", UIParent)
|
||||
inside:SetPoint("TOPLEFT", 10, -10)
|
||||
inside:SetWidth(100); inside:SetHeight(100)
|
||||
local red = inside:CreateTexture(nil, "ARTWORK")
|
||||
red:SetTexture(1, 0, 0)
|
||||
red:SetAllPoints()
|
||||
local outside = CreateFrame("Frame", "Outside")
|
||||
outside:SetPoint("BOTTOMLEFT", 0, 0)
|
||||
outside:SetWidth(100); outside:SetHeight(100)
|
||||
local green = outside:CreateTexture(nil, "ARTWORK")
|
||||
green:SetTexture(0, 1, 0)
|
||||
green:SetAllPoints()
|
||||
"#;
|
||||
|
||||
/// Both halves of the UI pass over [`TREE`] on a `w`×`h` window, the dial at `ui_scale`.
|
||||
fn app(w: u32, h: u32, ui_scale: f32) -> App {
|
||||
let script = UiScript::new().unwrap();
|
||||
script.run(TREE).unwrap();
|
||||
let mut app = App::new();
|
||||
app.insert_non_send_resource(script);
|
||||
app.init_resource::<UiQuads>();
|
||||
app.init_resource::<super::UiPassState>();
|
||||
app.init_resource::<Assets<Image>>();
|
||||
app.init_resource::<PortraitImages>();
|
||||
app.init_resource::<crate::portrait::BoothPanes>();
|
||||
app.init_resource::<crate::ui_models::UiModelTiles>();
|
||||
app.init_resource::<crate::minimap::MinimapWidget>();
|
||||
app.init_resource::<super::UiFrameCost>();
|
||||
app.init_resource::<super::UiCostWanted>();
|
||||
app.init_resource::<Time>();
|
||||
app.init_resource::<Time<Real>>();
|
||||
app.init_resource::<super::UiClock>();
|
||||
app.insert_resource(super::UiScaleCvar(ui_scale));
|
||||
app.world_mut().spawn((
|
||||
Window {
|
||||
resolution: UVec2::new(w, h).into(),
|
||||
..default()
|
||||
},
|
||||
PrimaryWindow,
|
||||
));
|
||||
app.add_systems(Update, (tick_script, paint_script).chain());
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
fn eval(app: &App, chunk: &str) -> f64 {
|
||||
app.world()
|
||||
.non_send_resource::<UiScript>()
|
||||
.eval::<f64>(chunk)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The window-px rect of the quad tinted `rgb`, y-down.
|
||||
fn quad(app: &App, rgb: [f32; 3]) -> Rect {
|
||||
let quads = &app.world().resource::<UiQuads>().quads;
|
||||
let q: &UiQuad = quads
|
||||
.iter()
|
||||
.find(|q| q.color[..3] == rgb)
|
||||
.expect("the marker quad extracted");
|
||||
q.rect
|
||||
}
|
||||
|
||||
fn close(a: f32, b: f32) -> bool {
|
||||
(a - b).abs() < 1e-3
|
||||
}
|
||||
|
||||
fn rect_close(got: Rect, want: Rect) -> bool {
|
||||
close(got.min.x, want.min.x)
|
||||
&& close(got.min.y, want.min.y)
|
||||
&& close(got.max.x, want.max.x)
|
||||
&& close(got.max.y, want.max.y)
|
||||
}
|
||||
|
||||
/// `UIParent:GetScale()` and `GetEffectiveScale()` answer the UI scale: it is `UIParent`'s own
|
||||
/// scale, set through the worker Lua's `SetScale` reaches (`0x494550` → `0x76ac10`).
|
||||
#[test]
|
||||
fn uiparent_answers_the_ui_scale() {
|
||||
let app = app(1024, 768, 0.9);
|
||||
assert!(close(eval(&app, "return UIParent:GetScale()") as f32, 0.9));
|
||||
assert!(close(
|
||||
eval(&app, "return UIParent:GetEffectiveScale()") as f32,
|
||||
0.9
|
||||
));
|
||||
assert!(close(
|
||||
eval(&app, "return Inside:GetEffectiveScale()") as f32,
|
||||
0.9
|
||||
));
|
||||
assert_eq!(eval(&app, "return Outside:GetEffectiveScale()"), 1.0);
|
||||
}
|
||||
|
||||
/// `GetScreenWidth`/`GetScreenHeight` (`0x48b480`/`0x48b4d0`) divide the 768-tall root by
|
||||
/// `UIParent`'s scale (`0x494590`), so they answer in `UIParent`'s units.
|
||||
#[test]
|
||||
fn the_screen_size_answers_in_uiparent_units() {
|
||||
let app = app(1024, 768, 0.9);
|
||||
assert!(close(
|
||||
eval(&app, "return GetScreenHeight()") as f32,
|
||||
768.0 / 0.9
|
||||
));
|
||||
assert!(close(
|
||||
eval(&app, "return GetScreenWidth()") as f32,
|
||||
1024.0 / 0.9
|
||||
));
|
||||
}
|
||||
|
||||
/// A frame with no parent hangs off the root, which keeps scale 1: one unit is `windowH/768` px
|
||||
/// whatever the dial says.
|
||||
#[test]
|
||||
fn a_parentless_frame_is_not_scaled_by_the_ui_scale() {
|
||||
let app = app(1280, 1024, 0.9);
|
||||
let s = 1024.0 / 768.0;
|
||||
let got = quad(&app, [0.0, 1.0, 0.0]);
|
||||
assert!(
|
||||
rect_close(got, Rect::new(0.0, 1024.0 - 100.0 * s, 100.0 * s, 1024.0)),
|
||||
"got {got:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Inside `UIParent` nothing moves: the dial scales the subtree exactly as it scaled the seam.
|
||||
#[test]
|
||||
fn a_frame_inside_uiparent_lands_where_the_dial_put_it() {
|
||||
let app = app(1280, 1024, 0.9);
|
||||
let s = 1024.0 / 768.0 * 0.9;
|
||||
let got = quad(&app, [1.0, 0.0, 0.0]);
|
||||
assert!(
|
||||
rect_close(got, Rect::new(10.0 * s, 10.0 * s, 110.0 * s, 110.0 * s)),
|
||||
"got {got:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether `frame` is `UIParent` or hangs below it.
|
||||
fn under_ui_parent(s: &UiScript, frame: benilla_ui::widget::FrameHandle) -> bool {
|
||||
let mut at = Some(frame);
|
||||
while let Some(h) = at {
|
||||
if s.frame_name(h).as_deref() == Some("UIParent") {
|
||||
return true;
|
||||
}
|
||||
at = s.frame_parent(h);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The whole stock interface, every frame shown: scaling `UIParent` by `S` in the 768-tall root
|
||||
/// must place every quad of its subtree, and every hit, exactly where enlarging the root by `1/S`
|
||||
/// with `UIParent` at scale 1 placed them, the seam having been the old home of the UI scale. A
|
||||
/// frame-local length the engine forgets to scale (a border, an inset, a thumb) shows up here.
|
||||
#[test]
|
||||
fn the_stock_interface_inside_uiparent_is_unmoved_by_where_the_scale_lives() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
const S: f32 = 0.9;
|
||||
let (w, h) = (768.0 * 16.0 / 9.0, 768.0);
|
||||
let mut script = UiScript::new().expect("VM");
|
||||
script.set_screen_size(w, h);
|
||||
let failures = super::load_default_ui(&script);
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"default UI failed to load: {failures:?}"
|
||||
);
|
||||
// Every frame shown, its parents first, so the most geometry the stock files declare draws.
|
||||
script
|
||||
.run(
|
||||
r#"
|
||||
local f = EnumerateFrames()
|
||||
while f do
|
||||
pcall(f.Show, f)
|
||||
f = EnumerateFrames(f)
|
||||
end
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = script.take_errors();
|
||||
|
||||
// The old home: a root `1/S` larger, `UIParent` at scale 1.
|
||||
script.set_screen_size(w / S, h / S);
|
||||
script.run("UIParent:SetScale(1)").unwrap();
|
||||
script.resolve();
|
||||
let old = script.extract();
|
||||
let grid: Vec<(f32, f32)> = (0..=40)
|
||||
.flat_map(|i| (0..=24).map(move |j| (w * i as f32 / 40.0, h * j as f32 / 24.0)))
|
||||
.collect();
|
||||
let old_hits: Vec<_> = grid
|
||||
.iter()
|
||||
.map(|&(x, y)| script.hit_test_frame(x / S, y / S))
|
||||
.collect();
|
||||
|
||||
// The reference's: a 768-tall root, `UIParent` at scale `S`.
|
||||
script.set_screen_size(w, h);
|
||||
assert!(script.set_ui_scale(S));
|
||||
script.resolve();
|
||||
let new = script.extract();
|
||||
let new_hits: Vec<_> = grid
|
||||
.iter()
|
||||
.map(|&(x, y)| script.hit_test_frame(x, y))
|
||||
.collect();
|
||||
|
||||
assert_eq!(old.len(), new.len(), "the same quads draw");
|
||||
let close = |a: f32, b: f32| (a - b).abs() < 0.02;
|
||||
let mut moved = Vec::new();
|
||||
for (a, b) in old.iter().zip(&new) {
|
||||
assert_eq!(a.target, b.target, "the same paint order");
|
||||
let Some(frame) = script.target_frame(b.target) else {
|
||||
continue;
|
||||
};
|
||||
if !under_ui_parent(&script, frame) {
|
||||
continue;
|
||||
}
|
||||
let same_rect = match (a.rect, b.rect) {
|
||||
(Some(ra), Some(rb)) => {
|
||||
close(ra.left * S, rb.left)
|
||||
&& close(ra.right * S, rb.right)
|
||||
&& close(ra.top * S, rb.top)
|
||||
&& close(ra.bottom * S, rb.bottom)
|
||||
}
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !same_rect || !close(a.scale * S, b.scale) {
|
||||
moved.push(format!(
|
||||
"{:?} {:?}: {:?}×{S} vs {:?}",
|
||||
script.target_owner_name(b.target),
|
||||
std::mem::discriminant(&b.content),
|
||||
a.rect,
|
||||
b.rect
|
||||
));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
moved.is_empty(),
|
||||
"{} quads inside UIParent moved, first: {:#?}",
|
||||
moved.len(),
|
||||
&moved[..moved.len().min(20)]
|
||||
);
|
||||
|
||||
let inside = |hit: Option<benilla_ui::widget::FrameHandle>| {
|
||||
hit.is_none_or(|h| under_ui_parent(&script, h))
|
||||
};
|
||||
let mut rehit = Vec::new();
|
||||
for ((&at, a), b) in grid.iter().zip(&old_hits).zip(&new_hits) {
|
||||
if inside(*a) && inside(*b) && a != b {
|
||||
rehit.push(format!(
|
||||
"{at:?}: {:?} vs {:?}",
|
||||
a.and_then(|h| script.frame_name(h)),
|
||||
b.and_then(|h| script.frame_name(h))
|
||||
));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
rehit.is_empty(),
|
||||
"{} hits inside UIParent changed, first: {:#?}",
|
||||
rehit.len(),
|
||||
&rehit[..rehit.len().min(20)]
|
||||
);
|
||||
}
|
||||
@@ -773,6 +773,28 @@ fn leaving_inside_the_deferral_window_drops_the_load_and_writes_nothing() {
|
||||
|
||||
// ───────────── The login one-shots wait for the in-game UI ─────────────
|
||||
|
||||
/// An app running the unit feed after `Startup`, with what the feed reads; the receiver is the
|
||||
/// wire, kept alive beside it.
|
||||
fn unit_feed_app() -> (App, crossbeam_channel::Receiver<crate::net::ClientCommand>) {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(crate::ui_unit::UiUnitPlugin);
|
||||
app.init_resource::<super::AddOnIdentity>();
|
||||
app.init_resource::<crate::minimap::MinimapZoom>();
|
||||
app.init_resource::<super::ReloadUiPending>();
|
||||
app.init_resource::<crate::target::Selection>();
|
||||
app.init_resource::<crate::names::NameCache>();
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
app.insert_resource(crate::net::NetCommands(tx));
|
||||
app.init_resource::<crate::net::Reputations>();
|
||||
app.init_resource::<crate::ui_party::GroupState>();
|
||||
app.init_resource::<crate::ui_chat::ChatLog>();
|
||||
app.init_resource::<crate::ui_guild::GuildState>();
|
||||
app.add_message::<crate::creature_anim::SwingImpact>();
|
||||
app.add_message::<crate::net::WorldEnterCascadeMessage>();
|
||||
super::setup_script(app.world_mut());
|
||||
(app, rx)
|
||||
}
|
||||
|
||||
/// The unit feed's login one-shots (`PLAYER_ENTERING_WORLD`, the first `PLAYER_XP_UPDATE` and
|
||||
/// `UPDATE_EXHAUSTION`) wait for the in-game UI instead of reaching a VM with no frames.
|
||||
#[test]
|
||||
@@ -781,22 +803,7 @@ fn the_login_one_shots_wait_for_the_in_game_ui() {
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let (tmp, _c, _h) = hermetic_probe("oneshot");
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(crate::ui_unit::UiUnitPlugin);
|
||||
app.init_resource::<super::AddOnIdentity>();
|
||||
app.init_resource::<crate::minimap::MinimapZoom>();
|
||||
app.init_resource::<super::ReloadUiPending>();
|
||||
app.init_resource::<crate::target::Selection>();
|
||||
app.init_resource::<crate::names::NameCache>();
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
app.insert_resource(crate::net::NetCommands(tx));
|
||||
app.init_resource::<crate::net::Reputations>();
|
||||
app.init_resource::<crate::ui_party::GroupState>();
|
||||
app.init_resource::<crate::ui_chat::ChatLog>();
|
||||
app.init_resource::<crate::ui_guild::GuildState>();
|
||||
app.add_message::<crate::creature_anim::SwingImpact>();
|
||||
super::setup_script(app.world_mut());
|
||||
let (mut app, _rx) = unit_feed_app();
|
||||
|
||||
// A frame registered as FrameXML registers, in the VM that exists before the entry load.
|
||||
app.world()
|
||||
@@ -1576,3 +1583,143 @@ fn a_login_and_a_reload_load_without_the_lost_target_sound() {
|
||||
drop(world);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
// ───────────── PLAYER_LOGIN fires from the world-enter cascade ─────────────
|
||||
|
||||
/// Records what file scope and the two cascade events see of `"player"`, in order.
|
||||
const LOGIN_PROBE_LUA: &str = "\
|
||||
LoginProbe = { order = \"\", fileExists = tostring(UnitExists(\"player\")),
|
||||
fileLevel = UnitLevel(\"player\") }
|
||||
local f = CreateFrame(\"Frame\")
|
||||
f:RegisterEvent(\"PLAYER_LOGIN\")
|
||||
f:RegisterEvent(\"PLAYER_ENTERING_WORLD\")
|
||||
f:SetScript(\"OnEvent\", function()
|
||||
if event == \"PLAYER_LOGIN\" then
|
||||
LoginProbe.order = LoginProbe.order .. \"LOGIN:\" .. tostring(UnitExists(\"player\"))
|
||||
.. \":\" .. UnitLevel(\"player\") .. \":\" .. UnitXP(\"player\") .. \" \"
|
||||
else
|
||||
LoginProbe.order = LoginProbe.order .. \"PEW \"
|
||||
end
|
||||
end)
|
||||
";
|
||||
|
||||
const LOGIN_PROBE_TOC: &str = "## Interface: 11200\nLoginProbe.lua\n";
|
||||
|
||||
/// Our own player as its create lands: level 17, 1234 of 5000 XP.
|
||||
fn spawn_self_player(world: &mut World) -> Entity {
|
||||
use benilla_protocol::messages::{ObjectFields, ObjectType};
|
||||
// `PLAYER_XP` and `PLAYER_NEXT_LEVEL_XP`, 716 and 717 in the 1.12 descriptor.
|
||||
let fields = ObjectFields::from_pairs(&[
|
||||
(benilla_protocol::field::FIELD_UNIT_LEVEL, 17),
|
||||
(716, 1234),
|
||||
(717, 5000),
|
||||
])
|
||||
.into_created(ObjectType::Player);
|
||||
world
|
||||
.spawn((
|
||||
crate::net::SelfPlayer,
|
||||
crate::net::Guid(1),
|
||||
crate::net::ObjectStore(fields),
|
||||
))
|
||||
.id()
|
||||
}
|
||||
|
||||
/// A probe global off the live VM, as a string.
|
||||
fn login_probe(app: &App, field: &str) -> String {
|
||||
app.world()
|
||||
.non_send_resource::<benilla_ui::script::UiScript>()
|
||||
.eval::<Option<String>>(&format!("return tostring(LoginProbe.{field})"))
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `PLAYER_LOGIN` has one fire site, `0x490959` in the world-enter cascade `0x4908c0`, which the
|
||||
/// UI load only arms (`0x49011d`). A fresh login skips the load's own entry (`0x490166 je`) and
|
||||
/// runs the cascade from the player's create (`0x5deb60`), so its handlers see the player, and
|
||||
/// `PLAYER_ENTERING_WORLD` follows at once (`0x49096a`). A worldport loads no UI, so its cascade
|
||||
/// finds the arm spent and fires 272 alone.
|
||||
#[test]
|
||||
fn player_login_fires_from_the_world_entry_with_the_player_seated() {
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let (tmp, _c, _h) = hermetic_addon("login", "LoginProbe", LOGIN_PROBE_TOC, LOGIN_PROBE_LUA);
|
||||
let (mut app, _rx) = unit_feed_app();
|
||||
app.insert_resource(State::new(crate::char_select::ClientState::InWorld));
|
||||
app.insert_resource(roster_named("Probelogin", 1));
|
||||
super::load_ingame_ui_on_world_entry(app.world_mut());
|
||||
|
||||
assert_eq!(
|
||||
login_probe(&app, "fileExists"),
|
||||
"nil",
|
||||
"a fresh login's file scope runs before the player object exists"
|
||||
);
|
||||
assert_eq!(
|
||||
login_probe(&app, "order"),
|
||||
"",
|
||||
"the load arms PLAYER_LOGIN; nothing fires it before the player is in the world"
|
||||
);
|
||||
|
||||
let me = spawn_self_player(app.world_mut());
|
||||
app.update();
|
||||
assert_eq!(
|
||||
login_probe(&app, "order"),
|
||||
"LOGIN:1:17:1234 PEW ",
|
||||
"PLAYER_LOGIN sees the player's own unit, then PLAYER_ENTERING_WORLD fires at once"
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(login_probe(&app, "order"), "LOGIN:1:17:1234 PEW ");
|
||||
|
||||
// A cross-map worldport: the player object is destroyed and created again.
|
||||
app.world_mut().despawn(me);
|
||||
app.update();
|
||||
spawn_self_player(app.world_mut());
|
||||
app.update();
|
||||
assert_eq!(
|
||||
login_probe(&app, "order"),
|
||||
"LOGIN:1:17:1234 PEW PEW ",
|
||||
"a worldport re-fires PLAYER_ENTERING_WORLD and not PLAYER_LOGIN"
|
||||
);
|
||||
|
||||
drop(app);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
/// On a `/reload` the player object stays in the world: `UI_Init` runs its file scope against it
|
||||
/// and enters the cascade itself (`0x490168`), so `PLAYER_LOGIN` then `PLAYER_ENTERING_WORLD` fire
|
||||
/// once each on the new VM, both seeing the player.
|
||||
#[test]
|
||||
fn a_reload_fires_player_login_once_against_the_live_player() {
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let (tmp, _c, _h) = hermetic_addon("relogin", "LoginProbe", LOGIN_PROBE_TOC, LOGIN_PROBE_LUA);
|
||||
let (mut app, _rx) = unit_feed_app();
|
||||
app.insert_resource(State::new(crate::char_select::ClientState::InWorld));
|
||||
app.insert_resource(roster_named("Probelogin", 1));
|
||||
super::load_ingame_ui_on_world_entry(app.world_mut());
|
||||
spawn_self_player(app.world_mut());
|
||||
app.update();
|
||||
assert_eq!(login_probe(&app, "order"), "LOGIN:1:17:1234 PEW ");
|
||||
|
||||
reload(app.world_mut(), crate::char_select::ClientState::InWorld);
|
||||
assert_eq!(
|
||||
(
|
||||
login_probe(&app, "fileExists"),
|
||||
login_probe(&app, "fileLevel")
|
||||
),
|
||||
("1".to_string(), "17".to_string()),
|
||||
"the reloaded file scope runs against the live player"
|
||||
);
|
||||
app.update();
|
||||
app.update();
|
||||
assert_eq!(
|
||||
login_probe(&app, "order"),
|
||||
"LOGIN:1:17:1234 PEW ",
|
||||
"the new VM gets PLAYER_LOGIN then PLAYER_ENTERING_WORLD, once each"
|
||||
);
|
||||
|
||||
drop(app);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! marker, [`crate::poi_marker`]), the unit blips, the player arrow and the full-screen quads.
|
||||
|
||||
use benilla_ui::script::{
|
||||
BattlefieldFlagView, BattlefieldPositionView, QuadContent, UiScript, WorldMapLandmarkView,
|
||||
BattlefieldFlagView, BattlefieldPositionView, QuadContent, UiScript, WorldMapLandmarkSource,
|
||||
ARROW_MODEL,
|
||||
};
|
||||
|
||||
@@ -34,6 +34,8 @@ fn harness() -> UiScript {
|
||||
super::test_ui::load_ui(&s, file);
|
||||
}
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
// A world position here is already map UV, so a landmark source sits where it says.
|
||||
s.set_world_loc_projector(Box::new(|_, _, x, y| Some((x, y))));
|
||||
s
|
||||
}
|
||||
|
||||
@@ -60,12 +62,14 @@ fn update(s: &mut UiScript) {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn landmark(name: &str, icon: u32, uv: (f32, f32)) -> WorldMapLandmarkView {
|
||||
WorldMapLandmarkView {
|
||||
/// A landmark at map UV `uv` on every level, through [`harness`]'s identity projection.
|
||||
fn landmark(name: &str, icon: u32, uv: (f32, f32)) -> WorldMapLandmarkSource {
|
||||
WorldMapLandmarkSource {
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
texture_index: icon,
|
||||
uv,
|
||||
map: 0,
|
||||
pos: uv,
|
||||
icons: [Some(icon); 3],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +79,7 @@ fn landmark(name: &str, icon: u32, uv: (f32, f32)) -> WorldMapLandmarkView {
|
||||
fn a_landmark_draws_its_poi_icon_at_its_map_position() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = harness();
|
||||
s.set_world_map_landmarks(vec![landmark("Stormwind Warrior Trainer", 6, (0.25, 0.5))]);
|
||||
s.set_world_map_landmark_sources(vec![landmark("Stormwind Warrior Trainer", 6, (0.25, 0.5))]);
|
||||
s.run("WorldMapFrame_Update()").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
@@ -115,7 +119,7 @@ fn a_landmark_draws_its_poi_icon_at_its_map_position() {
|
||||
fn the_poi_pool_grows_and_parks_its_tail() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = harness();
|
||||
s.set_world_map_landmarks(vec![
|
||||
s.set_world_map_landmark_sources(vec![
|
||||
landmark("The Bank", 6, (0.1, 0.1)),
|
||||
landmark("The Inn", 6, (0.2, 0.2)),
|
||||
landmark("The Auction House", 6, (0.3, 0.3)),
|
||||
@@ -126,7 +130,7 @@ fn the_poi_pool_grows_and_parks_its_tail() {
|
||||
.eval::<bool>("return WorldMapFramePOI3:IsShown()")
|
||||
.unwrap());
|
||||
|
||||
s.set_world_map_landmarks(vec![landmark("The Inn", 6, (0.2, 0.2))]);
|
||||
s.set_world_map_landmark_sources(vec![landmark("The Inn", 6, (0.2, 0.2))]);
|
||||
s.run("WorldMapFrame_Update()").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return NUM_WORLDMAP_POIS").unwrap(),
|
||||
@@ -158,7 +162,7 @@ fn the_poi_pool_grows_and_parks_its_tail() {
|
||||
fn hovering_a_poi_names_it_and_adds_a_status_line_only_when_there_is_one() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = harness();
|
||||
s.set_world_map_landmarks(vec![landmark("Lion's Pride Inn", 6, (0.5, 0.5))]);
|
||||
s.set_world_map_landmark_sources(vec![landmark("Lion's Pride Inn", 6, (0.5, 0.5))]);
|
||||
s.run("WorldMapFrame_Update()").unwrap();
|
||||
s.run("this = WorldMapFramePOI1 this:GetScript(\"OnEnter\")() this = nil")
|
||||
.unwrap();
|
||||
@@ -178,7 +182,7 @@ fn hovering_a_poi_names_it_and_adds_a_status_line_only_when_there_is_one() {
|
||||
|
||||
let mut with_status = landmark("Stables", 6, (0.5, 0.5));
|
||||
with_status.description = "In Conflict".into();
|
||||
s.set_world_map_landmarks(vec![with_status]);
|
||||
s.set_world_map_landmark_sources(vec![with_status]);
|
||||
s.run("WorldMapFrame_Update()").unwrap();
|
||||
s.run("this = WorldMapFramePOI1 this:GetScript(\"OnEnter\")() this = nil")
|
||||
.unwrap();
|
||||
@@ -197,7 +201,7 @@ fn the_landmark_getter_returns_the_references_five_values() {
|
||||
let mut s = harness();
|
||||
let mut with_status = landmark("Stables", 9, (0.25, 0.75));
|
||||
with_status.description = "In Conflict".into();
|
||||
s.set_world_map_landmarks(vec![landmark("Woo Ping", 6, (0.5, 0.5)), with_status]);
|
||||
s.set_world_map_landmark_sources(vec![landmark("Woo Ping", 6, (0.5, 0.5)), with_status]);
|
||||
|
||||
let (name, desc_is_nil, icon, x, y): (String, bool, i64, f32, f32) = s
|
||||
.eval("local n, d, t, x, y = GetMapLandmarkInfo(1) return n, d == nil, t, x, y")
|
||||
@@ -859,3 +863,66 @@ fn the_fullscreen_quads_follow_a_resize() {
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// Reopening the map after zooming out shows the player's zone from its first paint. The OnShow
|
||||
/// calls `SetMapToCurrentZone()` (WorldMapFrame.xml:600), whose setter `0x4a67a0` fires
|
||||
/// `WORLD_MAP_UPDATE` before it returns (`0x4a6ce4`, `SignalEvent` dispatching in place), so the
|
||||
/// stock `WorldMapFrame_Update` sets the zone's tiles inside the show, before any tick or paint.
|
||||
#[test]
|
||||
fn a_reopened_map_shows_the_players_zone_before_its_first_paint() {
|
||||
use benilla_ui::script::{WorldMapContinentView, WorldMapZoneView};
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
// The whole stock UI: the OnShow's `UpdateMicroButtons` and the full-screen panel slot.
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1600.0, 900.0);
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Probefour".into()),
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let failures = super::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||||
s.set_world_map_catalog(vec![WorldMapContinentView {
|
||||
name: "Eastern Kingdoms".into(),
|
||||
map_file: "Azeroth".into(),
|
||||
zones: vec![WorldMapZoneView {
|
||||
name: "Elwynn Forest".into(),
|
||||
area_id: 12,
|
||||
map_file: "Elwynn".into(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}]);
|
||||
// The player stands in Elwynn Forest.
|
||||
s.set_world_map_feed(Some((1, 1)), None, 0.0, None, Vec::new(), Vec::new());
|
||||
s.tick(0.01);
|
||||
let tile = |s: &UiScript| {
|
||||
s.eval::<String>("return WorldMapDetailTile1:GetTexture()")
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
s.run("ToggleWorldMap()").unwrap();
|
||||
s.tick(0.01);
|
||||
assert!(s.eval::<bool>("return WorldMapFrame:IsVisible()").unwrap());
|
||||
assert_eq!(tile(&s), "Interface\\WorldMap\\Elwynn\\Elwynn1");
|
||||
// A right-click on the map zooms out to the continent (WorldMapFrame.lua:294).
|
||||
s.run("WorldMapZoomOutButton_OnClick()").unwrap();
|
||||
s.tick(0.01);
|
||||
assert_eq!(tile(&s), "Interface\\WorldMap\\Azeroth\\Azeroth1");
|
||||
|
||||
s.run("ToggleWorldMap()").unwrap();
|
||||
s.tick(0.01);
|
||||
assert!(!s.eval::<bool>("return WorldMapFrame:IsVisible()").unwrap());
|
||||
s.run("ToggleWorldMap()").unwrap();
|
||||
assert!(s.eval::<bool>("return WorldMapFrame:IsVisible()").unwrap());
|
||||
assert_eq!(
|
||||
tile(&s),
|
||||
"Interface\\WorldMap\\Elwynn\\Elwynn1",
|
||||
"the reopened map paints its first frame with the continent it was closed on"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -273,6 +273,27 @@ fn room_to_room_hop_splashes_the_room_name_alone() {
|
||||
assert_eq!(text_of(&s, "SubZoneTextString"), "Library Wing");
|
||||
}
|
||||
|
||||
/// A nil pvpType, a PvE realm outside a capital: no territory line, and the zone and subzone in
|
||||
/// the stock else-arm's cream (`ZoneText.xml:32-33`), not a faction colour.
|
||||
#[test]
|
||||
fn a_nil_pvp_type_splashes_uncoloured_with_no_territory_line() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = harness();
|
||||
set_area(&mut s, "Elwynn Forest", "Goldshire", "", "");
|
||||
s.fire_event("ZONE_CHANGED_NEW_AREA", vec![]);
|
||||
assert!(visible(&s, "ZoneTextFrame"));
|
||||
assert_eq!(text_of(&s, "PVPInfoTextString"), "");
|
||||
for region in ["ZoneTextString", "SubZoneTextString"] {
|
||||
let (r, g, b) = s
|
||||
.eval::<(f32, f32, f32)>(&format!("return {region}:GetTextColor()"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
(r - 1.0).abs() < 1e-3 && (g - 0.9294).abs() < 1e-3 && (b - 0.7607).abs() < 1e-3,
|
||||
"{region} is ({r}, {g}, {b})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An FFA pit: `GetZonePVPInfo`'s isArena, the leaf area's flag `0x80`.
|
||||
#[test]
|
||||
fn arena_pit_shows_the_ffa_line() {
|
||||
|
||||
Reference in New Issue
Block a user