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:
co-authored by
Claude Opus 5.5
parent
f0dfa86148
commit
dc63a06178
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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})"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user