Files
moonwell-client-core/crates/benilla-ui/src/script/scrollframe.rs
T
Sam Sung 837a6e1336 Frame sizes and positions follow 1.12.1's screen-unit law (#1946)
Frame sizes and positions skipped 1.12.1's screen-unit law. The client keeps layout units: a setter stores `S(v) = RN24(K1·RN24(RN24(v)/1024A))` and a getter answers `RN24(1024A·l)/K1`, from the cells `0x41ad10` sets from the aspect (benilla-re `systems/screencoord.md`, two readings).

- The layout resolves in layout units with the reference's four float roundings (`0x767414`, `0x76741f`, `0x767428`, `0x767a30`); the pixel seam converts.
- Size, edge, centre and point getters and setters, the screen-size and cursor globals (glue and game), scroll ranges and scroll events follow the law; OnSizeChanged passes the layout size (`0x76b660`).
- Scroll bars hide on `floor(range) == 0`, as the stock template does.
- The minimap and world-map arrows re-seat when the cells change.

At 4:3, `GetScreenHeight()` is 767.9999427795418. Nothing moves on screen beyond 2e-4 px. A byte audit re-derived the law and the resolver.

Fixes #1918
Fixes #1321

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:02:31 +07:00

429 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The `ScrollFrame` methods (`CSimpleScrollFrame`): the scroll child, the two offsets, the two
//! ranges and `UpdateScrollChildRect`, over [`ScrollFrameState`], and the frame's own update
//! ([`update`]), which keeps the ranges; the layout's `resolve`, `extract` and `hit_test` place
//! and clip the child. The axes are one mechanism: `0x786d30`
//! (horizontal) is a byte-clone of `0x786db0` (vertical) on `[+0x324]`/`[+0x32c]` for
//! `[+0x328]`/`[+0x334]`, and `0x786e30` measures both ranges in one pass. The methods sit in
//! their own table, so on every other frame kind `SetScrollChild` is nil, as in the client.
use mlua::{Lua, Table, Value};
use super::binding_abi;
use super::object::{decode_id, frame_handle_of, frame_wrapper};
use super::{event, Model};
use crate::layout::SIZE_EPS;
use crate::widget::{FrameHandle, KindState, ScrollFrameState};
/// Registry key of the ScrollFrame method table.
pub(super) const REG_SCROLLFRAME_METHODS: &str = "__benilla_scrollframe_methods";
/// Run `f` over a frame's ScrollFrame state; errors when `this` is not a live ScrollFrame, which a
/// script reaches only by applying a method taken off the table to another frame.
fn with_scroll<T>(
lua: &Lua,
this: &Table,
f: impl FnOnce(&mut ScrollFrameState) -> T,
) -> mlua::Result<T> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let frame = model
.arena
.frame_mut(h)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
match &mut frame.kind_state {
KindState::Scroll(s) => Ok(f(s)),
_ => Err(mlua::Error::runtime("not a ScrollFrame")),
}
}
/// Both ranges as the recompute `0x786e30` measures them in one pass, `[horizontal, vertical]`.
/// The content box is the scroll child's whole subtree, not its own rect (`0x786e30` calls the
/// recursive `0x786f80`): stock `ItemTextPageScrollChild` is 10×10 around a 270×304 SimpleHTML
/// (`ItemTextFrame.xml:198`). Its left and bottom start at `f32::MAX` and its right and top at 0
/// (`0x786e5d`, `0x786e64`). Each range is the box's extent less the frame's (`0x786e84`), raised to 0
/// when not above it, NaN included (`0x786e8f`), times the reciprocal of the scroll frame's own
/// effective scale (`+0x7c`): at full precision for the horizontal (`0x786e9f`-`0x786eaa`), rounded
/// to f32 for the vertical (`0x786ea5`, `0x786ece`). The child's own scale is never read. All of it
/// is in layout units, the rects' own, as the ranges are stored.
fn scroll_ranges(model: &Model, h: FrameHandle) -> [f32; 2] {
let child = match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::Scroll(s)) => s.child,
_ => None,
};
let (Some(child), Some(frame), Some(&rect)) =
(child, model.arena.frame(h), model.frame_rects.get(&h))
else {
return [0.0, 0.0];
};
let mut content = SpanBox {
left: f32::MAX,
bottom: f32::MAX,
right: 0.0,
top: 0.0,
};
join_subtree(model, child, &mut content);
let floor = |x: f64| if x > 0.0 { x } else { 0.0 };
let inv = 1.0 / f64::from(frame.effective_scale);
let x =
floor(f64::from(content.right) - f64::from(content.left) - f64::from(rect.width())) * inv;
let y = floor(f64::from(content.top) - f64::from(content.bottom) - f64::from(rect.height()))
* f64::from(inv as f32);
[x as f32, y as f32]
}
/// The content box `0x786f80` grows, in the layout's units.
struct SpanBox {
left: f32,
bottom: f32,
right: f32,
top: f32,
}
impl SpanBox {
/// Grow to cover `r`; a NaN edge leaves the box's (the unordered compares keep it).
fn join(&mut self, r: &crate::layout::Rect) {
self.left = self.left.min(r.left);
self.bottom = self.bottom.min(r.bottom);
self.right = self.right.max(r.right);
self.top = self.top.max(r.top);
}
}
/// `0x786f80`: join `f`'s own resolved rect, shown or not (`0x786fb0`), each shown region's
/// (`0x78703b`), and recursively each child frame whose own shown bit is set (`+0xd0`,
/// `0x7870e0`), so a shown descendant of a hidden scroll child still counts. A part with no
/// resolved rect adds nothing.
fn join_subtree(model: &Model, f: FrameHandle, content: &mut SpanBox) {
let Some(frame) = model.arena.frame(f) else {
return;
};
if let Some(r) = model.frame_rects.get(&f) {
content.join(r);
}
for &rh in &frame.regions {
if model.region_data.get(&rh).is_some_and(|d| d.hidden) {
continue;
}
if let Some(r) = model.region_rects.get(&rh) {
content.join(r);
}
}
for &ch in &frame.children {
if model.arena.frame(ch).is_some_and(|c| c.shown) {
join_subtree(model, ch, content);
}
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
// SetScrollChild(frame | name | nil): nil or an unresolvable target clears.
m.set(
"SetScrollChild",
lua.create_function(|lua, (this, target): (Table, Value)| {
// A name is `_G[name]` with no `$parent`: `0x790fa0` calls `SetParent`'s resolver
// `0x76c760`.
let named = match &target {
Value::String(s) => s
.to_str()
.ok()
.map(|n| super::object::prefetch_named_target(lua, n.as_ref(), None)),
_ => None,
};
let new_child: Option<FrameHandle> = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
match &target {
Value::Table(t) => decode_id(t)
.ok()
.and_then(|id| model.id_to_frame.get(&id).copied()),
Value::String(_) => named.as_ref().and_then(|nt| {
super::object::resolve_named_target(&model, nt)
.and_then(|id| model.id_to_frame.get(&id).copied())
}),
_ => None,
}
};
// Every call marks the range (`0x786d16`), the same child again included.
with_scroll(lua, &this, |s| s.range_dirty = true)?;
{
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.arena.set_scroll_child(h, new_child);
}
// The old child keeps its point and is not invalidated (`0x786ce6`–`0x786cf4` only
// flags it); the new one is re-anchored on every call (`0x786d11`).
reanchor_child(lua, &this)?;
Ok(())
})?,
)?;
m.set(
"GetScrollChild",
lua.create_function(|lua, this: Table| {
let child = with_scroll(lua, &this, |s| s.child)?;
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
child
.filter(|&h| model.arena.frame(h).is_some())
.map(|h| model.frame_id(h))
};
match id {
Some(id) => Ok(Value::Table(frame_wrapper(lua, id)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// SetVerticalScroll(px) (`0x791400`): `lua_isnumber` (`0x791490`) else
// `Usage: %s:SetVerticalScroll(offset)` (`0x87b59c`) naming the frame. Then `0x786db0` stores
// the offset unclamped (`[+0x328]`), re-anchors the child (`0x787100`) and fires
// `OnVerticalScroll(self, offset)`, all only on a change: one under 2⁻²² layout units between
// the offsets as stored (`S`, `0x7914a8`; `0x786dc2`) is dropped whole, and a NaN, unordered,
// always stores and fires (`0x786dcd`). The range is never read here: every clamp
// is FrameXML's, through the scroll bar's min and max.
m.set(
"SetVerticalScroll",
lua.create_function(|lua, (this, px): (Table, Value)| {
with_scroll(lua, &this, |_| ())?;
let Some(px) = lua.coerce_number(px)? else {
return Err(binding_abi::usage(lua, &this, "SetVerticalScroll(offset)"));
};
let px = px as f32;
let units = script_units(lua);
let changed = with_scroll(lua, &this, |s| {
let changed = moved(units.store(s.vertical), units.store(px), SIZE_EPS);
if changed {
s.vertical = px;
}
changed
})?;
if !changed {
return Ok(());
}
// The re-anchor at the new offset (`0x786dd8` → `0x787100`).
reanchor_child(lua, &this)?;
fire_scroll(lua, &this, "OnVerticalScroll", units.load(units.store(px)))
})?,
)?;
m.set(
"GetVerticalScroll",
lua.create_function(|lua, this: Table| {
let v = with_scroll(lua, &this, |s| s.vertical)?;
let units = script_units(lua);
Ok(units.load(units.store(v)))
})?,
)?;
// The stored range (`0x7917d0` reads `[+0x320]` at `0x791873`) through the getter steps,
// stale while the frame is hidden or its range unmarked.
m.set(
"GetVerticalScrollRange",
lua.create_function(|lua, this: Table| {
let range = with_scroll(lua, &this, |s| s.range[1])?;
Ok(script_units(lua).load(range))
})?,
)?;
// ── The horizontal axis ──
// SetHorizontalScroll(px) (`0x7912f0` → `0x786d30`, `Usage: %s:SetHorizontalScroll(offset)`
// `0x87b574`) is the vertical setter's byte-clone on `[+0x324]`, firing `OnHorizontalScroll`.
// The sign is the reference's: `0x787100` anchors the child at `(+hScroll, +vScroll)`, so a
// positive x moves it right, where a positive y lifts it.
m.set(
"SetHorizontalScroll",
lua.create_function(|lua, (this, px): (Table, Value)| {
with_scroll(lua, &this, |_| ())?;
let Some(px) = lua.coerce_number(px)? else {
return Err(binding_abi::usage(
lua,
&this,
"SetHorizontalScroll(offset)",
));
};
let px = px as f32;
let units = script_units(lua);
let changed = with_scroll(lua, &this, |s| {
let changed = moved(units.store(s.horizontal), units.store(px), SIZE_EPS);
if changed {
s.horizontal = px;
}
changed
})?;
if !changed {
return Ok(());
}
reanchor_child(lua, &this)?;
fire_scroll(
lua,
&this,
"OnHorizontalScroll",
units.load(units.store(px)),
)
})?,
)?;
m.set(
"GetHorizontalScroll",
lua.create_function(|lua, this: Table| {
let v = with_scroll(lua, &this, |s| s.horizontal)?;
let units = script_units(lua);
Ok(units.load(units.store(v)))
})?,
)?;
m.set(
"GetHorizontalScrollRange",
lua.create_function(|lua, this: Table| {
let range = with_scroll(lua, &this, |s| s.range[0])?;
Ok(script_units(lua).load(range))
})?,
)?;
// UpdateScrollChildRect() (`0x7918b0`) marks the range and does nothing else (`0x791943`):
// the frame's next update while shown recomputes it.
m.set(
"UpdateScrollChildRect",
lua.create_function(|lua, this: Table| with_scroll(lua, &this, |s| s.range_dirty = true))?,
)?;
lua.set_named_registry_value(REG_SCROLLFRAME_METHODS, m)?;
Ok(())
}
/// The screen-scale cells the range getters convert through (`0x7916f0`, `0x7917d0`).
fn script_units(lua: &Lua) -> crate::layout::LayoutUnits {
lua.app_data_ref::<Model>()
.expect("model app_data")
.layout_units()
}
/// Whether `new` is a change from `old` by the reference's `fabs; fcomp [0x8029d4]; test ah,5`
/// (`0x786dc2`, `0x786ede`): at least `eps` apart, or unordered, so a NaN is always a change.
fn moved(old: f32, new: f32, eps: f64) -> bool {
let d = (f64::from(new) - f64::from(old)).abs();
d >= eps || d.is_nan()
}
/// Fire `On{Vertical,Horizontal}Scroll(self, offset)`, the reference's script slots `[+0x334]` and
/// `[+0x32c]`, outside any model borrow. The offset is the stored one through the getter steps,
/// `G([+0x328])` pushed as a double under `"%f"` (`0x786ded`–`0x786e10`, `0x835160`).
fn fire_scroll(lua: &Lua, this: &Table, script: &str, value: f64) -> mlua::Result<()> {
let id = {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.frame_id(h)
};
if let Err(e) = event::fire_widget_handler(lua, id, script, vec![Value::Number(value)]) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_lua_error(&e);
}
Ok(())
}
/// The frame's update (`0x787140`, the walk's slot after its `OnUpdate`), so only while shown. A
/// resize since the last one marks the range, as the size slot `0x7871f0` does (`0x787205`). A
/// marked range is recomputed (`0x787152`–`0x7871a0`, `0x786e30`) unless the frame has no rect
/// (`0x787186`) or no scroll child (`0x786e42`), and the mark is cleared either way, after the
/// notify (`0x7871a5`). The recompute stores both ranges (`0x786eaa`, `0x786ed1`) and fires
/// `OnScrollRangeChanged(self, xRange, yRange)` when either moved by 2⁻²² (`[0x8029d4]`) layout
/// units or more (`0x786ed7`–`0x786efd`), each range through the getter steps.
///
/// The rects it reads are the ones the pump's drain before the walk (`0x7656ba`) resolved: the
/// recompute reads them through `0x768320`, which never resolves, so a resize made earlier in the
/// walk counts from the next tick, and the size it notes is the one that drain applied.
pub(super) fn update(lua: &Lua, h: FrameHandle) {
let notify = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let rect = model.frame_rects.get(&h).copied();
let eps = SIZE_EPS;
let units = model.layout_units();
let Some(s) = scroll_state(&mut model, h) else {
return;
};
see_size(s, rect, eps);
if !s.range_dirty {
return;
}
let has_child = s.child.is_some();
let measured = (rect.is_some() && has_child).then(|| scroll_ranges(&model, h));
let Some(s) = scroll_state(&mut model, h) else {
return;
};
let moved = measured.filter(|new| {
let old = std::mem::replace(&mut s.range, *new);
// Unless both moved under 2^-22 (`0x786ede`, `0x786ef2`): a NaN fires.
(0..2).any(|i| moved(old[i], new[i], eps))
});
if moved.is_none() {
s.range_dirty = false;
}
moved.map(|range| (model.frame_id(h), range.map(|r| units.load(r))))
};
let Some((id, [x_range, y_range])) = notify else {
return;
};
// Horizontal first (`0x786eff`), which is why stock passes `arg2` on as the vertical range
// (`UIPanelTemplates.xml:188`).
if let Err(e) = event::fire_widget_handler(
lua,
id,
"OnScrollRangeChanged",
vec![Value::Number(x_range), Value::Number(y_range)],
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_lua_error(&e);
}
// The mark clears after the notify, so one the handler set is dropped.
if let Some(s) = scroll_state(&mut lua.app_data_mut::<Model>().expect("model app_data"), h) {
s.range_dirty = false;
}
}
/// Frame `h`'s ScrollFrame state, if it is a live ScrollFrame.
fn scroll_state(model: &mut Model, h: FrameHandle) -> Option<&mut ScrollFrameState> {
match model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
Some(KindState::Scroll(s)) => Some(s),
_ => None,
}
}
/// Note the frame's resolved size in layout units; a change since the last note marks the range, by
/// the width or height move that fires `OnSizeChanged` ([`crate::layout::size_changed`] at `eps`,
/// [`crate::layout::SIZE_EPS`]). No rect, no note.
fn see_size(s: &mut ScrollFrameState, rect: Option<crate::layout::Rect>, eps: f64) {
let Some(r) = rect else { return };
let size = [r.width(), r.height()];
let moved = |i: usize| (f64::from(size[i]) - f64::from(s.seen_size[i])).abs() >= eps;
if moved(0) || moved(1) {
s.range_dirty = true;
}
s.seen_size = size;
}
/// The re-anchor (`0x787100`): when the frame holds a child, clear its points (`0x787110` →
/// `0x767620`) and set one TOPLEFT on the frame's TOPLEFT at the raw `(horizontal, vertical)`
/// offsets, y up, with the invalidate flag (`0x787136`). A real write on the child: its own
/// points are gone, and it keeps the new one when the frame lets it go.
fn reanchor_child(lua: &Lua, this: &Table) -> mlua::Result<()> {
let (child, x, y) = with_scroll(lua, this, |s| (s.child, s.horizontal, s.vertical))?;
let Some(child) = child else {
return Ok(());
};
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if model.arena.frame(child).is_none() {
return Ok(());
}
let sid = model.frame_id(h);
let pin = crate::layout::Anchors::from([crate::layout::Anchor::new(
crate::layout::Point::TopLeft,
sid,
crate::layout::Point::TopLeft,
x,
y,
)]);
let input = model.layout_inputs.entry(child).or_default();
let changed = !input.anchors.bits_eq(&pin);
input.anchors = pin;
model.invalidate_frame(child, changed);
Ok(())
}