From dc63a0617811f3ef292cf81fb2144b17e476226f Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:17:23 +0700 Subject: [PATCH] The UI scale is UIParent's own scale over a 768-unit root (#841) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI scale was folded into the pixel seam, with the root and `UIParent` at scale 1. Frames outside `UIParent` (dropdown menus, need/greed rolls, the world map) shrank with it, `UIParent:GetScale()` answered 1, and `GetCursorPosition()` was off by the scale. In 1.12.1 the scale is `UIParent`'s own frame scale (`0x494550` → `0x76ac10`, floored at 0.64) over a 768-unit root. `GetScreenWidth`/`GetScreenHeight` divide by it (`0x494590`), and `GetCursorPosition` (`0x48b820`) does not. Now the seam is `height/768`, and the scale is applied to `UIParent` at the load's end and on a resize. The nameplates' compensation is gone. Backdrops, hit insets and minimap pings now follow a frame's own scale (`0x76b580`, `0x77e8d0`); tooltips at the cursor and `OnCursorChanged` divide by it (`0x530b20`, `0x77db23`). Tests cover each, and a stock-UI comparison shows nothing inside `UIParent` moves. Fixes #172 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 --- .../benilla-app/assets/ui/ScriptLogFrame.xml | 5 +- crates/benilla-app/src/capture/fixtures.rs | 16 +- crates/benilla-app/src/capture/probes/act.rs | 6 +- crates/benilla-app/src/chat_bubble.rs | 2 +- crates/benilla-app/src/minimap/blips/mod.rs | 9 +- crates/benilla-app/src/minimap/mod.rs | 9 +- crates/benilla-app/src/minimap/ping.rs | 19 +- crates/benilla-app/src/perf/hud.rs | 6 +- .../benilla-app/src/ui_script/extract/mod.rs | 34 ++- .../benilla-app/src/ui_script/extract/text.rs | 7 +- crates/benilla-app/src/ui_script/input.rs | 8 +- crates/benilla-app/src/ui_script/lifecycle.rs | 48 ++-- crates/benilla-app/src/ui_script/manifest.rs | 25 +- crates/benilla-app/src/ui_script/mod.rs | 65 +++-- .../src/ui_script/ui_scale_tests.rs | 272 ++++++++++++++++++ crates/benilla-app/src/ui_text/mod.rs | 11 +- crates/benilla-app/src/ui_tooltip/mod.rs | 10 +- crates/benilla-app/src/ui_tooltip/tests.rs | 1 - crates/benilla-app/src/ui_world_map.rs | 8 +- crates/benilla-app/src/vplates.rs | 12 +- crates/benilla-ui/src/script/backdrop.rs | 59 +++- crates/benilla-ui/src/script/colorselect.rs | 14 + crates/benilla-ui/src/script/editbox/flush.rs | 10 +- .../src/script/editbox/flush_tests.rs | 46 +++ crates/benilla-ui/src/script/extract.rs | 15 +- crates/benilla-ui/src/script/layout.rs | 3 +- crates/benilla-ui/src/script/minimap.rs | 13 +- crates/benilla-ui/src/script/mod.rs | 4 +- crates/benilla-ui/src/script/model.rs | 24 +- crates/benilla-ui/src/script/object.rs | 17 +- .../src/script/object/frame_state.rs | 11 +- .../src/script/object/layout_methods.rs | 8 +- crates/benilla-ui/src/script/pointer.rs | 23 +- crates/benilla-ui/src/script/region.rs | 12 + crates/benilla-ui/src/script/slider.rs | 33 ++- .../benilla-ui/src/script/tests/backdrop.rs | 45 +++ crates/benilla-ui/src/script/tests/input.rs | 21 ++ crates/benilla-ui/src/script/tests/minimap.rs | 15 + crates/benilla-ui/src/script/tests/slider.rs | 34 +++ .../src/script/tests/tooltip_unit.rs | 45 +++ crates/benilla-ui/src/script/tooltip_unit.rs | 22 +- crates/benilla-ui/src/script/ui_scale.rs | 221 ++++++++++++++ 42 files changed, 1071 insertions(+), 197 deletions(-) create mode 100644 crates/benilla-app/src/ui_script/ui_scale_tests.rs create mode 100644 crates/benilla-ui/src/script/ui_scale.rs diff --git a/crates/benilla-app/assets/ui/ScriptLogFrame.xml b/crates/benilla-app/assets/ui/ScriptLogFrame.xml index 9a2abc30..13fd9dcc 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 6ae24160..f5965a3c 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 bfad967f..ddc4ea14 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 2ad3a8f7..1a91b740 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 4d5bf2ce..463d8595 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 10f72ccf..c3909946 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 c218c756..bf4e6ba2 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 ec59c09b..d963a0a0 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_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index 972afda4..4fb1ef09 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 00d95d38..e7bd9312 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 dd1b5801..2a296cc6 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/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index 7e1a52aa..d91c92bb 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 { @@ -387,6 +383,9 @@ fn load_ingame_ui_seated(world: &mut World, seat: LoadSeat) { // 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) @@ -421,6 +420,21 @@ fn load_ingame_ui_seated(world: &mut World, seat: LoadSeat) { |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); }, ); }); @@ -766,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 the `PLAYER_LOGIN` arm (so -/// before the event too), 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( diff --git a/crates/benilla-app/src/ui_script/manifest.rs b/crates/benilla-app/src/ui_script/manifest.rs index 269d5f59..3a1da8b8 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 05383a58..2b01b917 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::() @@ -706,6 +727,9 @@ mod chat_resize_tests; #[cfg(test)] mod dropdown_tests; +#[cfg(test)] +mod ui_scale_tests; + #[cfg(test)] mod action_bar_tests; @@ -986,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); } } diff --git a/crates/benilla-app/src/ui_script/ui_scale_tests.rs b/crates/benilla-app/src/ui_script/ui_scale_tests.rs new file mode 100644 index 00000000..f38e9942 --- /dev/null +++ b/crates/benilla-app/src/ui_script/ui_scale_tests.rs @@ -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::(); + app.init_resource::(); + app.init_resource::>(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::