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::