diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index 7f435f33c..9b425483a 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -110,7 +110,8 @@ BenillaOptionsFrame:SetScale(k) end - -- The era tab width, label + 40, measured after OnShow sets the scale. + -- The era tab width, label + 40, measured after the window's OnShow sets the scale: + -- the tabs' own OnShows run before it, children first. function BenillaOptionsTab_FitWidth(tab) tab:SetWidth(getglobal(tab:GetName() .. "Text"):GetStringWidth() + 40) end @@ -1341,9 +1342,6 @@ - - BenillaOptionsTab_FitWidth(this) - @@ -3068,6 +3065,8 @@ BenillaOptionsFrame_UpdateScale() + BenillaOptionsTab_FitWidth(BenillaOptionsFrameGameTab) + BenillaOptionsTab_FitWidth(BenillaOptionsFrameAddOnsTab) BenillaOptionsFrame_SelectCategory(this.selectedCategory or "Controls") if UpdateMicroButtons then UpdateMicroButtons() end diff --git a/crates/benilla-app/assets/ui/ScriptLogFrame.xml b/crates/benilla-app/assets/ui/ScriptLogFrame.xml index 9a2abc309..13fd9dcc0 100644 --- a/crates/benilla-app/assets/ui/ScriptLogFrame.xml +++ b/crates/benilla-app/assets/ui/ScriptLogFrame.xml @@ -232,8 +232,9 @@ - - + + diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs index 6ae24160a..f5965a3c9 100644 --- a/crates/benilla-app/src/capture/fixtures.rs +++ b/crates/benilla-app/src/capture/fixtures.rs @@ -1358,15 +1358,23 @@ pub(super) fn seed_ui_fixture( warn!("capture: ui-chat-tabhover select failed: {e}"); } script.resolve(); + // Each in the root's units the cursor is fed in: the frame's own times its scale. let expr = match mode.as_str() { - "2" | "3" => "return ChatFrame2Tab:GetCenter()", + "2" | "3" => { + "local x, y = ChatFrame2Tab:GetCenter() \ + local k = ChatFrame2Tab:GetEffectiveScale() return x * k, y * k" + } "0" => { - "return (ChatFrame2:GetLeft() + ChatFrame2:GetRight()) / 2, \ - (ChatFrame2:GetBottom() + ChatFrame2:GetTop()) / 2" + "local k = ChatFrame2:GetEffectiveScale() \ + return (ChatFrame2:GetLeft() + ChatFrame2:GetRight()) / 2 * k, \ + (ChatFrame2:GetBottom() + ChatFrame2:GetTop()) / 2 * k" } // 9: park far away, so the dock conceals itself. "9" => "return 2000, 2000", - _ => "return ChatFrame1Tab:GetCenter()", + _ => { + "local x, y = ChatFrame1Tab:GetCenter() \ + local k = ChatFrame1Tab:GetEffectiveScale() return x * k, y * k" + } }; let centre: Result<(f32, f32), _> = script.eval(expr); match centre { diff --git a/crates/benilla-app/src/capture/probes/act.rs b/crates/benilla-app/src/capture/probes/act.rs index bfad967f9..ddc4ea146 100644 --- a/crates/benilla-app/src/capture/probes/act.rs +++ b/crates/benilla-app/src/capture/probes/act.rs @@ -550,13 +550,13 @@ struct ProbeDrag { to: (f32, f32), } -/// A frame's centre from its live `GetLeft`/`GetRight`/`GetTop`/`GetBottom`, in the space -/// [`crate::ui_script::input`] feeds the cursor in. +/// A frame's centre from its live `GetLeft`/`GetRight`/`GetTop`/`GetBottom`, times its effective +/// scale: the edges answer in the frame's own units, the cursor is fed in the root's. fn frame_centre(script: &benilla_ui::script::UiScript, name: &str) -> Option<(f32, f32)> { let read = |edge: &str| { script .eval::(&format!( - "local f = getglobal(\"{name}\") return f and f:Get{edge}()" + "local f = getglobal(\"{name}\") return f and f:Get{edge}() * f:GetEffectiveScale()" )) .ok() }; diff --git a/crates/benilla-app/src/chat_bubble.rs b/crates/benilla-app/src/chat_bubble.rs index 2ad3a8f7e..1a91b7402 100644 --- a/crates/benilla-app/src/chat_bubble.rs +++ b/crates/benilla-app/src/chat_bubble.rs @@ -553,7 +553,7 @@ fn draw_bubble( border_color: [1.0; 4], }; let up = GxRect::new(-frame.max.y, frame.min.x, -frame.min.y, frame.max.x); - for p in pieces(up, &bd) { + for p in pieces(up, &bd, 1.0) { // Equal insets keep every piece axis-aligned: a y-down rect from its TL and BR corners. let rect = Rect::new( p.corners[0][0], diff --git a/crates/benilla-app/src/minimap/blips/mod.rs b/crates/benilla-app/src/minimap/blips/mod.rs index 4d5bf2ced..463d85958 100644 --- a/crates/benilla-app/src/minimap/blips/mod.rs +++ b/crates/benilla-app/src/minimap/blips/mod.rs @@ -160,7 +160,6 @@ pub(super) type BlipInputs<'w, 's> = ( Query<'w, 's, &'static GlobalTransform, With>, Query<'w, 's, &'static Window, With>, ResMut<'w, MinimapBlipHover>, - Res<'w, crate::ui_script::UiScaleCvar>, Res<'w, crate::ui_party::GroupState>, TrackedCandidates<'w, 's>, Query<'w, 's, (&'static ObjectStore, &'static Guid), With>, @@ -200,11 +199,11 @@ pub(super) struct BlipCtx { pub(super) wz: f32, /// The cursor in the quads' y-down logical px. pub(super) cursor: Option, - /// The cursor in y-up UI space; the reference seats the blip tooltip at the cursor, its exact - /// offset untraced. + /// The cursor in the y-up root units; the reference seats the blip tooltip at the cursor, its + /// exact offset untraced. pub(super) cursor_ui: Option, - /// Window px per UI unit: everything else here is window px, while Lua - /// ([`Minimap:PingLocation`](super::ping)) speaks UI units. + /// Window px per root unit: everything else here is window px, while Lua + /// ([`Minimap:PingLocation`](super::ping)) hands root units. pub(super) seam: f32, } diff --git a/crates/benilla-app/src/minimap/mod.rs b/crates/benilla-app/src/minimap/mod.rs index 10f72ccfc..c39099465 100644 --- a/crates/benilla-app/src/minimap/mod.rs +++ b/crates/benilla-app/src/minimap/mod.rs @@ -351,7 +351,6 @@ fn emit_minimap( unit_pos, window, mut blip_hover, - ui_scale, group, tracked, self_store, @@ -621,7 +620,7 @@ fn emit_minimap( } let win = window.iter().next(); let cursor = win.and_then(|w| w.cursor_position()); - let seam = win.map_or(1.0, |w| crate::ui_script::seam_scale(w.height(), ui_scale.0)); + let seam = win.map_or(1.0, |w| crate::ui_script::seam_scale(w.height())); // The pan origin snapped to a half-logical-pixel grid: every blip offset and rim bearing // derives from `wx`/`wy`, so the layer steps together instead of rewriting the batch mesh // every frame while walking. Blip world positions stay exact. @@ -637,11 +636,11 @@ fn emit_minimap( wy: (wy / q).round() * q, wz: wow[2], cursor, - // The cursor in UI space (y-up, ÷ the seam scale): the tooltip anchor resolves in - // 768-virtual units, not window px. + // The cursor in the root's units (y-up, ÷ the seam scale): the tooltip anchor resolves + // in them, not window px. cursor_ui: cursor .zip(win) - .map(|(c, w)| Vec2::new(c.x / seam, (w.height() - c.y) / seam)), + .map(|(c, w)| crate::ui_script::window_to_ui(w.height(), c)), seam, } }); diff --git a/crates/benilla-app/src/minimap/ping.rs b/crates/benilla-app/src/minimap/ping.rs index c218c7560..bf4e6ba28 100644 --- a/crates/benilla-app/src/minimap/ping.rs +++ b/crates/benilla-app/src/minimap/ping.rs @@ -5,9 +5,10 @@ //! [`drive_minimap_ping`] republishes against the live view radius, so the marker follows the pan //! and the zoom with no second copy to fall out of step. //! -//! - In: the stock `Minimap_OnClick` calls `Minimap:PingLocation(dx, dy)` in UI units from the -//! centre; the renderer converts it with the geometry of the frame it draws: UI units × the seam -//! scale = window px, ÷ `px_per_yd` = yards. +//! - In: the stock `Minimap_OnClick` calls `Minimap:PingLocation(dx, dy)` in the minimap's units +//! from the centre, which the engine hands over in root units; the renderer converts them with +//! the geometry of the frame it draws: root units × the seam scale = window px, ÷ `px_per_yd` = +//! yards. //! - Across: our ping sends `MSG_MINIMAP_PING` (raw world floats, relayed to the rest of the group //! only, `GroupHandler.cpp:384-391`); a member's seats the same way. A ping seats locally at //! click time, so a solo ping works. @@ -59,8 +60,8 @@ impl MinimapPing { } } -/// A `Minimap:PingLocation(x, y)` click to the world point it names: `ui` is centre-relative UI -/// units (x right, y up), `seam` window px per UI unit ([`crate::ui_script::seam_scale`]), `ctx` +/// A `Minimap:PingLocation(x, y)` click to the world point it names: `ui` is centre-relative root +/// units (x right, y up), `seam` window px per root unit ([`crate::ui_script::seam_scale`]), `ctx` /// the map as drawn this frame. The inverse of [`BlipCtx::offset`]: screen right is −WoW y, screen /// up +WoW x. `None` outside the disc, the stock test (`Minimap.lua:135`) in yards. fn click_to_world(ctx: &BlipCtx, ui: (f32, f32), seam: f32) -> Option<(f32, f32)> { @@ -193,13 +194,13 @@ mod tests { } } - /// The click is in UI units and `px_per_yd` in window px: at 0.9 uiScale on a 1080p window the - /// seam is ≈1.27, and a conversion that skips it seats every ping ≈27 % too far out. + /// The click is in root units and `px_per_yd` in window px: on a 1080p window the seam is + /// ≈1.41, and a conversion that skips it seats every ping ≈41 % too far out. #[test] fn a_click_converts_through_the_seam_scale() { let c = ctx(); - let seam = 1080.0 / 768.0 * 0.9; // the shipped default at 1080p - // 20 UI units right → 20·seam px → yards west (−y). + let seam = 1080.0 / 768.0; + // 20 root units right → 20·seam px → yards west (−y). let (x, y) = click_to_world(&c, (20.0, 0.0), seam).expect("inside the disc"); let expect_yd = 20.0 * seam / c.px_per_yd; assert!((x - 0.0).abs() < 1e-3, "no northing from a due-east click"); diff --git a/crates/benilla-app/src/perf/hud.rs b/crates/benilla-app/src/perf/hud.rs index ec59c09b6..d963a0a02 100644 --- a/crates/benilla-app/src/perf/hud.rs +++ b/crates/benilla-app/src/perf/hud.rs @@ -113,9 +113,9 @@ pub(super) fn refresh_hud_snapshot( /// clear). The always-up world-state readout (`WorldStateAlwaysUpFrame`, stock /// `WorldStateFrame.xml`) shares the band, and the dev pill is the one that yields. /// -/// The layout answers in UI units, a screen `768/uiScale` units tall whatever the window, while -/// the pill draws in window px, so the chunk returns a fraction of the screen and the caller -/// scales it by the window height. Reading an edge settles the layout, one graph solve at most. +/// The layout answers in `UIParent`'s units, a screen `768/uiScale` units tall whatever the +/// window, while the pill draws in window px, so the chunk returns a fraction of the screen and the +/// caller scales it by the window height. Reading an edge settles the layout, one graph solve at most. pub(crate) fn top_centre_claimed(script: &UiScript, win_h: f32) -> f32 { // `WorldStateAlwaysUpFrame` is always shown; the readout occupies its `AlwaysUpFrame` rows, // built on demand and hidden when empty, so the claim is the lowest shown row's bottom. diff --git a/crates/benilla-app/src/ui_chat/channels.rs b/crates/benilla-app/src/ui_chat/channels.rs index 82175b243..12eb95848 100644 --- a/crates/benilla-app/src/ui_chat/channels.rs +++ b/crates/benilla-app/src/ui_chat/channels.rs @@ -444,8 +444,8 @@ pub(super) fn auto_join_zone_channels( walk.at = Some(zone_id); } -/// Feed the VM the zone-less catalog before any interface file runs: world entry runs FrameXML, -/// the addons and `PLAYER_LOGIN` in one call, ahead of the walk's first tick. +/// Feed the VM the zone-less catalog before any interface file runs: world entry runs FrameXML +/// and the addons in one call, ahead of the walk's first tick. pub(crate) fn seed_zone_channel_catalog( world: &mut World, script: &mut benilla_ui::script::UiScript, diff --git a/crates/benilla-app/src/ui_chat/settings.rs b/crates/benilla-app/src/ui_chat/settings.rs index 9286ae725..dfa25fa70 100644 --- a/crates/benilla-app/src/ui_chat/settings.rs +++ b/crates/benilla-app/src/ui_chat/settings.rs @@ -623,8 +623,9 @@ pub(crate) fn fold_dying_vm_chat_cache(world: &mut World) { pub(super) fn plugin(app: &mut App) { app.init_resource::() - // The restore runs from the world-entry UI load, not here: its events must precede the - // first chat line and `PLAYER_LOGIN`, which no `Update` ordering guarantees. + // The restore runs from the world-entry UI load, not here: its events must follow + // `VARIABLES_LOADED` inside the load, where the reference registers its reader + // (`0x4900d6`), and precede the first chat line and `PLAYER_LOGIN`. .add_systems( Update, // After the tick: every write it watches is Lua's. diff --git a/crates/benilla-app/src/ui_chat/tests.rs b/crates/benilla-app/src/ui_chat/tests.rs index 9d5e22f4a..f41b8b894 100644 --- a/crates/benilla-app/src/ui_chat/tests.rs +++ b/crates/benilla-app/src/ui_chat/tests.rs @@ -2397,8 +2397,9 @@ fn the_free_professions_line_is_printed_once() { // ───────────── the chat cache restores inside the login ───────── /// `UPDATE_CHAT_WINDOWS` registers the chat frames for `CHAT_MSG_*`, and the `UPDATE_CHAT_COLOR` -/// burst repaints id-0 lines through `ChatTypeInfo["REPLY"]`, so both come before `PLAYER_LOGIN`. -/// The probe is a loose addon, because the entry load builds the VM it runs on. +/// burst repaints id-0 lines through `ChatTypeInfo["REPLY"]`, so both come before `PLAYER_LOGIN`, +/// which the load arms and the world-enter cascade fires. The probe is a loose addon, because the +/// entry load builds the VM it runs on. #[test] fn the_chat_cache_restore_is_finished_before_player_login() { let _data = benilla_formats::wow_data_or_skip!(); @@ -2485,6 +2486,10 @@ fn the_chat_cache_restore_is_finished_before_player_login() { 1, )); crate::ui_script::load_ingame_ui_on_world_entry(&mut world); + // The cascade, as the unit feed runs it once the player is in the world. + world + .non_send_resource_mut::() + .fire_world_enter(); let read = |expr: &str| -> String { world @@ -2512,7 +2517,7 @@ fn the_chat_cache_restore_is_finished_before_player_login() { read("ChatOrderProbe.loginRegistered") ); // The reference's login order: `ADDON_LOADED` (`0x4900a3`), `VARIABLES_LOADED` (`0x4900b2`), - // the chat-cache burst (`0x4900d6`), `PLAYER_LOGIN` (`0x490959`). + // the chat-cache burst (`0x4900d6`), the arm (`0x49011d`), `PLAYER_LOGIN` (`0x490959`). assert_eq!( read("ChatOrderProbe.order"), "VARIABLES_LOADED UPDATE_CHAT_WINDOWS UPDATE_CHAT_COLOR PLAYER_LOGIN ", diff --git a/crates/benilla-app/src/ui_script/addons.rs b/crates/benilla-app/src/ui_script/addons.rs index 81fba5a66..5ba49f7de 100644 --- a/crates/benilla-app/src/ui_script/addons.rs +++ b/crates/benilla-app/src/ui_script/addons.rs @@ -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), diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index 972afda46..4fb1ef097 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -425,7 +425,11 @@ pub(super) fn tick_script( mut last_seam: Local, // The `scale_factor` measures were last answered under; 0 until the first frame. mut last_dpi: Local, - ui_scale: Res, + // The dial, `UIParent`'s scale in the VM, and the automatic scale's last display. + (ui_scale, mut auto_cache): ( + Res, + Option>, + ), mut ui_cost: ResMut, mut pass: ResMut, ) { @@ -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" ); } diff --git a/crates/benilla-app/src/ui_script/extract/text.rs b/crates/benilla-app/src/ui_script/extract/text.rs index 00d95d385..e7bd93126 100644 --- a/crates/benilla-app/src/ui_script/extract/text.rs +++ b/crates/benilla-app/src/ui_script/extract/text.rs @@ -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); diff --git a/crates/benilla-app/src/ui_script/input.rs b/crates/benilla-app/src/ui_script/input.rs index dd1b58019..2a296cc60 100644 --- a/crates/benilla-app/src/ui_script/input.rs +++ b/crates/benilla-app/src/ui_script/input.rs @@ -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, - // The uiScale dial folded into the seam scale. - ui_scale: Res, ) { 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 = std::sync::OnceLock::new(); let metering = diff --git a/crates/benilla-app/src/ui_script/inspect_tests.rs b/crates/benilla-app/src/ui_script/inspect_tests.rs index 0dfc3175d..dcc593128 100644 --- a/crates/benilla-app/src/ui_script/inspect_tests.rs +++ b/crates/benilla-app/src/ui_script/inspect_tests.rs @@ -492,16 +492,19 @@ fn toggle_inspect_reopens_after_a_close_and_raises_like_the_reference() { s.eval::("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:?}" ); } diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index eafdcd069..d91c92bb9 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -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::() - .map_or(1.0, |c| c.0); let (w, h) = { let mut q = world.query_filtered::<&Window, With>(); 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::() 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::() .and_then(record_from_roster) { script.set_player_record(record); } - if let Some(seat) = world - .get_resource::() - .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::() + .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::() + .map_or(1.0, |c| c.0); let zoom = { let z = world.resource::(); (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>(); + 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::(); + 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 { + 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 { @@ -648,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::().0) { return; @@ -667,7 +723,7 @@ 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. @@ -710,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, |_| {}, |_| {}); @@ -724,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( @@ -736,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 diff --git a/crates/benilla-app/src/ui_script/manifest.rs b/crates/benilla-app/src/ui_script/manifest.rs index 269d5f59c..3a1da8b8e 100644 --- a/crates/benilla-app/src/ui_script/manifest.rs +++ b/crates/benilla-app/src/ui_script/manifest.rs @@ -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 diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index 47df8f77e..2b01b917c 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -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::() .init_resource::() .init_resource::() .init_resource::() @@ -600,9 +621,9 @@ fn demo_unit_feed(script: Option>, mut fired: Local