Compare commits

...

1 Commits

Author SHA1 Message Date
sindoring ecb0a01d5c Integrate upstream UI fidelity updates through c2fc3cf3
Import the approved GitHub upstream snapshot, preserving MoonWell integration and the live-player seat on ReloadUI. Regenerated docs/MAP.md. Verified on Windows: scripts/gates.sh ALL GATES GREEN in 2963 seconds with vanilla reference data, workspace tests with and without install, player tests and both engine boot checks. 31 addon corpus tests skipped because the corpus is absent; no declared account for live login. Other platforms were not built.
2026-10-10 16:48:05 +04:00
68 changed files with 2221 additions and 474 deletions
@@ -110,7 +110,8 @@
BenillaOptionsFrame:SetScale(k)
end
-- The era tab width, label + 40, measured after OnShow sets the scale.
-- The era tab width, label + 40, measured after the window's OnShow sets the scale:
-- the tabs' own OnShows run before it, children first.
function BenillaOptionsTab_FitWidth(tab)
tab:SetWidth(getglobal(tab:GetName() .. "Text"):GetStringWidth() + 40)
end
@@ -1341,9 +1342,6 @@
<NormalFont inherits="GameFontHighlightSmall"/>
<HighlightFont inherits="GameFontHighlightSmall"/>
<DisabledFont inherits="GameFontHighlightSmall"/>
<Scripts>
<OnShow>BenillaOptionsTab_FitWidth(this)</OnShow>
</Scripts>
</Button>
<Button name="$parentAddOnsTab">
<Size><AbsDimension x="87" y="37"/></Size>
@@ -1371,7 +1369,6 @@
<!-- No addon list behind it: disabled, in the era's unselected gold. -->
<Scripts>
<OnLoad>this:Disable()</OnLoad>
<OnShow>BenillaOptionsTab_FitWidth(this)</OnShow>
</Scripts>
</Button>
@@ -3068,6 +3065,8 @@
<!-- Controls by default; selection persists and the scale re-fits, as the era's. -->
<OnShow>
BenillaOptionsFrame_UpdateScale()
BenillaOptionsTab_FitWidth(BenillaOptionsFrameGameTab)
BenillaOptionsTab_FitWidth(BenillaOptionsFrameAddOnsTab)
BenillaOptionsFrame_SelectCategory(this.selectedCategory or "Controls")
if UpdateMicroButtons then UpdateMicroButtons() end
</OnShow>
@@ -232,8 +232,9 @@
</Scripts>
</Button>
<Frame name="BenillaScriptLogFrame" hidden="true">
<!-- The standard left-slot panel size. No <Anchors>: ShowUIPanel seats it. -->
<Frame name="BenillaScriptLogFrame" parent="UIParent" hidden="true">
<!-- The standard left-slot panel size. No <Anchors>: ShowUIPanel seats it. Under UIParent,
as the stock panels are, so it takes the UI scale with them. -->
<Size><AbsDimension x="384" y="512"/></Size>
<Layers>
<Layer level="BACKGROUND">
+12 -4
View File
@@ -1358,15 +1358,23 @@ pub(super) fn seed_ui_fixture(
warn!("capture: ui-chat-tabhover select failed: {e}");
}
script.resolve();
// Each in the root's units the cursor is fed in: the frame's own times its scale.
let expr = match mode.as_str() {
"2" | "3" => "return ChatFrame2Tab:GetCenter()",
"2" | "3" => {
"local x, y = ChatFrame2Tab:GetCenter() \
local k = ChatFrame2Tab:GetEffectiveScale() return x * k, y * k"
}
"0" => {
"return (ChatFrame2:GetLeft() + ChatFrame2:GetRight()) / 2, \
(ChatFrame2:GetBottom() + ChatFrame2:GetTop()) / 2"
"local k = ChatFrame2:GetEffectiveScale() \
return (ChatFrame2:GetLeft() + ChatFrame2:GetRight()) / 2 * k, \
(ChatFrame2:GetBottom() + ChatFrame2:GetTop()) / 2 * k"
}
// 9: park far away, so the dock conceals itself.
"9" => "return 2000, 2000",
_ => "return ChatFrame1Tab:GetCenter()",
_ => {
"local x, y = ChatFrame1Tab:GetCenter() \
local k = ChatFrame1Tab:GetEffectiveScale() return x * k, y * k"
}
};
let centre: Result<(f32, f32), _> = script.eval(expr);
match centre {
+3 -3
View File
@@ -550,13 +550,13 @@ struct ProbeDrag {
to: (f32, f32),
}
/// A frame's centre from its live `GetLeft`/`GetRight`/`GetTop`/`GetBottom`, in the space
/// [`crate::ui_script::input`] feeds the cursor in.
/// A frame's centre from its live `GetLeft`/`GetRight`/`GetTop`/`GetBottom`, times its effective
/// scale: the edges answer in the frame's own units, the cursor is fed in the root's.
fn frame_centre(script: &benilla_ui::script::UiScript, name: &str) -> Option<(f32, f32)> {
let read = |edge: &str| {
script
.eval::<f32>(&format!(
"local f = getglobal(\"{name}\") return f and f:Get{edge}()"
"local f = getglobal(\"{name}\") return f and f:Get{edge}() * f:GetEffectiveScale()"
))
.ok()
};
+1 -1
View File
@@ -553,7 +553,7 @@ fn draw_bubble(
border_color: [1.0; 4],
};
let up = GxRect::new(-frame.max.y, frame.min.x, -frame.min.y, frame.max.x);
for p in pieces(up, &bd) {
for p in pieces(up, &bd, 1.0) {
// Equal insets keep every piece axis-aligned: a y-down rect from its TL and BR corners.
let rect = Rect::new(
p.corners[0][0],
+4 -5
View File
@@ -160,7 +160,6 @@ pub(super) type BlipInputs<'w, 's> = (
Query<'w, 's, &'static GlobalTransform, With<NetEntity>>,
Query<'w, 's, &'static Window, With<PrimaryWindow>>,
ResMut<'w, MinimapBlipHover>,
Res<'w, crate::ui_script::UiScaleCvar>,
Res<'w, crate::ui_party::GroupState>,
TrackedCandidates<'w, 's>,
Query<'w, 's, (&'static ObjectStore, &'static Guid), With<SelfPlayer>>,
@@ -200,11 +199,11 @@ pub(super) struct BlipCtx {
pub(super) wz: f32,
/// The cursor in the quads' y-down logical px.
pub(super) cursor: Option<Vec2>,
/// The cursor in y-up UI space; the reference seats the blip tooltip at the cursor, its exact
/// offset untraced.
/// The cursor in the y-up root units; the reference seats the blip tooltip at the cursor, its
/// exact offset untraced.
pub(super) cursor_ui: Option<Vec2>,
/// Window px per UI unit: everything else here is window px, while Lua
/// ([`Minimap:PingLocation`](super::ping)) speaks UI units.
/// Window px per root unit: everything else here is window px, while Lua
/// ([`Minimap:PingLocation`](super::ping)) hands root units.
pub(super) seam: f32,
}
+4 -5
View File
@@ -351,7 +351,6 @@ fn emit_minimap(
unit_pos,
window,
mut blip_hover,
ui_scale,
group,
tracked,
self_store,
@@ -621,7 +620,7 @@ fn emit_minimap(
}
let win = window.iter().next();
let cursor = win.and_then(|w| w.cursor_position());
let seam = win.map_or(1.0, |w| crate::ui_script::seam_scale(w.height(), ui_scale.0));
let seam = win.map_or(1.0, |w| crate::ui_script::seam_scale(w.height()));
// The pan origin snapped to a half-logical-pixel grid: every blip offset and rim bearing
// derives from `wx`/`wy`, so the layer steps together instead of rewriting the batch mesh
// every frame while walking. Blip world positions stay exact.
@@ -637,11 +636,11 @@ fn emit_minimap(
wy: (wy / q).round() * q,
wz: wow[2],
cursor,
// The cursor in UI space (y-up, ÷ the seam scale): the tooltip anchor resolves in
// 768-virtual units, not window px.
// The cursor in the root's units (y-up, ÷ the seam scale): the tooltip anchor resolves
// in them, not window px.
cursor_ui: cursor
.zip(win)
.map(|(c, w)| Vec2::new(c.x / seam, (w.height() - c.y) / seam)),
.map(|(c, w)| crate::ui_script::window_to_ui(w.height(), c)),
seam,
}
});
+10 -9
View File
@@ -5,9 +5,10 @@
//! [`drive_minimap_ping`] republishes against the live view radius, so the marker follows the pan
//! and the zoom with no second copy to fall out of step.
//!
//! - In: the stock `Minimap_OnClick` calls `Minimap:PingLocation(dx, dy)` in UI units from the
//! centre; the renderer converts it with the geometry of the frame it draws: UI units × the seam
//! scale = window px, ÷ `px_per_yd` = yards.
//! - In: the stock `Minimap_OnClick` calls `Minimap:PingLocation(dx, dy)` in the minimap's units
//! from the centre, which the engine hands over in root units; the renderer converts them with
//! the geometry of the frame it draws: root units × the seam scale = window px, ÷ `px_per_yd` =
//! yards.
//! - Across: our ping sends `MSG_MINIMAP_PING` (raw world floats, relayed to the rest of the group
//! only, `GroupHandler.cpp:384-391`); a member's seats the same way. A ping seats locally at
//! click time, so a solo ping works.
@@ -59,8 +60,8 @@ impl MinimapPing {
}
}
/// A `Minimap:PingLocation(x, y)` click to the world point it names: `ui` is centre-relative UI
/// units (x right, y up), `seam` window px per UI unit ([`crate::ui_script::seam_scale`]), `ctx`
/// A `Minimap:PingLocation(x, y)` click to the world point it names: `ui` is centre-relative root
/// units (x right, y up), `seam` window px per root unit ([`crate::ui_script::seam_scale`]), `ctx`
/// the map as drawn this frame. The inverse of [`BlipCtx::offset`]: screen right is −WoW y, screen
/// up +WoW x. `None` outside the disc, the stock test (`Minimap.lua:135`) in yards.
fn click_to_world(ctx: &BlipCtx, ui: (f32, f32), seam: f32) -> Option<(f32, f32)> {
@@ -193,13 +194,13 @@ mod tests {
}
}
/// The click is in UI units and `px_per_yd` in window px: at 0.9 uiScale on a 1080p window the
/// seam is ≈1.27, and a conversion that skips it seats every ping ≈27 % too far out.
/// The click is in root units and `px_per_yd` in window px: on a 1080p window the seam is
/// ≈1.41, and a conversion that skips it seats every ping ≈41 % too far out.
#[test]
fn a_click_converts_through_the_seam_scale() {
let c = ctx();
let seam = 1080.0 / 768.0 * 0.9; // the shipped default at 1080p
// 20 UI units right → 20·seam px → yards west (−y).
let seam = 1080.0 / 768.0;
// 20 root units right → 20·seam px → yards west (−y).
let (x, y) = click_to_world(&c, (20.0, 0.0), seam).expect("inside the disc");
let expect_yd = 20.0 * seam / c.px_per_yd;
assert!((x - 0.0).abs() < 1e-3, "no northing from a due-east click");
+3 -3
View File
@@ -113,9 +113,9 @@ pub(super) fn refresh_hud_snapshot(
/// clear). The always-up world-state readout (`WorldStateAlwaysUpFrame`, stock
/// `WorldStateFrame.xml`) shares the band, and the dev pill is the one that yields.
///
/// The layout answers in UI units, a screen `768/uiScale` units tall whatever the window, while
/// the pill draws in window px, so the chunk returns a fraction of the screen and the caller
/// scales it by the window height. Reading an edge settles the layout, one graph solve at most.
/// The layout answers in `UIParent`'s units, a screen `768/uiScale` units tall whatever the
/// window, while the pill draws in window px, so the chunk returns a fraction of the screen and the
/// caller scales it by the window height. Reading an edge settles the layout, one graph solve at most.
pub(crate) fn top_centre_claimed(script: &UiScript, win_h: f32) -> f32 {
// `WorldStateAlwaysUpFrame` is always shown; the readout occupies its `AlwaysUpFrame<n>` rows,
// built on demand and hidden when empty, so the claim is the lowest shown row's bottom.
+2 -2
View File
@@ -444,8 +444,8 @@ pub(super) fn auto_join_zone_channels(
walk.at = Some(zone_id);
}
/// Feed the VM the zone-less catalog before any interface file runs: world entry runs FrameXML,
/// the addons and `PLAYER_LOGIN` in one call, ahead of the walk's first tick.
/// Feed the VM the zone-less catalog before any interface file runs: world entry runs FrameXML
/// and the addons in one call, ahead of the walk's first tick.
pub(crate) fn seed_zone_channel_catalog(
world: &mut World,
script: &mut benilla_ui::script::UiScript,
+3 -2
View File
@@ -623,8 +623,9 @@ pub(crate) fn fold_dying_vm_chat_cache(world: &mut World) {
pub(super) fn plugin(app: &mut App) {
app.init_resource::<ChatWindowFile>()
// The restore runs from the world-entry UI load, not here: its events must precede the
// first chat line and `PLAYER_LOGIN`, which no `Update` ordering guarantees.
// The restore runs from the world-entry UI load, not here: its events must follow
// `VARIABLES_LOADED` inside the load, where the reference registers its reader
// (`0x4900d6`), and precede the first chat line and `PLAYER_LOGIN`.
.add_systems(
Update,
// After the tick: every write it watches is Lua's.
+8 -3
View File
@@ -2397,8 +2397,9 @@ fn the_free_professions_line_is_printed_once() {
// ───────────── the chat cache restores inside the login ─────────
/// `UPDATE_CHAT_WINDOWS` registers the chat frames for `CHAT_MSG_*`, and the `UPDATE_CHAT_COLOR`
/// burst repaints id-0 lines through `ChatTypeInfo["REPLY"]`, so both come before `PLAYER_LOGIN`.
/// The probe is a loose addon, because the entry load builds the VM it runs on.
/// burst repaints id-0 lines through `ChatTypeInfo["REPLY"]`, so both come before `PLAYER_LOGIN`,
/// which the load arms and the world-enter cascade fires. The probe is a loose addon, because the
/// entry load builds the VM it runs on.
#[test]
fn the_chat_cache_restore_is_finished_before_player_login() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -2485,6 +2486,10 @@ fn the_chat_cache_restore_is_finished_before_player_login() {
1,
));
crate::ui_script::load_ingame_ui_on_world_entry(&mut world);
// The cascade, as the unit feed runs it once the player is in the world.
world
.non_send_resource_mut::<benilla_ui::script::UiScript>()
.fire_world_enter();
let read = |expr: &str| -> String {
world
@@ -2512,7 +2517,7 @@ fn the_chat_cache_restore_is_finished_before_player_login() {
read("ChatOrderProbe.loginRegistered")
);
// The reference's login order: `ADDON_LOADED` (`0x4900a3`), `VARIABLES_LOADED` (`0x4900b2`),
// the chat-cache burst (`0x4900d6`), `PLAYER_LOGIN` (`0x490959`).
// the chat-cache burst (`0x4900d6`), the arm (`0x49011d`), `PLAYER_LOGIN` (`0x490959`).
assert_eq!(
read("ChatOrderProbe.order"),
"VARIABLES_LOADED UPDATE_CHAT_WINDOWS UPDATE_CHAT_COLOR PLAYER_LOGIN ",
+10 -3
View File
@@ -1692,9 +1692,10 @@ mod tests {
);
}
/// `UI_Init 0x48fbf0` loads the addons at `0x4900a3` (each `ADDON_LOADED` at `0x51f5ad`) and
/// fires `VARIABLES_LOADED` at `0x4900b2`; `PLAYER_LOGIN` comes after, on a fresh login from
/// the player's create (`0x5deb60` → `0x4908c0`), on a `/reload` from `0x490168`.
/// `UI_Init 0x48fbf0` loads the addons at `0x4900a3` (each `ADDON_LOADED` at `0x51f5ad`),
/// fires `VARIABLES_LOADED` at `0x4900b2` and arms `PLAYER_LOGIN` (`0x49011d`), which the
/// world-enter cascade fires: on a fresh login from the player's create (`0x5deb60` →
/// `0x4908c0`), on a `/reload` from `0x490168`.
#[test]
fn the_ui_init_events_fire_in_the_reference_order() {
let _l = crate::local_state::test_env::ENV_LOCK
@@ -1728,6 +1729,12 @@ mod tests {
let failures = load_third_party(&mut script, None, &[], true);
assert!(failures.is_empty(), "load errors: {failures:?}");
crate::ui_script::finish_ui_load(&mut script);
assert_eq!(
event_log(&script),
vec!["ADDON_LOADED:EventProbe", "VARIABLES_LOADED:nil"],
"the load only arms PLAYER_LOGIN"
);
script.fire_world_enter();
assert_eq!(
event_log(&script),
+23 -11
View File
@@ -425,7 +425,11 @@ pub(super) fn tick_script(
mut last_seam: Local<f32>,
// The `scale_factor` measures were last answered under; 0 until the first frame.
mut last_dpi: Local<f32>,
ui_scale: Res<super::UiScaleCvar>,
// The dial, `UIParent`'s scale in the VM, and the automatic scale's last display.
(ui_scale, mut auto_cache): (
Res<super::UiScaleCvar>,
Option<ResMut<super::UiAutoScaleCache>>,
),
mut ui_cost: ResMut<super::UiFrameCost>,
mut pass: ResMut<super::UiPassState>,
) {
@@ -442,9 +446,9 @@ pub(super) fn tick_script(
};
let (w, h) = (window.width(), window.height());
// The client's UI space is 768 units tall at every aspect (`f(screenH) = 768`, `0x41ad10`; the
// caret's `H_px/192`, `0x77b8c0`), `768/uiScale` with the dial. The VM lives in it: quads
// scale ×s out, and the mouse and the measures ÷s in.
let s = super::seam_scale(h, ui_scale.0);
// caret's `H_px/192`, `0x77b8c0`), the dial being `UIParent`'s scale inside it. The VM lives in
// it: quads scale ×s out, and the mouse and the measures ÷s in.
let s = super::seam_scale(h);
// A moved seam scale or DPI stales every text metric the engine caches: integer-stepped
// advances do not rescale, and the size snap moves a string's width by several percent,
// enough for the ellipsis to eat fitting text. A monitor hop moves the DPI alone.
@@ -486,8 +490,17 @@ pub(super) fn tick_script(
{
let _span = bevy::log::info_span!("ui_script: tick").entered();
let resized = script.set_screen_size(w / s, if h > 0.0 { h / s } else { 768.0 });
// A resize re-runs what the interface computed from the old screen size.
// A display change re-applies the UI scale (`0x492e90`), whose narrow-aspect cap follows
// the window; a moved dial applies once, as its CVar callback does (`0x490770`).
if resized {
let display = (w.round() as u32, h.round() as u32);
let mut none = None;
let cache = auto_cache.as_deref_mut().map_or(&mut none, |c| &mut c.0);
script.apply_ui_scale(display, cache);
}
let rescaled = script.set_ui_scale(ui_scale.0);
// Either re-runs what the interface computed from the old `GetScreenHeight()`.
if resized || rescaled {
super::manifest::on_screen_resized(&script);
}
script.tick(time.delta_secs());
@@ -1713,9 +1726,10 @@ mod clip_plumb_tests {
);
}
/// At dial 0.5 the VM sees a 2048×1536 screen, and every window-px rect halves.
/// The dial is `UIParent`'s scale, so a ScrollFrame with no parent keeps its px rect and clip
/// at dial 0.5: the root stays 768 units tall.
#[test]
fn the_uiscale_dial_scales_the_extracted_rects() {
fn the_uiscale_dial_leaves_a_parentless_frame_alone() {
let mut app = app_with_scrolled_marker();
app.insert_resource(crate::ui_script::UiScaleCvar(0.5));
app.update();
@@ -1726,12 +1740,10 @@ mod clip_plumb_tests {
.find(|q| q.color[0] == 1.0 && q.color[1] == 0.0 && q.color[2] == 0.0)
.expect("the colored marker quad extracted");
// WoW space: top 1536 − 100 = 1436, bottom 1236, right 300; ×0.5 is y-up px 618..718,
// and y-down through the 768 window the top is 50 and the bottom 150.
assert_eq!(
marker.clip,
Some(Rect::new(0.0, 50.0, 150.0, 150.0)),
"dial 0.5 halves the px rect and re-hangs the frame from the taller virtual top"
Some(Rect::new(0.0, 100.0, 300.0, 300.0)),
"dial 0.5 moves nothing outside UIParent"
);
}
@@ -40,10 +40,11 @@ pub(super) struct TextHost<'a> {
/// The focused editbox's text UI, unfiltered: [`emit`] matches it to this quad by `target`.
pub ebox: Option<&'a EditBoxTextUi>,
pub screen_h: f32,
/// The seam scale `windowH/768 × uiScale`, for the unit-space inputs; rects arrive in px.
/// The seam scale `windowH/768`, for the root-unit inputs; rects arrive in px.
pub scale: f32,
/// The frame's effective scale, already in the rect: glyph size and shadow offset ride it, as
/// the 1.12 client's text rides `SetScale`; the editbox x-offsets arrive in screen UI units.
/// the 1.12 client's text rides `SetScale` and the UI scale; the editbox x-offsets arrive in
/// root units.
pub font_scale: f32,
/// Captures pin the caret on for deterministic pixels; live, the engine's blink decides.
pub caret_pinned: bool,
@@ -387,7 +388,7 @@ mod tests {
assert_eq!(band_clip_slack(Some(2.0)), 3.0);
}
/// `1.0546875` is the seam scale of a 1600×900 window at uiScale 0.9.
/// `1.0546875` is a 1600×900 window's seam times the 0.9 UI scale.
#[test]
fn a_shadow_offset_is_a_whole_pixel_and_never_vanishes() {
assert_eq!(shadow_offset_px(1.0), 1.0);
+2 -6
View File
@@ -94,8 +94,6 @@ pub(super) fn feed_ui_input(
),
// The characters the active layout makes, which name a key for a keyboard frame.
layout: Res<crate::bindings::LayoutNames>,
// The uiScale dial folded into the seam scale.
ui_scale: Res<super::UiScaleCvar>,
) {
let (keyboard, keys, capture, clipboard) = (&mut kbd.0, &kbd.1, &mut kbd.2, &mut kbd.3);
let world_pick = pointer.world_pick();
@@ -139,10 +137,8 @@ pub(super) fn feed_ui_input(
.or_else(crate::target::hover_probe_point)
.filter(|_| !synthetic)
{
// The window cursor is logical px, y-down from the top left; the UI is y-up in 768-high
// units under uiScale: flip through the window height, then undo the extract seam's scale.
let s = super::seam_scale(window.height(), ui_scale.0);
let (x, y) = (cursor.x / s, (window.height() - cursor.y) / s);
// The window cursor in the root's y-up 768-high units, as `GetCursorPosition` answers it.
let Vec2 { x, y } = super::window_to_ui(window.height(), cursor);
// `WOW_HIT_COST=1` meters this call's per-frame hit-test rebuild, one line a second.
static HIT_COST: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
let metering =
@@ -492,16 +492,19 @@ fn toggle_inspect_reopens_after_a_close_and_raises_like_the_reference() {
s.eval::<bool>("return InspectFrame:IsVisible()").unwrap(),
"the window still shows — Show() runs before OnShow raises"
);
// Two raises: `InspectFrame` and `InspectPaperDollFrame` each run their own `OnShow` under
// their own pcall, the first dying at the portrait's gate, the second at `UnitLevel`'s.
// Two raises: `InspectPaperDollFrame` and `InspectFrame` each run their own `OnShow` under
// their own pcall, the page first, since the show cascade notifies children before their
// parent (`0x76aed5`, then `0x76aef5`): the page dies at `UnitLevel`'s gate, the window at
// the portrait's.
let errors = s.errors();
assert_eq!(errors.len(), 2, "one raise per OnShow handler: {errors:?}");
assert!(
errors[0].contains(r#"Usage: SetPortraitTexture(texture, "unit")"#),
"the portrait's gate, before the name line is ever reached: {errors:?}"
errors[0].contains(r#"Usage: UnitLevel("unit")"#),
"the paper doll's own handler, dying at its first getter: {errors:?}"
);
assert!(
errors[1].contains(r#"Usage: UnitLevel("unit")"#),
"and the paper doll's own handler, dying at its first getter: {errors:?}"
errors[1].contains(r#"Usage: SetPortraitTexture(texture, "unit")"#),
"then the window's, at the portrait's gate, before the name line is ever reached: \
{errors:?}"
);
}
+92 -35
View File
@@ -230,26 +230,22 @@ pub(crate) fn run_pending_entry_load(world: &mut World) {
);
}
/// Seat the VM's screen size and text measurer for the load, under the current window and
/// `uiScale`: the load-edge half of [`super::extract::tick_script`]'s per-frame pair. A fresh VM
/// answers 1024×768 until then, and a value computed once at load keeps it: stock
/// `WorldMapFrame_OnLoad` sizes the full-screen `BlackoutWorld` from `GetScreenWidth()` and
/// `GetScreenHeight()` and never again (`WorldMapFrame.lua:19-28`). With no atlas yet (it bakes on
/// the first `Update`) the per-frame pass seats the measurer.
/// Seat the VM's screen size and text measurer for the load, under the current window: the
/// load-edge half of [`super::extract::tick_script`]'s per-frame pair. A fresh VM answers 1024×768
/// until then, and a value computed once at load keeps it: stock `WorldMapFrame_OnLoad` sizes the
/// full-screen `BlackoutWorld` from `GetScreenWidth()` and `GetScreenHeight()` and never again
/// (`WorldMapFrame.lua:19-28`), which answer the root while `UIParent` is still at scale 1. With no
/// atlas yet (it bakes on the first `Update`) the per-frame pass seats the measurer.
fn seat_raster_seam_for_load(world: &mut World, script: &mut UiScript) {
let ui_scale = world
.get_resource::<super::UiScaleCvar>()
.map_or(1.0, |c| c.0);
let (w, h) = {
let mut q = world.query_filtered::<&Window, With<bevy::window::PrimaryWindow>>();
q.single(world)
.map_or((0.0, 0.0), |win| (win.width(), win.height()))
};
let s = super::seam_scale(h, ui_scale);
let s = super::seam_scale(h);
if w > 0.0 && h > 0.0 {
// The VM lives in 768-tall virtual space, so logical size over the seam scale is its
// screen. No `UIParent_ManageFramePositions` here, unlike `tick_script`: nothing is laid
// out yet.
// The VM lives in a 768-tall root, so logical size over the seam scale is its screen. No
// `UIParent_ManageFramePositions` here, unlike `tick_script`: nothing is laid out yet.
script.set_screen_size(w / s, h / s);
}
let Some(atlas) = world.get_resource::<crate::ui_text::UiFontAtlas>() else {
@@ -258,12 +254,29 @@ fn seat_raster_seam_for_load(world: &mut World, script: &mut UiScript) {
super::extract::seat_text_measurer(script, atlas, s);
}
/// Which `"player"` a UI load runs its files under, by the edge that runs it.
#[derive(Clone, Copy, PartialEq, Eq)]
enum LoadSeat {
/// World entry: `UI_Init` runs before `CMSG_PLAYER_LOGIN` goes out (the load at `0x46c236`,
/// the send in a later step at `0x46c272`), so no player object exists and the roster's
/// record stands in ([`seat_from_roster`]).
Roster,
/// `/reload`: the player object stays in the world through the teardown and the load, so the
/// live one is seated ([`crate::ui_unit::live_player_seat`]), the roster's if none exists.
Live,
}
/// Load the in-game UI for this session onto a VM built for it, once per world entry. The
/// reference builds the UI in `UI_Init` (`0x48fbf0`, re-making the Lua state at `0x48fe97`) and
/// destroys it at `0x490bd0`, so every login runs every addon's file scope afresh. Safe on the
/// state edge because the initial transition runs after `PostStartup`: a capture booting straight
/// into `InWorld` still loads after [`benilla_assets::AssetSet::Open`].
pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
load_ingame_ui_seated(world, LoadSeat::Roster);
}
/// [`load_ingame_ui_on_world_entry`] under the `"player"` its edge has.
fn load_ingame_ui_seated(world: &mut World, seat: LoadSeat) {
// The parked VM goes back into the world, where the CVar fold reads it; a reload has none
// parked and uses the live one.
unpark_boot_vm(world);
@@ -322,19 +335,31 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
}
// The player too, before the addon walk. The record first: the reference's
// `UnitName`/`UnitRace`/`UnitClass`/`UnitSex` read only a copy of the char-enum row made at the
// Enter World commit (`0x5abd9e`) and never cleared, and each rebuilt VM is told once. The
// snapshot stands in for the descriptor until that streams in.
// Enter World commit (`0x5abd9e`) and never cleared, and each rebuilt VM is told once. Then
// the unit: the live player on a `/reload`, else the roster's stand-in until the descriptor
// streams in.
if let Some(record) = world
.get_resource::<crate::char_select::Roster>()
.and_then(record_from_roster)
{
script.set_player_record(record);
}
if let Some(seat) = world
.get_resource::<crate::char_select::Roster>()
.and_then(seat_from_roster)
match (seat == LoadSeat::Live)
.then(|| live_player_seat(world))
.flatten()
{
script.set_unit("player", Some(seat));
Some(live) => {
script.set_unit("player", Some(live.state));
script.set_player_xp(live.xp.0, live.xp.1);
}
None => {
if let Some(seat) = world
.get_resource::<crate::char_select::Roster>()
.and_then(seat_from_roster)
{
script.set_unit("player", Some(seat));
}
}
}
// The addon version gate, read from the persisted base the fold above just made current, so a
// character-screen "Load out of date AddOns" click counts. A bare test world is check on.
@@ -358,6 +383,9 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
// 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)
@@ -392,6 +420,21 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
|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);
},
);
});
@@ -418,6 +461,17 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
}
}
/// The live player's seat off the world, through the unit feed's own builder; `None` before our
/// own create. A world without the feed's resources (a bare test world) has none to read.
fn live_player_seat(world: &mut World) -> Option<crate::ui_unit::LivePlayerSeat> {
world
.run_system_cached(crate::ui_unit::live_player_seat)
.unwrap_or_else(|e| {
warn!("ui_script: no live player to seat for the load ({e}); the roster stands in");
None
})
}
/// The wire's gender byte (0 male, 1 female) on `UnitSex`'s 2/3 scale, as `ui_unit::snapshot`
/// maps the descriptor's; the record and the seat share it.
fn roster_sex(gender: u8) -> u8 {
@@ -648,11 +702,13 @@ pub(crate) fn end_ui_session(world: &mut World) {
pub(crate) struct ReloadUiPending(pub(crate) bool);
/// Run a pending `ReloadUI()`: the reference's teardown and rebuild (`0x495664 call 0x490bd0`,
/// `0x495669 call 0x48fbf0`), for us [`end_ui_session`] then [`load_ingame_ui_on_world_entry`]
/// without leaving the world, so a `DisableAddOn` staged in the dying VM reaches `AddOns.txt`
/// before the rebuild reads it. `PLAYER_ENTERING_WORLD` then refires from [`crate::ui_unit`]'s
/// feed on the new VM. In-world only, as the reference's gate (`0x494a50(0xa)`) is; elsewhere the
/// request is dropped.
/// `0x495669 call 0x48fbf0`), for us [`end_ui_session`] then the load without leaving the world,
/// so a `DisableAddOn` staged in the dying VM reaches `AddOns.txt` before the rebuild reads it.
/// The load runs under the live player, which never left. `UI_Init` then enters the world-enter
/// cascade itself (`0x490168`), firing the `PLAYER_LOGIN` it armed and `PLAYER_ENTERING_WORLD`;
/// ours fire from [`crate::ui_unit`]'s feed on the new VM's first `Update`, once it has re-seated
/// the player. In-world only, as the reference's gate (`0x494a50(0xa)`) is; elsewhere the request
/// is dropped.
pub(crate) fn run_pending_reload(world: &mut World) {
if !std::mem::take(&mut world.resource_mut::<ReloadUiPending>().0) {
return;
@@ -667,7 +723,7 @@ pub(crate) fn run_pending_reload(world: &mut World) {
}
info!("ui_script: ReloadUI — ending the UI session and building a new one");
end_ui_session(world);
load_ingame_ui_on_world_entry(world);
load_ingame_ui_seated(world, LoadSeat::Live);
// `UI_Init` ends by re-entering the world-enter cascade (`0x490168`), its sends included, but
// only with the active player set (`0x490166 je`): our own player's entity exists. Not
// `SetActiveMover`, which only the player's create runs.
@@ -710,13 +766,13 @@ pub(crate) fn shutdown_on_exit(
/// saved file and fires `VARIABLES_LOADED` (`0x4900b2` → `0x4913b0`), and arms `PLAYER_LOGIN`
/// (`[0xb4e260]`, set at `0x49011d`). The world-enter cascade `0x4908c0` fires `PLAYER_LOGIN` if
/// armed (read at `0x49094b`, fired at `0x490959`, disarmed at `0x49095e`), then
/// `PLAYER_ENTERING_WORLD` (`0x49096a`).
/// `PLAYER_ENTERING_WORLD` (`0x49096a`): [`UiScript::fire_world_enter`].
///
/// On a `/reload`, `UI_Init` enters the cascade itself (`0x490168`). On a fresh login that call is
/// skipped (`0x490166 je`, the active-player GUID still 0) and the cascade runs from the local
/// player's own create (`0x5deb60 call 0x4908c0`). This fires `PLAYER_LOGIN` at the end of the
/// load on both, the `/reload` shape; `PLAYER_ENTERING_WORLD` fires from [`crate::ui_unit`] once
/// the self descriptor lands.
/// player's own create (`0x5deb60 call 0x4908c0`). Either way the player object exists when it
/// runs, so this only arms, and [`crate::ui_unit`]'s feed fires both once it has seated the
/// player.
#[cfg(test)]
pub(crate) fn finish_ui_load(script: &mut UiScript) {
finish_ui_load_with(script, |_| {}, |_| {});
@@ -724,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 `PLAYER_LOGIN`, 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(
@@ -736,11 +792,12 @@ pub(crate) fn finish_ui_load_with(
between: impl FnOnce(&mut UiScript),
) {
// Still the load edge, so still bounded: re-armed so the saved variables and every
// `PLAYER_LOGIN` handler get the full allowance; the entry edge disarms it after.
// `VARIABLES_LOADED` handler get the full allowance; the entry edge disarms it after. The
// world-enter events fire later, unbounded, as every event the feeds fire is.
script.set_instruction_budget(addons::LOAD_INSTRUCTION_BUDGET);
crate::ui_saved::load_saved_variables(script, host_settings);
between(script);
script.fire_event("PLAYER_LOGIN", vec![]);
script.arm_player_login();
}
/// Run the patch chain's `Interface\FrameXML\GlobalStrings.lua`, the first file in the reference's
+14 -11
View File
@@ -68,13 +68,13 @@ pub(super) fn apply_buff_durations(script: &UiScript) -> Result<(), String> {
.map_err(|e| e.to_string())
}
/// What a screen-size change re-runs, from `extract::tick_script`'s resize arm: anchors follow the
/// screen, but not a size or seat computed from the old one. The managed pass re-wraps the open
/// bags, whose columns `updateContainerFrameAnchors` lays out by `GetScreenHeight()`.
/// What a screen-size or UI-scale change re-runs, from `extract::tick_script`: anchors follow the
/// screen, but not a size or seat computed from the old `GetScreenHeight()`. The managed pass
/// re-wraps the open bags, whose columns `updateContainerFrameAnchors` lays out by it.
/// Deviation: [`FULLSCREEN_QUADS_RESEAT`] re-sizes the full-screen quads, which the stock files
/// size only in their OnLoads, because a resize or `uiScale` change here keeps the loaded UI and
/// the blackout would stop short of the new screen. Host-side, not in the layer: it adapts the
/// engine's resizable window, and changes nothing the stock UI does at a fixed size.
/// size only in their OnLoads, because a resize here keeps the loaded UI and the blackout would
/// stop short of the new screen. Host-side, not in the layer: it adapts the engine's resizable
/// window, and changes nothing the stock UI does at a fixed size.
pub(super) fn on_screen_resized(script: &UiScript) {
let _ = script.run("if UIParent_ManageFramePositions then UIParent_ManageFramePositions() end");
if let Err(e) = script.run(FULLSCREEN_QUADS_RESEAT) {
@@ -85,11 +85,14 @@ pub(super) fn on_screen_resized(script: &UiScript) {
/// The sizing arithmetic of `WorldMapFrame_OnLoad` (`WorldMapFrame.lua:19-28`) and
/// `CinematicFrame_OnLoad` (`CinematicFrame.lua:6-21`), each existence-guarded: the glue screens
/// have neither. Below 4:3 the bars go back to `CinematicFrame.xml`'s declared 1024 x 128, where
/// the stock OnLoad leaves them.
/// the stock OnLoad leaves them. The OnLoads read the screen root's size, `UIParent` being still at
/// scale 1 then, and their frames hang off the root, so this re-reads the root:
/// `GetScreenWidth()` times `UIParent`'s scale.
const FULLSCREEN_QUADS_RESEAT: &str = r#"
local rootScale = UIParent and UIParent:GetEffectiveScale() or 1
if BlackoutWorld then
local width = GetScreenWidth()
local height = GetScreenHeight()
local width = GetScreenWidth() * rootScale
local height = GetScreenHeight() * rootScale
if ( width / height < 4 / 3 ) then
width = width * 1.25
height = height * 1.25
@@ -98,8 +101,8 @@ if BlackoutWorld then
BlackoutWorld:SetHeight( height )
end
if UpperBlackBar and LowerBlackBar then
local width = GetScreenWidth()
local height = GetScreenHeight()
local width = GetScreenWidth() * rootScale
local height = GetScreenHeight() * rootScale
local barWidth, blackBarHeight = 1024, 128
if ( width / height > 4 / 3 ) then
local desiredHeight = width / 2
+50 -19
View File
@@ -1,8 +1,9 @@
//! The UI-engine bridge: hosts [`benilla_ui::script::UiScript`] and feeds its
//! [`extract`](benilla_ui::script::UiScript::extract) output into the quad pass
//! ([`crate::ui_pass::UiQuads`]) every frame. The script side is WoW UI space (y-up, origin
//! bottom-left, a screen `768/uiScale` units tall); [`seam_scale`] carries quads ×s out and the
//! mouse ÷s in, and extraction flips to y-down window px.
//! bottom-left, a screen root 768 units tall at scale 1); [`seam_scale`] carries quads ×s out and
//! the mouse ÷s in, and extraction flips to y-down window px. The UI scale is `UIParent`'s own
//! frame scale inside the VM ([`UiScaleCvar`]), never part of the seam.
use bevy::prelude::*;
@@ -199,8 +200,10 @@ pub(crate) fn run_or_warn(script: &benilla_ui::script::UiScript, chunk: &str) {
}
}
/// The reference's `uiScale` CVar: the VM's screen is `768/uiScale` units tall, so
/// `uiScale = 768/screenH` is pixel-perfect. Tests pin the `Default` 1.0.
/// The reference's `uiScale` CVar, which the VM makes `UIParent`'s own scale
/// ([`UiScript::set_ui_scale`]): the screen root stays 768 units tall, so `uiScale = 768/screenH`
/// is pixel-perfect inside `UIParent`, and a frame outside it is not scaled. Tests pin the
/// `Default` 1.0.
#[derive(Resource)]
pub(crate) struct UiScaleCvar(pub(crate) f32);
@@ -213,8 +216,17 @@ impl Default for UiScaleCvar {
/// The shipped `uiScale`. Deviation: a flat 0.9, because 1.0 reads oversized. With `useUiScale`
/// off, its default (`0x8430c0`), the reference sets `max(768/H, 0.9)` above 768 px tall and 1.0
/// at or below (`0x492f70`, on a mode set and from `0x4908ad`), so the two agree from ~853 px up.
/// benilla never reads `useUiScale`: this CVar always applies to `UIParent` alone, as with it on.
/// A change goes through the CVar callback (`0x490770`: the narrow-aspect cap, the 0.64 floor);
/// the load and a display change through the display handler's ON leg (`0x492e90`: the cap, and
/// below 0.64 the automatic scale `0x492f70` instead of the floor).
pub(crate) const DEFAULT_UI_SCALE: f32 = 0.9;
/// The display the reference's automatic UI scale last ran for (`0x492f70`'s `[0xb4e304]` and
/// `[0xb4e308]`), which outlives every UI: the display handler skips a display it already ran for.
#[derive(Resource, Default)]
pub(crate) struct UiAutoScaleCache(pub(crate) Option<(u32, u32)>);
/// `WOW_UI_SCALE=` if set, clamped to the dial's range, else [`DEFAULT_UI_SCALE`].
fn default_ui_scale() -> f32 {
std::env::var("WOW_UI_SCALE")
@@ -224,16 +236,24 @@ fn default_ui_scale() -> f32 {
.unwrap_or(DEFAULT_UI_SCALE)
}
/// The seam scale `s`, window px per UI unit (`windowH/768 × uiScale`), used at every crossing of
/// the VM boundary; identity for a degenerate window (h ≤ 0, before winit).
pub(crate) fn seam_scale(window_h: f32, ui_scale: f32) -> f32 {
/// The seam scale `s`, window px per screen-root unit (`windowH/768`: the root is 768 units tall at
/// every aspect, `0x41ad10`), used at every crossing of the VM boundary; identity for a degenerate
/// window (h ≤ 0, before winit). The UI scale is not in it: it is `UIParent`'s, inside the VM.
pub(crate) fn seam_scale(window_h: f32) -> f32 {
if window_h > 0.0 {
window_h / 768.0 * ui_scale
window_h / 768.0
} else {
1.0
}
}
/// A window cursor (logical px, y-down from the top left) in the VM's root units, y up: what the
/// pointer feed hands the engine and `GetCursorPosition` answers (`0x48b820`, no scale divided out).
pub(crate) fn window_to_ui(window_h: f32, cursor: Vec2) -> Vec2 {
let s = seam_scale(window_h);
Vec2::new(cursor.x / s, (window_h - cursor.y) / s)
}
/// The Lua UI host (a `NonSend` resource: an mlua VM is `!Send`) and its per-frame passes.
pub(crate) struct UiScriptPlugin;
@@ -263,6 +283,7 @@ impl Plugin for UiScriptPlugin {
// The quit root runs in `Last`: the close button's `AppExit` is written in `PostUpdate`.
crate::shutdown::on_app_exit(app, shutdown_on_exit.into_configs());
app.insert_resource(UiScaleCvar(default_ui_scale()))
.init_resource::<UiAutoScaleCache>()
.init_resource::<UiFrameCost>()
.init_resource::<crate::bindings::WheelNotches>()
.init_resource::<UiCostWanted>()
@@ -600,9 +621,9 @@ fn demo_unit_feed(script: Option<NonSendMut<UiScript>>, mut fired: Local<VmMemo<
},
]);
script.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
// 70% XP, set before `PLAYER_ENTERING_WORLD` so the bar's first update reads it.
// 70% XP, set before the world-enter events so the bar's first update reads it.
script.set_player_xp(4200, 6000);
script.fire_event("PLAYER_ENTERING_WORLD", vec![]);
script.fire_world_enter();
// The bottom multibars ship off and a capture has no toggle byte, so raise them as the
// Options rows do; `WOW_DEMO_BOTTOM_BARS=0` leaves them down for the stance shelf art,
// which `ShapeshiftBar_UpdatePosition` hides under the bottom-left bar.
@@ -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);
}
}
+5 -1
View File
@@ -660,6 +660,8 @@ pub(super) const BAG_UI: &[&str] = &[
"Interface\\FrameXML\\UIDropDownMenu.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
// `CharacterFrame.xml`'s tab template, for the hidden parent `PaperDollFrame` needs below.
r"Interface\FrameXML\CharacterFrameTemplates.xml",
// The dialog engine, after the `UIPanelCloseButton` it inherits.
r"Interface\FrameXML\StaticPopup.xml",
// `ContainerFrameItemButton_OnClick` hides `StackSplitFrame` on every plain click
@@ -683,7 +685,9 @@ pub(super) const BAG_UI: &[&str] = &[
"Interface\\FrameXML\\ItemButtonTemplate.xml",
// `BagSlotButtonTemplate` inherits `PaperDollItemSlotButtonTemplate`, and its OnLoad
// (`PaperDollItemSlotButton_OnLoad`) gives each bag button its inventory-slot id, 20..23.
// `CharacterFrame.xml` stays out: a missing `parent=` only warns.
// Its hidden parent `CharacterFrame` comes first: a parentless `PaperDollFrame` would be
// visible at the end of its load and run its OnShow, which reaches for the Honor tab.
"Interface\\FrameXML\\CharacterFrame.xml",
"Interface\\FrameXML\\PaperDollFrame.xml",
// The stock bag bar, with `BagSlotButtonTemplate` and `KEYRING_CONTAINER`.
"Interface\\FrameXML\\MainMenuBarBagButtons.xml",
@@ -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)]
);
}
@@ -773,6 +773,28 @@ fn leaving_inside_the_deferral_window_drops_the_load_and_writes_nothing() {
// ───────────── The login one-shots wait for the in-game UI ─────────────
/// An app running the unit feed after `Startup`, with what the feed reads; the receiver is the
/// wire, kept alive beside it.
fn unit_feed_app() -> (App, crossbeam_channel::Receiver<crate::net::ClientCommand>) {
let mut app = App::new();
app.add_plugins(crate::ui_unit::UiUnitPlugin);
app.init_resource::<super::AddOnIdentity>();
app.init_resource::<crate::minimap::MinimapZoom>();
app.init_resource::<super::ReloadUiPending>();
app.init_resource::<crate::target::Selection>();
app.init_resource::<crate::names::NameCache>();
let (tx, rx) = crossbeam_channel::unbounded();
app.insert_resource(crate::net::NetCommands(tx));
app.init_resource::<crate::net::Reputations>();
app.init_resource::<crate::ui_party::GroupState>();
app.init_resource::<crate::ui_chat::ChatLog>();
app.init_resource::<crate::ui_guild::GuildState>();
app.add_message::<crate::creature_anim::SwingImpact>();
app.add_message::<crate::net::WorldEnterCascadeMessage>();
super::setup_script(app.world_mut());
(app, rx)
}
/// The unit feed's login one-shots (`PLAYER_ENTERING_WORLD`, the first `PLAYER_XP_UPDATE` and
/// `UPDATE_EXHAUSTION`) wait for the in-game UI instead of reaching a VM with no frames.
#[test]
@@ -781,22 +803,7 @@ fn the_login_one_shots_wait_for_the_in_game_ui() {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_probe("oneshot");
let mut app = App::new();
app.add_plugins(crate::ui_unit::UiUnitPlugin);
app.init_resource::<super::AddOnIdentity>();
app.init_resource::<crate::minimap::MinimapZoom>();
app.init_resource::<super::ReloadUiPending>();
app.init_resource::<crate::target::Selection>();
app.init_resource::<crate::names::NameCache>();
let (tx, _rx) = crossbeam_channel::unbounded();
app.insert_resource(crate::net::NetCommands(tx));
app.init_resource::<crate::net::Reputations>();
app.init_resource::<crate::ui_party::GroupState>();
app.init_resource::<crate::ui_chat::ChatLog>();
app.init_resource::<crate::ui_guild::GuildState>();
app.add_message::<crate::creature_anim::SwingImpact>();
super::setup_script(app.world_mut());
let (mut app, _rx) = unit_feed_app();
// A frame registered as FrameXML registers, in the VM that exists before the entry load.
app.world()
@@ -1576,3 +1583,143 @@ fn a_login_and_a_reload_load_without_the_lost_target_sound() {
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
// ───────────── PLAYER_LOGIN fires from the world-enter cascade ─────────────
/// Records what file scope and the two cascade events see of `"player"`, in order.
const LOGIN_PROBE_LUA: &str = "\
LoginProbe = { order = \"\", fileExists = tostring(UnitExists(\"player\")),
fileLevel = UnitLevel(\"player\") }
local f = CreateFrame(\"Frame\")
f:RegisterEvent(\"PLAYER_LOGIN\")
f:RegisterEvent(\"PLAYER_ENTERING_WORLD\")
f:SetScript(\"OnEvent\", function()
if event == \"PLAYER_LOGIN\" then
LoginProbe.order = LoginProbe.order .. \"LOGIN:\" .. tostring(UnitExists(\"player\"))
.. \":\" .. UnitLevel(\"player\") .. \":\" .. UnitXP(\"player\") .. \" \"
else
LoginProbe.order = LoginProbe.order .. \"PEW \"
end
end)
";
const LOGIN_PROBE_TOC: &str = "## Interface: 11200\nLoginProbe.lua\n";
/// Our own player as its create lands: level 17, 1234 of 5000 XP.
fn spawn_self_player(world: &mut World) -> Entity {
use benilla_protocol::messages::{ObjectFields, ObjectType};
// `PLAYER_XP` and `PLAYER_NEXT_LEVEL_XP`, 716 and 717 in the 1.12 descriptor.
let fields = ObjectFields::from_pairs(&[
(benilla_protocol::field::FIELD_UNIT_LEVEL, 17),
(716, 1234),
(717, 5000),
])
.into_created(ObjectType::Player);
world
.spawn((
crate::net::SelfPlayer,
crate::net::Guid(1),
crate::net::ObjectStore(fields),
))
.id()
}
/// A probe global off the live VM, as a string.
fn login_probe(app: &App, field: &str) -> String {
app.world()
.non_send_resource::<benilla_ui::script::UiScript>()
.eval::<Option<String>>(&format!("return tostring(LoginProbe.{field})"))
.ok()
.flatten()
.unwrap_or_default()
}
/// `PLAYER_LOGIN` has one fire site, `0x490959` in the world-enter cascade `0x4908c0`, which the
/// UI load only arms (`0x49011d`). A fresh login skips the load's own entry (`0x490166 je`) and
/// runs the cascade from the player's create (`0x5deb60`), so its handlers see the player, and
/// `PLAYER_ENTERING_WORLD` follows at once (`0x49096a`). A worldport loads no UI, so its cascade
/// finds the arm spent and fires 272 alone.
#[test]
fn player_login_fires_from_the_world_entry_with_the_player_seated() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_addon("login", "LoginProbe", LOGIN_PROBE_TOC, LOGIN_PROBE_LUA);
let (mut app, _rx) = unit_feed_app();
app.insert_resource(State::new(crate::char_select::ClientState::InWorld));
app.insert_resource(roster_named("Probelogin", 1));
super::load_ingame_ui_on_world_entry(app.world_mut());
assert_eq!(
login_probe(&app, "fileExists"),
"nil",
"a fresh login's file scope runs before the player object exists"
);
assert_eq!(
login_probe(&app, "order"),
"",
"the load arms PLAYER_LOGIN; nothing fires it before the player is in the world"
);
let me = spawn_self_player(app.world_mut());
app.update();
assert_eq!(
login_probe(&app, "order"),
"LOGIN:1:17:1234 PEW ",
"PLAYER_LOGIN sees the player's own unit, then PLAYER_ENTERING_WORLD fires at once"
);
app.update();
assert_eq!(login_probe(&app, "order"), "LOGIN:1:17:1234 PEW ");
// A cross-map worldport: the player object is destroyed and created again.
app.world_mut().despawn(me);
app.update();
spawn_self_player(app.world_mut());
app.update();
assert_eq!(
login_probe(&app, "order"),
"LOGIN:1:17:1234 PEW PEW ",
"a worldport re-fires PLAYER_ENTERING_WORLD and not PLAYER_LOGIN"
);
drop(app);
let _ = std::fs::remove_dir_all(&tmp);
}
/// On a `/reload` the player object stays in the world: `UI_Init` runs its file scope against it
/// and enters the cascade itself (`0x490168`), so `PLAYER_LOGIN` then `PLAYER_ENTERING_WORLD` fire
/// once each on the new VM, both seeing the player.
#[test]
fn a_reload_fires_player_login_once_against_the_live_player() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_addon("relogin", "LoginProbe", LOGIN_PROBE_TOC, LOGIN_PROBE_LUA);
let (mut app, _rx) = unit_feed_app();
app.insert_resource(State::new(crate::char_select::ClientState::InWorld));
app.insert_resource(roster_named("Probelogin", 1));
super::load_ingame_ui_on_world_entry(app.world_mut());
spawn_self_player(app.world_mut());
app.update();
assert_eq!(login_probe(&app, "order"), "LOGIN:1:17:1234 PEW ");
reload(app.world_mut(), crate::char_select::ClientState::InWorld);
assert_eq!(
(
login_probe(&app, "fileExists"),
login_probe(&app, "fileLevel")
),
("1".to_string(), "17".to_string()),
"the reloaded file scope runs against the live player"
);
app.update();
app.update();
assert_eq!(
login_probe(&app, "order"),
"LOGIN:1:17:1234 PEW ",
"the new VM gets PLAYER_LOGIN then PLAYER_ENTERING_WORLD, once each"
);
drop(app);
let _ = std::fs::remove_dir_all(&tmp);
}
+6 -5
View File
@@ -52,11 +52,12 @@ pub(crate) fn fontstring_em(height: Option<f32>) -> Option<f32> {
height.map(|h| h.min(FONTSTRING_EM_CAP))
}
/// The drawn pixel height of a UI FontString times the seam scale `s = windowH/768 × uiScale`
/// (`crate::ui_script::seam_scale`), under the reference's two size regimes (getter `0x7727b0`):
/// one-to-one, the default, draws the raster size ([`FONTSTRING_EM_CAP`] applied in units), and a
/// `SetTextHeight` size (`text_height`) draws uncapped, the reference magnifying the raster to it
/// (`0x771600` clears bit `0x200`; the drawn em is `round(size/768·deviceH)`).
/// The drawn pixel height of a UI FontString at `s`, the seam scale `windowH/768`
/// (`crate::ui_script::seam_scale`) times the owner's effective scale, the UI scale included,
/// under the reference's two size regimes (getter `0x7727b0`): one-to-one, the default, draws the
/// raster size ([`FONTSTRING_EM_CAP`] applied in units), and a `SetTextHeight` size
/// (`text_height`) draws uncapped, the reference magnifying the raster to it (`0x771600` clears bit
/// `0x200`; the drawn em is `round(size/768·deviceH)`).
pub(crate) fn drawn_px(font_height: Option<f32>, text_height: Option<f32>, s: f32) -> Option<f32> {
match text_height {
Some(t) => Some(t * s),
+5 -5
View File
@@ -114,8 +114,6 @@ fn drive_mouseover_tooltip(
go_inputs: crate::target::lock::GoLockInputs,
// The known-spell set the resolver's SKILL arm scans.
player_actions: Res<crate::ui_action::PlayerActions>,
// The cursor seat crosses the VM seam: the anchor below is UI units, not px.
ui_scale: Res<crate::ui_script::UiScaleCvar>,
mut memo: HoverMemo,
// `ChrClasses.dbc` field 16, `UnitHasRelicSlot`'s input, and the form table the creature type
// reads.
@@ -229,15 +227,17 @@ fn drive_mouseover_tooltip(
.templates
.get(guid)
.is_some_and(|t| t.floating_tooltip);
// Window px to the VM's y-up UI units, as the input seam converts.
// Window px to the VM's y-up root units, as the input seam converts.
let cursor_ui = cursor_seated
.then(|| {
window.iter().next().and_then(|w| {
let s = crate::ui_script::seam_scale(w.height(), ui_scale.0);
// The hover probe's aim stands in for a missing cursor; a real pointer wins.
w.cursor_position()
.or_else(crate::target::hover_probe_point)
.map(|c| (c.x / s, (w.height() - c.y) / s))
.map(|c| {
let at = crate::ui_script::window_to_ui(w.height(), c);
(at.x, at.y)
})
})
})
.flatten();
@@ -882,7 +882,6 @@ fn mouseover_app() -> (App, Entity, Entity) {
let mut app = App::new();
let (tx, _rx) = crossbeam_channel::unbounded();
app.insert_resource(NetCommands(tx))
.insert_resource(crate::ui_script::UiScaleCvar(1.0))
.init_resource::<Hovered>()
.init_resource::<HoveredObject>()
.init_resource::<NameCache>()
+93 -22
View File
@@ -101,7 +101,7 @@ struct UnitFeedMemo {
/// The lazy caches' landing counters: their per-frame `&mut` misses would trip `is_changed`.
names_generation: gate::Watch,
guild_generation: gate::Watch,
/// Whether `PLAYER_ENTERING_WORLD` has fired for this world entry.
/// Whether the world-enter events have fired for this world entry.
entered_world: bool,
/// Per token, the last snapshot pushed.
last: HashMap<String, UnitState>,
@@ -1194,6 +1194,75 @@ pub(crate) fn fire_transitions(
}
}
/// `"player"`'s snapshot off our own descriptor: no reaction (the unit is ours), the raid icon,
/// both faction-group spellings and the guild. One builder for [`feed_units`] and for the seat a
/// `/reload`'s load runs under ([`live_player_seat`]).
fn player_snapshot(
store: &ObjectStore,
guid: u64,
tables: &SnapshotTables,
names: &NameCache,
commands: &NetCommands,
group: &crate::ui_party::GroupState,
factions: Option<&Factions>,
guild: &mut crate::ui_guild::GuildState,
) -> UnitState {
let name = names
.resolve_unit(guid, Some(store), commands)
.map(str::to_string);
let mut s = snapshot(store, guid, name, 0, tables.classes(), tables.types(names));
s.is_player = true;
s.raid_target = group.raid_target_index(guid);
s.faction_group = faction_group(store, factions);
s.faction_group_localized = faction_group_localized(store, factions);
// `GetGuildInfo`: the public guild fields (191/192) joined against the lazy guild cache.
s.guild = crate::ui_guild::unit_guild(&store.0, guild, commands);
s
}
/// `UnitXP("player")` and `UnitXPMax("player")` off our own descriptor, zero while unsent.
fn xp_pair(store: &ObjectStore) -> (u32, u32) {
(
store.0.player_xp().unwrap_or(0),
store.0.player_next_level_xp().unwrap_or(0),
)
}
/// The live player a UI load seats before any file runs: its snapshot and XP pair, as
/// [`feed_units`] pushes them.
pub(crate) struct LivePlayerSeat {
pub(crate) state: UnitState,
pub(crate) xp: (u32, u32),
}
/// [`LivePlayerSeat`] for an exclusive edge, `None` before our own create. A `/reload` loads with
/// the player object still in the world, so `UI_Init`'s file scope, `ADDON_LOADED` and
/// `VARIABLES_LOADED` read it there as `PLAYER_LOGIN` does (`0x490168`).
pub(crate) fn live_player_seat(
tables: SnapshotTables,
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
names: Res<NameCache>,
commands: Res<NetCommands>,
group: Res<crate::ui_party::GroupState>,
factions: Option<Res<Factions>>,
mut guild: ResMut<crate::ui_guild::GuildState>,
) -> Option<LivePlayerSeat> {
let (store, guid) = self_q.iter().next()?;
Some(LivePlayerSeat {
state: player_snapshot(
store,
guid.0,
&tables,
&names,
&commands,
&group,
factions.as_deref(),
&mut guild,
),
xp: xp_pair(store),
})
}
fn feed_units(
script: Option<NonSendMut<UiScript>>,
tables: SnapshotTables,
@@ -1280,17 +1349,16 @@ fn feed_units(
// A missing unit is `None`, which `set_unit` clears; a name miss lands on a later frame.
let self_pair = self_q.iter().next();
let player = self_pair.map(|(store, guid)| {
let name = names
.resolve_unit(guid.0, Some(store), &commands)
.map(str::to_string);
let mut s = snapshot(store, guid.0, name, 0, chr, types);
s.is_player = true;
s.raid_target = group.raid_target_index(guid.0);
s.faction_group = faction_group(store, factions.as_deref());
s.faction_group_localized = faction_group_localized(store, factions.as_deref());
// `GetGuildInfo`: the public guild fields (191/192) joined against the lazy guild cache.
s.guild = crate::ui_guild::unit_guild(&store.0, &mut guild, &commands);
s
player_snapshot(
store,
guid.0,
&tables,
&names,
&commands,
&group,
factions.as_deref(),
&mut guild,
)
});
// Log once when our template names no side, almost always GM mode (vmangos forces template
// 35, group mask 0): every `UnitFactionGroup` surface loses its side, as in the reference.
@@ -1390,12 +1458,11 @@ fn feed_units(
}
}
// The XP pair and `PLAYER_XP_UPDATE`, ahead of the `PLAYER_ENTERING_WORLD` fire so its
// handlers read real values. The event fires on first sight too, at login and after a
// `/reload`, where the reference's field watchers stay silent.
// The XP pair and `PLAYER_XP_UPDATE`, ahead of the world-enter events so `PLAYER_LOGIN` and
// `PLAYER_ENTERING_WORLD` handlers read real values. The event fires on first sight too, at
// login and after a `/reload`, where the reference's field watchers stay silent.
if let Some((store, _)) = self_q.iter().next() {
let xp = store.0.player_xp().unwrap_or(0);
let next = store.0.player_next_level_xp().unwrap_or(0);
let (xp, next) = xp_pair(store);
if memo.last_xp != Some((xp, next)) {
gate.audit("feed_units", "the XP pair");
memo.last_xp = Some((xp, next));
@@ -1404,7 +1471,7 @@ fn feed_units(
}
}
// The rest snapshot (rest-state byte, pool, `PLAYER_FLAGS`), ahead of the entering-world fire
// The rest snapshot (rest-state byte, pool, `PLAYER_FLAGS`), ahead of the world-enter events
// as the reference's descriptor is. Below `0x5ee990`'s local-GUID gate (`0x5eea93`) each arm
// tests its own bits: `PLAYER_UPDATE_RESTING` on either edge of `0x20` (`0x5eead0`, fire
// `0x5eeaf2`), `PLAYTIME_CHANGED` on `0x3000` (`0x5eeb65`, fire `0x5eeb6f`).
@@ -1495,12 +1562,16 @@ fn feed_units(
}
}
// `PLAYER_ENTERING_WORLD` once per world entry, after our descriptor lands, as the reference's.
// At world exit, re-arm it and forget the player-global memos so the next character seeds them.
// The world-enter cascade's events once per world entry, after our descriptor lands and the
// pushes above seat it: the reference runs the cascade from the local player's own create
// (`0x5deb60 call 0x4908c0`) and, on a `/reload`, from `UI_Init` (`0x490168`), the player in
// the world both times. `PLAYER_LOGIN` only when this VM's load armed it, so a worldport's
// re-entry fires `PLAYER_ENTERING_WORLD` alone. At world exit, re-arm the entry and forget the
// player-global memos so the next character seeds them.
if self_pair.is_some() {
if !memo.entered_world {
gate.audit("feed_units", "the PLAYER_ENTERING_WORLD arm");
script.fire_event("PLAYER_ENTERING_WORLD", vec![]);
gate.audit("feed_units", "the world-enter events");
script.fire_world_enter();
memo.entered_world = true;
}
} else if memo.entered_world {
+3 -5
View File
@@ -710,7 +710,6 @@ fn dev_map_jump(
buttons: Res<ButtonInput<MouseButton>>,
keys: Res<ButtonInput<KeyCode>>,
windows: Query<&Window, With<bevy::window::PrimaryWindow>>,
ui_scale: Res<crate::ui_script::UiScaleCvar>,
script: Option<NonSendMut<UiScript>>,
data: Option<Res<WorldMapUiData>>,
net: Res<crate::net::NetCommands>,
@@ -729,10 +728,9 @@ fn dev_map_jump(
let Some(cursor) = window.cursor_position() else {
return;
};
// Window px (y-down) to the VM's y-up 768-unit space, as the pointer feed converts them.
let s = crate::ui_script::seam_scale(window.height(), ui_scale.0);
let Some((u, v)) = script.world_map_uv_at(cursor.x / s, (window.height() - cursor.y) / s)
else {
// Window px (y-down) to the VM's y-up root units, as the pointer feed converts them.
let at = crate::ui_script::window_to_ui(window.height(), cursor);
let Some((u, v)) = script.world_map_uv_at(at.x, at.y) else {
return;
};
let (c, z, direct) = script.world_map_selection();
+4 -8
View File
@@ -308,7 +308,6 @@ fn drive_vplates(
net_commands: Res<NetCommands>,
// `None` in a run with no UI VM.
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
ui_scale: Res<crate::ui_script::UiScaleCvar>,
anchor_q: (
Query<&BoneAttach>,
Query<&benilla_world::rig_anim::RigPose>,
@@ -374,10 +373,8 @@ fn drive_vplates(
let (pw, ph) = (gx(PLATE_W), gx(PLATE_H));
let scale = window.map_or(1.0, |w| w.scale_factor());
// Everything below is in window px, divided by this once for the widget layer's units.
let seam = window.map_or(1.0, |w| {
crate::ui_script::seam_scale(w.height(), ui_scale.0)
});
// Everything below is in window px, divided by this once for the widget layer's root units.
let seam = window.map_or(1.0, |w| crate::ui_script::seam_scale(w.height()));
let mut states: Vec<PlateState> = Vec::new();
// Plates seat in squared-distance order from the gx point (0.4, 0.3), Y mirrored from the
@@ -541,9 +538,8 @@ fn drive_vplates(
}
// ── The plate's state, handed to the widget layer ──
//
// The reference's plates sit outside the uiScale cascade, but benilla folds uiScale into
// one global seam, so dividing by it here keeps plate pixels uiScale-blind.
// The raid-target index is 1-based here, 0-based for the atlas cell.
// The plates hang off `WorldFrame`, outside `UIParent`'s scale, so root units are their
// own. The raid-target index is 1-based here, 0-based for the atlas cell.
let mark = group.raid_target_index(guid.0);
let x_units = (plate.min.x + plate.max.x) * 0.5 / seam;
let y_units = (viewport.y - plate.min.y) / seam;
+5 -10
View File
@@ -374,10 +374,9 @@ fn only_the_bids_pane_gets_its_page_without_being_opened() {
assert!(s.take_errors().is_empty());
}
/// The reference's show cascade `0x76ae10` is post-order: a frame marks itself visible
/// (`0x76ae7b`), walks its children re-reading live links, and fires its own `OnShow` last
/// (`0x76aef5`). benilla's is pre-order, a gap in the engine; for this window both orders reach
/// the same state, so only the `ShowOrder` assertions are benilla's order.
/// The show cascade `0x76ae10` is post-order: a frame marks itself visible (`0x76ae7b`), walks its
/// children re-reading live links, and fires its own `OnShow` last (`0x76aef5`), so the pane the
/// XML leaves shown is notified before the window.
#[test]
fn the_show_cascade_notifies_the_bids_pane_once_and_keeps_its_page() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -408,11 +407,7 @@ fn the_show_cascade_notifies_the_bids_pane_once_and_keeps_its_page() {
};
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
assert_eq!(
order(&s),
"window,pane",
"OURS, and a known deviation: the reference's cascade is post-order, \"pane,window\""
);
assert_eq!(order(&s), "pane,window", "children first, the window last");
assert_eq!(
page(&s),
"0",
@@ -431,7 +426,7 @@ fn the_show_cascade_notifies_the_bids_pane_once_and_keeps_its_page() {
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
assert_eq!(
order(&s),
"window,pane,window",
"pane,window,window",
"the reopen notifies the window and not the pane it left hidden"
);
assert_eq!(page(&s), "0", "kept from the first open, not re-assigned");
+12 -4
View File
@@ -315,8 +315,10 @@ fn element_from_node(node: roxmltree::Node) -> Element {
/// attributes override or extend the template's (case-insensitively). The reference merges
/// nothing: `LoadXML` re-enters itself on the one object against each template, the most distant
/// first, then the instance (`0x76985c`; `<Frames>` repeat the walk, `0x76a060`), and reads `name`
/// and `id` from the instance alone (`PreLoadXML 0x769770`). Here `name` and `virtual` merge like
/// any attribute. A cycle is skipped with a warning.
/// and `id` from the instance alone (`PreLoadXML 0x769770`; a region's factory reads its `name`
/// once, outside the walk, `0x6f2751`/`0x6f27e1`). So here `name` and `id` come from the element
/// alone, and an unnamed instance stays unnamed; `virtual` merges like any attribute, read only at
/// registration. A cycle is skipped with a warning.
pub fn expand(
element: &Element,
templates: &HashMap<&str, &Element>,
@@ -390,9 +392,15 @@ fn expand_inner(
}
}
/// [`expand`]'s merge; `over`'s tag wins, and its body when it has one.
/// [`expand`]'s merge; `over`'s tag wins, and its body when it has one. `base`'s `name` and `id`
/// are dropped: they belong to the node they are written on.
fn merge(base: &Element, over: &Element) -> Element {
let mut attrs = base.attrs.clone();
let mut attrs: Vec<(String, String)> = base
.attrs
.iter()
.filter(|(k, _)| !k.eq_ignore_ascii_case("name") && !k.eq_ignore_ascii_case("id"))
.cloned()
.collect();
for (k, v) in &over.attrs {
if let Some(slot) = attrs.iter_mut().find(|(ek, _)| ek.eq_ignore_ascii_case(k)) {
slot.1 = v.clone();
+11 -2
View File
@@ -642,7 +642,7 @@ impl Loader<'_> {
}
/// Everything a frame element does to an existing frame, from `LoadXML` attributes to its
/// `OnLoad` (last, bottom-up, `0x76a060`); shared by [`Self::materialize`] and
/// `OnLoad` (bottom-up, `0x76a060`) and the post-load OnShow; shared by [`Self::materialize`] and
/// [`apply_template`]. `$parent` is `self_name` in this frame's contents and `parent_name` in
/// its own anchors (`0x76c5b0`).
pub(super) fn decorate(
@@ -655,6 +655,9 @@ impl Loader<'_> {
) {
// This frame's deferred anchors drain before its OnLoad; a nested frame drains its own.
let deferred_mark = self.deferred_anchors.len();
// 1c · the pre-load hook opens the frame's load window (`0x7697c2`): until the post-load
// hook, its own OnShow and OnHide fire nothing.
crate::script::visibility::set_load_window(self.lua(), wrapper, true);
// 2 · LoadXML attributes (`0x769820`).
self.apply_attrs(el, wrapper, dbg_name);
// 3 · <Size> and 4 · <Anchors> (the CLayoutFrame geometry base, `0x767800`).
@@ -681,7 +684,9 @@ impl Loader<'_> {
// 6 · <Scripts> handlers (`0x769ef0`); OnLoad is captured to fire bottom-up below.
let onload = self.apply_scripts(el, wrapper, dbg_name);
// 7 · nested <Frames>, whose OnLoads fire before ours (`0x76a060`).
// 7 · the post-load hook (`0x76a2f0`): close the window (`0x76a2ff`), then the nested
// <Frames>, whose OnLoads fire before ours (`0x76a060`).
crate::script::visibility::set_load_window(self.lua(), wrapper, false);
for frames_el in children_named(el, "Frames") {
for child in &frames_el.children {
let expanded = self.expand(child);
@@ -717,6 +722,8 @@ impl Loader<'_> {
if let Some(func) = onload {
self.fire_onload(wrapper, &func, dbg_name);
}
// 9 · and its OnShow, if it is visible now (`0x76a3b3`).
crate::script::visibility::post_load_show(self.lua(), wrapper);
}
/// Apply one anchor, deferring it (when `may_defer`) while its named `relativeTo` is unbuilt.
@@ -898,5 +905,7 @@ pub(super) fn font_object_from_element(el: &Element) -> FontObject {
}
}
#[cfg(test)]
mod template_name_tests;
#[cfg(test)]
mod tests;
@@ -0,0 +1,87 @@
//! `name=` and `id=` belong to the node they are written on: `PreLoadXML 0x769770` reads them from
//! the instance before `LoadXML` walks its templates (`0x76985c`), and the region factories read
//! `name` once, outside that walk (`0x6f2751`, `0x6f27e1`).
use crate::framexml;
use crate::loader::load;
use crate::script::UiScript;
const DOC: &str = r#"<Ui>
<Button name="TmpCloseTemplate" id="3" virtual="true">
<Size><AbsDimension x="32" y="32"/></Size>
</Button>
<Texture name="TmpTexTemplate" virtual="true">
<Color r="1" g="0" b="0" a="1"/>
</Texture>
<Frame name="Holder">
<Size><AbsDimension x="64" y="64"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture inherits="TmpTexTemplate"/>
</Layer>
</Layers>
<Frames>
<Button inherits="TmpCloseTemplate"/>
<Button name="$parentClose" inherits="TmpCloseTemplate"/>
</Frames>
</Frame>
</Ui>"#;
fn loaded() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let report = load(&s, &framexml::parse(DOC).unwrap(), &|_| None);
assert!(report.errors.is_empty(), "{:?}", report.errors);
s
}
/// Stock `UIOptionsFrame.xml:1477`'s unnamed `<Button inherits="UIPanelCloseButton">` is the
/// shape: in 1.12.1 the template's name is no global and the button answers no name.
#[test]
fn an_unnamed_instance_takes_neither_its_templates_name_nor_its_id() {
let s = loaded();
assert!(
s.eval::<bool>("return TmpCloseTemplate == nil").unwrap(),
"the unnamed instance published the template's name as a global"
);
assert!(s
.eval::<bool>("return ({Holder:GetChildren()})[1]:GetName() == nil")
.unwrap());
assert_eq!(
s.eval::<i64>("return ({Holder:GetChildren()})[1]:GetID()")
.unwrap(),
0
);
}
#[test]
fn a_named_instance_keeps_its_own_name_and_takes_no_template_id() {
let s = loaded();
assert_eq!(
s.eval::<String>("return HolderClose:GetName()").unwrap(),
"HolderClose"
);
assert_eq!(s.eval::<i64>("return HolderClose:GetID()").unwrap(), 0);
}
#[test]
fn an_unnamed_region_takes_no_name_from_its_template() {
let s = loaded();
assert!(s.eval::<bool>("return TmpTexTemplate == nil").unwrap());
assert!(s
.eval::<bool>("return ({Holder:GetRegions()})[1]:GetName() == nil")
.unwrap());
}
/// `CreateFrame` builds its node with the `name` it was given (`0x70622d`), nil included.
#[test]
fn create_frame_with_no_name_stays_unnamed_under_a_named_template() {
let s = loaded();
assert!(s
.eval::<bool>(
"local b = CreateFrame('Button', nil, Holder, 'TmpCloseTemplate') \
return b:GetName() == nil and TmpCloseTemplate == nil"
)
.unwrap());
}
+106
View File
@@ -2470,6 +2470,112 @@ mod chunk_name_tests {
}
}
/// The post-load hook's OnShow (`0x76a2f0`), for a frame visible at the end of its load.
mod post_load_show_tests {
use crate::framexml;
use crate::loader::*;
use crate::script::UiScript;
fn no_files(_: &str) -> Option<Vec<u8>> {
None
}
fn parse(text: &str) -> framexml::ParsedDocument {
framexml::parse(text).expect("valid FrameXML")
}
/// The post-load hook `0x76a2f0` builds the `<Frames>` children (`0x76a36f`, their own hooks
/// inside), fires OnLoad (`0x76a387`), then fires OnShow (`0x76a3b3`) when the frame is visible
/// (`0x76a38c`) and has the handler (`0x76a396`): a frame visible at the end of its load gets
/// one OnShow, after its OnLoad and its children's.
#[test]
fn a_frame_visible_at_the_end_of_its_load_gets_one_onshow_after_its_onload() {
let s = UiScript::new().unwrap();
s.run("LOG = {} function Log(tag) table.insert(LOG, tag) end")
.unwrap();
let doc = parse(
r#"<Ui>
<Frame name="Born">
<Scripts>
<OnLoad>Log("load")</OnLoad>
<OnShow>Log("show")</OnShow>
</Scripts>
<Frames>
<Frame name="BornChild">
<Scripts>
<OnLoad>Log("child-load")</OnLoad>
<OnShow>Log("child-show")</OnShow>
</Scripts>
</Frame>
</Frames>
</Frame>
</Ui>"#,
);
let report = load_in(&s, &doc, "Test.xml", &no_files);
assert!(report.errors.is_empty(), "{:?}", report.errors);
assert_eq!(
s.eval::<String>("return table.concat(LOG, ',')").unwrap(),
"child-load,child-show,load,show"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The post-load OnShow is keyed on the frame's visibility at `0x76a38c`, and nothing records
/// an OnShow already given: a frame first shown by its own OnLoad gets that `Show()`'s OnShow
/// and then the hook's, while a frame still hidden gets none.
#[test]
fn a_frame_shown_by_its_onload_gets_two_onshows_and_a_hidden_one_none() {
let s = UiScript::new().unwrap();
s.run("LOG = {} function Log(tag) table.insert(LOG, tag) end")
.unwrap();
let doc = parse(
r#"<Ui>
<Frame name="ShownByLoad" hidden="true">
<Scripts>
<OnLoad>Log("a-load") this:Show() Log("a-load-end")</OnLoad>
<OnShow>Log("a-show")</OnShow>
</Scripts>
</Frame>
<Frame name="StaysHidden" hidden="true">
<Scripts>
<OnLoad>Log("b-load")</OnLoad>
<OnShow>Log("b-show")</OnShow>
<OnHide>Log("b-hide")</OnHide>
</Scripts>
</Frame>
</Ui>"#,
);
let report = load_in(&s, &doc, "Test.xml", &no_files);
assert!(report.errors.is_empty(), "{:?}", report.errors);
assert_eq!(
s.eval::<String>("return table.concat(LOG, ',')").unwrap(),
"a-load,a-show,a-load-end,a-show,b-load"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The post-load OnShow is the script alone (`0x76a3b3` calls the runner `0x702690`, not the
/// notify slot `+0x30`), so an `autoFocus` EditBox born visible runs its OnShow and still takes
/// no keyboard: the focus is the EditBox notify's (`0x77a750`), which only a transition reaches.
#[test]
fn the_post_load_onshow_runs_the_script_without_the_editbox_autofocus() {
let s = UiScript::new().unwrap();
let doc = parse(
r#"<Ui>
<EditBox name="BornBox" autoFocus="true">
<Size><AbsDimension x="100" y="20"/></Size>
<Scripts><OnShow>BOX_SHOWN = true</OnShow></Scripts>
</EditBox>
</Ui>"#,
);
let report = load_in(&s, &doc, "Test.xml", &no_files);
assert!(report.errors.is_empty(), "{:?}", report.errors);
assert!(s.eval::<bool>("return BOX_SHOWN == true").unwrap());
assert_eq!(s.focused_editbox_name(), None);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
}
#[test]
fn join_ref_resolves_relative_framexml_paths() {
use super::join_ref;
+44 -15
View File
@@ -123,18 +123,25 @@ fn aa_corners(x0: f32, x1: f32, y_bottom: f32, y_top: f32) -> [[f32; 2]; 4] {
/// The drawable pieces for a frame's resolved rect (y-up), in paint order: the background (layer
/// `BACKGROUND`), then the 8 border pieces (layer `BORDER`). Geometry `0x77e8d0`, UVs `0x77f0c0`.
pub fn pieces(frame: Rect, bd: &Backdrop) -> Vec<BackdropPiece> {
/// The pieces are textures anchored and sized in the frame's own units, so the layout multiplies
/// their geometry by the frame's effective `scale` (`0x76ac90`), as it does any region's. The
/// texcoord repeats are not: `0x77f0c0` divides the scaled rect by the stored, unscaled
/// `edgeSize` and `tileSize` (`0x77f101 fdiv [esi+0x48]`, `0x77f195`), so a scaled frame's edge
/// and background repeat the art more or less often than at scale 1.
pub fn pieces(frame: Rect, bd: &Backdrop, scale: f32) -> Vec<BackdropPiece> {
let mut out = Vec::with_capacity(9);
let e = bd.edge_size;
let e = bd.edge_size * scale;
// The texcoord divisor, unscaled.
let e_uv = bd.edge_size;
let (l, r, b, t) = (frame.left, frame.right, frame.bottom, frame.top);
// ── The background piece ──
if bd.has_bg() {
let (il, ir, it, ib) = (
bd.insets.left,
bd.insets.right,
bd.insets.top,
bd.insets.bottom,
bd.insets.left * scale,
bd.insets.right * scale,
bd.insets.top * scale,
bd.insets.bottom * scale,
);
// The four anchors, y-up. Bottom-right takes the top inset, as the reference does
// (`0x77e9ae` loads `+0x50`, top, not `+0x54`); invisible when the two are equal.
@@ -146,7 +153,11 @@ pub fn pieces(frame: Rect, bd: &Backdrop) -> Vec<BackdropPiece> {
];
// Untiled, no `SetTexCoord` runs and the background stretches over `[0,1]` (`0x77f0c0`).
let uvs = if bd.tile {
let period = if bd.tile_size != 0.0 { bd.tile_size } else { e };
let period = if bd.tile_size != 0.0 {
bd.tile_size
} else {
e_uv
};
let bg_w = (r - ir) - (l + il);
let bg_h = (t - it) - (b + ib);
let (wt, ht) = (bg_w / period, bg_h / period);
@@ -165,8 +176,8 @@ pub fn pieces(frame: Rect, bd: &Backdrop) -> Vec<BackdropPiece> {
// ── The 8 border pieces ──
if bd.has_border() {
// Edge strips tile `run` periods between the corners, 0 below two edges (`0x77f0c0`).
let w_run = ((r - l) / e - 2.0).max(0.0);
let h_run = ((t - b) / e - 2.0).max(0.0);
let w_run = ((r - l) / e_uv - 2.0).max(0.0);
let h_run = ((t - b) / e_uv - 2.0).max(0.0);
let mut edge = |x0: f32, x1: f32, y0: f32, y1: f32, uvs: [[f32; 2]; 4]| {
out.push(BackdropPiece {
@@ -311,7 +322,7 @@ mod tests {
fn piece_geometry_200x100_edge16_inset5() {
let frame = Rect::new(0.0, 0.0, 100.0, 200.0);
let bd = tooltip_backdrop(16.0, 5.0);
let ps = pieces(frame, &bd);
let ps = pieces(frame, &bd, 1.0);
assert_eq!(ps.len(), 9);
// Symmetric insets hide the bottom-right quirk.
@@ -365,7 +376,7 @@ mod tests {
},
..tooltip_backdrop(16.0, 0.0)
};
let bg = pieces(frame, &bd)[0];
let bg = pieces(frame, &bd, 1.0)[0];
assert_eq!(bg.corners[3], [3.0, 9.0]);
// BR takes the top inset, 7, not the bottom's 9.
assert_eq!(bg.corners[2], [196.0, 7.0]);
@@ -378,7 +389,7 @@ mod tests {
// 20/16 - 2 = -0.75.
let frame = Rect::new(0.0, 0.0, 20.0, 20.0);
let bd = tooltip_backdrop(16.0, 5.0);
let ps = pieces(frame, &bd);
let ps = pieces(frame, &bd, 1.0);
let left = ps[1]; // LEFT edge
assert_eq!(left.uvs[3][1], 0.0, "h_run clamped to 0"); // BL v
let top = ps[3]; // TOP edge
@@ -388,16 +399,34 @@ mod tests {
#[test]
fn presence_gates_pieces() {
let frame = Rect::new(0.0, 0.0, 100.0, 100.0);
assert!(pieces(frame, &Backdrop::default()).is_empty());
assert!(pieces(frame, &Backdrop::default(), 1.0).is_empty());
let bg_only = Backdrop {
bg_file: Some("bg".into()),
..Default::default()
};
assert_eq!(pieces(frame, &bg_only).len(), 1);
assert_eq!(pieces(frame, &bg_only, 1.0).len(), 1);
let border_only = Backdrop {
edge_file: Some("edge".into()),
..Default::default()
};
assert_eq!(pieces(frame, &border_only).len(), 8);
assert_eq!(pieces(frame, &border_only, 1.0).len(), 8);
}
/// A half-scale frame: the geometry halves, but `0x77f0c0` divides the scaled rect by the
/// unscaled sizes, so a 100-wide (on screen) edge repeats `100/16 - 2` times and the 96-wide
/// background `96/16` times.
#[test]
fn a_scaled_frames_texcoords_repeat_by_the_unscaled_sizes() {
let frame = Rect::new(0.0, 0.0, 50.0, 100.0);
let bd = tooltip_backdrop(16.0, 4.0);
let ps = pieces(frame, &bd, 0.5);
let bg = ps[0];
assert_eq!(bg.corners[0], [2.0, 48.0], "inset 4 units, 2 on screen");
assert_eq!(bg.uvs[2], [6.0, 46.0 / 16.0]);
// TOP, slice 2, rotated: `u` holds the run.
let top = ps[3];
assert_eq!(top.corners[0][1], 50.0);
assert_eq!(top.corners[2][1], 42.0, "8 thick on screen");
assert_eq!(top.uvs[0][1], 100.0 / 16.0 - 2.0);
}
}
@@ -798,6 +798,20 @@ mod tests {
}
}
/// The markers are textures of the picker, sized in its units, so a half-scale picker draws
/// them at half size: 5 and 24 on screen.
#[test]
fn the_markers_scale_with_the_picker() {
let mut s = picker();
s.run("TestPicker:SetScale(0.5); TestPicker:SetColorRGB(1, 0, 0)")
.unwrap();
s.resolve();
let wheel = marker_rect(&s, 5.0).expect("the wheel marker, 10 units at half scale");
assert!((wheel.top - wheel.bottom - 5.0).abs() < 0.01, "{wheel:?}");
let value = marker_rect(&s, 24.0).expect("the value marker, 48 units at half scale");
assert!((value.top - value.bottom - 7.0).abs() < 0.01, "{value:?}");
}
/// The extracted rect of the marker `width` wide: the two share one BLP and differ by size, 10
/// for the wheel's and 48 for the strip's.
fn marker_rect(s: &UiScript, width: f32) -> Option<crate::layout::Rect> {
@@ -64,7 +64,7 @@ fn caret(lua: &Lua, h: FrameHandle) -> bool {
placed
}
/// `OnCursorChanged(x, y, w, h)` (`0x77de1b`, in UI units via `0x77dd5f`), `x` the caret's advance
/// `OnCursorChanged(x, y, w, h)` (`0x77de1b`, in the box's own units via `0x77dd5f`), `x` the caret's advance
/// along its line, `y` minus the row index times the row pitch, `w` the constant 4.0 and `h` the
/// line height. The arguments are computed only for a box with the script (`0x77dd8c`), from the
/// box's advance table, which the installed font engine measures here when the text or font
@@ -103,6 +103,14 @@ fn cursor_changed(lua: &Lua, h: FrameHandle) -> bool {
let Some((x, y, pitch)) = args else {
return false;
};
// The advance table is in root units (measured at the box's scale, divided by the seam
// alone); the handler gets the box's own, the reference dividing by its effective scale
// (`0x77db23 fdiv [esi+0x7c]`).
let k = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
crate::script::object::eff_scale(&model, h)
};
let (x, y, pitch) = (x / k, y / k, pitch / k);
if let Err(e) = event::fire_widget_handler(
lua,
id,
@@ -509,3 +509,49 @@ fn on_cursor_changed_reports_the_first_row_at_plus_zero() {
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// [`Mono`] as the host answers a scaled box: glyphs 7 and lines 14 in the box's own units, the
/// table in root units (measured at the box's scale, divided by the seam alone).
struct ScaledMono;
impl TextMeasure for ScaledMono {
fn measure(&mut self, req: &MeasureRequest) -> (f32, f32, f32) {
Mono.measure(req)
}
fn editbox_advances(
&mut self,
req: &EditBoxAdvanceRequest,
) -> Option<(Vec<f32>, Vec<usize>, f32)> {
let (cum, rows, pitch) = Mono.editbox_advances(req)?;
let k = req.scale;
Some((cum.into_iter().map(|a| a * k).collect(), rows, pitch * k))
}
}
/// `OnCursorChanged` answers in the box's own units: the reference divides by its effective scale
/// (`0x77db23 fdiv [esi+0x7c]`), so a half-scale box reports what a full-scale one does.
#[test]
fn on_cursor_changed_answers_in_the_boxs_own_units() {
let mut s = script();
s.set_text_measurer(Box::new(ScaledMono));
logged_box(&mut s);
s.run(
r#"
E:SetScale(0.5)
E:SetScript("OnCursorChanged", function()
table.insert(LOG, format("cursor@%g,%g,%g", arg1, arg2, arg4))
end)
E:SetMultiLine(true)
E:SetText("abc\nd")
"#,
)
.unwrap();
s.tick(0.016);
assert_eq!(
take_log(&s),
"early update cursor@7,-14,14 text:abc\nd late",
"one glyph along the second row, a row pitch down, one pitch tall"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
-70
View File
@@ -10,7 +10,6 @@ use mlua::{Function, Lua, Table, Value};
use super::{Model, ScriptValue, REG_SCRIPTS};
use crate::script::object::frame_wrapper;
use crate::widget::{ButtonState, FrameHandle};
/// `UiScript::fire_event` for engine code that holds only the Lua context.
pub(super) fn fire_global(lua: &Lua, event: &str, args: &[ScriptValue]) {
@@ -108,75 +107,6 @@ pub(super) fn fire_size_changes(lua: &Lua) {
}
}
/// Fire `OnShow`/`OnHide` for the frames whose effective visibility just changed, recording
/// errors in [`Model::errors`]. A shown `toplevel` frame raises here, after the subtree has
/// propagated and before its `OnShow` (`0x76ae10` at `0x76aee0`), so a show that does not come
/// through Lua `Show` raises too.
pub(super) fn fire_visibility_changes(lua: &Lua, changed: Vec<FrameHandle>) {
// Resolve (handle, id, now-visible?) under one short borrow, then fire with no borrow held.
let items: Vec<(FrameHandle, u32, bool)> = {
let mut model = lua.app_data_mut::<Model>().expect("model");
changed
.into_iter()
.filter_map(|h| {
let vis = model.arena.frame(h)?.effective_visible;
Some((h, model.frame_id(h), vis))
})
.collect()
};
// Hiding the hovered frame, directly or by an ancestor, fires its `OnLeave` inside the hide and
// before its `OnHide` (`0x764ba0`'s tail in `0x76ad50`, the leave at `0x764cce`), clears the
// hover and the drag-arm (`+0x100`/`+0x104`) and arms the re-pick; a show arms it too
// (`0x764b8d`). This leave is how a tooltip closes with its window; the reference has no other.
let left: Option<(FrameHandle, u32)> = {
let mut model = lua.app_data_mut::<Model>().expect("model");
let hidden_hover = model
.mouseover
.filter(|&m| items.iter().any(|&(h, _, vis)| h == m && !vis));
// The leave is a virtual call (`0x764cce`, `[vtable+0x50]`): a disabled Button's own
// `0x7794e0` skips its `OnLeave`, while the hover and the drag-arm still clear.
let notified = super::button::hover_notify_runs(&model, hidden_hover);
if let Some(m) = hidden_hover {
model.mouseover = None;
if model.drag.as_ref().is_some_and(|d| d.source == m) {
model.drag = None;
}
}
model.hover_repick |= hidden_hover.is_some() || items.iter().any(|&(_, _, vis)| vis);
hidden_hover
.filter(|_| notified)
.map(|m| (m, model.frame_id(m)))
};
if let Some((_, oid)) = left {
if let Err(e) = fire_widget_handler(lua, oid, "OnLeave", vec![Value::Boolean(true)]) {
lua.app_data_mut::<Model>()
.expect("model")
.record_script_error(e.to_string());
}
}
for (h, id, visible) in items {
if visible {
let mut model = lua.app_data_mut::<Model>().expect("model");
super::object::toplevel::raise_on_show(&mut model, h);
} else {
// `CSimpleButton`'s hide notify (`+0x34`, `0x7791e0`) un-presses it, unless disabled
// or locked, before the base notify, so a button hidden while held comes back unpushed.
let mut model = lua.app_data_mut::<Model>().expect("model");
super::button::edge(&mut model, h, ButtonState::on_hide);
}
let name = if visible { "OnShow" } else { "OnHide" };
if let Err(e) = fire(lua, id, name, None, Vec::new()) {
lua.app_data_mut::<Model>()
.expect("model")
.record_script_error(e.to_string());
}
// The EditBox show/hide overrides (`0x81c910` `+0x30`/`+0x34`) call the base notify first,
// so this runs after the `fire`: an `autoFocus` box takes a free focus, a hidden box
// releases its own.
super::editbox::visibility_focus(lua, h, visible);
}
}
/// Whether a handler is bound under `script`, without firing it: the keyboard walk consumes on
/// the slot's presence (`0x76b7d0`). A failed lookup reads absent, so the gate never raises.
pub(super) fn has_widget_handler(lua: &Lua, id: u32, script: &str) -> bool {
+11 -4
View File
@@ -130,9 +130,10 @@ impl UiScript {
owner_frame.map(|f| &f.kind_state)
{
if sl.thumb == Some(rh) {
// The thumb's own `CSimpleTexture::GetWidth`/`GetHeight`, as the drag
// reads them (`slider::thumb_extent`), not its authored `<Size>`.
let tsize = super::region::virtual_span(&model, rh);
// The thumb's own `CSimpleTexture::GetWidth`/`GetHeight` at the
// slider's scale, as the drag reads them (`slider::thumb_extent`), not
// its authored `<Size>`.
let tsize = super::region::screen_span(&model, rh);
rect = rect
.map(|r| slider::thumb_rect(r, tsize, sl.vertical, sl.fraction()));
thumb_fill = true;
@@ -145,7 +146,13 @@ impl UiScript {
if let Some(crate::widget::KindState::ColorSelect(cs)) =
owner_frame.map(|f| &f.kind_state)
{
let tsize = model.region_data.get(&rh).and_then(|d| d.size);
// The markers' authored size, in the picker's units.
let scale = owner_frame.map_or(1.0, |f| f.effective_scale);
let tsize = model
.region_data
.get(&rh)
.and_then(|d| d.size)
.map(|(w, h)| (w * scale, h * scale));
if cs.wheel_thumb == Some(rh) {
rect = cs
.wheel
+2 -1
View File
@@ -1239,7 +1239,8 @@ impl UiScript {
let Some(bd) = model.backdrops.get(&fh) else {
return;
};
for piece in backdrop::pieces(fr, bd) {
let scale = crate::script::object::eff_scale(model, fh);
for piece in backdrop::pieces(fr, bd, scale) {
// The piece's screen bounding box: the render is axis-aligned, so the reference's slant
// under unequal top and bottom insets is not drawn (stock insets are symmetric).
let xs = piece.corners.map(|c| c[0]);
+8 -5
View File
@@ -114,13 +114,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
m.set(
"PingLocation",
// Centre-relative offsets in UI units, x right and y up, parked for the app to resolve in
// the frame it draws the map; one pending click, not one per widget.
// Centre-relative offsets in the minimap's own units, x right and y up, which the
// reference reads against the widget's own size (`0x4eeca0`); parked in screen units, its
// effective scale applied, for the app to resolve in the frame it draws the map. One
// pending click, not one per widget.
lua.create_function(|lua, (this, x, y): (Table, f32, f32)| {
with_minimap(lua, &this, |_| ())?;
lua.app_data_mut::<Model>()
.expect("model app_data")
.minimap_ping_request = Some((x, y));
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let s = super::object::eff_scale(&model, h);
model.minimap_ping_request = Some((x * s, y * s));
Ok(())
})?,
)?;
+4 -1
View File
@@ -130,6 +130,7 @@ mod tooltip_item;
mod tooltip_spell;
mod tooltip_unit;
mod ui_errors;
mod ui_scale;
pub use tooltip_unit::TooltipTint;
mod trade;
mod tradeskill;
@@ -137,6 +138,7 @@ mod trainer;
mod types;
mod unit;
mod video_pairs;
pub(crate) mod visibility;
mod weapon_enchant;
mod who_sort;
mod worldmap;
@@ -724,7 +726,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()
}
+25 -3
View File
@@ -228,8 +228,11 @@ pub(crate) struct Model {
pub(crate) diagnostics: super::diagnostics::DiagnosticLog,
/// Non-fatal warnings for the host, such as a dropped `inherits=` template.
pub(crate) warnings: Vec<String>,
/// The screen-root rect (`[bottom, left, top, right]`), the anchor base for top-level frames.
/// The screen-root rect (`[bottom, left, top, right]`), the anchor base for top-level frames:
/// 768 tall at scale 1, whatever the UI scale ([`super::ui_scale`]).
pub(crate) screen: Rect,
/// The host's `uiScale`, which [`super::UiScript::apply_ui_scale`] makes `UIParent`'s scale.
pub(crate) ui_scale_request: Option<f32>,
/// Each unit token's state as pushed this frame, keyed lowercased because 1.12's resolver
/// (`0x515970`) matches with `SStrCmpI`: read it only through `unit`, where the fold lives.
@@ -346,6 +349,9 @@ pub(crate) struct Model {
pub(crate) camera_view_requests: Vec<camera_view::CameraViewRequest>,
/// `Logout`, `Quit`, `CancelLogout`, `ForceQuit` calls; Lua sees the countdown only as events.
pub(crate) session_requests: Vec<session::SessionRequest>,
/// `PLAYER_LOGIN`'s one-shot, the reference's `[0xb4e260]`: armed by the UI load, spent by
/// the next world-enter cascade ([`super::UiScript::fire_world_enter`]).
pub(crate) player_login_armed: bool,
/// `TogglePVP` calls, a count because `CMSG_TOGGLE_PVP` has no body.
pub(crate) pvp_toggles: u32,
/// The player's private honor fields; before the first push the six self getters read zeros.
@@ -916,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`).
@@ -1103,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(),
@@ -1159,6 +1167,7 @@ impl Model {
follow_requests: Vec::new(),
camera_view_requests: Vec::new(),
session_requests: Vec::new(),
player_login_armed: false,
pvp_toggles: 0,
honor: None,
inspect_honor: None,
@@ -1494,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) {
+19 -16
View File
@@ -154,11 +154,12 @@ pub struct NamePlateClick {
pub button: String,
}
/// What a sync fires outside the model borrow: `OnShow`/`OnHide` for frames whose visibility
/// flipped and `OnValueChanged` for moved bars (pfUI hooks it, `nameplates.lua:393`).
/// What a sync runs outside the model borrow: the plates' `Show`/`Hide`, whose transitions fire
/// handlers, and `OnValueChanged` for moved bars (pfUI hooks it, `nameplates.lua:393`).
#[derive(Default)]
struct SyncEffects {
visibility: Vec<FrameHandle>,
/// `(plate, shown)` in the order the sync issued them.
visibility: Vec<(FrameHandle, bool)>,
values: Vec<(FrameHandle, f32)>,
}
@@ -247,7 +248,7 @@ impl UiScript {
};
sync(&mut model, world, geometry, states)
};
super::event::fire_visibility_changes(lua, effects.visibility);
apply_visibility(lua, effects.visibility);
for (bar, value) in effects.values {
super::statusbar::fire_engine_value_changed(lua, bar, value);
}
@@ -304,7 +305,7 @@ impl UiScript {
}
effects
};
super::event::fire_visibility_changes(lua, effects.visibility);
apply_visibility(lua, effects.visibility);
}
/// How many plate widgets the pool holds.
@@ -315,6 +316,13 @@ impl UiScript {
}
}
/// Run the plates' `Show`/`Hide` a sync issued, each a full transition with its handlers.
fn apply_visibility(lua: &mlua::Lua, verbs: Vec<(FrameHandle, bool)>) {
for (frame, shown) in verbs {
super::visibility::set_shown(lua, frame, shown);
}
}
/// Record a click on `id` if it is a live plate; every button click comes here.
pub(super) fn note_click(lua: &mlua::Lua, id: u32, button: &str) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
@@ -368,7 +376,7 @@ fn sync(
let free = (0..model.nameplates.plates.len())
.find(|i| model.nameplates.plates[*i].last.is_none());
let i = free.unwrap_or_else(|| {
let plate = Plate::create(model, world, &mut effects);
let plate = Plate::create(model, world);
model.nameplates.plates.push(plate);
model.nameplates.plates.len() - 1
});
@@ -396,7 +404,7 @@ fn sync(
impl Plate {
/// Build one plate: the Button, its six regions in the reference's order, and the health bar.
/// Written once, so a region an addon blanks stays blank.
fn create(model: &mut Model, world: FrameHandle, effects: &mut SyncEffects) -> Plate {
fn create(model: &mut Model, world: FrameHandle) -> Plate {
// Anonymous, as the reference's name is zeroed (`0x76c50b`): plate addons reject a named
// `WorldFrame` child.
let frame = model.arena.create(FrameKind::Button, None, Some(world));
@@ -479,9 +487,8 @@ impl Plate {
}
// Born retired and hidden: addons tell a live plate from a pooled one by `IsShown()`.
effects
.visibility
.extend(model.arena.set_shown(frame, false));
// Silent, since nothing can have set a handler on a frame not yet handed out.
model.arena.set_shown(frame, false);
model
.nameplates
@@ -625,9 +632,7 @@ impl Plate {
}
if last.is_none() {
effects
.visibility
.extend(model.arena.set_shown(frame, true));
effects.visibility.push((frame, true));
}
if last.as_ref().is_none_or(|l| l.alpha != state.alpha) {
model.arena.set_alpha(frame, state.alpha);
@@ -722,9 +727,7 @@ impl Plate {
return;
}
let frame = model.nameplates.plates[i].frame;
effects
.visibility
.extend(model.arena.set_shown(frame, false));
effects.visibility.push((frame, false));
model.nameplates.plates[i].last = None;
}
}
+4 -13
View File
@@ -352,19 +352,20 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let create_frame = lua.create_function(create_frame)?;
lua.globals().set("CreateFrame", create_frame)?;
// `GetScreenWidth`/`GetScreenHeight`: the screen root's size in UI units.
// `GetScreenWidth`/`GetScreenHeight` (`0x48b480`/`0x48b4d0`): the screen root's size divided by
// `UIParent`'s scale (`GetUiScale`, `0x494590`), so they answer in `UIParent`'s units.
lua.globals().set(
"GetScreenWidth",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.screen.width())
Ok(model.screen.width() / super::ui_scale::ui_parent_scale(&model))
})?,
)?;
lua.globals().set(
"GetScreenHeight",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.screen.height())
Ok(model.screen.height() / super::ui_scale::ui_parent_scale(&model))
})?,
)?;
@@ -396,16 +397,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// `GetCursorPosition()`: the last cursor position the host fed, in screen UI units, y up, as
// the reference returns it; a caller in a scaled frame divides by its `GetEffectiveScale()`.
lua.globals().set(
"GetCursorPosition",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.cursor_pos)
})?,
)?;
// The modifier keys the app feeds each frame before any mouse event
// ([`UiScript::set_modifiers`]), answered 1 or nil as in 1.12, never a boolean: an addon
// testing `IsShiftKeyDown() == 1` (`ColorPickerPlus.lua:121`) reads `true` as not held.
@@ -5,7 +5,7 @@ use mlua::{Lua, Table, Value};
use crate::script::region_map::{set_shared, Side};
use crate::order::Strata;
use crate::script::{event, Backdrop, Insets, Model};
use crate::script::{visibility, Backdrop, Insets, Model};
use crate::widget::FrameKind;
use super::{decode_id, draw_layer_from_str, frame_handle_of, frame_wrapper, strata_from_str};
@@ -248,8 +248,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
)?;
// `SetParent` (`0x7a1550`): a frame, a name (`0x76c760`) or an explicit nil; an absent argument
// raises like an unknown name (`0x6f3400` gives -1), and so does a cycle (`0x87cb14`). As in
// the reference, hide fires before show, between `reparent_begin` and `reparent_finish`,
// since `fire_visibility_changes` reads each frame's live state for the direction.
// the reference, the hide transition runs between `reparent_begin` and `reparent_finish`, and
// the show transition after.
set_shared(
lua,
m,
@@ -330,24 +330,28 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
)));
}
}
let hidden = {
let was_visible = {
let mut model = lua.app_data_mut::<Model>().expect("model");
model.arena.reparent_begin(h, new_parent)
};
// None: the same parent, a total no-op (`0x76ab20`), not even a layout touch.
let Some(hidden) = hidden else { return Ok(()) };
let was_visible = !hidden.is_empty();
event::fire_visibility_changes(lua, hidden);
let shown = {
let Some(was_visible) = was_visible else {
return Ok(());
};
if was_visible {
visibility::hide(lua, h);
}
{
let mut model = lua.app_data_mut::<Model>().expect("model");
let shown = model.arena.reparent_finish(h, new_parent, was_visible);
model.arena.reparent_finish(h, new_parent);
// A reparent moves the subtree's effective scale, a layout input and part of every
// descendant FontString's measure key.
model.touch_layout_reparent(h);
model.touch_measure_all();
shown
};
event::fire_visibility_changes(lua, shown);
}
if was_visible {
visibility::show(lua, h);
}
Ok(())
},
)?;
@@ -414,14 +418,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(())
})?,
)?;
@@ -789,11 +788,7 @@ fn strata_name(s: Strata) -> &'static str {
fn set_shown(lua: &Lua, this: &Table, shown: bool) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
let changed = {
let mut model = lua.app_data_mut::<Model>().expect("model");
model.arena.set_shown(h, shown)
};
event::fire_visibility_changes(lua, changed);
visibility::set_shown(lua, h, shown);
Ok(())
}
@@ -197,11 +197,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
})
})?;
// GetEffectiveScale(): parent scale times own scale, from a root of 1: `uiScale` is applied at
// the raster seam (a screen `768/uiScale` units tall), so every coordinate Lua sees,
// `GetCursorPosition()` included, is already in UI units. The reference instead makes
// `uiScale` `UIParent`'s own scale (`0x494550` calls `SetScale`), so there
// `UIParent:GetEffectiveScale()` answers it.
// GetEffectiveScale(): parent scale times own scale (`0x76ac90`), from a root of 1. The UI
// scale is `UIParent`'s own (`0x494550`), so `UIParent:GetEffectiveScale()` answers it and a
// frame outside `UIParent` does not carry it.
m.set(
"GetEffectiveScale",
lua.create_function(|lua, this: Table| {
+14 -9
View File
@@ -22,8 +22,9 @@ use super::{button, cursor, editbox, event, Model, UiScript};
/// each release only has to hit the frame.
pub(super) const DOUBLE_CLICK_SECS: f64 = 0.300;
/// Install `GetCursorPosition()` and `GetMouseFocus()`. The cursor is in UI units, as 1.12
/// answers it; the engine's y-up UI space is 1:1 logical pixels, so no scale factor applies.
/// Install `GetCursorPosition()` and `GetMouseFocus()`. The cursor (`0x48b820`) answers in the
/// screen root's units, y up, with no scale divided out: a caller divides by its frame's
/// `GetEffectiveScale()`, which carries the UI scale inside `UIParent`.
pub(super) fn install(lua: &mlua::Lua) -> mlua::Result<()> {
lua.globals().set(
"GetCursorPosition",
@@ -107,7 +108,7 @@ impl UiScript {
// tested, so the reticle is placed through it.
&& !model.nameplates.vetoes(fh)
&& model.resolved.get(&fh).is_some_and(|r| {
point_in_rect(inset_rect(*r, model.arena.hit_rect_insets(fh)), x, y)
point_in_rect(hit_rect(&model, fh, *r), x, y)
})
&& effective_clip(&model, &scroll_sources, fh)
.is_none_or(|c| point_in_rect(c, x, y))
@@ -124,9 +125,10 @@ impl UiScript {
// The wheel flag alone: the wheel index is separate from the mouse's, so the wheel
// passes a frame that only takes the mouse, as a scroll pane's chrome does.
model.arena.is_mouse_wheel_enabled(fh)
&& model.resolved.get(&fh).is_some_and(|r| {
point_in_rect(inset_rect(*r, model.arena.hit_rect_insets(fh)), x, y)
})
&& model
.resolved
.get(&fh)
.is_some_and(|r| point_in_rect(hit_rect(&model, fh, *r), x, y))
&& effective_clip(&model, &scroll_sources, fh)
.is_none_or(|c| point_in_rect(c, x, y))
})?;
@@ -628,9 +630,12 @@ pub(super) fn point_in_rect(r: Rect, x: f32, y: f32) -> bool {
x >= r.left && x <= r.right && y >= r.bottom && y <= r.top
}
/// A frame's mouse rect: its resolved rect shrunk by its `[left, right, top, bottom]` hit-rect
/// insets (y-up); an over-shrunk rect collapses to empty rather than inverting.
fn inset_rect(r: Rect, [left, right, top, bottom]: [f32; 4]) -> Rect {
/// A frame's mouse rect (`0x76b580`): its resolved rect shrunk by its `[left, right, top, bottom]`
/// hit-rect insets times its effective scale (y-up), the insets being in the frame's own units; an
/// over-shrunk rect collapses to empty rather than inverting.
fn hit_rect(model: &Model, fh: FrameHandle, r: Rect) -> Rect {
let s = super::object::eff_scale(model, fh);
let [left, right, top, bottom] = model.arena.hit_rect_insets(fh).map(|i| i * s);
Rect {
left: r.left + left,
right: (r.right - right).max(r.left + left),
+12
View File
@@ -684,3 +684,15 @@ pub(super) fn virtual_span(model: &Model, rh: RegionHandle) -> (f32, f32) {
_ => (aw, ah),
}
}
/// [`virtual_span`] on screen: the region's own units times its owner's effective scale, as the
/// layout sizes a region (`0x76ac90`). For the parts the engine seats itself, with no anchors: a
/// slider's thumb, a colour picker's markers.
pub(super) fn screen_span(model: &Model, rh: RegionHandle) -> (f32, f32) {
let (w, h) = virtual_span(model, rh);
let s = model
.arena
.region(rh)
.map_or(1.0, |r| super::object::eff_scale(model, r.owner));
(w * s, h * s)
}
+24 -1
View File
@@ -1,6 +1,7 @@
//! The session verbs: the game menu's Logout and Exit Game, their dialogs' answers, `ReloadUI`
//! and the cinematic pair. Each queues a [`SessionRequest`] the app turns into a packet or an
//! exit; the server decides whether a logout is instant or a 20-second countdown.
//! exit; the server decides whether a logout is instant or a 20-second countdown. Beside them,
//! the world-enter cascade's two events and the `PLAYER_LOGIN` arm a UI load sets.
use mlua::Lua;
@@ -41,6 +42,28 @@ impl super::UiScript {
pub fn queue_session_request(&mut self, request: SessionRequest) {
self.model_mut().session_requests.push(request);
}
/// Arm `PLAYER_LOGIN`, the UI load's last step: `UI_Init` sets the one-shot `[0xb4e260]` at
/// `0x49011d`, unconditionally, and [`Self::fire_world_enter`] spends it. The flag lives in
/// this VM, so each load arms its own and a worldport, which loads nothing, re-arms nothing.
pub fn arm_player_login(&mut self) {
self.model_mut().player_login_armed = true;
}
/// The world-enter cascade's events (`0x4908c0`): `PLAYER_LOGIN` (270, `0x490959`) only if a
/// load armed it (read at `0x49094b`, cleared at `0x49095e`), then `PLAYER_ENTERING_WORLD`
/// (272, `0x49096a`) every time. The cascade runs with the player object in the world, so its
/// caller seats `"player"` first, and inside its own sound-suppression bracket (`0x4908d5` to
/// `0x490a56`), so a handler's `PlaySound` is dropped.
pub fn fire_world_enter(&mut self) {
let armed = std::mem::take(&mut self.model_mut().player_login_armed);
self.push_sound_suppression();
if armed {
self.fire_event("PLAYER_LOGIN", Vec::new());
}
self.fire_event("PLAYER_ENTERING_WORLD", Vec::new());
self.pop_sound_suppression();
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
+26 -7
View File
@@ -263,8 +263,9 @@ pub(super) fn thumb_rect(r: Rect, thumb_size: (f32, f32), vertical: bool, fracti
/// The thumb region's own width and height, as `0x789ba0` reads them through the texture's
/// geometry slots (`[vtable+0x1c]`/`+0x20`, `0x770720`/`0x770790`): the authored span, else the
/// art's texel span, else 0. `None` without a thumb region: `0x789ba0` gates the value math on
/// `+0x328`, so a thumbless slider captures a press but never moves.
/// art's texel span, else 0, in the slider's units, unscaled, though the track it is subtracted
/// from is the scaled rect (`0x789bd4`–`0x789bfe`). `None` without a thumb region: `0x789ba0`
/// gates the value math on `+0x328`, so a thumbless slider captures a press but never moves.
fn thumb_extent(model: &Model, thumb: Option<crate::widget::RegionHandle>) -> Option<(f32, f32)> {
Some(super::region::virtual_span(model, thumb?))
}
@@ -303,18 +304,36 @@ pub(super) fn begin_drag(
});
return None;
};
let trect = thumb_rect(r, size, vertical, fraction);
// The thumb as drawn, at the slider's scale, decides whether the press is on it.
let drawn = thumb.map_or(size, |t| super::region::screen_span(model, t));
let trect = thumb_rect(r, drawn, vertical, fraction);
let on_thumb = point_in_rect(trect, x, y);
// Distances from the track's leading edge, where the thumb sits at `min`: `top - y` on a
// vertical track in this y-up arena, `x - left` on a horizontal one.
let (cursor, thumb_lead, thumb_len) = if vertical {
(r.top - y, r.top - trect.top, trect.top - trect.bottom)
let (cursor, thumb_lead, drawn_len, own_len) = if vertical {
(
r.top - y,
r.top - trect.top,
trect.top - trect.bottom,
size.1,
)
} else {
(x - r.left, trect.left - r.left, trect.right - trect.left)
(
x - r.left,
trect.left - r.left,
trect.right - trect.left,
size.0,
)
};
// Off the thumb the cursor takes its centre, half its own unscaled length (`0x789bf5`).
let grab_offset = if on_thumb {
slider_grab(cursor, thumb_lead, drawn_len)
} else {
own_len * 0.5
};
model.slider_drag = Some(SliderDrag {
slider: h,
grab_offset: slider_grab(cursor, thumb_lead, thumb_len),
grab_offset,
});
if on_thumb {
return None; // no value change from the grab itself
@@ -216,3 +216,48 @@ fn backdrop_colors_coerce_rgb_but_default_alpha_opaque() {
// Stored as a byte: 0.5 reads back as 128/255.
assert_eq!(a, 128.0 / 255.0);
}
/// The pieces are textures anchored and sized in the frame's own units (`0x77e8d0`), so the layout
/// scales them by its effective scale like any region: a half-scale frame's border is half as
/// thick and its background inset half as deep.
#[test]
fn a_scaled_frames_backdrop_scales_with_it() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local f = CreateFrame("Frame", "Half")
f:SetScale(0.5)
f:SetPoint("TOPLEFT", nil, "TOPLEFT", 100, -100)
f:SetWidth(200); f:SetHeight(100)
f:SetBackdrop({
bgFile = "bg", edgeFile = "edge", tile = false, edgeSize = 16,
insets = { left = 4, right = 4, top = 4, bottom = 4 },
})
"#,
)
.unwrap();
s.resolve();
let rects: Vec<crate::layout::Rect> = s
.extract()
.into_iter()
.filter(|q| matches!(q.content, QuadContent::Backdrop { .. }))
.map(|q| q.rect.expect("piece rect"))
.collect();
assert_eq!(rects.len(), 9);
// The frame on screen: left 50, top 550, 100 × 50.
let bg = rects[0];
assert_eq!(
(bg.left, bg.right, bg.bottom, bg.top),
(52.0, 148.0, 502.0, 548.0),
"the insets are 4 units of a half-scale frame"
);
// Every border piece is 16 units, 8 on screen, thick.
for r in &rects[1..] {
assert_eq!(
r.width().min(r.height()),
8.0,
"a border piece {r:?} is edgeSize units of a half-scale frame"
);
}
}
@@ -430,3 +430,37 @@ fn an_empty_scroll_child_is_reported() {
);
assert!(s.eval::<bool>("return Hollow ~= nil").unwrap());
}
/// `CreateFrame` builds through the same instantiator as the XML, `0x6ee280` (called at
/// `0x70628b`), so its post-load hook `0x76a2f0` fires a visible frame's template OnShow once,
/// after its OnLoad (`0x76a3b3`); one created under a hidden parent gets none.
#[test]
fn a_runtime_template_fires_its_onshow_after_its_onload_when_visible() {
let s = script();
register(
&s,
r#"<Ui>
<Frame name="ShowProbeTemplate" virtual="true">
<Scripts>
<OnLoad>table.insert(LOG, this:GetName() .. ":load")</OnLoad>
<OnShow>table.insert(LOG, this:GetName() .. ":show")</OnShow>
</Scripts>
</Frame>
</Ui>"#,
);
s.run(
r#"
LOG = {}
CreateFrame("Frame", "Seen", nil, "ShowProbeTemplate")
local hidden = CreateFrame("Frame", "HiddenHost")
hidden:Hide()
CreateFrame("Frame", "Unseen", hidden, "ShowProbeTemplate")
"#,
)
.unwrap();
assert_eq!(
s.eval::<String>("return table.concat(LOG, ',')").unwrap(),
"Seen:load,Seen:show,Unseen:load"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
@@ -330,3 +330,49 @@ fn an_all_events_listener_runs_after_the_events_own() {
assert_eq!(s.eval::<String>("return order[2]").unwrap(), "all");
assert_eq!(s.eval::<i64>("return table.getn(order)").unwrap(), 2);
}
/// The world-enter cascade (`0x4908c0`) fires `PLAYER_LOGIN` (`0x490959`) only while a load's arm
/// (`0x49011d`) is unspent, clearing it (`0x49095e`), then `PLAYER_ENTERING_WORLD` (`0x49096a`)
/// every time, inside its own sound bracket (`0x4908d5` to `0x490a56`).
#[test]
fn the_world_enter_cascade_spends_the_login_arm_once_and_plays_nothing() {
let mut s = script();
s.run(
r#"
order = ""
local f = CreateFrame("Frame")
f:RegisterEvent("PLAYER_LOGIN")
f:RegisterEvent("PLAYER_ENTERING_WORLD")
f:SetScript("OnEvent", function()
order = order .. event .. " "
PlaySound("igMainMenuOpen")
end)
"#,
)
.unwrap();
s.fire_world_enter();
assert_eq!(
s.eval::<String>("return order").unwrap(),
"PLAYER_ENTERING_WORLD ",
"no load armed it"
);
s.run(r#"order = """#).unwrap();
s.arm_player_login();
s.fire_world_enter();
s.fire_world_enter();
assert_eq!(
s.eval::<String>("return order").unwrap(),
"PLAYER_LOGIN PLAYER_ENTERING_WORLD PLAYER_ENTERING_WORLD ",
"the arm is one-shot"
);
assert!(
s.take_sounds().is_empty(),
"the cascade's handlers are silent"
);
s.run(r#"PlaySound("igMainMenuOpen")"#).unwrap();
assert_eq!(
s.take_sounds().len(),
1,
"and the bracket closed behind them"
);
}
@@ -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)));
}
@@ -92,8 +92,9 @@ fn a_visible_reparent_refires_onhide_then_onshow() {
let log: Vec<String> = s.eval("return log").unwrap();
assert_eq!(
log,
vec!["hide:RTOld", "show:RTNew", "kidshow"],
"OnHide observes the OLD parent, OnShow the new, and the refire walks the subtree"
vec!["hide:RTOld", "kidshow", "show:RTNew"],
"OnHide observes the OLD parent, OnShow the new, and the refire walks the subtree, the \
child notified before its parent (`0x76aed5`, then `0x76aef5`)"
);
assert_eq!(
s.eval::<i64>("return RTMover:GetFrameLevel()").unwrap(),
@@ -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})"
);
}
@@ -64,3 +64,151 @@ fn tick_runs_onupdate_only_when_effectively_visible() {
s.tick(0.25); // visible again → fires
assert_eq!(s.eval::<i64>("return ticks").unwrap(), 2);
}
/// The show cascade `0x76ae10` walks its children (`0x76aed5`) before its own notify
/// (`0x76aef5`), and the hide cascade `0x76ad50` likewise (`0x76adee`, then `0x76adfd`): both are
/// post-order, depth-first in child-link order.
#[test]
fn a_cascade_notifies_children_before_their_parent() {
let s = script();
s.run(
r#"
LOG = {}
local function log(tag) return function() table.insert(LOG, tag) end end
local p = CreateFrame("Frame", "PostP")
local c1 = CreateFrame("Frame", "PostC1", p)
local g = CreateFrame("Frame", "PostG", c1)
local c2 = CreateFrame("Frame", "PostC2", p)
for f, tag in pairs({ [p] = "p", [c1] = "c1", [g] = "g", [c2] = "c2" }) do
f:SetScript("OnShow", log(tag .. "+"))
f:SetScript("OnHide", log(tag .. "-"))
end
p:Hide()
p:Show()
"#,
)
.unwrap();
assert_eq!(
s.eval::<String>("return table.concat(LOG, ',')").unwrap(),
"g-,c1-,c2-,p-,g+,c1+,c2+,p+"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The child walk follows live links and each child's show re-checks its own gate when the walk
/// reaches it (`0x76ae1d`): a sibling an earlier sibling's OnShow hides gets no OnShow, and, never
/// having become visible, no OnHide either.
#[test]
fn a_sibling_hidden_by_an_earlier_siblings_onshow_gets_no_onshow() {
let s = script();
s.run(
r#"
LOG = {}
local p = CreateFrame("Frame", "LiveP")
local c1 = CreateFrame("Frame", "LiveC1", p)
local c2 = CreateFrame("Frame", "LiveC2", p)
p:Hide()
c1:SetScript("OnShow", function() table.insert(LOG, "c1+") LiveC2:Hide() end)
c2:SetScript("OnShow", function() table.insert(LOG, "c2+") end)
c2:SetScript("OnHide", function() table.insert(LOG, "c2-") end)
p:SetScript("OnShow", function() table.insert(LOG, "p+") end)
p:Show()
"#,
)
.unwrap();
assert_eq!(
s.eval::<String>("return table.concat(LOG, ',')").unwrap(),
"c1+,p+"
);
assert!(!s.eval::<bool>("return LiveC2:IsVisible() == 1").unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A sibling an earlier sibling's OnHide hides runs its whole hide, OnHide included, nested in that
/// handler (`0x7758bb`); when the parent's walk reaches it, it is already invisible and the hide
/// gate (`0x76ad5b`) returns without a second OnHide. The parent's own OnHide comes last.
#[test]
fn a_sibling_hidden_by_an_earlier_siblings_onhide_is_notified_once_inside_it() {
let s = script();
s.run(
r#"
LOG = {}
local p = CreateFrame("Frame", "NestP")
local c1 = CreateFrame("Frame", "NestC1", p)
local c2 = CreateFrame("Frame", "NestC2", p)
c1:SetScript("OnHide", function()
table.insert(LOG, "c1-")
NestC2:Hide()
table.insert(LOG, "c1-end")
end)
c2:SetScript("OnHide", function() table.insert(LOG, "c2-") end)
p:SetScript("OnHide", function() table.insert(LOG, "p-") end)
p:Hide()
"#,
)
.unwrap();
assert_eq!(
s.eval::<String>("return table.concat(LOG, ',')").unwrap(),
"c1-,c2-,c1-end,p-"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A child's OnShow that hides the parent: the parent's hide cascade runs nested (the child's
/// OnHide, then the parent's), the next sibling then fails its parent gate (`0x76ae35`) and is
/// never shown, and the parent's own OnShow still fires last, since nothing between the walk and
/// `0x76aef5` re-checks.
#[test]
fn a_parent_hidden_by_a_childs_onshow_stops_the_walk_but_still_gets_its_onshow() {
let s = script();
s.run(
r#"
LOG = {}
local p = CreateFrame("Frame", "StopP")
local c1 = CreateFrame("Frame", "StopC1", p)
local c2 = CreateFrame("Frame", "StopC2", p)
p:Hide()
c1:SetScript("OnShow", function() table.insert(LOG, "c1+") StopP:Hide() end)
c1:SetScript("OnHide", function() table.insert(LOG, "c1-") end)
c2:SetScript("OnShow", function() table.insert(LOG, "c2+") end)
p:SetScript("OnShow", function() table.insert(LOG, "p+") end)
p:SetScript("OnHide", function() table.insert(LOG, "p-") end)
p:Show()
"#,
)
.unwrap();
assert_eq!(
s.eval::<String>("return table.concat(LOG, ',')").unwrap(),
"c1+,c1-,p-,p+"
);
assert!(!s.eval::<bool>("return StopC2:IsVisible() == 1").unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The Lua `Show` writes the shown bit and dispatches the cascade unconditionally (`0x7757f1`,
/// `0x7757fb`), so a frame whose bit is already set but which is not visible (a `SetParent` from a
/// hidden parent to a shown one skips the show half, `0x76abe3`) becomes visible on `Show()`.
#[test]
fn show_on_a_shown_but_invisible_frame_makes_it_visible() {
let s = script();
s.run(
r#"
SHOWS = 0
local hidden = CreateFrame("Frame", "StaleHidden")
hidden:Hide()
local shown = CreateFrame("Frame", "StaleShown")
local f = CreateFrame("Frame", "Stale", hidden)
f:SetScript("OnShow", function() SHOWS = SHOWS + 1 end)
f:SetParent(shown)
"#,
)
.unwrap();
assert!(
!s.eval::<bool>("return Stale:IsVisible() == 1").unwrap(),
"the reparent from an invisible parent runs no show half"
);
s.run("Stale:Show()").unwrap();
assert!(s.eval::<bool>("return Stale:IsVisible() == 1").unwrap());
assert_eq!(s.eval::<i64>("return SHOWS").unwrap(), 1);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
+2 -6
View File
@@ -355,13 +355,9 @@ fn now(lua: &Lua) -> f64 {
crate::script::clock::now(lua)
}
/// Shows or hides through the arena and fires the visibility events.
/// Shows or hides as the Lua verbs do, handlers included.
pub(super) fn set_shown(lua: &Lua, h: FrameHandle, shown: bool) {
let changed = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.arena.set_shown(h, shown)
};
event::fire_visibility_changes(lua, changed);
crate::script::visibility::set_shown(lua, h, shown);
}
/// The default line colour, gold `0xffffd200` (stored at `0xc0d3e8` by `0x528e50`). The zone
+18 -4
View File
@@ -350,8 +350,8 @@ impl super::UiScript {
if let Ok(t) = tip_mut(&mut model, h) {
t.owner = Some(root);
}
let new = cursor_anchor_at(&model, h, root_id, ui_x, ui_y);
let input = model.layout_inputs.entry(h).or_default();
let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y);
let same = input.anchors.len() == 1
&& super::object::anchor_bits_eq(&input.anchors[0], &new);
if !same {
@@ -440,10 +440,10 @@ impl super::UiScript {
{
let mut model = self.model_mut();
clear_content(&mut model, h);
let input = model.layout_inputs.entry(h).or_default();
// Anchor only: the frame's own clamp flag (`Frame::clamped_to_screen`, G flags bit 4)
// slides a plate near the window edge back on screen.
let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y);
let new = cursor_anchor_at(&model, h, root_id, ui_x, ui_y);
let input = model.layout_inputs.entry(h).or_default();
// Compare-then-touch, so a still cursor does not dirty the layout.
let same =
input.anchors.len() == 1 && super::object::anchor_bits_eq(&input.anchors[0], &new);
@@ -497,8 +497,8 @@ impl super::UiScript {
return;
}
let root_id = model.frame_id(root);
let new = cursor_anchor_at(&model, h, root_id, ui_x, ui_y);
let input = model.layout_inputs.entry(h).or_default();
let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y);
// Compare-then-touch: this runs on every pointer event.
let same =
input.anchors.len() == 1 && super::object::anchor_bits_eq(&input.anchors[0], &new);
@@ -559,3 +559,17 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
)?;
Ok(())
}
/// The plate's BOTTOM on the cursor: `(ui_x, ui_y)` is in the screen root's units, and an anchor
/// offset is in the tooltip's own, so it is divided by the tooltip's effective scale, the UI scale
/// included, as the cursor arm `0x530b20` does ([`super::tooltip`]'s `cursor_anchor`).
fn cursor_anchor_at(model: &Model, h: FrameHandle, root_id: u32, ui_x: f32, ui_y: f32) -> Anchor {
let s = super::object::eff_scale(model, h);
Anchor::new(
Point::Bottom,
root_id,
Point::BottomLeft,
ui_x / s,
ui_y / s,
)
}
+221
View File
@@ -0,0 +1,221 @@
//! The UI scale: `UIParent`'s own frame scale, never the screen's. The screen root keeps scale 1
//! and is 768 units tall at every window size; `uiScale` reaches only `UIParent`'s subtree,
//! through the worker Lua's `SetScale` calls (`0x494550` → `0x76ac10` on the frame named
//! `"UIParent"`, looked up at `0x49006a`), so a frame with no parent, or under `WorldFrame`, draws
//! at scale 1.
use super::{Model, UiScript};
/// `SetUiScale`'s floor (`0x494550`: `[0x804584]`, below which `0x494563` stores 0.64).
pub(crate) const UI_SCALE_FLOOR: f32 = 0.64;
/// `GetUiScale` (`0x494590`): `UIParent`'s effective scale (`[UIParent+0x7c]`), else 1.0 when there
/// is no `UIParent`. `GetScreenWidth`/`GetScreenHeight` (`0x48b480`/`0x48b4d0`) divide the root by
/// it, and nothing else reads it. A zero scale reads 1, as the reference's writer `0x768360`
/// refuses one.
pub(crate) fn ui_parent_scale(model: &Model) -> f32 {
model
.arena
.lookup("UIParent")
.map_or(1.0, |h| super::object::eff_scale(model, h))
}
/// The cap both `uiScale` legs apply before the sink (`0x490770`, and the display handler's ON leg
/// `0x492e90`): below a 4:3 aspect `a` the value is capped at `0.75·a` (`a·3 ≥ 4` skips the cap, so
/// 4:3 itself is never capped).
pub(crate) fn capped_ui_scale(ui_scale: f32, aspect: f32) -> f32 {
if aspect * 3.0 < 4.0 {
ui_scale.min(aspect * 0.75)
} else {
ui_scale
}
}
/// `SetUiScale`'s floor (`0x494550`): above [`UI_SCALE_FLOOR`] the value stands, else (NaN
/// included) the floor.
pub(crate) fn floored_ui_scale(v: f32) -> f32 {
if v > UI_SCALE_FLOOR {
v
} else {
UI_SCALE_FLOOR
}
}
/// What the CVar callback applies (`0x490770` → `0x494550`): capped, then floored.
pub(crate) fn effective_ui_scale(ui_scale: f32, aspect: f32) -> f32 {
floored_ui_scale(capped_ui_scale(ui_scale, aspect))
}
/// The automatic scale `0x492f70` computes for a `w`×`h` display: 1.0, or `768/h` above 768 tall
/// (`0x492fbc cmp esi,0x300`), under the same narrow-aspect cap, then raised to 0.9 unless above it
/// (`0x493005`–`0x493012`).
pub(crate) fn auto_ui_scale(w: u32, h: u32) -> f32 {
let mut v = if h > 768 { 768.0 / h as f32 } else { 1.0 };
if h > 0 {
v = capped_ui_scale(v, w as f32 / h as f32);
}
if v > 0.9 {
v
} else {
0.9
}
}
impl UiScript {
/// The host's `uiScale`, compared first like [`Self::set_screen_size`]: a new value applies at
/// once through the CVar's change callback (`0x490770`: capped, floored at 0.64), and `true`
/// says it reached a `UIParent`. The same value again does nothing, so an addon's own
/// `UIParent:SetScale` holds until the next change.
pub fn set_ui_scale(&mut self, ui_scale: f32) -> bool {
let applied = {
let mut model = self.model_mut();
if model.ui_scale_request == Some(ui_scale) {
return false;
}
model.ui_scale_request = Some(ui_scale);
let aspect = model.screen.width() / model.screen.height();
model
.arena
.lookup("UIParent")
.map(|h| (h, effective_ui_scale(ui_scale, aspect)))
};
let Some((ui_parent, v)) = applied else {
return false;
};
self.model_mut().set_frame_scale(ui_parent, v);
true
}
/// Record `ui_scale` without applying it, then apply it through the display handler, as the UI
/// load does once its files and `VARIABLES_LOADED` are done (`0x49010e` → `0x492e90`): the
/// CVar's own callback found no `UIParent` when it fired at registration.
pub fn seat_ui_scale(
&mut self,
ui_scale: f32,
display: (u32, u32),
auto_cache: &mut Option<(u32, u32)>,
) -> bool {
self.model_mut().ui_scale_request = Some(ui_scale);
self.apply_ui_scale(display, auto_cache)
}
/// The display handler `0x492e90` on the last [`Self::set_ui_scale`], for the load and a
/// display change. Its ON leg caps the value and hands it to the sink only at 0.64 or more
/// (`0x492efd fcomp [0x804584]; test ah,1; jne 0x492f4a`); below, it falls to the automatic
/// scale `0x492f70(w, h, force = 0)` ([`auto_ui_scale`]), which does nothing at all when the
/// display is the one it last ran for (`auto_cache`, the reference's `[0xb4e304]`/`[0xb4e308]`,
/// which outlive the UI). `display` is the window in logical px, the host's unit for the
/// reference's device size. `true` when a scale reached `UIParent`.
pub fn apply_ui_scale(
&mut self,
display: (u32, u32),
auto_cache: &mut Option<(u32, u32)>,
) -> bool {
let applied = {
let model = self.model_ref();
let Some(ui_scale) = model.ui_scale_request else {
return false;
};
let aspect = model.screen.width() / model.screen.height();
let v = capped_ui_scale(ui_scale, aspect);
let v = if v >= UI_SCALE_FLOOR {
v
} else {
let (w, h) = display;
if w > 0 && h > 0 && *auto_cache == Some(display) {
return false;
}
*auto_cache = Some(display);
floored_ui_scale(auto_ui_scale(w, h))
};
model.arena.lookup("UIParent").map(|h| (h, v))
};
let Some((ui_parent, v)) = applied else {
return false;
};
self.model_mut().set_frame_scale(ui_parent, v);
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_callback_ladder_caps_below_four_thirds_and_floors_at_the_sink() {
// 4:3 and wider: the dial as set.
assert_eq!(effective_ui_scale(0.9, 4.0 / 3.0), 0.9);
assert_eq!(effective_ui_scale(0.9, 16.0 / 9.0), 0.9);
assert_eq!(effective_ui_scale(1.2, 16.0 / 9.0), 1.2);
// 5:4 caps a full dial at 0.9375 and leaves 0.9 alone.
assert_eq!(effective_ui_scale(1.0, 1.25), 0.9375);
assert_eq!(effective_ui_scale(0.9, 1.25), 0.9);
// The floor, on both sides of it.
assert_eq!(effective_ui_scale(0.5, 16.0 / 9.0), UI_SCALE_FLOOR);
assert_eq!(effective_ui_scale(0.65, 16.0 / 9.0), 0.65);
// A square window caps at 0.75, a narrow one bottoms out at the floor.
assert_eq!(effective_ui_scale(0.9, 1.0), 0.75);
assert_eq!(effective_ui_scale(0.9, 0.5), UI_SCALE_FLOOR);
}
#[test]
fn the_scale_lands_on_uiparent_alone_and_only_on_a_change() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
assert!(!s.set_ui_scale(0.9), "no UIParent yet: nothing to scale");
s.run(r#"CreateFrame("Frame", "UIParent") CreateFrame("Frame", "Loose")"#)
.unwrap();
assert!(!s.set_ui_scale(0.9), "the same value is no change");
assert!(
s.apply_ui_scale((1024, 768), &mut None),
"the load edge applies what was set"
);
assert_eq!(s.eval::<f32>("return UIParent:GetScale()").unwrap(), 0.9);
assert_eq!(s.eval::<f32>("return Loose:GetScale()").unwrap(), 1.0);
// An addon's own scale holds until the dial moves.
s.run("UIParent:SetScale(0.7)").unwrap();
assert!(!s.set_ui_scale(0.9));
assert_eq!(s.eval::<f32>("return UIParent:GetScale()").unwrap(), 0.7);
assert!(s.set_ui_scale(0.8));
assert_eq!(s.eval::<f32>("return UIParent:GetScale()").unwrap(), 0.8);
}
/// The automatic scale (`0x492f70`): 1.0 up to 768 tall, `768/h` above it, never under 0.9.
#[test]
fn the_auto_scale_follows_the_height_and_stops_at_nine_tenths() {
assert_eq!(auto_ui_scale(1024, 768), 1.0);
assert_eq!(auto_ui_scale(1280, 800), 0.96);
assert_eq!(auto_ui_scale(1920, 1080), 0.9);
// A tall narrow display: capped, then raised to 0.9.
assert_eq!(auto_ui_scale(600, 700), 0.9);
}
/// The display handler (`0x492e90`) never floors: a dial capped below 0.64 falls to the
/// automatic scale, which skips a display it already ran for; the CVar callback (`0x490770`)
/// floors instead.
#[test]
fn below_the_floor_the_display_handler_falls_to_the_auto_scale() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(r#"CreateFrame("Frame", "UIParent")"#).unwrap();
let scale = |s: &UiScript| s.eval::<f32>("return UIParent:GetScale()").unwrap();
let mut cache = None;
assert!(s.seat_ui_scale(0.5, (1280, 800), &mut cache));
assert_eq!(scale(&s), 0.96, "the load took the auto scale for 1280×800");
assert_eq!(cache, Some((1280, 800)));
// The same display again: the auto path does nothing at all.
s.run("UIParent:SetScale(0.7)").unwrap();
assert!(!s.apply_ui_scale((1280, 800), &mut cache));
assert_eq!(scale(&s), 0.7);
// A new display runs it.
assert!(s.apply_ui_scale((1920, 1080), &mut cache));
assert_eq!(scale(&s), 0.9);
// The CVar callback floors.
assert!(s.set_ui_scale(0.55));
assert_eq!(scale(&s), UI_SCALE_FLOOR);
// At the floor or above, the display handler hands the value to the sink.
assert!(s.seat_ui_scale(0.8, (1920, 1080), &mut cache));
assert_eq!(scale(&s), 0.8);
}
}
+157
View File
@@ -0,0 +1,157 @@
//! The show and hide transitions with their notifies (`0x76ae10`, `0x76ad50`). Each marks its
//! frame before anything runs, walks the live child list re-gating each child as the walk reaches
//! it, and fires its own notify last, so handlers run inside the walk, children before their
//! parent, and a frame an earlier handler hid is never notified.
use mlua::{Lua, Table, Value};
use super::{event, Model};
use crate::widget::{ButtonState, ChildWalk, FrameHandle};
/// The Lua `Show`/`Hide` (`0x775750`/`0x775810`): write the shown bit (`0x7757f1`/`0x7758b1`),
/// then dispatch the transition (`0x7757fb`/`0x7758bb`), which gates itself, so `Show()` on a
/// frame already shown but not visible still runs.
pub(super) fn set_shown(lua: &Lua, h: FrameHandle, shown: bool) {
match model_mut(lua).arena.frame_mut(h) {
Some(f) => f.shown = shown,
None => return,
}
if shown {
show(lua, h);
} else {
hide(lua, h);
}
}
/// The show transition (`0x76ae10`, slot `+0x88`): the frame's own step, its children's
/// transitions in link order, the toplevel raise (`0x76aeec`), then its own OnShow (`0x76aef5`).
pub(super) fn show(lua: &Lua, h: FrameHandle) {
{
let mut model = model_mut(lua);
if !model.arena.show_step(h) {
return;
}
// Entering the mouse index schedules a hover re-pick (`0x764b8d`).
model.hover_repick = true;
}
walk_children(lua, h, show);
super::object::toplevel::raise_on_show(&mut model_mut(lua), h);
notify(lua, h, true);
}
/// The hide transition (`0x76ad50`, slot `+0x84`): the frame's own step with its hover leave,
/// its children's transitions in link order, then its own OnHide (`0x76adfd`).
pub(super) fn hide(lua: &Lua, h: FrameHandle) {
let left = {
let mut model = model_mut(lua);
if !model.arena.hide_step(h) {
return;
}
leave_if_hovered(&mut model, h)
};
if let Some(id) = left {
let fired = event::fire_widget_handler(lua, id, "OnLeave", vec![Value::Boolean(true)]);
record(lua, fired);
}
walk_children(lua, h, hide);
// `CSimpleButton`'s hide notify (`+0x34`, `0x7791e0`) un-presses it, unless disabled or
// locked, before the base notify, so a button hidden while held comes back unpushed.
super::button::edge(&mut model_mut(lua), h, ButtonState::on_hide);
notify(lua, h, false);
}
/// Run `step` on each child of `parent` as a live walk reaches it
/// ([`crate::widget::WidgetArena::next_child`]), with no borrow held across the step.
fn walk_children(lua: &Lua, parent: FrameHandle, step: fn(&Lua, FrameHandle)) {
let mut walk = ChildWalk::default();
loop {
let next = lua
.app_data_ref::<Model>()
.expect("model app_data")
.arena
.next_child(parent, &mut walk);
let Some(child) = next else { break };
step(lua, child);
}
}
/// The hide's de-registration (`0x764920`, at `0x76ad9d`, before the child walk) ends a hover on
/// this frame: the removal tail (`0x764ba0`) clears the hover, schedules a re-pick (`0x764cbb`)
/// and calls the leave notify with 1 (`0x764cce`), whose base `0x76b6f0` clears the drag-arm
/// (`+0x100`/`+0x104`), so the hovered frame's OnLeave runs before its own OnHide and its
/// ancestors'.
/// The leave is a virtual call: a disabled Button's own `0x7794e0` skips its OnLeave. This leave is
/// how a tooltip closes with its window; the reference has no other. The id whose OnLeave fires.
fn leave_if_hovered(model: &mut Model, h: FrameHandle) -> Option<u32> {
if model.mouseover != Some(h) {
return None;
}
let notified = super::button::hover_notify_runs(model, Some(h));
model.mouseover = None;
if model.drag.as_ref().is_some_and(|d| d.source == h) {
model.drag = None;
}
model.hover_repick = true;
notified.then(|| model.frame_id(h))
}
/// The notify slot (`+0x30` OnShow, `+0x34` OnHide): the base fires the script unless the frame is
/// in its load window (`0x76b260`/`0x76b290`), and the EditBox's overrides then take or drop the
/// focus (`0x77a750`/`0x77a780`, base first).
fn notify(lua: &Lua, h: FrameHandle, visible: bool) {
let id = {
let mut model = model_mut(lua);
match model.arena.frame(h) {
Some(f) if f.load_window => None,
Some(_) => Some(model.frame_id(h)),
None => return,
}
};
if let Some(id) = id {
let script = if visible { "OnShow" } else { "OnHide" };
let fired = event::fire_widget_handler(lua, id, script, Vec::new());
record(lua, fired);
}
super::editbox::visibility_focus(lua, h, visible);
}
/// Open or close a frame's load window: the pre-load hook's `+0x114 = 1` (`0x7697c2`) and the
/// post-load hook's first write, `+0x114 = 0` (`0x76a2ff`).
pub(crate) fn set_load_window(lua: &Lua, wrapper: &Table, open: bool) {
let Ok(h) = super::object::frame_handle_of(lua, wrapper) else {
return;
};
if let Some(f) = model_mut(lua).arena.frame_mut(h) {
f.load_window = open;
}
}
/// The post-load hook's OnShow (`0x76a38c`–`0x76a3b3`), after the children and the OnLoad: the
/// script alone, through the runner and not the notify slot, so no raise and no EditBox focus,
/// when the frame is visible, has the handler and is out of its load window. It has no OnHide
/// counterpart, and it fires whatever OnShows the frame already had.
pub(crate) fn post_load_show(lua: &Lua, wrapper: &Table) {
let Ok(h) = super::object::frame_handle_of(lua, wrapper) else {
return;
};
let id = {
let mut model = model_mut(lua);
match model.arena.frame(h) {
Some(f) if f.effective_visible && !f.load_window => model.frame_id(h),
_ => return,
}
};
let fired = event::fire_widget_handler(lua, id, "OnShow", Vec::new());
record(lua, fired);
}
fn model_mut(lua: &Lua) -> mlua::AppDataRefMut<'_, Model> {
lua.app_data_mut::<Model>().expect("model app_data")
}
/// A handler error is recorded in [`Model::errors`], never propagated to the verb.
fn record(lua: &Lua, fired: mlua::Result<()>) {
if let Err(e) = fired {
model_mut(lua).record_script_error(e.to_string());
}
}
+13
View File
@@ -162,6 +162,10 @@ pub struct Frame {
pub shown: bool,
/// `shown` and every ancestor effectively visible (`+0xd4`).
pub effective_visible: bool,
/// The frame's own load window (`+0x114`): set by the pre-load hook (`0x7697c2`), cleared as
/// the post-load hook starts (`0x76a2ff`). While it is set the frame's own OnShow and OnHide
/// fire nothing (`0x76b270`/`0x76b2a0`); it still transitions and walks its children.
pub load_window: bool,
/// `EnableMouse`: the mouse bit (bit 2) of the input mask `[frame+0xcc]`.
pub mouse_enabled: bool,
/// `EnableMouseWheel`, or an `<OnMouseWheel>` script at XML load: the mask's wheel bit (3).
@@ -228,6 +232,14 @@ pub struct Region {
pub detached: bool,
}
/// A cursor over a frame's child list for [`WidgetArena::next_child`]: the child last returned and
/// where it stood, so a walk that a handler interrupts resumes from the list as it is now.
#[derive(Clone, Copy, Debug, Default)]
pub struct ChildWalk {
at: usize,
last: Option<FrameHandle>,
}
// ── The arena ────────────────────────────────────────────────────────────────────────────────
/// The frame arena: frames, regions, the name registry and the propagation mutators. The screen
@@ -424,6 +436,7 @@ impl WidgetArena {
effective_alpha,
shown,
effective_visible: shown && parent_visible,
load_window: false,
mouse_enabled: mouse_enabled_by_ctor(kind),
// The WorldFrame ctor takes the wheel (`0x481b1f`); the reference's ScrollFrame and
// ScrollingMessageFrame ctors do not, and a stock one takes it from `<OnMouseWheel>`.
+96 -65
View File
@@ -2,51 +2,88 @@
use crate::order::Strata;
use super::{FrameHandle, RegionHandle, WidgetArena, SCALE_EPS};
use super::{ChildWalk, FrameHandle, RegionHandle, WidgetArena, SCALE_EPS};
impl WidgetArena {
// ── Visibility ───────────────────────────────────────────────────────────────────────────────
/// Set `shown` and propagate effective visibility down the subtree (show `0x76ae10`, hide
/// `0x76ad50`), returning the frames that changed, a node before its descendants, for the
/// caller to fire `OnShow`/`OnHide`. The reference notifies post-order (`0x76aef5`) and
/// re-reads live links, so a frame a handler hides mid-cascade fails its gate (`0x76ae1d`) and
/// is never notified; this list is a snapshot that still holds it.
/// The show transition's own step (`0x76ae10` up to its child walk): nothing unless the frame
/// is shown (`0x76ae1d`), its parent is visible or absent (`0x76ae35`) and it is not visible
/// yet (`0x76ae43`); otherwise it marks itself visible (`0x76ae7b`), before any child or
/// handler runs, and relinks at its bucket's tail (`0x764780`). Whether it transitioned.
pub fn show_step(&mut self, h: FrameHandle) -> bool {
match self.frame(h) {
Some(f) if f.shown && !f.effective_visible => {}
_ => return false,
}
if !self.parent_visible(h) {
return false;
}
self.frame_mut(h).unwrap().effective_visible = true;
self.resequence_to_tail(h);
true
}
/// The hide transition's own step (`0x76ad50` up to its child walk): nothing unless the frame
/// is visible (`0x76ad5b`); otherwise it marks itself invisible (`0x76ad93`). The shown bit is
/// not consulted. Whether it transitioned.
pub fn hide_step(&mut self, h: FrameHandle) -> bool {
match self.frame_mut(h) {
Some(f) if f.effective_visible => {
f.effective_visible = false;
true
}
_ => false,
}
}
/// The next child of `parent` in a live walk of its child list (`0x76aec7`, `0x76ade0`). The
/// reference reads the next link from the current node after the call returns, so a child a
/// handler adds or removes later in the list is seen. A walked child that was itself moved
/// out leaves the walk at the child now in its place; the reference reads a stale link there.
pub fn next_child(&self, parent: FrameHandle, walk: &mut ChildWalk) -> Option<FrameHandle> {
let children = &self.frame(parent)?.children;
let at = match walk.last {
None => 0,
Some(last) if children.get(walk.at) == Some(&last) => walk.at + 1,
Some(last) => children
.iter()
.position(|&c| c == last)
.map_or(walk.at, |i| i + 1),
};
let next = *children.get(at)?;
walk.at = at;
walk.last = Some(next);
Some(next)
}
/// The Lua `Show`/`Hide` (`0x7757f1`/`0x7757fb`, `0x7758b1`/`0x7758bb`) with no handler to
/// fire: write the shown bit, then run the transition, which gates itself. Returns the frames
/// that changed in the order their notifies run, children before their parent. A caller with
/// handlers walks with [`Self::show_step`], [`Self::hide_step`] and [`Self::next_child`].
pub fn set_shown(&mut self, h: FrameHandle, shown: bool) -> Vec<FrameHandle> {
let mut changed = Vec::new();
match self.frame_mut(h) {
Some(f) if f.shown != shown => f.shown = shown,
_ => return changed,
if let Some(f) = self.frame_mut(h) {
f.shown = shown;
self.cascade_silent(h, shown, &mut changed);
}
let parent_visible = self.parent_visible(h);
self.propagate_visible(h, parent_visible, &mut changed);
changed
}
fn propagate_visible(
&mut self,
h: FrameHandle,
parent_visible: bool,
changed: &mut Vec<FrameHandle>,
) {
let (shown, old_ev, children) = {
let f = self.frame(h).expect("live node in propagation");
(f.shown, f.effective_visible, f.children.clone())
fn cascade_silent(&mut self, h: FrameHandle, show: bool, changed: &mut Vec<FrameHandle>) {
let stepped = if show {
self.show_step(h)
} else {
self.hide_step(h)
};
let new_ev = shown && parent_visible;
if new_ev == old_ev {
// No change here means none below: prune, as the client's recursion does.
if !stepped {
return;
}
self.frame_mut(h).unwrap().effective_visible = new_ev;
if new_ev {
// Re-added at its bucket's tail, as the client does, and each descendant after it.
self.resequence_to_tail(h);
let mut walk = ChildWalk::default();
while let Some(c) = self.next_child(h, &mut walk) {
self.cascade_silent(c, show, changed);
}
changed.push(h);
for c in children {
self.propagate_visible(c, new_ev, changed);
}
}
// ── Strata ───────────────────────────────────────────────────────────────────────────────────
@@ -202,18 +239,20 @@ impl WidgetArena {
// ── Reparenting ──────────────────────────────────────────────────────────────────────────────
/// Phase 1 of `SetParent` (`0x76ab10`): the guards and the hide half. `None` when the parent
/// is unchanged (`0x76ab20` skips everything), the frame is dead, or the move would cycle (the
/// Lua binding raises first, `0x7a177f`); otherwise the frames that lost effective visibility,
/// in pre-order, whose `OnHide` runs under the old parent (`0x76ab41`).
/// Phase 1 of `SetParent` (`0x76ab10`): the guards. `None` when the parent is unchanged
/// (`0x76ab20` skips everything), the frame is dead, or the move would cycle (the Lua binding
/// raises first, `0x7a177f`); otherwise whether the frame is visible (`0x76ab2b`), in which
/// case the caller runs its hide transition under the old parent (`0x76ab41`) before
/// [`Self::reparent_finish`], and its show transition after it (`0x76abfd`).
///
/// Deviation: the frame is still in the old parent's child list during `OnHide`, unlike the
/// reference, because a handler that itself reparents must not find the lists half-spliced.
/// reference (`0x76ab34` unlinks first), because a handler that itself reparents must not find
/// the lists half-spliced.
pub fn reparent_begin(
&mut self,
h: FrameHandle,
new_parent: Option<FrameHandle>,
) -> Option<Vec<FrameHandle>> {
) -> Option<bool> {
self.frame(h)?;
let new_parent = new_parent.filter(|&p| self.frame(p).is_some());
if let Some(np) = new_parent {
@@ -221,32 +260,22 @@ impl WidgetArena {
return None;
}
}
if self.frame(h).unwrap().parent == new_parent {
let f = self.frame(h).unwrap();
if f.parent == new_parent {
return None;
}
let mut hidden = Vec::new();
if self.frame(h).unwrap().effective_visible {
self.propagate_visible(h, false, &mut hidden);
}
Some(hidden)
Some(f.effective_visible)
}
/// Phase 2 of `SetParent` (`0x76ab10`): relink, strata := the parent's (`0x76ab5a`, MEDIUM for
/// none), level := the parent's + 1 without propagating (`0x76ab65`, 0 for none), scale
/// re-inherited, then the show half, returning the frames that became visible; alpha is
/// untouched. `h`'s own children keep their levels, so one can end up below `h`.
/// `was_visible` is whether phase 1 hid anything (the reference's `ebx` at `0x76ab2b`); the
/// show half runs only then (`0x76abfd`), so a hidden frame moved under a visible parent keeps
/// a stale effective visibility until something shows it.
pub fn reparent_finish(
&mut self,
h: FrameHandle,
new_parent: Option<FrameHandle>,
was_visible: bool,
) -> Vec<FrameHandle> {
let mut shown = Vec::new();
/// re-inherited; alpha is untouched. `h`'s own children keep their levels, so one can end up
/// below `h`. The show half is the caller's, and runs only when phase 1 found the frame
/// visible (`0x76abe3`), so a hidden frame moved under a visible parent keeps a stale
/// effective visibility until something shows it.
pub fn reparent_finish(&mut self, h: FrameHandle, new_parent: Option<FrameHandle>) {
if self.frame(h).is_none() {
return shown; // died inside an OnHide: nothing to move
return; // died inside an OnHide: nothing to move
}
let new_parent = new_parent.filter(|&p| self.frame(p).is_some());
// Relink from the current parent: an `OnHide` may have reparented it, and the reference's
@@ -274,25 +303,27 @@ impl WidgetArena {
self.set_frame_level(h, plevel, false);
let ps = self.parent_effective_scale(h);
self.propagate_scale(h, ps);
if was_visible && self.parent_visible(h) {
self.propagate_visible(h, true, &mut shown);
}
shown
}
/// Both reparent phases in one call, hide changes first, for a caller with no `OnHide` to fire.
/// `SetParent` whole, for a caller with no handler to fire: the frames that lost visibility,
/// then those that gained it, each half in notify order.
pub fn set_parent(
&mut self,
h: FrameHandle,
new_parent: Option<FrameHandle>,
) -> Vec<FrameHandle> {
let Some(hidden) = self.reparent_begin(h, new_parent) else {
let Some(was_visible) = self.reparent_begin(h, new_parent) else {
return Vec::new();
};
let was_visible = !hidden.is_empty();
let shown = self.reparent_finish(h, new_parent, was_visible);
[hidden, shown].concat()
let mut changed = Vec::new();
if was_visible {
self.cascade_silent(h, false, &mut changed);
}
self.reparent_finish(h, new_parent);
if was_visible {
self.cascade_silent(h, true, &mut changed);
}
changed
}
/// `Region:SetParent` for a texture or font string; `None` detaches. The client re-links in
+5 -4
View File
@@ -34,7 +34,8 @@ fn mid_tree_hide_blocks_a_shown_grandchild() {
assert!(a.frame(grand).unwrap().effective_visible);
let changed = a.set_shown(child, false);
assert_eq!(changed, vec![child, grand]); // pre-order
// Notify order: post-order, children before their parent (`0x76adee`, then `0x76adfd`).
assert_eq!(changed, vec![grand, child]);
assert!(a.frame(root).unwrap().effective_visible);
assert!(!a.frame(child).unwrap().effective_visible);
assert!(!a.frame(grand).unwrap().effective_visible);
@@ -44,7 +45,7 @@ fn mid_tree_hide_blocks_a_shown_grandchild() {
);
let changed = a.set_shown(child, true);
assert_eq!(changed, vec![child, grand]);
assert_eq!(changed, vec![grand, child]);
assert!(a.frame(grand).unwrap().effective_visible);
}
@@ -58,10 +59,10 @@ fn hidden_grandchild_stays_hidden_when_ancestor_reshows() {
a.set_shown(grand, false);
let changed = a.set_shown(root, false);
// grand is already invisible, so the hide does not report it.
assert_eq!(changed, vec![root, child]);
assert_eq!(changed, vec![child, root]);
let changed = a.set_shown(root, true);
assert_eq!(changed, vec![root, child]);
assert_eq!(changed, vec![child, root]);
assert!(a.frame(child).unwrap().effective_visible);
assert!(!a.frame(grand).unwrap().effective_visible);
}