The UI scale is UIParent's own scale over a 768-unit root (#841)

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 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-09 13:17:23 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent f0dfa86148
commit dc63a06178
42 changed files with 1071 additions and 197 deletions
@@ -232,8 +232,9 @@
</Scripts>
</Button>
<Frame name="BenillaScriptLogFrame" hidden="true">
<!-- The standard left-slot panel size. No <Anchors>: ShowUIPanel seats it. -->
<Frame name="BenillaScriptLogFrame" parent="UIParent" hidden="true">
<!-- The standard left-slot panel size. No <Anchors>: ShowUIPanel seats it. Under UIParent,
as the stock panels are, so it takes the UI scale with them. -->
<Size><AbsDimension x="384" y="512"/></Size>
<Layers>
<Layer level="BACKGROUND">
+12 -4
View File
@@ -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 {
+3 -3
View File
@@ -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::<f32>(&format!(
"local f = getglobal(\"{name}\") return f and f:Get{edge}()"
"local f = getglobal(\"{name}\") return f and f:Get{edge}() * f:GetEffectiveScale()"
))
.ok()
};
+1 -1
View File
@@ -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],
+4 -5
View File
@@ -160,7 +160,6 @@ pub(super) type BlipInputs<'w, 's> = (
Query<'w, 's, &'static GlobalTransform, With<NetEntity>>,
Query<'w, 's, &'static Window, With<PrimaryWindow>>,
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<SelfPlayer>>,
@@ -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<Vec2>,
/// 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<Vec2>,
/// 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,
}
+4 -5
View File
@@ -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,
}
});
+10 -9
View File
@@ -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");
+3 -3
View File
@@ -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<n>` rows,
// built on demand and hidden when empty, so the claim is the lowest shown row's bottom.
+23 -11
View File
@@ -425,7 +425,11 @@ pub(super) fn tick_script(
mut last_seam: Local<f32>,
// The `scale_factor` measures were last answered under; 0 until the first frame.
mut last_dpi: Local<f32>,
ui_scale: Res<super::UiScaleCvar>,
// The dial, `UIParent`'s scale in the VM, and the automatic scale's last display.
(ui_scale, mut auto_cache): (
Res<super::UiScaleCvar>,
Option<ResMut<super::UiAutoScaleCache>>,
),
mut ui_cost: ResMut<super::UiFrameCost>,
mut pass: ResMut<super::UiPassState>,
) {
@@ -442,9 +446,9 @@ pub(super) fn tick_script(
};
let (w, h) = (window.width(), window.height());
// The client's UI space is 768 units tall at every aspect (`f(screenH) = 768`, `0x41ad10`; the
// caret's `H_px/192`, `0x77b8c0`), `768/uiScale` with the dial. The VM lives in it: quads
// scale ×s out, and the mouse and the measures ÷s in.
let s = super::seam_scale(h, ui_scale.0);
// caret's `H_px/192`, `0x77b8c0`), the dial being `UIParent`'s scale inside it. The VM lives in
// it: quads scale ×s out, and the mouse and the measures ÷s in.
let s = super::seam_scale(h);
// A moved seam scale or DPI stales every text metric the engine caches: integer-stepped
// advances do not rescale, and the size snap moves a string's width by several percent,
// enough for the ellipsis to eat fitting text. A monitor hop moves the DPI alone.
@@ -486,8 +490,17 @@ pub(super) fn tick_script(
{
let _span = bevy::log::info_span!("ui_script: tick").entered();
let resized = script.set_screen_size(w / s, if h > 0.0 { h / s } else { 768.0 });
// A resize re-runs what the interface computed from the old screen size.
// A display change re-applies the UI scale (`0x492e90`), whose narrow-aspect cap follows
// the window; a moved dial applies once, as its CVar callback does (`0x490770`).
if resized {
let display = (w.round() as u32, h.round() as u32);
let mut none = None;
let cache = auto_cache.as_deref_mut().map_or(&mut none, |c| &mut c.0);
script.apply_ui_scale(display, cache);
}
let rescaled = script.set_ui_scale(ui_scale.0);
// Either re-runs what the interface computed from the old `GetScreenHeight()`.
if resized || rescaled {
super::manifest::on_screen_resized(&script);
}
script.tick(time.delta_secs());
@@ -1713,9 +1726,10 @@ mod clip_plumb_tests {
);
}
/// At dial 0.5 the VM sees a 2048×1536 screen, and every window-px rect halves.
/// The dial is `UIParent`'s scale, so a ScrollFrame with no parent keeps its px rect and clip
/// at dial 0.5: the root stays 768 units tall.
#[test]
fn the_uiscale_dial_scales_the_extracted_rects() {
fn the_uiscale_dial_leaves_a_parentless_frame_alone() {
let mut app = app_with_scrolled_marker();
app.insert_resource(crate::ui_script::UiScaleCvar(0.5));
app.update();
@@ -1726,12 +1740,10 @@ mod clip_plumb_tests {
.find(|q| q.color[0] == 1.0 && q.color[1] == 0.0 && q.color[2] == 0.0)
.expect("the colored marker quad extracted");
// WoW space: top 1536 − 100 = 1436, bottom 1236, right 300; ×0.5 is y-up px 618..718,
// and y-down through the 768 window the top is 50 and the bottom 150.
assert_eq!(
marker.clip,
Some(Rect::new(0.0, 50.0, 150.0, 150.0)),
"dial 0.5 halves the px rect and re-hangs the frame from the taller virtual top"
Some(Rect::new(0.0, 100.0, 300.0, 300.0)),
"dial 0.5 moves nothing outside UIParent"
);
}
@@ -40,10 +40,11 @@ pub(super) struct TextHost<'a> {
/// The focused editbox's text UI, unfiltered: [`emit`] matches it to this quad by `target`.
pub ebox: Option<&'a EditBoxTextUi>,
pub screen_h: f32,
/// The seam scale `windowH/768 × uiScale`, for the unit-space inputs; rects arrive in px.
/// The seam scale `windowH/768`, for the root-unit inputs; rects arrive in px.
pub scale: f32,
/// The frame's effective scale, already in the rect: glyph size and shadow offset ride it, as
/// the 1.12 client's text rides `SetScale`; the editbox x-offsets arrive in screen UI units.
/// the 1.12 client's text rides `SetScale` and the UI scale; the editbox x-offsets arrive in
/// root units.
pub font_scale: f32,
/// Captures pin the caret on for deterministic pixels; live, the engine's blink decides.
pub caret_pinned: bool,
@@ -387,7 +388,7 @@ mod tests {
assert_eq!(band_clip_slack(Some(2.0)), 3.0);
}
/// `1.0546875` is the seam scale of a 1600×900 window at uiScale 0.9.
/// `1.0546875` is a 1600×900 window's seam times the 0.9 UI scale.
#[test]
fn a_shadow_offset_is_a_whole_pixel_and_never_vanishes() {
assert_eq!(shadow_offset_px(1.0), 1.0);
+2 -6
View File
@@ -94,8 +94,6 @@ pub(super) fn feed_ui_input(
),
// The characters the active layout makes, which name a key for a keyboard frame.
layout: Res<crate::bindings::LayoutNames>,
// The uiScale dial folded into the seam scale.
ui_scale: Res<super::UiScaleCvar>,
) {
let (keyboard, keys, capture, clipboard) = (&mut kbd.0, &kbd.1, &mut kbd.2, &mut kbd.3);
let world_pick = pointer.world_pick();
@@ -139,10 +137,8 @@ pub(super) fn feed_ui_input(
.or_else(crate::target::hover_probe_point)
.filter(|_| !synthetic)
{
// The window cursor is logical px, y-down from the top left; the UI is y-up in 768-high
// units under uiScale: flip through the window height, then undo the extract seam's scale.
let s = super::seam_scale(window.height(), ui_scale.0);
let (x, y) = (cursor.x / s, (window.height() - cursor.y) / s);
// The window cursor in the root's y-up 768-high units, as `GetCursorPosition` answers it.
let Vec2 { x, y } = super::window_to_ui(window.height(), cursor);
// `WOW_HIT_COST=1` meters this call's per-frame hit-test rebuild, one line a second.
static HIT_COST: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
let metering =
+31 -17
View File
@@ -230,26 +230,22 @@ pub(crate) fn run_pending_entry_load(world: &mut World) {
);
}
/// Seat the VM's screen size and text measurer for the load, under the current window and
/// `uiScale`: the load-edge half of [`super::extract::tick_script`]'s per-frame pair. A fresh VM
/// answers 1024×768 until then, and a value computed once at load keeps it: stock
/// `WorldMapFrame_OnLoad` sizes the full-screen `BlackoutWorld` from `GetScreenWidth()` and
/// `GetScreenHeight()` and never again (`WorldMapFrame.lua:19-28`). With no atlas yet (it bakes on
/// the first `Update`) the per-frame pass seats the measurer.
/// Seat the VM's screen size and text measurer for the load, under the current window: the
/// load-edge half of [`super::extract::tick_script`]'s per-frame pair. A fresh VM answers 1024×768
/// until then, and a value computed once at load keeps it: stock `WorldMapFrame_OnLoad` sizes the
/// full-screen `BlackoutWorld` from `GetScreenWidth()` and `GetScreenHeight()` and never again
/// (`WorldMapFrame.lua:19-28`), which answer the root while `UIParent` is still at scale 1. With no
/// atlas yet (it bakes on the first `Update`) the per-frame pass seats the measurer.
fn seat_raster_seam_for_load(world: &mut World, script: &mut UiScript) {
let ui_scale = world
.get_resource::<super::UiScaleCvar>()
.map_or(1.0, |c| c.0);
let (w, h) = {
let mut q = world.query_filtered::<&Window, With<bevy::window::PrimaryWindow>>();
q.single(world)
.map_or((0.0, 0.0), |win| (win.width(), win.height()))
};
let s = super::seam_scale(h, ui_scale);
let s = super::seam_scale(h);
if w > 0.0 && h > 0.0 {
// The VM lives in 768-tall virtual space, so logical size over the seam scale is its
// screen. No `UIParent_ManageFramePositions` here, unlike `tick_script`: nothing is laid
// out yet.
// The VM lives in a 768-tall root, so logical size over the seam scale is its screen. No
// `UIParent_ManageFramePositions` here, unlike `tick_script`: nothing is laid out yet.
script.set_screen_size(w / s, h / s);
}
let Some(atlas) = world.get_resource::<crate::ui_text::UiFontAtlas>() else {
@@ -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::<super::UiScaleCvar>()
.map_or(1.0, |c| c.0);
let zoom = {
let z = world.resource::<crate::minimap::MinimapZoom>();
(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<bevy::window::PrimaryWindow>>();
q.single(world).map_or((0, 0), |w| {
(w.width().round() as u32, w.height().round() as u32)
})
};
let mut none = None;
let mut cache = world.get_resource_mut::<super::UiAutoScaleCache>();
let cache = cache.as_deref_mut().map_or(&mut none, |c| &mut c.0);
script.seat_ui_scale(ui_scale, display, cache);
},
);
});
@@ -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(
+14 -11
View File
@@ -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
+48 -17
View File
@@ -1,8 +1,9 @@
//! The UI-engine bridge: hosts [`benilla_ui::script::UiScript`] and feeds its
//! [`extract`](benilla_ui::script::UiScript::extract) output into the quad pass
//! ([`crate::ui_pass::UiQuads`]) every frame. The script side is WoW UI space (y-up, origin
//! bottom-left, a screen `768/uiScale` units tall); [`seam_scale`] carries quads ×s out and the
//! mouse ÷s in, and extraction flips to y-down window px.
//! bottom-left, a screen root 768 units tall at scale 1); [`seam_scale`] carries quads ×s out and
//! the mouse ÷s in, and extraction flips to y-down window px. The UI scale is `UIParent`'s own
//! frame scale inside the VM ([`UiScaleCvar`]), never part of the seam.
use bevy::prelude::*;
@@ -199,8 +200,10 @@ pub(crate) fn run_or_warn(script: &benilla_ui::script::UiScript, chunk: &str) {
}
}
/// The reference's `uiScale` CVar: the VM's screen is `768/uiScale` units tall, so
/// `uiScale = 768/screenH` is pixel-perfect. Tests pin the `Default` 1.0.
/// The reference's `uiScale` CVar, which the VM makes `UIParent`'s own scale
/// ([`UiScript::set_ui_scale`]): the screen root stays 768 units tall, so `uiScale = 768/screenH`
/// is pixel-perfect inside `UIParent`, and a frame outside it is not scaled. Tests pin the
/// `Default` 1.0.
#[derive(Resource)]
pub(crate) struct UiScaleCvar(pub(crate) f32);
@@ -213,8 +216,17 @@ impl Default for UiScaleCvar {
/// The shipped `uiScale`. Deviation: a flat 0.9, because 1.0 reads oversized. With `useUiScale`
/// off, its default (`0x8430c0`), the reference sets `max(768/H, 0.9)` above 768 px tall and 1.0
/// at or below (`0x492f70`, on a mode set and from `0x4908ad`), so the two agree from ~853 px up.
/// benilla never reads `useUiScale`: this CVar always applies to `UIParent` alone, as with it on.
/// A change goes through the CVar callback (`0x490770`: the narrow-aspect cap, the 0.64 floor);
/// the load and a display change through the display handler's ON leg (`0x492e90`: the cap, and
/// below 0.64 the automatic scale `0x492f70` instead of the floor).
pub(crate) const DEFAULT_UI_SCALE: f32 = 0.9;
/// The display the reference's automatic UI scale last ran for (`0x492f70`'s `[0xb4e304]` and
/// `[0xb4e308]`), which outlives every UI: the display handler skips a display it already ran for.
#[derive(Resource, Default)]
pub(crate) struct UiAutoScaleCache(pub(crate) Option<(u32, u32)>);
/// `WOW_UI_SCALE=` if set, clamped to the dial's range, else [`DEFAULT_UI_SCALE`].
fn default_ui_scale() -> f32 {
std::env::var("WOW_UI_SCALE")
@@ -224,16 +236,24 @@ fn default_ui_scale() -> f32 {
.unwrap_or(DEFAULT_UI_SCALE)
}
/// The seam scale `s`, window px per UI unit (`windowH/768 × uiScale`), used at every crossing of
/// the VM boundary; identity for a degenerate window (h ≤ 0, before winit).
pub(crate) fn seam_scale(window_h: f32, ui_scale: f32) -> f32 {
/// The seam scale `s`, window px per screen-root unit (`windowH/768`: the root is 768 units tall at
/// every aspect, `0x41ad10`), used at every crossing of the VM boundary; identity for a degenerate
/// window (h ≤ 0, before winit). The UI scale is not in it: it is `UIParent`'s, inside the VM.
pub(crate) fn seam_scale(window_h: f32) -> f32 {
if window_h > 0.0 {
window_h / 768.0 * ui_scale
window_h / 768.0
} else {
1.0
}
}
/// A window cursor (logical px, y-down from the top left) in the VM's root units, y up: what the
/// pointer feed hands the engine and `GetCursorPosition` answers (`0x48b820`, no scale divided out).
pub(crate) fn window_to_ui(window_h: f32, cursor: Vec2) -> Vec2 {
let s = seam_scale(window_h);
Vec2::new(cursor.x / s, (window_h - cursor.y) / s)
}
/// The Lua UI host (a `NonSend` resource: an mlua VM is `!Send`) and its per-frame passes.
pub(crate) struct UiScriptPlugin;
@@ -263,6 +283,7 @@ impl Plugin for UiScriptPlugin {
// The quit root runs in `Last`: the close button's `AppExit` is written in `PostUpdate`.
crate::shutdown::on_app_exit(app, shutdown_on_exit.into_configs());
app.insert_resource(UiScaleCvar(default_ui_scale()))
.init_resource::<UiAutoScaleCache>()
.init_resource::<UiFrameCost>()
.init_resource::<crate::bindings::WheelNotches>()
.init_resource::<UiCostWanted>()
@@ -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);
}
}
@@ -0,0 +1,272 @@
//! The UI scale through the real UI pass: `uiScale` is `UIParent`'s own frame scale (`0x494550`
//! calls `0x76ac10` on the frame named `"UIParent"`), and the screen root under it keeps scale 1
//! and is 768 units tall, so a frame outside `UIParent` is not scaled by it.
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use benilla_ui::script::UiScript;
use super::extract::{paint_script, tick_script};
use crate::portrait::PortraitImages;
use crate::ui_pass::{UiQuad, UiQuads};
/// A `UIParent` filling the root with a red 100-unit square inside it, 10 units in from its top
/// left, and a green 100-unit square at the root's bottom left with no parent.
const TREE: &str = r#"
local ui = CreateFrame("Frame", "UIParent")
ui:SetPoint("BOTTOMLEFT", 0, 0)
ui:SetPoint("TOPRIGHT", 0, 0)
local inside = CreateFrame("Frame", "Inside", UIParent)
inside:SetPoint("TOPLEFT", 10, -10)
inside:SetWidth(100); inside:SetHeight(100)
local red = inside:CreateTexture(nil, "ARTWORK")
red:SetTexture(1, 0, 0)
red:SetAllPoints()
local outside = CreateFrame("Frame", "Outside")
outside:SetPoint("BOTTOMLEFT", 0, 0)
outside:SetWidth(100); outside:SetHeight(100)
local green = outside:CreateTexture(nil, "ARTWORK")
green:SetTexture(0, 1, 0)
green:SetAllPoints()
"#;
/// Both halves of the UI pass over [`TREE`] on a `w`×`h` window, the dial at `ui_scale`.
fn app(w: u32, h: u32, ui_scale: f32) -> App {
let script = UiScript::new().unwrap();
script.run(TREE).unwrap();
let mut app = App::new();
app.insert_non_send_resource(script);
app.init_resource::<UiQuads>();
app.init_resource::<super::UiPassState>();
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::ui_models::UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<super::UiFrameCost>();
app.init_resource::<super::UiCostWanted>();
app.init_resource::<Time>();
app.init_resource::<Time<Real>>();
app.init_resource::<super::UiClock>();
app.insert_resource(super::UiScaleCvar(ui_scale));
app.world_mut().spawn((
Window {
resolution: UVec2::new(w, h).into(),
..default()
},
PrimaryWindow,
));
app.add_systems(Update, (tick_script, paint_script).chain());
app.update();
app
}
fn eval(app: &App, chunk: &str) -> f64 {
app.world()
.non_send_resource::<UiScript>()
.eval::<f64>(chunk)
.unwrap()
}
/// The window-px rect of the quad tinted `rgb`, y-down.
fn quad(app: &App, rgb: [f32; 3]) -> Rect {
let quads = &app.world().resource::<UiQuads>().quads;
let q: &UiQuad = quads
.iter()
.find(|q| q.color[..3] == rgb)
.expect("the marker quad extracted");
q.rect
}
fn close(a: f32, b: f32) -> bool {
(a - b).abs() < 1e-3
}
fn rect_close(got: Rect, want: Rect) -> bool {
close(got.min.x, want.min.x)
&& close(got.min.y, want.min.y)
&& close(got.max.x, want.max.x)
&& close(got.max.y, want.max.y)
}
/// `UIParent:GetScale()` and `GetEffectiveScale()` answer the UI scale: it is `UIParent`'s own
/// scale, set through the worker Lua's `SetScale` reaches (`0x494550` → `0x76ac10`).
#[test]
fn uiparent_answers_the_ui_scale() {
let app = app(1024, 768, 0.9);
assert!(close(eval(&app, "return UIParent:GetScale()") as f32, 0.9));
assert!(close(
eval(&app, "return UIParent:GetEffectiveScale()") as f32,
0.9
));
assert!(close(
eval(&app, "return Inside:GetEffectiveScale()") as f32,
0.9
));
assert_eq!(eval(&app, "return Outside:GetEffectiveScale()"), 1.0);
}
/// `GetScreenWidth`/`GetScreenHeight` (`0x48b480`/`0x48b4d0`) divide the 768-tall root by
/// `UIParent`'s scale (`0x494590`), so they answer in `UIParent`'s units.
#[test]
fn the_screen_size_answers_in_uiparent_units() {
let app = app(1024, 768, 0.9);
assert!(close(
eval(&app, "return GetScreenHeight()") as f32,
768.0 / 0.9
));
assert!(close(
eval(&app, "return GetScreenWidth()") as f32,
1024.0 / 0.9
));
}
/// A frame with no parent hangs off the root, which keeps scale 1: one unit is `windowH/768` px
/// whatever the dial says.
#[test]
fn a_parentless_frame_is_not_scaled_by_the_ui_scale() {
let app = app(1280, 1024, 0.9);
let s = 1024.0 / 768.0;
let got = quad(&app, [0.0, 1.0, 0.0]);
assert!(
rect_close(got, Rect::new(0.0, 1024.0 - 100.0 * s, 100.0 * s, 1024.0)),
"got {got:?}"
);
}
/// Inside `UIParent` nothing moves: the dial scales the subtree exactly as it scaled the seam.
#[test]
fn a_frame_inside_uiparent_lands_where_the_dial_put_it() {
let app = app(1280, 1024, 0.9);
let s = 1024.0 / 768.0 * 0.9;
let got = quad(&app, [1.0, 0.0, 0.0]);
assert!(
rect_close(got, Rect::new(10.0 * s, 10.0 * s, 110.0 * s, 110.0 * s)),
"got {got:?}"
);
}
/// Whether `frame` is `UIParent` or hangs below it.
fn under_ui_parent(s: &UiScript, frame: benilla_ui::widget::FrameHandle) -> bool {
let mut at = Some(frame);
while let Some(h) = at {
if s.frame_name(h).as_deref() == Some("UIParent") {
return true;
}
at = s.frame_parent(h);
}
false
}
/// The whole stock interface, every frame shown: scaling `UIParent` by `S` in the 768-tall root
/// must place every quad of its subtree, and every hit, exactly where enlarging the root by `1/S`
/// with `UIParent` at scale 1 placed them, the seam having been the old home of the UI scale. A
/// frame-local length the engine forgets to scale (a border, an inset, a thumb) shows up here.
#[test]
fn the_stock_interface_inside_uiparent_is_unmoved_by_where_the_scale_lives() {
let _data = benilla_formats::wow_data_or_skip!();
const S: f32 = 0.9;
let (w, h) = (768.0 * 16.0 / 9.0, 768.0);
let mut script = UiScript::new().expect("VM");
script.set_screen_size(w, h);
let failures = super::load_default_ui(&script);
assert!(
failures.is_empty(),
"default UI failed to load: {failures:?}"
);
// Every frame shown, its parents first, so the most geometry the stock files declare draws.
script
.run(
r#"
local f = EnumerateFrames()
while f do
pcall(f.Show, f)
f = EnumerateFrames(f)
end
"#,
)
.unwrap();
let _ = script.take_errors();
// The old home: a root `1/S` larger, `UIParent` at scale 1.
script.set_screen_size(w / S, h / S);
script.run("UIParent:SetScale(1)").unwrap();
script.resolve();
let old = script.extract();
let grid: Vec<(f32, f32)> = (0..=40)
.flat_map(|i| (0..=24).map(move |j| (w * i as f32 / 40.0, h * j as f32 / 24.0)))
.collect();
let old_hits: Vec<_> = grid
.iter()
.map(|&(x, y)| script.hit_test_frame(x / S, y / S))
.collect();
// The reference's: a 768-tall root, `UIParent` at scale `S`.
script.set_screen_size(w, h);
assert!(script.set_ui_scale(S));
script.resolve();
let new = script.extract();
let new_hits: Vec<_> = grid
.iter()
.map(|&(x, y)| script.hit_test_frame(x, y))
.collect();
assert_eq!(old.len(), new.len(), "the same quads draw");
let close = |a: f32, b: f32| (a - b).abs() < 0.02;
let mut moved = Vec::new();
for (a, b) in old.iter().zip(&new) {
assert_eq!(a.target, b.target, "the same paint order");
let Some(frame) = script.target_frame(b.target) else {
continue;
};
if !under_ui_parent(&script, frame) {
continue;
}
let same_rect = match (a.rect, b.rect) {
(Some(ra), Some(rb)) => {
close(ra.left * S, rb.left)
&& close(ra.right * S, rb.right)
&& close(ra.top * S, rb.top)
&& close(ra.bottom * S, rb.bottom)
}
(None, None) => true,
_ => false,
};
if !same_rect || !close(a.scale * S, b.scale) {
moved.push(format!(
"{:?} {:?}: {:?}×{S} vs {:?}",
script.target_owner_name(b.target),
std::mem::discriminant(&b.content),
a.rect,
b.rect
));
}
}
assert!(
moved.is_empty(),
"{} quads inside UIParent moved, first: {:#?}",
moved.len(),
&moved[..moved.len().min(20)]
);
let inside = |hit: Option<benilla_ui::widget::FrameHandle>| {
hit.is_none_or(|h| under_ui_parent(&script, h))
};
let mut rehit = Vec::new();
for ((&at, a), b) in grid.iter().zip(&old_hits).zip(&new_hits) {
if inside(*a) && inside(*b) && a != b {
rehit.push(format!(
"{at:?}: {:?} vs {:?}",
a.and_then(|h| script.frame_name(h)),
b.and_then(|h| script.frame_name(h))
));
}
}
assert!(
rehit.is_empty(),
"{} hits inside UIParent changed, first: {:#?}",
rehit.len(),
&rehit[..rehit.len().min(20)]
);
}
+6 -5
View File
@@ -52,11 +52,12 @@ pub(crate) fn fontstring_em(height: Option<f32>) -> Option<f32> {
height.map(|h| h.min(FONTSTRING_EM_CAP))
}
/// The drawn pixel height of a UI FontString times the seam scale `s = windowH/768 × uiScale`
/// (`crate::ui_script::seam_scale`), under the reference's two size regimes (getter `0x7727b0`):
/// one-to-one, the default, draws the raster size ([`FONTSTRING_EM_CAP`] applied in units), and a
/// `SetTextHeight` size (`text_height`) draws uncapped, the reference magnifying the raster to it
/// (`0x771600` clears bit `0x200`; the drawn em is `round(size/768·deviceH)`).
/// The drawn pixel height of a UI FontString at `s`, the seam scale `windowH/768`
/// (`crate::ui_script::seam_scale`) times the owner's effective scale, the UI scale included,
/// under the reference's two size regimes (getter `0x7727b0`): one-to-one, the default, draws the
/// raster size ([`FONTSTRING_EM_CAP`] applied in units), and a `SetTextHeight` size
/// (`text_height`) draws uncapped, the reference magnifying the raster to it (`0x771600` clears bit
/// `0x200`; the drawn em is `round(size/768·deviceH)`).
pub(crate) fn drawn_px(font_height: Option<f32>, text_height: Option<f32>, s: f32) -> Option<f32> {
match text_height {
Some(t) => Some(t * s),
+5 -5
View File
@@ -114,8 +114,6 @@ fn drive_mouseover_tooltip(
go_inputs: crate::target::lock::GoLockInputs,
// The known-spell set the resolver's SKILL arm scans.
player_actions: Res<crate::ui_action::PlayerActions>,
// The cursor seat crosses the VM seam: the anchor below is UI units, not px.
ui_scale: Res<crate::ui_script::UiScaleCvar>,
mut memo: HoverMemo,
// `ChrClasses.dbc` field 16, `UnitHasRelicSlot`'s input, and the form table the creature type
// reads.
@@ -229,15 +227,17 @@ fn drive_mouseover_tooltip(
.templates
.get(guid)
.is_some_and(|t| t.floating_tooltip);
// Window px to the VM's y-up UI units, as the input seam converts.
// Window px to the VM's y-up root units, as the input seam converts.
let cursor_ui = cursor_seated
.then(|| {
window.iter().next().and_then(|w| {
let s = crate::ui_script::seam_scale(w.height(), ui_scale.0);
// The hover probe's aim stands in for a missing cursor; a real pointer wins.
w.cursor_position()
.or_else(crate::target::hover_probe_point)
.map(|c| (c.x / s, (w.height() - c.y) / s))
.map(|c| {
let at = crate::ui_script::window_to_ui(w.height(), c);
(at.x, at.y)
})
})
})
.flatten();
@@ -882,7 +882,6 @@ fn mouseover_app() -> (App, Entity, Entity) {
let mut app = App::new();
let (tx, _rx) = crossbeam_channel::unbounded();
app.insert_resource(NetCommands(tx))
.insert_resource(crate::ui_script::UiScaleCvar(1.0))
.init_resource::<Hovered>()
.init_resource::<HoveredObject>()
.init_resource::<NameCache>()
+3 -5
View File
@@ -710,7 +710,6 @@ fn dev_map_jump(
buttons: Res<ButtonInput<MouseButton>>,
keys: Res<ButtonInput<KeyCode>>,
windows: Query<&Window, With<bevy::window::PrimaryWindow>>,
ui_scale: Res<crate::ui_script::UiScaleCvar>,
script: Option<NonSendMut<UiScript>>,
data: Option<Res<WorldMapUiData>>,
net: Res<crate::net::NetCommands>,
@@ -729,10 +728,9 @@ fn dev_map_jump(
let Some(cursor) = window.cursor_position() else {
return;
};
// Window px (y-down) to the VM's y-up 768-unit space, as the pointer feed converts them.
let s = crate::ui_script::seam_scale(window.height(), ui_scale.0);
let Some((u, v)) = script.world_map_uv_at(cursor.x / s, (window.height() - cursor.y) / s)
else {
// Window px (y-down) to the VM's y-up root units, as the pointer feed converts them.
let at = crate::ui_script::window_to_ui(window.height(), cursor);
let Some((u, v)) = script.world_map_uv_at(at.x, at.y) else {
return;
};
let (c, z, direct) = script.world_map_selection();
+4 -8
View File
@@ -308,7 +308,6 @@ fn drive_vplates(
net_commands: Res<NetCommands>,
// `None` in a run with no UI VM.
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
ui_scale: Res<crate::ui_script::UiScaleCvar>,
anchor_q: (
Query<&BoneAttach>,
Query<&benilla_world::rig_anim::RigPose>,
@@ -374,10 +373,8 @@ fn drive_vplates(
let (pw, ph) = (gx(PLATE_W), gx(PLATE_H));
let scale = window.map_or(1.0, |w| w.scale_factor());
// Everything below is in window px, divided by this once for the widget layer's units.
let seam = window.map_or(1.0, |w| {
crate::ui_script::seam_scale(w.height(), ui_scale.0)
});
// Everything below is in window px, divided by this once for the widget layer's root units.
let seam = window.map_or(1.0, |w| crate::ui_script::seam_scale(w.height()));
let mut states: Vec<PlateState> = Vec::new();
// Plates seat in squared-distance order from the gx point (0.4, 0.3), Y mirrored from the
@@ -541,9 +538,8 @@ fn drive_vplates(
}
// ── The plate's state, handed to the widget layer ──
//
// The reference's plates sit outside the uiScale cascade, but benilla folds uiScale into
// one global seam, so dividing by it here keeps plate pixels uiScale-blind.
// The raid-target index is 1-based here, 0-based for the atlas cell.
// The plates hang off `WorldFrame`, outside `UIParent`'s scale, so root units are their
// own. The raid-target index is 1-based here, 0-based for the atlas cell.
let mark = group.raid_target_index(guid.0);
let x_units = (plate.min.x + plate.max.x) * 0.5 / seam;
let y_units = (viewport.y - plate.min.y) / seam;
+44 -15
View File
@@ -123,18 +123,25 @@ fn aa_corners(x0: f32, x1: f32, y_bottom: f32, y_top: f32) -> [[f32; 2]; 4] {
/// The drawable pieces for a frame's resolved rect (y-up), in paint order: the background (layer
/// `BACKGROUND`), then the 8 border pieces (layer `BORDER`). Geometry `0x77e8d0`, UVs `0x77f0c0`.
pub fn pieces(frame: Rect, bd: &Backdrop) -> Vec<BackdropPiece> {
/// The pieces are textures anchored and sized in the frame's own units, so the layout multiplies
/// their geometry by the frame's effective `scale` (`0x76ac90`), as it does any region's. The
/// texcoord repeats are not: `0x77f0c0` divides the scaled rect by the stored, unscaled
/// `edgeSize` and `tileSize` (`0x77f101 fdiv [esi+0x48]`, `0x77f195`), so a scaled frame's edge
/// and background repeat the art more or less often than at scale 1.
pub fn pieces(frame: Rect, bd: &Backdrop, scale: f32) -> Vec<BackdropPiece> {
let mut out = Vec::with_capacity(9);
let e = bd.edge_size;
let e = bd.edge_size * scale;
// The texcoord divisor, unscaled.
let e_uv = bd.edge_size;
let (l, r, b, t) = (frame.left, frame.right, frame.bottom, frame.top);
// ── The background piece ──
if bd.has_bg() {
let (il, ir, it, ib) = (
bd.insets.left,
bd.insets.right,
bd.insets.top,
bd.insets.bottom,
bd.insets.left * scale,
bd.insets.right * scale,
bd.insets.top * scale,
bd.insets.bottom * scale,
);
// The four anchors, y-up. Bottom-right takes the top inset, as the reference does
// (`0x77e9ae` loads `+0x50`, top, not `+0x54`); invisible when the two are equal.
@@ -146,7 +153,11 @@ pub fn pieces(frame: Rect, bd: &Backdrop) -> Vec<BackdropPiece> {
];
// Untiled, no `SetTexCoord` runs and the background stretches over `[0,1]` (`0x77f0c0`).
let uvs = if bd.tile {
let period = if bd.tile_size != 0.0 { bd.tile_size } else { e };
let period = if bd.tile_size != 0.0 {
bd.tile_size
} else {
e_uv
};
let bg_w = (r - ir) - (l + il);
let bg_h = (t - it) - (b + ib);
let (wt, ht) = (bg_w / period, bg_h / period);
@@ -165,8 +176,8 @@ pub fn pieces(frame: Rect, bd: &Backdrop) -> Vec<BackdropPiece> {
// ── The 8 border pieces ──
if bd.has_border() {
// Edge strips tile `run` periods between the corners, 0 below two edges (`0x77f0c0`).
let w_run = ((r - l) / e - 2.0).max(0.0);
let h_run = ((t - b) / e - 2.0).max(0.0);
let w_run = ((r - l) / e_uv - 2.0).max(0.0);
let h_run = ((t - b) / e_uv - 2.0).max(0.0);
let mut edge = |x0: f32, x1: f32, y0: f32, y1: f32, uvs: [[f32; 2]; 4]| {
out.push(BackdropPiece {
@@ -311,7 +322,7 @@ mod tests {
fn piece_geometry_200x100_edge16_inset5() {
let frame = Rect::new(0.0, 0.0, 100.0, 200.0);
let bd = tooltip_backdrop(16.0, 5.0);
let ps = pieces(frame, &bd);
let ps = pieces(frame, &bd, 1.0);
assert_eq!(ps.len(), 9);
// Symmetric insets hide the bottom-right quirk.
@@ -365,7 +376,7 @@ mod tests {
},
..tooltip_backdrop(16.0, 0.0)
};
let bg = pieces(frame, &bd)[0];
let bg = pieces(frame, &bd, 1.0)[0];
assert_eq!(bg.corners[3], [3.0, 9.0]);
// BR takes the top inset, 7, not the bottom's 9.
assert_eq!(bg.corners[2], [196.0, 7.0]);
@@ -378,7 +389,7 @@ mod tests {
// 20/16 - 2 = -0.75.
let frame = Rect::new(0.0, 0.0, 20.0, 20.0);
let bd = tooltip_backdrop(16.0, 5.0);
let ps = pieces(frame, &bd);
let ps = pieces(frame, &bd, 1.0);
let left = ps[1]; // LEFT edge
assert_eq!(left.uvs[3][1], 0.0, "h_run clamped to 0"); // BL v
let top = ps[3]; // TOP edge
@@ -388,16 +399,34 @@ mod tests {
#[test]
fn presence_gates_pieces() {
let frame = Rect::new(0.0, 0.0, 100.0, 100.0);
assert!(pieces(frame, &Backdrop::default()).is_empty());
assert!(pieces(frame, &Backdrop::default(), 1.0).is_empty());
let bg_only = Backdrop {
bg_file: Some("bg".into()),
..Default::default()
};
assert_eq!(pieces(frame, &bg_only).len(), 1);
assert_eq!(pieces(frame, &bg_only, 1.0).len(), 1);
let border_only = Backdrop {
edge_file: Some("edge".into()),
..Default::default()
};
assert_eq!(pieces(frame, &border_only).len(), 8);
assert_eq!(pieces(frame, &border_only, 1.0).len(), 8);
}
/// A half-scale frame: the geometry halves, but `0x77f0c0` divides the scaled rect by the
/// unscaled sizes, so a 100-wide (on screen) edge repeats `100/16 - 2` times and the 96-wide
/// background `96/16` times.
#[test]
fn a_scaled_frames_texcoords_repeat_by_the_unscaled_sizes() {
let frame = Rect::new(0.0, 0.0, 50.0, 100.0);
let bd = tooltip_backdrop(16.0, 4.0);
let ps = pieces(frame, &bd, 0.5);
let bg = ps[0];
assert_eq!(bg.corners[0], [2.0, 48.0], "inset 4 units, 2 on screen");
assert_eq!(bg.uvs[2], [6.0, 46.0 / 16.0]);
// TOP, slice 2, rotated: `u` holds the run.
let top = ps[3];
assert_eq!(top.corners[0][1], 50.0);
assert_eq!(top.corners[2][1], 42.0, "8 thick on screen");
assert_eq!(top.uvs[0][1], 100.0 / 16.0 - 2.0);
}
}
@@ -798,6 +798,20 @@ mod tests {
}
}
/// The markers are textures of the picker, sized in its units, so a half-scale picker draws
/// them at half size: 5 and 24 on screen.
#[test]
fn the_markers_scale_with_the_picker() {
let mut s = picker();
s.run("TestPicker:SetScale(0.5); TestPicker:SetColorRGB(1, 0, 0)")
.unwrap();
s.resolve();
let wheel = marker_rect(&s, 5.0).expect("the wheel marker, 10 units at half scale");
assert!((wheel.top - wheel.bottom - 5.0).abs() < 0.01, "{wheel:?}");
let value = marker_rect(&s, 24.0).expect("the value marker, 48 units at half scale");
assert!((value.top - value.bottom - 7.0).abs() < 0.01, "{value:?}");
}
/// The extracted rect of the marker `width` wide: the two share one BLP and differ by size, 10
/// for the wheel's and 48 for the strip's.
fn marker_rect(s: &UiScript, width: f32) -> Option<crate::layout::Rect> {
@@ -64,7 +64,7 @@ fn caret(lua: &Lua, h: FrameHandle) -> bool {
placed
}
/// `OnCursorChanged(x, y, w, h)` (`0x77de1b`, in UI units via `0x77dd5f`), `x` the caret's advance
/// `OnCursorChanged(x, y, w, h)` (`0x77de1b`, in the box's own units via `0x77dd5f`), `x` the caret's advance
/// along its line, `y` minus the row index times the row pitch, `w` the constant 4.0 and `h` the
/// line height. The arguments are computed only for a box with the script (`0x77dd8c`), from the
/// box's advance table, which the installed font engine measures here when the text or font
@@ -103,6 +103,14 @@ fn cursor_changed(lua: &Lua, h: FrameHandle) -> bool {
let Some((x, y, pitch)) = args else {
return false;
};
// The advance table is in root units (measured at the box's scale, divided by the seam
// alone); the handler gets the box's own, the reference dividing by its effective scale
// (`0x77db23 fdiv [esi+0x7c]`).
let k = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
crate::script::object::eff_scale(&model, h)
};
let (x, y, pitch) = (x / k, y / k, pitch / k);
if let Err(e) = event::fire_widget_handler(
lua,
id,
@@ -509,3 +509,49 @@ fn on_cursor_changed_reports_the_first_row_at_plus_zero() {
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// [`Mono`] as the host answers a scaled box: glyphs 7 and lines 14 in the box's own units, the
/// table in root units (measured at the box's scale, divided by the seam alone).
struct ScaledMono;
impl TextMeasure for ScaledMono {
fn measure(&mut self, req: &MeasureRequest) -> (f32, f32, f32) {
Mono.measure(req)
}
fn editbox_advances(
&mut self,
req: &EditBoxAdvanceRequest,
) -> Option<(Vec<f32>, Vec<usize>, f32)> {
let (cum, rows, pitch) = Mono.editbox_advances(req)?;
let k = req.scale;
Some((cum.into_iter().map(|a| a * k).collect(), rows, pitch * k))
}
}
/// `OnCursorChanged` answers in the box's own units: the reference divides by its effective scale
/// (`0x77db23 fdiv [esi+0x7c]`), so a half-scale box reports what a full-scale one does.
#[test]
fn on_cursor_changed_answers_in_the_boxs_own_units() {
let mut s = script();
s.set_text_measurer(Box::new(ScaledMono));
logged_box(&mut s);
s.run(
r#"
E:SetScale(0.5)
E:SetScript("OnCursorChanged", function()
table.insert(LOG, format("cursor@%g,%g,%g", arg1, arg2, arg4))
end)
E:SetMultiLine(true)
E:SetText("abc\nd")
"#,
)
.unwrap();
s.tick(0.016);
assert_eq!(
take_log(&s),
"early update cursor@7,-14,14 text:abc\nd late",
"one glyph along the second row, a row pitch down, one pitch tall"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
+11 -4
View File
@@ -130,9 +130,10 @@ impl UiScript {
owner_frame.map(|f| &f.kind_state)
{
if sl.thumb == Some(rh) {
// The thumb's own `CSimpleTexture::GetWidth`/`GetHeight`, as the drag
// reads them (`slider::thumb_extent`), not its authored `<Size>`.
let tsize = super::region::virtual_span(&model, rh);
// The thumb's own `CSimpleTexture::GetWidth`/`GetHeight` at the
// slider's scale, as the drag reads them (`slider::thumb_extent`), not
// its authored `<Size>`.
let tsize = super::region::screen_span(&model, rh);
rect = rect
.map(|r| slider::thumb_rect(r, tsize, sl.vertical, sl.fraction()));
thumb_fill = true;
@@ -145,7 +146,13 @@ impl UiScript {
if let Some(crate::widget::KindState::ColorSelect(cs)) =
owner_frame.map(|f| &f.kind_state)
{
let tsize = model.region_data.get(&rh).and_then(|d| d.size);
// The markers' authored size, in the picker's units.
let scale = owner_frame.map_or(1.0, |f| f.effective_scale);
let tsize = model
.region_data
.get(&rh)
.and_then(|d| d.size)
.map(|(w, h)| (w * scale, h * scale));
if cs.wheel_thumb == Some(rh) {
rect = cs
.wheel
+2 -1
View File
@@ -1239,7 +1239,8 @@ impl UiScript {
let Some(bd) = model.backdrops.get(&fh) else {
return;
};
for piece in backdrop::pieces(fr, bd) {
let scale = crate::script::object::eff_scale(model, fh);
for piece in backdrop::pieces(fr, bd, scale) {
// The piece's screen bounding box: the render is axis-aligned, so the reference's slant
// under unequal top and bottom insets is not drawn (stock insets are symmetric).
let xs = piece.corners.map(|c| c[0]);
+8 -5
View File
@@ -114,13 +114,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
m.set(
"PingLocation",
// Centre-relative offsets in UI units, x right and y up, parked for the app to resolve in
// the frame it draws the map; one pending click, not one per widget.
// Centre-relative offsets in the minimap's own units, x right and y up, which the
// reference reads against the widget's own size (`0x4eeca0`); parked in screen units, its
// effective scale applied, for the app to resolve in the frame it draws the map. One
// pending click, not one per widget.
lua.create_function(|lua, (this, x, y): (Table, f32, f32)| {
with_minimap(lua, &this, |_| ())?;
lua.app_data_mut::<Model>()
.expect("model app_data")
.minimap_ping_request = Some((x, y));
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let s = super::object::eff_scale(&model, h);
model.minimap_ping_request = Some((x * s, y * s));
Ok(())
})?,
)?;
+3 -1
View File
@@ -130,6 +130,7 @@ mod tooltip_item;
mod tooltip_spell;
mod tooltip_unit;
mod ui_errors;
mod ui_scale;
pub use tooltip_unit::TooltipTint;
mod trade;
mod tradeskill;
@@ -724,7 +725,8 @@ impl UiScript {
self.model_mut().minimap_ping = ping;
}
/// Drain a `Minimap:PingLocation(x, y)`: centre-relative offsets in UI units, x right, y up.
/// Drain a `Minimap:PingLocation(x, y)`: centre-relative offsets in the screen root's units,
/// x right, y up.
pub fn take_minimap_ping_request(&mut self) -> Option<(f32, f32)> {
self.model_mut().minimap_ping_request.take()
}
+21 -3
View File
@@ -228,8 +228,11 @@ pub(crate) struct Model {
pub(crate) diagnostics: super::diagnostics::DiagnosticLog,
/// Non-fatal warnings for the host, such as a dropped `inherits=` template.
pub(crate) warnings: Vec<String>,
/// The screen-root rect (`[bottom, left, top, right]`), the anchor base for top-level frames.
/// The screen-root rect (`[bottom, left, top, right]`), the anchor base for top-level frames:
/// 768 tall at scale 1, whatever the UI scale ([`super::ui_scale`]).
pub(crate) screen: Rect,
/// The host's `uiScale`, which [`super::UiScript::apply_ui_scale`] makes `UIParent`'s scale.
pub(crate) ui_scale_request: Option<f32>,
/// Each unit token's state as pushed this frame, keyed lowercased because 1.12's resolver
/// (`0x515970`) matches with `SStrCmpI`: read it only through `unit`, where the fold lives.
@@ -919,8 +922,9 @@ pub(crate) struct Model {
/// The last cursor position in UI space (logical px, y-up), behind `GetCursorPosition()`.
pub(crate) cursor_pos: (f32, f32),
/// A `Minimap:PingLocation(x, y)`, drained the same frame: centre-relative offsets in UI units,
/// `GetCursorPosition`'s space, not the window pixels of the app's minimap geometry.
/// A `Minimap:PingLocation(x, y)`, drained the same frame: centre-relative offsets in the screen
/// root's units, `GetCursorPosition`'s space, not the window pixels of the app's minimap
/// geometry.
pub(crate) minimap_ping_request: Option<(f32, f32)>,
/// The ping's offsets as fractions of the minimap's side, updated by the app every frame.
/// `GetPingPosition()` always answers both, from statics never cleared (`0x4eefd0`).
@@ -1106,6 +1110,7 @@ impl Model {
warnings: Vec::new(),
// 1024x768 until the host calls `set_screen_size`; y-up `[bottom, left, top, right]`.
screen: Rect::new(0.0, 0.0, 768.0, 1024.0),
ui_scale_request: None,
units_by_lower: HashMap::new(),
units_by_guid: HashMap::new(),
player_auras: Vec::new(),
@@ -1498,6 +1503,19 @@ impl Model {
id
}
/// `CSimpleFrame::SetScale` (`0x76ac10`): store `h`'s own scale, cascade the effective scale
/// down its subtree, and re-lay out and re-measure on a change, since the owner's effective
/// scale is in every descendant FontString's measure key. Lua `SetScale` and the UI scale
/// ([`super::ui_scale`]) both come here, as both reach `0x76ac10` in the reference.
pub(crate) fn set_frame_scale(&mut self, h: FrameHandle, scale: f32) {
let changed = self.arena.frame(h).is_some_and(|f| f.scale != scale);
self.arena.set_scale(h, scale);
if changed {
self.touch_layout();
self.touch_measure_all();
}
}
/// A layout write that names no node. The resolve's tooltip pre-pass must not call this, or
/// the gate never closes; a missed call is caught by `WOW_LAYOUT_VERIFY`.
pub(crate) fn touch_layout(&mut self) {
+4 -13
View File
@@ -352,19 +352,20 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let create_frame = lua.create_function(create_frame)?;
lua.globals().set("CreateFrame", create_frame)?;
// `GetScreenWidth`/`GetScreenHeight`: the screen root's size in UI units.
// `GetScreenWidth`/`GetScreenHeight` (`0x48b480`/`0x48b4d0`): the screen root's size divided by
// `UIParent`'s scale (`GetUiScale`, `0x494590`), so they answer in `UIParent`'s units.
lua.globals().set(
"GetScreenWidth",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.screen.width())
Ok(model.screen.width() / super::ui_scale::ui_parent_scale(&model))
})?,
)?;
lua.globals().set(
"GetScreenHeight",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.screen.height())
Ok(model.screen.height() / super::ui_scale::ui_parent_scale(&model))
})?,
)?;
@@ -396,16 +397,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// `GetCursorPosition()`: the last cursor position the host fed, in screen UI units, y up, as
// the reference returns it; a caller in a scaled frame divides by its `GetEffectiveScale()`.
lua.globals().set(
"GetCursorPosition",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.cursor_pos)
})?,
)?;
// The modifier keys the app feeds each frame before any mouse event
// ([`UiScript::set_modifiers`]), answered 1 or nil as in 1.12, never a boolean: an addon
// testing `IsShiftKeyDown() == 1` (`ColorPickerPlus.lua:121`) reads `true` as not held.
@@ -414,14 +414,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
"SetScale",
lua.create_function(|lua, (this, scale): (Table, f32)| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let changed = model.arena.frame(h).is_some_and(|f| f.scale != scale);
model.arena.set_scale(h, scale);
if changed {
model.touch_layout();
// The owner's effective scale is in every descendant FontString's measure key.
model.touch_measure_all();
}
lua.app_data_mut::<Model>()
.expect("model")
.set_frame_scale(h, scale);
Ok(())
})?,
)?;
@@ -197,11 +197,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
})
})?;
// GetEffectiveScale(): parent scale times own scale, from a root of 1: `uiScale` is applied at
// the raster seam (a screen `768/uiScale` units tall), so every coordinate Lua sees,
// `GetCursorPosition()` included, is already in UI units. The reference instead makes
// `uiScale` `UIParent`'s own scale (`0x494550` calls `SetScale`), so there
// `UIParent:GetEffectiveScale()` answers it.
// GetEffectiveScale(): parent scale times own scale (`0x76ac90`), from a root of 1. The UI
// scale is `UIParent`'s own (`0x494550`), so `UIParent:GetEffectiveScale()` answers it and a
// frame outside `UIParent` does not carry it.
m.set(
"GetEffectiveScale",
lua.create_function(|lua, this: Table| {
+14 -9
View File
@@ -22,8 +22,9 @@ use super::{button, cursor, editbox, event, Model, UiScript};
/// each release only has to hit the frame.
pub(super) const DOUBLE_CLICK_SECS: f64 = 0.300;
/// Install `GetCursorPosition()` and `GetMouseFocus()`. The cursor is in UI units, as 1.12
/// answers it; the engine's y-up UI space is 1:1 logical pixels, so no scale factor applies.
/// Install `GetCursorPosition()` and `GetMouseFocus()`. The cursor (`0x48b820`) answers in the
/// screen root's units, y up, with no scale divided out: a caller divides by its frame's
/// `GetEffectiveScale()`, which carries the UI scale inside `UIParent`.
pub(super) fn install(lua: &mlua::Lua) -> mlua::Result<()> {
lua.globals().set(
"GetCursorPosition",
@@ -107,7 +108,7 @@ impl UiScript {
// tested, so the reticle is placed through it.
&& !model.nameplates.vetoes(fh)
&& model.resolved.get(&fh).is_some_and(|r| {
point_in_rect(inset_rect(*r, model.arena.hit_rect_insets(fh)), x, y)
point_in_rect(hit_rect(&model, fh, *r), x, y)
})
&& effective_clip(&model, &scroll_sources, fh)
.is_none_or(|c| point_in_rect(c, x, y))
@@ -124,9 +125,10 @@ impl UiScript {
// The wheel flag alone: the wheel index is separate from the mouse's, so the wheel
// passes a frame that only takes the mouse, as a scroll pane's chrome does.
model.arena.is_mouse_wheel_enabled(fh)
&& model.resolved.get(&fh).is_some_and(|r| {
point_in_rect(inset_rect(*r, model.arena.hit_rect_insets(fh)), x, y)
})
&& model
.resolved
.get(&fh)
.is_some_and(|r| point_in_rect(hit_rect(&model, fh, *r), x, y))
&& effective_clip(&model, &scroll_sources, fh)
.is_none_or(|c| point_in_rect(c, x, y))
})?;
@@ -628,9 +630,12 @@ pub(super) fn point_in_rect(r: Rect, x: f32, y: f32) -> bool {
x >= r.left && x <= r.right && y >= r.bottom && y <= r.top
}
/// A frame's mouse rect: its resolved rect shrunk by its `[left, right, top, bottom]` hit-rect
/// insets (y-up); an over-shrunk rect collapses to empty rather than inverting.
fn inset_rect(r: Rect, [left, right, top, bottom]: [f32; 4]) -> Rect {
/// A frame's mouse rect (`0x76b580`): its resolved rect shrunk by its `[left, right, top, bottom]`
/// hit-rect insets times its effective scale (y-up), the insets being in the frame's own units; an
/// over-shrunk rect collapses to empty rather than inverting.
fn hit_rect(model: &Model, fh: FrameHandle, r: Rect) -> Rect {
let s = super::object::eff_scale(model, fh);
let [left, right, top, bottom] = model.arena.hit_rect_insets(fh).map(|i| i * s);
Rect {
left: r.left + left,
right: (r.right - right).max(r.left + left),
+12
View File
@@ -684,3 +684,15 @@ pub(super) fn virtual_span(model: &Model, rh: RegionHandle) -> (f32, f32) {
_ => (aw, ah),
}
}
/// [`virtual_span`] on screen: the region's own units times its owner's effective scale, as the
/// layout sizes a region (`0x76ac90`). For the parts the engine seats itself, with no anchors: a
/// slider's thumb, a colour picker's markers.
pub(super) fn screen_span(model: &Model, rh: RegionHandle) -> (f32, f32) {
let (w, h) = virtual_span(model, rh);
let s = model
.arena
.region(rh)
.map_or(1.0, |r| super::object::eff_scale(model, r.owner));
(w * s, h * s)
}
+26 -7
View File
@@ -263,8 +263,9 @@ pub(super) fn thumb_rect(r: Rect, thumb_size: (f32, f32), vertical: bool, fracti
/// The thumb region's own width and height, as `0x789ba0` reads them through the texture's
/// geometry slots (`[vtable+0x1c]`/`+0x20`, `0x770720`/`0x770790`): the authored span, else the
/// art's texel span, else 0. `None` without a thumb region: `0x789ba0` gates the value math on
/// `+0x328`, so a thumbless slider captures a press but never moves.
/// art's texel span, else 0, in the slider's units, unscaled, though the track it is subtracted
/// from is the scaled rect (`0x789bd4`–`0x789bfe`). `None` without a thumb region: `0x789ba0`
/// gates the value math on `+0x328`, so a thumbless slider captures a press but never moves.
fn thumb_extent(model: &Model, thumb: Option<crate::widget::RegionHandle>) -> Option<(f32, f32)> {
Some(super::region::virtual_span(model, thumb?))
}
@@ -303,18 +304,36 @@ pub(super) fn begin_drag(
});
return None;
};
let trect = thumb_rect(r, size, vertical, fraction);
// The thumb as drawn, at the slider's scale, decides whether the press is on it.
let drawn = thumb.map_or(size, |t| super::region::screen_span(model, t));
let trect = thumb_rect(r, drawn, vertical, fraction);
let on_thumb = point_in_rect(trect, x, y);
// Distances from the track's leading edge, where the thumb sits at `min`: `top - y` on a
// vertical track in this y-up arena, `x - left` on a horizontal one.
let (cursor, thumb_lead, thumb_len) = if vertical {
(r.top - y, r.top - trect.top, trect.top - trect.bottom)
let (cursor, thumb_lead, drawn_len, own_len) = if vertical {
(
r.top - y,
r.top - trect.top,
trect.top - trect.bottom,
size.1,
)
} else {
(x - r.left, trect.left - r.left, trect.right - trect.left)
(
x - r.left,
trect.left - r.left,
trect.right - trect.left,
size.0,
)
};
// Off the thumb the cursor takes its centre, half its own unscaled length (`0x789bf5`).
let grab_offset = if on_thumb {
slider_grab(cursor, thumb_lead, drawn_len)
} else {
own_len * 0.5
};
model.slider_drag = Some(SliderDrag {
slider: h,
grab_offset: slider_grab(cursor, thumb_lead, thumb_len),
grab_offset,
});
if on_thumb {
return None; // no value change from the grab itself
@@ -216,3 +216,48 @@ fn backdrop_colors_coerce_rgb_but_default_alpha_opaque() {
// Stored as a byte: 0.5 reads back as 128/255.
assert_eq!(a, 128.0 / 255.0);
}
/// The pieces are textures anchored and sized in the frame's own units (`0x77e8d0`), so the layout
/// scales them by its effective scale like any region: a half-scale frame's border is half as
/// thick and its background inset half as deep.
#[test]
fn a_scaled_frames_backdrop_scales_with_it() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local f = CreateFrame("Frame", "Half")
f:SetScale(0.5)
f:SetPoint("TOPLEFT", nil, "TOPLEFT", 100, -100)
f:SetWidth(200); f:SetHeight(100)
f:SetBackdrop({
bgFile = "bg", edgeFile = "edge", tile = false, edgeSize = 16,
insets = { left = 4, right = 4, top = 4, bottom = 4 },
})
"#,
)
.unwrap();
s.resolve();
let rects: Vec<crate::layout::Rect> = s
.extract()
.into_iter()
.filter(|q| matches!(q.content, QuadContent::Backdrop { .. }))
.map(|q| q.rect.expect("piece rect"))
.collect();
assert_eq!(rects.len(), 9);
// The frame on screen: left 50, top 550, 100 × 50.
let bg = rects[0];
assert_eq!(
(bg.left, bg.right, bg.bottom, bg.top),
(52.0, 148.0, 502.0, 548.0),
"the insets are 4 units of a half-scale frame"
);
// Every border piece is 16 units, 8 on screen, thick.
for r in &rects[1..] {
assert_eq!(
r.width().min(r.height()),
8.0,
"a border piece {r:?} is edgeSize units of a half-scale frame"
);
}
}
@@ -253,6 +253,27 @@ fn hit_rect_insets_shrink_the_mouse_rect_only() {
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The hit rect is the rect less each inset times the frame's effective scale (`0x76b580`), so a
/// half-scale frame's 20-unit header is 10 on screen.
#[test]
fn hit_rect_insets_scale_with_the_frame() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local a = CreateFrame("Frame", "HalfHit")
a:SetScale(0.5)
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(100); a:SetHeight(100); a:EnableMouse(true)
a:SetHitRectInsets(0, 0, 20, 0)
"#,
)
.unwrap();
s.resolve();
// On screen the frame is 50 × 50 from the origin, its header the top 10.
assert!(s.hit_test(25.0, 45.0).is_none(), "inside the scaled header");
assert!(s.hit_test(25.0, 38.0).is_some(), "below the scaled header");
}
#[test]
fn hit_rect_insets_default_to_zero() {
let mut s = script();
@@ -282,3 +282,18 @@ fn frame_effective_alpha_reads_the_shown_frames_alpha() {
"hidden through the parent"
);
}
/// `Minimap_OnClick` divides the cursor by the minimap's effective scale and hands
/// `PingLocation` offsets in the minimap's own units; the reference reads them against the
/// widget's own size (`0x4eeca0`), so they reach the host scaled back to screen units.
#[test]
fn ping_location_reaches_the_host_in_screen_units() {
let mut s = UiScript::new().unwrap();
s.run(
r#"m = CreateFrame("Minimap", "HalfMinimap")
m:SetScale(0.5)
m:PingLocation(10, -20)"#,
)
.unwrap();
assert_eq!(s.take_minimap_ping_request(), Some((5.0, -10.0)));
}
@@ -628,3 +628,37 @@ fn a_step_before_any_range_is_the_whole_call() {
let (min, max): (f32, f32) = s.eval("return SlNoRange:GetMinMaxValues()").unwrap();
assert_eq!((min, max), (0.0, 0.0), "no range was pushed");
}
/// The thumb is a texture of the slider, sized in its units, so it draws at the slider's
/// effective scale; the drag reads its unscaled size against the scaled track, as the bytes do.
#[test]
fn a_scaled_sliders_thumb_scales_with_it() {
let mut s = script();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
local sl = CreateFrame("Slider", "SlHalf")
sl:SetScale(0.5)
-- The render test's scrollbar at half scale: on screen 8 wide, 50 tall, from (50, 50).
sl:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100)
sl:SetWidth(16); sl:SetHeight(100)
sl:SetThumbTexture("Interface\\Buttons\\UI-ScrollBar-Knob")
local t = sl:GetThumbTexture()
t:SetWidth(16); t:SetHeight(16)
sl:SetMinMaxValues(0, 100)
sl:SetValue(0)
"#,
)
.unwrap();
s.resolve();
assert_eq!(
thumb_rect(&s.extract(), "Knob"),
(50.0, 58.0, 92.0, 100.0),
"a 16-unit thumb of a half-scale slider is 8 on screen, flush at the track top"
);
// The drag (`0x789ba0`) takes the thumb's own unscaled 16 from the scaled 50-tall track: a
// press 40 below the track top is (40 - 8) of the 34 travel.
s.mouse_button(54.0, 60.0, "LeftButton", true);
let v = s.eval::<f64>("return SlHalf:GetValue()").unwrap();
assert!((v - 100.0 * 32.0 / 34.0).abs() < 1e-3, "value {v}");
}
@@ -679,3 +679,48 @@ fn minimap_blip_tooltip_shows_and_fades() {
.unwrap();
assert!(s.take_errors().is_empty());
}
/// A cursor-seated plate sits on the cursor under the UI scale: the cursor is in root units and
/// the anchor offset in the tooltip's own, so the cursor arm divides by its effective scale
/// (`0x530b20`).
#[test]
fn a_cursor_seated_plate_lands_on_the_cursor_under_the_ui_scale() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local up = CreateFrame("Frame", "UIParent")
up:SetPoint("BOTTOMLEFT", 0, 0); up:SetPoint("TOPRIGHT", 0, 0)
CreateFrame("GameTooltip", "GameTooltip", UIParent)
"#,
)
.unwrap();
assert!(s.set_ui_scale(0.9));
let at = |s: &mut crate::script::UiScript| -> (f64, f64) {
let answers: Vec<(u32, f32, f32, u64)> = s
.fontstrings_needing_measure()
.iter()
.map(|r| (r.id, 80.0, 10.0, r.key))
.collect();
s.set_measured_text_unwrapped(&answers);
s.resolve();
s.eval::<(f64, f64)>(
"local k = GameTooltip:GetEffectiveScale() \
local x = GameTooltip:GetCenter() \
return x * k, GameTooltip:GetBottom() * k",
)
.unwrap()
};
assert!(s.minimap_tooltip("Stormwind", 400.0, 300.0, false));
let (x, y) = at(&mut s);
assert!(
(x - 400.0).abs() < 1e-3 && (y - 300.0).abs() < 1e-3,
"({x}, {y})"
);
s.world_tooltip_move(200.0, 100.0);
let (x, y) = at(&mut s);
assert!(
(x - 200.0).abs() < 1e-3 && (y - 100.0).abs() < 1e-3,
"({x}, {y})"
);
}
+18 -4
View File
@@ -350,8 +350,8 @@ impl super::UiScript {
if let Ok(t) = tip_mut(&mut model, h) {
t.owner = Some(root);
}
let new = cursor_anchor_at(&model, h, root_id, ui_x, ui_y);
let input = model.layout_inputs.entry(h).or_default();
let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y);
let same = input.anchors.len() == 1
&& super::object::anchor_bits_eq(&input.anchors[0], &new);
if !same {
@@ -440,10 +440,10 @@ impl super::UiScript {
{
let mut model = self.model_mut();
clear_content(&mut model, h);
let input = model.layout_inputs.entry(h).or_default();
// Anchor only: the frame's own clamp flag (`Frame::clamped_to_screen`, G flags bit 4)
// slides a plate near the window edge back on screen.
let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y);
let new = cursor_anchor_at(&model, h, root_id, ui_x, ui_y);
let input = model.layout_inputs.entry(h).or_default();
// Compare-then-touch, so a still cursor does not dirty the layout.
let same =
input.anchors.len() == 1 && super::object::anchor_bits_eq(&input.anchors[0], &new);
@@ -497,8 +497,8 @@ impl super::UiScript {
return;
}
let root_id = model.frame_id(root);
let new = cursor_anchor_at(&model, h, root_id, ui_x, ui_y);
let input = model.layout_inputs.entry(h).or_default();
let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y);
// Compare-then-touch: this runs on every pointer event.
let same =
input.anchors.len() == 1 && super::object::anchor_bits_eq(&input.anchors[0], &new);
@@ -559,3 +559,17 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
)?;
Ok(())
}
/// The plate's BOTTOM on the cursor: `(ui_x, ui_y)` is in the screen root's units, and an anchor
/// offset is in the tooltip's own, so it is divided by the tooltip's effective scale, the UI scale
/// included, as the cursor arm `0x530b20` does ([`super::tooltip`]'s `cursor_anchor`).
fn cursor_anchor_at(model: &Model, h: FrameHandle, root_id: u32, ui_x: f32, ui_y: f32) -> Anchor {
let s = super::object::eff_scale(model, h);
Anchor::new(
Point::Bottom,
root_id,
Point::BottomLeft,
ui_x / s,
ui_y / s,
)
}
+221
View File
@@ -0,0 +1,221 @@
//! The UI scale: `UIParent`'s own frame scale, never the screen's. The screen root keeps scale 1
//! and is 768 units tall at every window size; `uiScale` reaches only `UIParent`'s subtree,
//! through the worker Lua's `SetScale` calls (`0x494550` → `0x76ac10` on the frame named
//! `"UIParent"`, looked up at `0x49006a`), so a frame with no parent, or under `WorldFrame`, draws
//! at scale 1.
use super::{Model, UiScript};
/// `SetUiScale`'s floor (`0x494550`: `[0x804584]`, below which `0x494563` stores 0.64).
pub(crate) const UI_SCALE_FLOOR: f32 = 0.64;
/// `GetUiScale` (`0x494590`): `UIParent`'s effective scale (`[UIParent+0x7c]`), else 1.0 when there
/// is no `UIParent`. `GetScreenWidth`/`GetScreenHeight` (`0x48b480`/`0x48b4d0`) divide the root by
/// it, and nothing else reads it. A zero scale reads 1, as the reference's writer `0x768360`
/// refuses one.
pub(crate) fn ui_parent_scale(model: &Model) -> f32 {
model
.arena
.lookup("UIParent")
.map_or(1.0, |h| super::object::eff_scale(model, h))
}
/// The cap both `uiScale` legs apply before the sink (`0x490770`, and the display handler's ON leg
/// `0x492e90`): below a 4:3 aspect `a` the value is capped at `0.75·a` (`a·3 ≥ 4` skips the cap, so
/// 4:3 itself is never capped).
pub(crate) fn capped_ui_scale(ui_scale: f32, aspect: f32) -> f32 {
if aspect * 3.0 < 4.0 {
ui_scale.min(aspect * 0.75)
} else {
ui_scale
}
}
/// `SetUiScale`'s floor (`0x494550`): above [`UI_SCALE_FLOOR`] the value stands, else (NaN
/// included) the floor.
pub(crate) fn floored_ui_scale(v: f32) -> f32 {
if v > UI_SCALE_FLOOR {
v
} else {
UI_SCALE_FLOOR
}
}
/// What the CVar callback applies (`0x490770` → `0x494550`): capped, then floored.
pub(crate) fn effective_ui_scale(ui_scale: f32, aspect: f32) -> f32 {
floored_ui_scale(capped_ui_scale(ui_scale, aspect))
}
/// The automatic scale `0x492f70` computes for a `w`×`h` display: 1.0, or `768/h` above 768 tall
/// (`0x492fbc cmp esi,0x300`), under the same narrow-aspect cap, then raised to 0.9 unless above it
/// (`0x493005`–`0x493012`).
pub(crate) fn auto_ui_scale(w: u32, h: u32) -> f32 {
let mut v = if h > 768 { 768.0 / h as f32 } else { 1.0 };
if h > 0 {
v = capped_ui_scale(v, w as f32 / h as f32);
}
if v > 0.9 {
v
} else {
0.9
}
}
impl UiScript {
/// The host's `uiScale`, compared first like [`Self::set_screen_size`]: a new value applies at
/// once through the CVar's change callback (`0x490770`: capped, floored at 0.64), and `true`
/// says it reached a `UIParent`. The same value again does nothing, so an addon's own
/// `UIParent:SetScale` holds until the next change.
pub fn set_ui_scale(&mut self, ui_scale: f32) -> bool {
let applied = {
let mut model = self.model_mut();
if model.ui_scale_request == Some(ui_scale) {
return false;
}
model.ui_scale_request = Some(ui_scale);
let aspect = model.screen.width() / model.screen.height();
model
.arena
.lookup("UIParent")
.map(|h| (h, effective_ui_scale(ui_scale, aspect)))
};
let Some((ui_parent, v)) = applied else {
return false;
};
self.model_mut().set_frame_scale(ui_parent, v);
true
}
/// Record `ui_scale` without applying it, then apply it through the display handler, as the UI
/// load does once its files and `VARIABLES_LOADED` are done (`0x49010e` → `0x492e90`): the
/// CVar's own callback found no `UIParent` when it fired at registration.
pub fn seat_ui_scale(
&mut self,
ui_scale: f32,
display: (u32, u32),
auto_cache: &mut Option<(u32, u32)>,
) -> bool {
self.model_mut().ui_scale_request = Some(ui_scale);
self.apply_ui_scale(display, auto_cache)
}
/// The display handler `0x492e90` on the last [`Self::set_ui_scale`], for the load and a
/// display change. Its ON leg caps the value and hands it to the sink only at 0.64 or more
/// (`0x492efd fcomp [0x804584]; test ah,1; jne 0x492f4a`); below, it falls to the automatic
/// scale `0x492f70(w, h, force = 0)` ([`auto_ui_scale`]), which does nothing at all when the
/// display is the one it last ran for (`auto_cache`, the reference's `[0xb4e304]`/`[0xb4e308]`,
/// which outlive the UI). `display` is the window in logical px, the host's unit for the
/// reference's device size. `true` when a scale reached `UIParent`.
pub fn apply_ui_scale(
&mut self,
display: (u32, u32),
auto_cache: &mut Option<(u32, u32)>,
) -> bool {
let applied = {
let model = self.model_ref();
let Some(ui_scale) = model.ui_scale_request else {
return false;
};
let aspect = model.screen.width() / model.screen.height();
let v = capped_ui_scale(ui_scale, aspect);
let v = if v >= UI_SCALE_FLOOR {
v
} else {
let (w, h) = display;
if w > 0 && h > 0 && *auto_cache == Some(display) {
return false;
}
*auto_cache = Some(display);
floored_ui_scale(auto_ui_scale(w, h))
};
model.arena.lookup("UIParent").map(|h| (h, v))
};
let Some((ui_parent, v)) = applied else {
return false;
};
self.model_mut().set_frame_scale(ui_parent, v);
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_callback_ladder_caps_below_four_thirds_and_floors_at_the_sink() {
// 4:3 and wider: the dial as set.
assert_eq!(effective_ui_scale(0.9, 4.0 / 3.0), 0.9);
assert_eq!(effective_ui_scale(0.9, 16.0 / 9.0), 0.9);
assert_eq!(effective_ui_scale(1.2, 16.0 / 9.0), 1.2);
// 5:4 caps a full dial at 0.9375 and leaves 0.9 alone.
assert_eq!(effective_ui_scale(1.0, 1.25), 0.9375);
assert_eq!(effective_ui_scale(0.9, 1.25), 0.9);
// The floor, on both sides of it.
assert_eq!(effective_ui_scale(0.5, 16.0 / 9.0), UI_SCALE_FLOOR);
assert_eq!(effective_ui_scale(0.65, 16.0 / 9.0), 0.65);
// A square window caps at 0.75, a narrow one bottoms out at the floor.
assert_eq!(effective_ui_scale(0.9, 1.0), 0.75);
assert_eq!(effective_ui_scale(0.9, 0.5), UI_SCALE_FLOOR);
}
#[test]
fn the_scale_lands_on_uiparent_alone_and_only_on_a_change() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
assert!(!s.set_ui_scale(0.9), "no UIParent yet: nothing to scale");
s.run(r#"CreateFrame("Frame", "UIParent") CreateFrame("Frame", "Loose")"#)
.unwrap();
assert!(!s.set_ui_scale(0.9), "the same value is no change");
assert!(
s.apply_ui_scale((1024, 768), &mut None),
"the load edge applies what was set"
);
assert_eq!(s.eval::<f32>("return UIParent:GetScale()").unwrap(), 0.9);
assert_eq!(s.eval::<f32>("return Loose:GetScale()").unwrap(), 1.0);
// An addon's own scale holds until the dial moves.
s.run("UIParent:SetScale(0.7)").unwrap();
assert!(!s.set_ui_scale(0.9));
assert_eq!(s.eval::<f32>("return UIParent:GetScale()").unwrap(), 0.7);
assert!(s.set_ui_scale(0.8));
assert_eq!(s.eval::<f32>("return UIParent:GetScale()").unwrap(), 0.8);
}
/// The automatic scale (`0x492f70`): 1.0 up to 768 tall, `768/h` above it, never under 0.9.
#[test]
fn the_auto_scale_follows_the_height_and_stops_at_nine_tenths() {
assert_eq!(auto_ui_scale(1024, 768), 1.0);
assert_eq!(auto_ui_scale(1280, 800), 0.96);
assert_eq!(auto_ui_scale(1920, 1080), 0.9);
// A tall narrow display: capped, then raised to 0.9.
assert_eq!(auto_ui_scale(600, 700), 0.9);
}
/// The display handler (`0x492e90`) never floors: a dial capped below 0.64 falls to the
/// automatic scale, which skips a display it already ran for; the CVar callback (`0x490770`)
/// floors instead.
#[test]
fn below_the_floor_the_display_handler_falls_to_the_auto_scale() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(r#"CreateFrame("Frame", "UIParent")"#).unwrap();
let scale = |s: &UiScript| s.eval::<f32>("return UIParent:GetScale()").unwrap();
let mut cache = None;
assert!(s.seat_ui_scale(0.5, (1280, 800), &mut cache));
assert_eq!(scale(&s), 0.96, "the load took the auto scale for 1280×800");
assert_eq!(cache, Some((1280, 800)));
// The same display again: the auto path does nothing at all.
s.run("UIParent:SetScale(0.7)").unwrap();
assert!(!s.apply_ui_scale((1280, 800), &mut cache));
assert_eq!(scale(&s), 0.7);
// A new display runs it.
assert!(s.apply_ui_scale((1920, 1080), &mut cache));
assert_eq!(scale(&s), 0.9);
// The CVar callback floors.
assert!(s.set_ui_scale(0.55));
assert_eq!(scale(&s), UI_SCALE_FLOOR);
// At the floor or above, the display handler hands the value to the sink.
assert!(s.seat_ui_scale(0.8, (1920, 1080), &mut cache));
assert_eq!(scale(&s), 0.8);
}
}