This checkout had no git history at all, so this one commit carries the whole
of the Everwood work as a single import on top of chairmanpow/benilla-octo's
`main` (706cd71). The two trees have genuinely diverged — each has files the
other does not — so this is a fork point, not a catch-up.
Today's work, which is the reason a baseline exists at all:
- Booth previews no longer cast world shadows (`NO_BOOTH_SHADOW`). Portrait
characters were entering the sun's shadow cascades, so every skinned-rig
shadow specialization compiled live mid-frame; the hitch detector logged
"main thread blocked 1316 ms" beside a `compiled LIVE` warning. This was
the Fashion/Tmog stutter.
- Per-pane dress-up: each `DressUpModel` pane assembles and stages its own
tried-on look instead of every pane mirroring the live player.
- Item template cache is append-only JSONL written on the IO pool, replacing
an 11.6 MB whole-map JSON rewrite on the main thread every 10 s.
- `WOW_SYSTEM_TIMING` rebuilt per-thread: it took 2 mutexes per system enter
and 4 per exit, costing 15 ms a frame (590 fps -> 60) and corrupting the
measurements it was taken to produce.
- The visibility-churn sweep moved to its own `WOW_FLASH_CHURN` switch, so a
frame-cost reading no longer pays 2.7 ms a frame for it.
Local state (`benilla-config/`, `logs/`, `target/`) is excluded by the
existing .gitignore; the account credential it holds is not in this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
334 lines
13 KiB
Rust
334 lines
13 KiB
Rust
//! The `ScrollFrame` methods (`CSimpleScrollFrame`): the scroll child, the two offsets, the two
|
||
//! ranges and `UpdateScrollChildRect`, over [`ScrollFrameState`]; 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::object::{decode_id, frame_handle_of, frame_wrapper};
|
||
use super::{event, Model};
|
||
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")),
|
||
}
|
||
}
|
||
|
||
/// The axis of an offset or a range. `0x786e30` computes both ranges in one pass over one subtree
|
||
/// union: `[+0x31c] = max(0, maxRIGHT − minLEFT − width) / scale`, and `[+0x320]` the same with
|
||
/// top, bottom and height.
|
||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||
enum Axis {
|
||
Horizontal,
|
||
Vertical,
|
||
}
|
||
|
||
/// `Get{Vertical,Horizontal}ScrollRange()`: `max(0, content − frame)` from the resolved rects,
|
||
/// computed on every call, where the reference caches both (`[+0x31c]`, `[+0x320]`) and recomputes
|
||
/// them only once marked dirty. The content 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`). Each extent is divided by its frame's effective
|
||
/// scale, since the offset is in local units; a range in screen px under-scrolls a scaled frame.
|
||
fn scroll_range(model: &Model, h: FrameHandle, axis: Axis) -> f32 {
|
||
let child = match model.arena.frame(h).map(|f| &f.kind_state) {
|
||
Some(KindState::Scroll(s)) => s.child,
|
||
_ => None,
|
||
};
|
||
let Some(child) = child else { return 0.0 };
|
||
let scale_of = |f: FrameHandle| {
|
||
model
|
||
.arena
|
||
.frame(f)
|
||
.map(|x| x.effective_scale)
|
||
.filter(|s| s.abs() >= 1e-6)
|
||
.unwrap_or(1.0)
|
||
};
|
||
let Some(viewport) = model.resolved.get(&h).map(|r| {
|
||
let extent = match axis {
|
||
Axis::Horizontal => r.width(),
|
||
Axis::Vertical => r.height(),
|
||
};
|
||
extent / scale_of(h)
|
||
}) else {
|
||
return 0.0;
|
||
};
|
||
let Some((far, near)) = subtree_span(model, child, axis) else {
|
||
return 0.0;
|
||
};
|
||
((far - near) / scale_of(child) - viewport).max(0.0)
|
||
}
|
||
|
||
/// The span of `f`'s subtree along `axis` in screen px, as `(far, near)`: its own rect unioned with
|
||
/// its shown regions and visible child frames, recursively (`0x786f80`). Hidden parts add no range:
|
||
/// the client guards both lists on visibility.
|
||
fn subtree_span(model: &Model, f: FrameHandle, axis: Axis) -> Option<(f32, f32)> {
|
||
let edges = |r: &crate::layout::Rect| match axis {
|
||
Axis::Horizontal => (r.right, r.left),
|
||
Axis::Vertical => (r.top, r.bottom),
|
||
};
|
||
let frame = model.arena.frame(f)?;
|
||
let mut span: Option<(f32, f32)> = model.resolved.get(&f).map(edges);
|
||
let union = |s: &mut Option<(f32, f32)>, far: f32, near: f32| {
|
||
*s = Some(match *s {
|
||
Some((of, on)) => (of.max(far), on.min(near)),
|
||
None => (far, near),
|
||
});
|
||
};
|
||
for &rh in &frame.regions {
|
||
if model.region_data.get(&rh).is_some_and(|d| d.hidden) {
|
||
continue;
|
||
}
|
||
if let Some(r) = model.region_resolved.get(&rh) {
|
||
let (far, near) = edges(r);
|
||
union(&mut span, far, near);
|
||
}
|
||
}
|
||
for &ch in &frame.children {
|
||
if !model.arena.frame(ch).is_some_and(|c| c.effective_visible) {
|
||
continue;
|
||
}
|
||
if let Some((far, near)) = subtree_span(model, ch, axis) {
|
||
union(&mut span, far, near);
|
||
}
|
||
}
|
||
span
|
||
}
|
||
|
||
/// Every FontString under `f` (its own regions, then its visible children's), in tree order.
|
||
fn subtree_font_strings(model: &Model, f: FrameHandle) -> Vec<crate::widget::RegionHandle> {
|
||
let mut out = Vec::new();
|
||
let Some(frame) = model.arena.frame(f) else {
|
||
return out;
|
||
};
|
||
for &rh in &frame.regions {
|
||
if model
|
||
.arena
|
||
.region(rh)
|
||
.is_some_and(|r| matches!(r.kind, crate::widget::RegionKind::FontString))
|
||
{
|
||
out.push(rh);
|
||
}
|
||
}
|
||
for &ch in &frame.children {
|
||
if model.arena.frame(ch).is_some_and(|c| c.effective_visible) {
|
||
out.extend(subtree_font_strings(model, ch));
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
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,
|
||
}
|
||
};
|
||
let changed = with_scroll(lua, &this, |s| {
|
||
let changed = s.child != new_child;
|
||
s.child = new_child;
|
||
changed
|
||
})?;
|
||
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();
|
||
}
|
||
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) (`0x786db0`): stores the offset unclamped (`[+0x328]`), re-anchors the
|
||
// child (`0x787100`) and fires `OnVerticalScroll(self, offset)`, all only on a change; the
|
||
// reference skips a change under 2⁻²² from the old offset, where this compares bits. 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, f32)| {
|
||
let changed = with_scroll(lua, &this, |s| {
|
||
let changed = s.vertical.to_bits() != px.to_bits();
|
||
s.vertical = px;
|
||
changed
|
||
})?;
|
||
if !changed {
|
||
return Ok(());
|
||
}
|
||
lua.app_data_mut::<Model>().expect("model").touch_layout();
|
||
fire_scroll(lua, &this, "OnVerticalScroll", px)
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"GetVerticalScroll",
|
||
lua.create_function(|lua, this: Table| with_scroll(lua, &this, |s| s.vertical))?,
|
||
)?;
|
||
m.set(
|
||
"GetVerticalScrollRange",
|
||
lua.create_function(|lua, this: Table| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
Ok(scroll_range(&model, h, Axis::Vertical))
|
||
})?,
|
||
)?;
|
||
|
||
// ── The horizontal axis ──
|
||
// SetHorizontalScroll(px) (`0x7912f0` → `0x786d30`) 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, f32)| {
|
||
let changed = with_scroll(lua, &this, |s| {
|
||
let changed = s.horizontal.to_bits() != px.to_bits();
|
||
s.horizontal = px;
|
||
changed
|
||
})?;
|
||
if !changed {
|
||
return Ok(());
|
||
}
|
||
lua.app_data_mut::<Model>().expect("model").touch_layout();
|
||
fire_scroll(lua, &this, "OnHorizontalScroll", px)
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"GetHorizontalScroll",
|
||
lua.create_function(|lua, this: Table| with_scroll(lua, &this, |s| s.horizontal))?,
|
||
)?;
|
||
m.set(
|
||
"GetHorizontalScrollRange",
|
||
lua.create_function(|lua, this: Table| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
Ok(scroll_range(&model, h, Axis::Horizontal))
|
||
})?,
|
||
)?;
|
||
// UpdateScrollChildRect(): recomputes both ranges and fires `OnScrollRangeChanged` at once,
|
||
// where the reference only marks them dirty (`0x791943`) for its next pump, which fires on a
|
||
// change. A script resize reaches the resolved rects only at the app's next resolve, so this
|
||
// resolves first.
|
||
m.set(
|
||
"UpdateScrollChildRect",
|
||
lua.create_function(|lua, this: Table| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
// The reference measures text synchronously and ours lands a frame later, so the
|
||
// child's FontStrings are measured first: `QuestLog_UpdateQuestDetails` calls this
|
||
// right after its SetTexts (`QuestLogFrame.lua:458`).
|
||
let strings: Vec<crate::widget::RegionHandle> = {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
match model.arena.frame(h).map(|f| &f.kind_state) {
|
||
Some(KindState::Scroll(sf)) => sf
|
||
.child
|
||
.map(|c| subtree_font_strings(&model, c))
|
||
.unwrap_or_default(),
|
||
_ => Vec::new(),
|
||
}
|
||
};
|
||
for rh in strings {
|
||
super::measure::ensure_measured(lua, rh);
|
||
}
|
||
let (id, x_range, y_range) = {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
super::UiScript::resolve_layout(&mut model);
|
||
let x_range = scroll_range(&model, h, Axis::Horizontal);
|
||
let y_range = scroll_range(&model, h, Axis::Vertical);
|
||
(model.frame_id(h), x_range, y_range)
|
||
};
|
||
// The resolve can move sizes, and the reference fires `OnSizeChanged` during layout, so
|
||
// those fire before the range notify.
|
||
event::fire_size_changes(lua);
|
||
// `OnScrollRangeChanged(self, xRange, yRange)`: 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(f64::from(x_range)),
|
||
Value::Number(f64::from(y_range)),
|
||
],
|
||
) {
|
||
lua.app_data_mut::<Model>()
|
||
.expect("model app_data")
|
||
.record_script_error(e.to_string());
|
||
}
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
|
||
lua.set_named_registry_value(REG_SCROLLFRAME_METHODS, m)?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Fire `On{Vertical,Horizontal}Scroll(self, offset)`, the reference's script slots `[+0x334]` and
|
||
/// `[+0x32c]`, outside any model borrow.
|
||
fn fire_scroll(lua: &Lua, this: &Table, script: &str, value: f32) -> 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(f64::from(value))])
|
||
{
|
||
lua.app_data_mut::<Model>()
|
||
.expect("model app_data")
|
||
.record_script_error(e.to_string());
|
||
}
|
||
Ok(())
|
||
}
|