Files
benilla-octo/crates/benilla-ui/src/script/ui_scale.rs
T
Sam SungandClaude Opus 5.5 dc63a06178 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>
2026-10-09 13:17:23 +07:00

222 lines
9.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}