scrollframe: the child's re-anchor is a SetPoint at the setter, not a per-frame override

`SetVerticalScroll`'s own note here already said it: `0x786db0` stores the offset,
**re-anchors the child (0x787100)** and fires its event, "all only on a change".
0x787100 passes the offsets to `SetPoint` — so the engine writes the child's
TOPLEFT once, when the scroll state changes. This applied it instead as a layout
override, recomputed every frame, which is a different thing: it made the
engine's anchor permanent and unoverridable.

That is why AI_VoiceOver's options could be opened and read but not scrolled.
AceGUI-3.0 scrolls by moving the child **itself** — `SetScroll` is
`content:ClearAllPoints()` then `content:SetPoint("TOPLEFT", 0, offset)`
(AceGUIContainer-ScrollFrame.lua:62-77) and it never calls `SetVerticalScroll` at
all — so the override put the content back at the top on every frame. Measured
through the harness: `SetScroll(1000)` computes offset 170 (content 549 − view
379) and the content's top now moves 563 → 733 where before it did not move.

`SetScrollChild` and both offset setters now write the merged TOPLEFT through
`layout_methods::write_anchor`, which is the commit `SetPoint` itself uses, so
the merge rule is shared rather than reimplemented and a child authored
TOPLEFT/TOPRIGHT keeps the TOPRIGHT that gives it its width. The whole override
path goes away with it: no third element on the layout plan, no override in the
node hash, no special case in the edge walk or in `retarget`, and
`LayoutSolver::set_frame_anchored` is gone.

One behaviour genuinely changes and the test says so now: `SetScrollChild(nil)`
no longer restores the child's authored anchor, because the authored TOPLEFT was
replaced the first time the frame took the child. Nothing was ever saved to
restore — that expectation was an artefact of the override model, not a trace.
The child keeps where the last scroll left it, and its own `SetPoint` wins from
there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-17 08:49:01 -06:00
co-authored by Claude Opus 5
parent bbe2e3aaa9
commit 3cc71f4c7d
6 changed files with 182 additions and 206 deletions
-106
View File
@@ -708,33 +708,6 @@ impl LayoutSolver {
self.live.push(h);
}
/// [`set_frame`](Self::set_frame) with `anchor` **merged into** its anchors the way a
/// `SetPoint` call merges: the entry at that same point is replaced, every other point kept.
/// A ScrollFrame child's re-anchor is the one caller, and the reference does it by calling
/// `SetPoint` on the child (`0x787100`) — which in 1.12 adds a point rather than clearing the
/// list, so the child keeps whatever else it was anchored with.
///
/// This replaced the list outright before, and the difference is not academic: AceGUI's
/// scroll child is authored `TOPLEFT`/`TOPRIGHT` to its parent with an explicit height, so
/// dropping `TOPRIGHT` left it with one corner and no width — **no rect at all**, and with it
/// no rect for anything inside. That is what drew AI_VoiceOver's options window as chrome and
/// tabs over an empty content area: the whole widget tree was built, sized and shown, and
/// unresolved from the scroll child down.
pub fn set_frame_anchored(&mut self, h: Handle, src: &LayoutInput, anchor: Anchor) {
self.ensure(h);
self.touch(h);
let dst = &mut self.input[h as usize];
dst.anchors.clear();
dst.anchors.extend_from_slice(&src.anchors);
match dst.anchors.iter_mut().find(|a| a.point == anchor.point) {
Some(slot) => *slot = anchor,
None => dst.anchors.push(anchor),
}
Self::copy_fields(dst, src);
self.is_frame[h as usize] = true;
self.live.push(h);
}
fn copy_fields(dst: &mut LayoutInput, src: &LayoutInput) {
dst.width = src.width;
dst.height = src.height;
@@ -1299,83 +1272,4 @@ mod tests {
);
assert!(s.unresolvable().is_empty());
}
/// The override keeps the size and scale and **merges** into the anchors the way `SetPoint`
/// does — its own point replaced, every other kept — and the reused anchors `Vec` must not
/// leak the last round's.
#[test]
fn set_frame_anchored_merges_the_override_into_the_authored_points() {
const A: Handle = 1;
let mut s = LayoutSolver::new();
let input = LayoutInput::sized(
vec![
Anchor::new(Point::TopLeft, SCREEN, Point::TopLeft, 0.0, 0.0),
Anchor::new(Point::BottomRight, SCREEN, Point::BottomRight, 0.0, 0.0),
],
100.0,
40.0,
);
// Plain: both anchors pin all four edges, so the size is ignored.
s.begin();
s.set_external(SCREEN, screen_rect());
s.set_frame(A, &input);
s.solve();
assert_eq!(
bits(s.rect(A).expect("resolved")),
bits(Rect::new(0.0, 0.0, 600.0, 800.0))
);
// Overridden: TOPLEFT moves to (10, -5) and the authored BOTTOMRIGHT **stands**, so both
// corners are still pinned and the size decides nothing. This is what gives a scroll child
// authored as a corner pair its width; replacing the list left it with one corner.
s.begin();
s.set_external(SCREEN, screen_rect());
s.set_frame_anchored(
A,
&input,
Anchor::new(Point::TopLeft, SCREEN, Point::TopLeft, 10.0, -5.0),
);
s.solve();
assert_eq!(
bits(s.rect(A).expect("resolved")),
bits(Rect::new(0.0, 10.0, 595.0, 800.0))
);
// A point the frame does not author is added rather than substituted: with BOTTOMRIGHT
// alone the override still completes the pair, to the same rect.
let bottom_right_only = LayoutInput::sized(
vec![Anchor::new(
Point::BottomRight,
SCREEN,
Point::BottomRight,
0.0,
0.0,
)],
100.0,
40.0,
);
s.begin();
s.set_external(SCREEN, screen_rect());
s.set_frame_anchored(
A,
&bottom_right_only,
Anchor::new(Point::TopLeft, SCREEN, Point::TopLeft, 10.0, -5.0),
);
s.solve();
assert_eq!(
bits(s.rect(A).expect("resolved")),
bits(Rect::new(0.0, 10.0, 595.0, 800.0))
);
// And the next plain round carries none of it: the `Vec` is cleared, not appended to.
s.begin();
s.set_external(SCREEN, screen_rect());
s.set_frame(A, &input);
s.solve();
assert_eq!(
bits(s.rect(A).expect("resolved")),
bits(Rect::new(0.0, 0.0, 600.0, 800.0))
);
}
}
+17 -75
View File
@@ -1,8 +1,8 @@
use std::collections::HashMap;
use crate::layout::{self, Anchor, LayoutInput, Point, Rect};
use crate::layout::{self, Anchor, LayoutInput, Rect};
use crate::order::ZTarget;
use crate::widget::{FrameHandle, FrameKind, KindState, RegionHandle};
use crate::widget::{FrameHandle, RegionHandle};
use super::backdrop;
use super::types::RegionData;
@@ -89,13 +89,9 @@ const NO_NODE: u64 = 0;
/// A frame's per-node hash; the full derive and the incremental pass must agree bit for bit.
#[inline]
fn frame_node_hash(input: &LayoutInput, over: Option<Anchor>) -> u64 {
fn frame_node_hash(input: &LayoutInput) -> u64 {
let mut node = InputFingerprint::default();
node.input(input);
match over {
Some(a) => node.anchors(std::slice::from_ref(&a)),
None => node.feed(u64::MAX),
}
node.finish()
}
@@ -192,8 +188,8 @@ pub(crate) struct LayoutScope {
dirty: Vec<bool>,
/// The closure's worklist.
stack: Vec<u32>,
/// The cached frame roster, `(handle, id, ScrollFrame anchor override)`, kept across resolves.
plan: Vec<(FrameHandle, u32, Option<Anchor>)>,
/// The cached frame roster, `(handle, id)`, kept across resolves.
plan: Vec<(FrameHandle, u32)>,
/// The cached roster of live, anchored regions.
regions: Vec<RegionRow>,
/// An incremental pass's `(id, hash)` writes to [`Self::last`], applied only on convergence.
@@ -271,8 +267,7 @@ impl LayoutScope {
/// Re-point node `id`'s outgoing edges after an anchor retarget. `old` and `new` are its full
/// anchor-target lists, so duplicate anchors stay exact. `false` means a conservative touch:
/// `id` has no node, or it is a ScrollFrame child, whose edges are its anchors *plus* its
/// override's target and so are re-derived conservatively rather than patched here.
/// `id` has no node.
pub(crate) fn retarget(&mut self, id: u32, old: &[u32], new: &[u32]) -> bool {
let Some(&n) = self.node_of.get(id as usize) else {
return false;
@@ -280,9 +275,6 @@ impl LayoutScope {
if n == u32::MAX {
return false;
}
if n & REGION_TAG == 0 && self.plan[n as usize].2.is_some() {
return false; // a ScrollFrame override owns this frame's edges, not its anchors
}
for &t in old {
self.unlink_edge(t, id);
}
@@ -400,8 +392,6 @@ struct PreambleProf {
watched: u128,
/// The ids rebuild and the scale and clamp sync into `layout_inputs`.
ids: u128,
/// The ScrollFrame override map.
scroll: u128,
/// `region_resolved`'s liveness retain.
retain: u128,
/// `LayoutScope::begin_full`.
@@ -444,7 +434,6 @@ impl PreambleProf {
let total = self.tooltip
+ self.watched
+ self.ids
+ self.scroll
+ self.retain
+ self.begin
+ self.fp_frames
@@ -452,14 +441,13 @@ impl PreambleProf {
+ self.seed;
eprintln!(
"[layout-pre] skips={} incr={} frames={frames} anchored={regions} total_us={total} \
tooltip={} watched={} ids={} scroll={} retain={} begin={} \
tooltip={} watched={} ids={} retain={} begin={} \
fp_frames={} fp_regions={} seed={}",
u8::from(skips),
u8::from(self.incremental),
self.tooltip,
self.watched,
self.ids,
self.scroll,
self.retain,
self.begin,
self.fp_frames,
@@ -621,9 +609,9 @@ impl UiScript {
None => continue,
}
} else {
let (h, _, over) = scope.plan[n as usize];
let (h, _) = scope.plan[n as usize];
match layout_inputs.get(&h) {
Some(i) => frame_node_hash(i, over),
Some(i) => frame_node_hash(i),
None => continue,
}
};
@@ -765,24 +753,21 @@ impl UiScript {
}
}
for &i in &seed_frames {
let (h, id, _) = scope.plan[i as usize];
let (h, id) = scope.plan[i as usize];
if let Some(r) = resolved.get(&h) {
solver.set_external(id, *r);
}
}
for &i in &solve_frames {
let (h, id, over) = scope.plan[i as usize];
let (h, id) = scope.plan[i as usize];
let Some(input) = layout_inputs.get(&h) else {
continue;
};
match over {
Some(a) => solver.set_frame_anchored(id, input, a),
None => solver.set_frame(id, input),
}
solver.set_frame(id, input);
}
solver.solve();
for &i in &solve_frames {
let (h, id, _) = scope.plan[i as usize];
let (h, id) = scope.plan[i as usize];
match solver.rect(id) {
Some(r) => {
if resolved.get(&h) != Some(&r) {
@@ -1011,40 +996,6 @@ impl UiScript {
}
pre.ids = pre.lap();
// A live ScrollFrame's live child has its TOPLEFT re-pointed, for this solve only, to the
// ScrollFrame's TOPLEFT at the raw, unnegated `(horizontal, vertical)` offsets, as the
// reference's re-anchor `0x787100` passes them to `SetPoint`: y up, so a positive vertical
// lifts the child. One point, merged — the child's other authored points stand, which is
// what `SetPoint` does and what gives a `TOPLEFT`/`TOPRIGHT`-authored child its width. Its
// stored anchors and size are untouched, so `SetScrollChild(nil)` needs no restore.
let mut scroll_child_anchor: HashMap<FrameHandle, Anchor> = HashMap::new();
for &(h, id) in &ids {
let Some(frame) = arena.frame(h) else {
continue;
};
if frame.kind != FrameKind::ScrollFrame {
continue;
}
let KindState::Scroll(state) = &frame.kind_state else {
continue;
};
let Some(child) = state.child else { continue };
if arena.frame(child).is_none() {
continue; // a stale/destroyed child contributes no override
}
scroll_child_anchor.insert(
child,
Anchor::new(
Point::TopLeft,
id,
Point::TopLeft,
state.horizontal,
state.vertical,
),
);
}
pre.scroll = pre.lap();
// The reference resolves one graph in which frames and regions anchor each other; ours
// alternates a frame solve and a region sweep until a round changes no rect, compared
// exactly. The round cap is the node count, so only a cycle exhausts it, warned like the
@@ -1071,29 +1022,20 @@ impl UiScript {
let Some(input) = layout_inputs.get(&h) else {
continue;
};
let over = scroll_child_anchor.get(&h).copied();
#[allow(clippy::cast_possible_truncation)]
let i = scope.plan.len() as u32;
fp.feed(u64::from(id));
fp.input(input);
match over {
Some(a) => fp.anchors(std::slice::from_ref(&a)),
None => fp.feed(u64::MAX),
}
if let Some(slot) = scope.now.get_mut(id as usize) {
*slot = frame_node_hash(input, over);
*slot = frame_node_hash(input);
scope.node_of[id as usize] = i;
}
scope.plan.push((h, id, over));
// A frame reads its anchor targets' rects — and a ScrollFrame child reads its
// override's target as well, since `set_frame_anchored` merges the override into the
// authored anchors rather than replacing them.
scope.plan.push((h, id));
// A frame reads its anchor targets' rects. A ScrollFrame child is no special case:
// its re-anchor was written into those anchors at the setter.
for a in &input.anchors {
scope.edge(id, a.relative_to);
}
if let Some(a) = over {
scope.edge(id, a.relative_to);
}
}
#[allow(clippy::cast_possible_truncation)]
{
+1 -1
View File
@@ -20,7 +20,7 @@ use crate::widget::{FrameHandle, FrameKind};
pub(crate) mod anchor_args;
mod events_regions;
mod frame_state;
mod layout_methods;
pub(crate) mod layout_methods;
pub(crate) use layout_methods::eff_scale;
pub(crate) mod movable;
pub(crate) mod toplevel;
@@ -288,8 +288,23 @@ fn set_point(lua: &Lua, this: &Table, args: &MultiValue) -> mlua::Result<()> {
let rel_to_id = resolve_rel_target(&model, &p.target, &who, "SetPoint", me, parent)?;
let (point, rel_point, x, y) = (p.point, p.rel_point, p.x, p.y);
write_anchor(
&mut model,
h,
Anchor::new(point, rel_to_id, rel_point, x, y),
);
Ok(())
}
/// Merge one anchor into a frame's stored list, which is all `SetPoint` does: the entry at that
/// same point is replaced, every other point kept, and the layout is told whether the change
/// moved a target.
///
/// Shared with the ScrollFrame setters, because the reference's re-anchor of a scroll child
/// (`0x787100`) **is** a `SetPoint` call — see `script/scrollframe.rs`.
pub(crate) fn write_anchor(model: &mut Model, h: FrameHandle, new: Anchor) {
let point = new.point;
let input = model.layout_inputs.entry(h).or_default();
let new = Anchor::new(point, rel_to_id, rel_point, x, y);
// A no-op only when the identical anchor is already last and no earlier one has this point,
// mirroring the retain and push below; bits compared, as the fingerprint tells -0.0 from 0.0.
let same_at_tail = input
@@ -299,27 +314,27 @@ fn set_point(lua: &Lua, this: &Table, args: &MultiValue) -> mlua::Result<()> {
&& !input.anchors[..input.anchors.len() - 1]
.iter()
.any(|a| a.point == point);
if !same_at_tail {
// Target lists are collected only for a retarget: the value-only change is the per-frame
// idiom (a dragged window) and must stay allocation-free.
let structural = anchor_retarget_is_structural(&input.anchors, &new);
let old_targets: Option<Vec<u32>> =
structural.then(|| input.anchors.iter().map(|a| a.relative_to).collect());
input.anchors.retain(|a| a.point != point);
input.anchors.push(new);
match old_targets {
None => model.touch_layout_frame(h),
Some(old) => {
let new_targets: Vec<u32> = model.layout_inputs[&h]
.anchors
.iter()
.map(|a| a.relative_to)
.collect();
model.touch_layout_retarget_frame(h, &old, &new_targets);
}
if same_at_tail {
return;
}
// Target lists are collected only for a retarget: the value-only change is the per-frame
// idiom (a dragged window) and must stay allocation-free.
let structural = anchor_retarget_is_structural(&input.anchors, &new);
let old_targets: Option<Vec<u32>> =
structural.then(|| input.anchors.iter().map(|a| a.relative_to).collect());
input.anchors.retain(|a| a.point != point);
input.anchors.push(new);
match old_targets {
None => model.touch_layout_frame(h),
Some(old) => {
let new_targets: Vec<u32> = model.layout_inputs[&h]
.anchors
.iter()
.map(|a| a.relative_to)
.collect();
model.touch_layout_retarget_frame(h, &old, &new_targets);
}
}
Ok(())
}
/// Whether this `SetPoint` changes the node's anchor targets, mirroring the setters' retain and
@@ -9,6 +9,7 @@ use mlua::{Lua, Table, Value};
use super::object::{decode_id, frame_handle_of, frame_wrapper};
use super::{event, Model};
use crate::layout::{Anchor, Point};
use crate::widget::{FrameHandle, KindState, ScrollFrameState};
/// Registry key of the ScrollFrame method table.
@@ -136,6 +137,43 @@ fn subtree_font_strings(model: &Model, f: FrameHandle) -> Vec<crate::widget::Reg
out
}
/// The reference's re-anchor of the scroll child, `0x787100`: its `TOPLEFT` to the ScrollFrame's
/// `TOPLEFT` at the raw, unnegated `(horizontal, vertical)` — y up, so a positive vertical lifts
/// the child and a positive horizontal moves it right.
///
/// **Written through to the child's own anchors, at the setter, on a change** — not applied as a
/// per-frame layout override. That is what `0x787100` being a `SetPoint` call means, and what the
/// three setters' own notes here already said; the timing is the whole behaviour. An addon that
/// scrolls by moving the child *itself* has to be able to win, and AceGUI-3.0 is exactly that
/// addon: its `SetScroll` is `content:ClearAllPoints()` then `content:SetPoint("TOPLEFT", 0,
/// offset)` (`AceGUIContainer-ScrollFrame.lua:62-77`), and it never calls `SetVerticalScroll` at
/// all. Re-applying the engine's anchor every frame put the content back at the top every frame,
/// so an Ace options panel could be opened and read but not scrolled.
///
/// One point, merged, because `SetPoint` merges: a child authored `TOPLEFT`/`TOPRIGHT` keeps the
/// `TOPRIGHT` that gives it its width.
fn reanchor_child(lua: &Lua, this: &Table) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(frame) = model.arena.frame(h) else {
return Ok(());
};
let KindState::Scroll(s) = &frame.kind_state else {
return Ok(());
};
let (child, horizontal, vertical) = (s.child, s.horizontal, s.vertical);
let Some(child) = child.filter(|&c| model.arena.frame(c).is_some()) else {
return Ok(()); // no child, or one that has been destroyed: nothing to place
};
let id = model.frame_id(h);
crate::script::object::layout_methods::write_anchor(
&mut model,
child,
Anchor::new(Point::TopLeft, id, Point::TopLeft, horizontal, vertical),
);
Ok(())
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
@@ -173,6 +211,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
if changed {
// The child is an input of the layout resolve, so a real change dirties it.
lua.app_data_mut::<Model>().expect("model").touch_layout();
// And the new child is placed now, as `0x790fa0` does, not at the next resolve:
// a caller sets its own points straight after this and must win.
reanchor_child(lua, &this)?;
}
Ok(())
})?,
@@ -210,6 +251,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
return Ok(());
}
lua.app_data_mut::<Model>().expect("model").touch_layout();
reanchor_child(lua, &this)?;
fire_scroll(lua, &this, "OnVerticalScroll", px)
})?,
)?;
@@ -242,6 +284,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
return Ok(());
}
lua.app_data_mut::<Model>().expect("model").touch_layout();
reanchor_child(lua, &this)?;
fire_scroll(lua, &this, "OnHorizontalScroll", px)
})?,
)?;
@@ -131,7 +131,7 @@ fn get_scroll_child_roundtrips_wrapper_and_name_and_clears_on_nil() {
/// A positive offset lifts the child; offsets past the range or below zero are stored and applied
/// as given, since `0x786db0` never reads the range.
#[test]
fn scroll_child_top_tracks_vertical_scroll_unclamped_then_restores_on_clear() {
fn scroll_child_top_tracks_vertical_scroll_unclamped_and_keeps_it_on_clear() {
let mut s = script();
s.set_screen_size(800.0, 600.0); // screen rect: bottom 0, left 0, top 600, right 800
@@ -199,15 +199,97 @@ fn scroll_child_top_tracks_vertical_scroll_unclamped_then_restores_on_clear() {
"a negative offset lowers the child: frame top 500 - 30"
);
// Clearing the child stops the override, and its own untouched anchor applies again.
// Clearing the child does **not** put its authored anchor back: the re-anchor was a
// `SetPoint` on the child, so the authored TOPLEFT was replaced the first time the frame took
// it as a scroll child, and there is nothing saved to restore. The child simply stops being
// moved, keeping the last position the scroll put it in.
s.run("SF:SetScrollChild(nil)").unwrap();
s.resolve();
let quads = s.extract();
assert_eq!(
marker_rect(&quads, "marker:child").map(|r| r.top),
Some(580.0),
"authored anchor survives: screen top 600 - 20"
Some(470.0),
"it stays where the last scroll left it; nothing restores the authored anchor"
);
// And the child's own `SetPoint` wins from here, because the engine is no longer writing it.
s.run(r#"Child:SetPoint("TOPLEFT", 20, -20)"#).unwrap();
s.resolve();
let quads = s.extract();
assert_eq!(
marker_rect(&quads, "marker:child").map(|r| r.top),
Some(580.0),
"screen top 600 - 20, set by the caller rather than inherited"
);
}
/// An addon that scrolls by moving the child **itself** has to win, and that is the whole reason
/// the re-anchor is a write at the setter and not a per-frame override.
///
/// AceGUI-3.0 is that addon: its `SetScroll` is `content:ClearAllPoints()` followed by
/// `content:SetPoint("TOPLEFT", 0, offset)` (`AceGUIContainer-ScrollFrame.lua:62-77`), and it
/// never calls `SetVerticalScroll` at all. Re-applying the engine's anchor every frame put the
/// content back at the top on every frame, so an Ace options panel opened, drew, and could not be
/// scrolled — which is exactly what AI_VoiceOver's did.
#[test]
fn a_scroll_child_the_addon_re_anchors_itself_stays_where_the_addon_put_it() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local frame = CreateFrame("ScrollFrame", "AceScroll")
frame:SetPoint("TOPLEFT", 0, -100) -- screen top 600 -> 500
frame:SetWidth(300) frame:SetHeight(200)
local content = CreateFrame("Frame", "AceContent", frame)
content:SetPoint("TOPLEFT", 0, 0)
content:SetPoint("TOPRIGHT", 0, 0)
content:SetHeight(600)
frame:SetScrollChild(content)
local marker = content:CreateTexture(nil, "ARTWORK")
marker:SetTexture("marker:content")
marker:SetAllPoints()
"#,
)
.unwrap();
s.resolve();
assert_eq!(
marker_rect(&s.extract(), "marker:content").map(|r| r.top),
Some(500.0),
"SetScrollChild places it at the viewport's top, as the reference's does"
);
// The library's own scroll: clear the points and re-anchor lower, with no scroll verb at all.
s.run(
r#"
AceContent:ClearAllPoints()
AceContent:SetPoint("TOPLEFT", 0, 120)
AceContent:SetPoint("TOPRIGHT", 0, 120)
"#,
)
.unwrap();
s.resolve();
assert_eq!(
marker_rect(&s.extract(), "marker:content").map(|r| r.top),
Some(620.0),
"the addon's own offset stands: nothing re-applies the engine's anchor behind it"
);
// A real scroll verb still takes it back, which is the other half of the contract.
s.run("AceScroll:SetVerticalScroll(40)").unwrap();
s.resolve();
assert_eq!(
marker_rect(&s.extract(), "marker:content").map(|r| r.top),
Some(540.0),
"frame top 500 + 40, and the addon's TOPRIGHT still gives it its width"
);
assert_eq!(
s.eval::<f32>("return AceContent:GetRight()").unwrap(),
300.0,
"the TOPRIGHT the merge must not drop"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `0x786db0` acts only when the offset differs from the stored one; a value past the range is