addons come alive: Bagnon draws, hovers and clicks

- Lua 5.0's generic-for and nested long strings are restored in a forked VM — 60 corpus addons stopped dying on one deleted opcode
- a day of demand-driven carving takes the 218-addon corpus from 24 addons surviving a session start to 114, with 144 loading without a single error
- Bagnon draws a real bag and answers real gestures — hover tooltip, pickup, use, drag — because our frames wear the reference's names, and the reference's own ContainerFrame.lua runs off the player's install instead of being transcribed
- the physics broad phase leaves: nothing in the client reads a contact, and it was measured at up to 41% of main-thread samples in real sessions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-11 18:19:08 +07:00
co-authored by Claude Fable 5
parent b5bc569156
commit 251daa14c5
104 changed files with 14356 additions and 2306 deletions
@@ -0,0 +1,317 @@
//! The two message-frame widget classes, and the display machinery they share.
//!
//! 1.12 ships **two** of them, and msgframe-runtime.md is emphatic that they are *siblings, not
//! parent and child* (different ctors, different vtables, different Lua type tags, offsets that do
//! not transfer):
//!
//! - [`scrolling`] — `CSimpleMessageScrollFrame` (ctor `0x787670`), the chat window's class: a true
//! ring of `maxLines`, a scrollback cursor, `AddMessage(text[,r,g,b[,id]])` with alpha forced
//! opaque.
//! - [`plain`] — `CSimpleMessageFrame` (ctor `0x785640`), `UIErrorsFrame`'s class: no ring, no
//! scrollback, `insertMode`, and `AddMessage(text[,r,g,b[,a]])` whose fourth numeric is a real
//! **alpha**.
//!
//! Each gets its **own** Lua method table, consulted by the frame `__index` dispatcher only for its
//! own [`FrameKind`](crate::widget::FrameKind) — so a duck-typing addon (`if f.SetInsertMode then`)
//! sees `nil` everywhere else, exactly as against the client's per-class registrar tables.
//!
//! What *is* shared sits here: both classes display a stack of [`MessageLine`] records, so the
//! wrapped-row measure round-trip, the band emit, and the font/justification read are one code path
//! over [`KindState::message_lines`]. Everything that actually differs stays on the two states.
use crate::layout::Rect;
use crate::order::ZTarget;
use crate::widget::{FrameHandle, InsertMode, KindState, MessageLine};
use super::layout::FramePaint;
use super::{
ExtractedQuad, FontShadow, JustifyH, JustifyV, LineMeasureRequest, Model, Outline, QuadContent,
UiScript,
};
mod plain;
mod scrolling;
pub(super) use plain::REG_MESSAGEFRAME_METHODS;
pub(super) use scrolling::REG_SCROLLINGMESSAGEFRAME_METHODS;
/// Install both classes' method tables (and the chat input globals the scrolling one carries).
pub(super) fn install(lua: &mlua::Lua) -> mlua::Result<()> {
scrolling::install(lua)?;
plain::install(lua)
}
/// The font a message frame's lines draw with — its declared `<FontString>` child, which is exactly
/// how both reference frames spell it (`<FontString inherits="ChatFontNormal" justifyH="LEFT"/>` on
/// a chat frame, `<FontString inherits="ErrorFont" justifyH="CENTER"/>` on `UIErrorsFrame.xml`).
/// Shared by the emit and the measure round-trip so bands, measures and glyphs agree. Missing ⇒ the
/// renderer's default face at the ~14px chat default.
#[derive(Clone)]
pub(super) struct MessageFont {
pub(super) path: Option<String>,
pub(super) height: Option<f32>,
pub(super) shadow: Option<FontShadow>,
pub(super) outline: Outline,
/// The child's `justifyH`. **Load-bearing, not decoration**: it is the only reason the
/// reference's error toasts sit centred in their 512-wide frame while chat lines run flush
/// left, and the class draws its lines through that font instance. A frame with no declared
/// FontString falls back to LEFT (the chat shape), never to the FontString default of CENTER —
/// there is no region here whose own default could apply.
pub(super) justify_h: JustifyH,
}
impl UiScript {
pub(super) fn message_frame_font(model: &Model, fh: FrameHandle) -> MessageFont {
model
.arena
.frame(fh)
.and_then(|frame| {
frame
.regions
.iter()
.find(|&&rh| {
matches!(
model.arena.region(rh).map(|r| r.kind),
Some(crate::widget::RegionKind::FontString)
)
})
.and_then(|rh| model.region_data.get(rh))
.map(|d| MessageFont {
path: d.font_path.clone(),
height: d.font_height,
shadow: d.font_shadow,
outline: d.outline,
justify_h: d.justify_h,
})
})
.unwrap_or(MessageFont {
path: None,
height: None,
shadow: None,
outline: Outline::default(),
justify_h: JustifyH::Left,
})
}
/// How many display **rows** frame `fh`'s resolved rect holds at its line font's pitch — the
/// client's layout-derived `numLinesDisplayed`. `0` when the frame has no resolved rect.
///
/// Both classes need it and neither can compute it from its own state: it is the page size for
/// the scrolling class's `PageUp`/`PageDown` and the whole capacity law for the plain one.
pub(super) fn message_viewport_rows(model: &Model, fh: FrameHandle) -> usize {
let pitch = Self::message_frame_font(model, fh)
.height
.unwrap_or(14.0)
.max(1.0);
model.resolved.get(&fh).map_or(0, |r| {
((r.top - r.bottom) / pitch).floor().max(0.0) as usize
})
}
/// Message lines whose wrapped **row count** needs a host measurement (a new line, or the
/// frame's width/font changed under it) — the message-frame half of the measure round-trip.
/// Call after [`UiScript::resolve`] (widths must be resolved); answer with
/// [`UiScript::set_message_line_rows`] before extract. Cache keys keep this empty on quiet
/// frames.
pub fn message_lines_needing_measure(&mut self) -> Vec<LineMeasureRequest> {
use std::hash::{Hash, Hasher};
let mut model = self.model_mut();
let mut out = Vec::new();
let frames: Vec<(FrameHandle, Rect)> = model
.resolved
.iter()
.filter(|(&fh, _)| {
model
.arena
.frame(fh)
.is_some_and(|f| f.kind_state.message_lines().is_some() && f.effective_visible)
})
.map(|(&fh, &fr)| (fh, fr))
.collect();
for (fh, fr) in frames {
let wrap_width = fr.right - fr.left;
if wrap_width <= 1.0 {
continue; // unresolved/degenerate width — nothing meaningful to wrap against
}
let scale = model
.arena
.frame(fh)
.map(|f| f.effective_scale)
.unwrap_or(1.0);
let font = Self::message_frame_font(&model, fh);
let frame_id = model.frame_id(fh);
let Some(frame) = model.arena.frame(fh) else {
continue;
};
let Some(lines) = frame.kind_state.message_lines() else {
continue;
};
for (index, line) in lines.iter().enumerate() {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
line.text.hash(&mut hasher);
font.path.hash(&mut hasher);
font.height.map(f32::to_bits).hash(&mut hasher);
wrap_width.to_bits().hash(&mut hasher);
(font.outline as u8).hash(&mut hasher);
scale.to_bits().hash(&mut hasher);
let key = hasher.finish();
if line.rows_key == key {
continue;
}
out.push(LineMeasureRequest {
frame: frame_id,
index: index as u32,
font: font.path.clone(),
height: font.height,
wrap_width,
outline: font.outline,
scale,
text: line.text.clone(),
key,
});
}
}
out
}
/// Store host row-count answers for [`LineMeasureRequest`]s (`(frame, index, rows, key)` —
/// frame/index/key verbatim from the request). The key is stored beside the rows, so a line
/// whose width/font changed again since the request simply re-requests next frame.
pub fn set_message_line_rows(&mut self, rows: &[(u32, u32, u16, u64)]) {
let mut model = self.model_mut();
for &(frame_id, index, n, key) in rows {
let Some(&fh) = model.id_to_frame.get(&frame_id) else {
continue;
};
let Some(frame) = model.arena.frame_mut(fh) else {
continue;
};
let Some(lines) = frame.kind_state.message_lines_mut() else {
continue;
};
if let Some(line) = lines.get_mut(index as usize) {
line.rows = n.max(1);
line.rows_key = key;
}
}
}
/// Push one [`QuadContent::Text`] per visible message of either message-frame class (no-op for
/// any other kind). Each message occupies `rows × pitch` (its host-measured wrapped row count —
/// the message-line measure round-trip), so a long line pushes its neighbours by its real
/// height. The pitch is the **font height itself** — the client's own line-step law
/// (`LayoutLines` 0x5cdc20: step = px(size) + spacing, spacing 0), the same law the app's text
/// renderer lays wrapped rows at, so band grid and glyph rows coincide by construction. (The
/// msgframe's own relayout `0x788750/0x788c00` is only partially read in wow-re — if the look
/// pass ever shows the ref spacing chat lines wider than the font height, that residual is the
/// place to pin.)
///
/// **Which edge the stack grows from is the one place the two classes diverge visibly.** A
/// ScrollingMessageFrame always stacks bottom-up with `scroll_offset` picking which message
/// sits on the bottom row. A MessageFrame has no scrollback and instead honours `insertMode`:
/// BOTTOM (the ctor default) is the same bottom-up stack, TOP hangs the newest message off the
/// frame's top edge with older ones stepping *down* — the shape `UIErrorsFrame.xml:4` asks for,
/// and the one every corpus `SetInsertMode` caller asks for. The growth *anchor* per mode is
/// wow-re's own named residual (it lives in the unwalked wrap/layout pass), so this is the
/// INFERRED half: it is the reading that reproduces both shipped usages, and the place to
/// re-pin if a reference A/B ever disagrees.
///
/// A message that only partially fits at the far edge still draws — clipped to the frame rect,
/// so its inner wrapped rows show, never inking outside the frame. A fully-faded scrolling line
/// draws nothing but still holds its rows (the reference's chat never re-packs as old lines
/// fade); a faded MessageFrame line is gone from the state entirely by then, because that class
/// frees its lines instead of holding slots.
pub(super) fn emit_message_lines(
model: &Model,
fh: FrameHandle,
fr: Rect,
z: u64,
paint: FramePaint,
clip: Option<Rect>,
out: &mut Vec<ExtractedQuad>,
) {
let Some(frame) = model.arena.frame(fh) else {
return;
};
// (lines, index of the message on the anchored row, which edge it hangs off).
let (lines, top_index, from_top): (&std::collections::VecDeque<MessageLine>, usize, bool) =
match &frame.kind_state {
KindState::ScrollingMessage(smf) => (
&smf.lines,
smf.lines.len().saturating_sub(1 + smf.scroll_offset),
false,
),
KindState::Message(mf) => (
&mf.lines,
mf.lines.len().saturating_sub(1),
matches!(mf.insert_mode, InsertMode::Top),
),
_ => return,
};
if lines.is_empty() {
return;
}
let font = Self::message_frame_font(model, fh);
// The band grid lives in the frame's RESOLVED (scale-multiplied) rect, so the row pitch
// rides the frame scale exactly like the glyphs it must coincide with (the font height
// itself is frame-local).
let pitch = font.height.unwrap_or(14.0) * paint.scale;
if pitch <= 0.0 || fr.top <= fr.bottom {
return;
}
// Message text never inks outside its frame: a partially-fitting outermost message is
// scissored at the frame edge (intersected with any ScrollFrame ancestor clip).
let line_clip = Some(match clip {
Some(c) => super::clip::intersect_rect(c, fr),
None => fr,
});
let mut used = 0.0f32; // rows already consumed, in px in from the anchored edge
for idx in (0..=top_index).rev() {
if used >= fr.top - fr.bottom {
break; // the next band would start outside the frame — nothing more can show
}
let line = &lines[idx];
let band_h = f32::from(line.rows.max(1)) * pitch;
let (bottom, top) = if from_top {
(fr.top - used - band_h, fr.top - used)
} else {
(fr.bottom + used, fr.bottom + used + band_h)
};
used += band_h;
if line.alpha <= 0.0 {
continue; // fully faded — holds its place, draws nothing
}
out.push(ExtractedQuad {
target: ZTarget::Frame(fh),
z,
rect: Some(Rect::new(bottom, fr.left, top, fr.right)),
alpha: paint.alpha,
clip: line_clip,
content: QuadContent::Text {
text: Some(line.text.clone()),
color: Some([
f32::from(line.color[0]) / 255.0,
f32::from(line.color[1]) / 255.0,
f32::from(line.color[2]) / 255.0,
line.alpha,
]),
justify_h: font.justify_h,
// The band is our own per-message construct (rows × pitch tall, not a
// FontString rect) — keep the block at its top so the band math, not MIDDLE
// centering, owns the vertical rhythm; the band height equals the wrapped
// block height exactly, so Top means "fills the band".
justify_v: JustifyV::Top,
font: font.path.clone(),
// The font object's own drop shadow (ChatFontNormal's `(1,-1)` black) — what
// makes the lines read against the world behind the transparent frame.
shadow: font.shadow,
font_height: font.height,
text_height: None, // message lines are never SetTextHeight'd
outline: font.outline,
alpha_gradient: None,
},
scale: paint.scale,
});
}
}
}
@@ -0,0 +1,422 @@
//! The `MessageFrame` method surface — `CSimpleMessageFrame` (ctor `0x785640`), the class
//! `UIErrorsFrame` is and the one `CreateFrame("MessageFrame")` makes.
//!
//! Grounded in wow-re's byte-verified reads (msgframe-runtime.md's §5 pair, re-confirmed at the
//! registrar tables in `widget-api-batch-benilla.md` Q4). Three facts decide this whole file, and
//! each is one an implementation would otherwise get wrong in a way nothing would catch:
//!
//! 1. **`AddMessage(text [, r, g, b [, a]])` — the fifth argument is ALPHA and there is no sixth.**
//! Three corpus addons pass six (`EasyCopy.lua:12`, `QuestHistory.lua:1678`, `QuestItem.lua:300`,
//! all `UIErrorsFrame:AddMessage(msg, r, g, b, a, holdTime)`), and the real 1.12.1 binding
//! `0x795590` reads five and stops. Honouring that trailing number as a per-message hold time
//! would be **unfaithful, not generous** — the client shows the message for the *frame's*
//! `displayDuration` and nothing else. It is ignored here, deliberately.
//! 2. **This `AddMessage` is not the scrolling class's.** `0x792900` takes an **id** in the same
//! slot and forces alpha `0xFF` (`792add: or edi,0xffffff00`); `0x795590` packs a real
//! `0xAARRGGBB` with a default of 1.0. Two bindings, two tables, no sharing.
//! 3. **`SetInsertMode`/`GetInsertMode` (`0x794ed0`/`0x794ff0`) live on this class only** — the
//! scrolling class has neither, and no `insertMode` XML attribute either.
//!
//! The state (display lines, fade phases, the vertical cap) is
//! [`MessageFrameState`](crate::widget::MessageFrameState) in `crate::widget`; this is the thin Lua
//! binding over it. The band emit and the wrapped-row measure round-trip are shared with the
//! scrolling class and live in [`super`].
use mlua::{Lua, Table, Value};
use crate::script::object::frame_handle_of;
use crate::script::Model;
use crate::widget::{InsertMode, KindState, MessageFrameState};
/// Registry key of the MessageFrame method table (the MAXCSTACK discipline: Lua-side root, named
/// key).
pub(crate) const REG_MESSAGEFRAME_METHODS: &str = "__benilla_plain_messageframe_methods";
/// Run `f` over a frame's MessageFrame state under one short write borrow. Errors if `this` is not
/// a live MessageFrame (unreachable through the kind dispatcher, but the method table is a plain
/// Lua value — a caller could fish it out and misapply it).
fn with_mf<T>(
lua: &Lua,
this: &Table,
f: impl FnOnce(&mut MessageFrameState) -> 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::Message(mf) => Ok(f(mf)),
_ => Err(mlua::Error::runtime("not a MessageFrame")),
}
}
/// A Lua number-ish → f32 (nil/other → 0.0), for the colour args.
fn num_f32(v: &Value) -> f32 {
match v {
Value::Number(n) => *n as f32,
Value::Integer(i) => *i as f32,
_ => 0.0,
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
// AddMessage(text [, r, g, b [, a]]) — binding 0x795590.
//
// r/g/b are required **as a trio** (three presence checks ANDed at 0x7956xx); absent ⇒ hard
// 0xFFFFFFFF white. The fourth numeric is a real alpha, `lua_isnumber`-gated with a default of
// 1.0 (`795752: mov [ebp-0x18],0x3f800000`).
//
// **The signature stops there, and the closure's arity is the enforcement.** The corpus's three
// blocked callers all pass a sixth argument they believe is a hold time; mlua hands us the
// first five and drops the rest, exactly as the real binding does by reading five stack slots.
// Adding a `holdTime` parameter here would make benilla show those messages for a duration no
// 1.12 client ever showed them for.
m.set(
"AddMessage",
lua.create_function(
|lua, (this, text, r, g, b, a): (Table, String, Value, Value, Value, Value)| {
let has_rgb = !matches!(
(&r, &g, &b),
(Value::Nil, _, _) | (_, Value::Nil, _) | (_, _, Value::Nil)
);
let (r, g, b) = if has_rgb {
(num_f32(&r), num_f32(&g), num_f32(&b))
} else {
(1.0, 1.0, 1.0)
};
// Alpha only counts when rgb came with it — the client reads arg 5 off the same
// parse that required the trio, and a lone `AddMessage(text, nil, nil, nil, 0.5)`
// has no colour to apply it to.
let a = match (&a, has_rgb) {
(Value::Number(_) | Value::Integer(_), true) => num_f32(&a),
_ => 1.0,
};
with_mf(lua, &this, |mf| mf.add(text, r, g, b, a))
},
)?,
)?;
m.set(
"Clear",
lua.create_function(|lua, this: Table| with_mf(lua, &this, MessageFrameState::clear))?,
)?;
// SetInsertMode("TOP"|"BOTTOM") / GetInsertMode — 0x794ed0 / 0x794ff0, THIS CLASS ONLY. The
// client compares the string against "BOTTOM" (0x871404) and stores `streq-BOTTOM`, so anything
// that is not literally BOTTOM lands on TOP; the ctor default is 1 = BOTTOM.
m.set(
"SetInsertMode",
lua.create_function(|lua, (this, mode): (Table, Value)| {
let mode = match &mode {
Value::String(s) => {
if s.to_str()?.trim().eq_ignore_ascii_case("BOTTOM") {
InsertMode::Bottom
} else {
InsertMode::Top
}
}
// A non-string argument fails the streq and therefore reads as TOP, like the
// client's own comparison against a string it never got.
_ => InsertMode::Top,
};
with_mf(lua, &this, |mf| mf.insert_mode = mode)
})?,
)?;
m.set(
"GetInsertMode",
lua.create_function(|lua, this: Table| {
with_mf(lua, &this, |mf| match mf.insert_mode {
InsertMode::Top => "TOP",
InsertMode::Bottom => "BOTTOM",
})
})?,
)?;
// The fade trio — the same names and the same field the scrolling class exposes (the XML attr
// is `displayDuration`, the Lua accessors call it `TimeVisible`), over this class's own state.
m.set(
"SetFading",
lua.create_function(|lua, (this, on): (Table, Value)| {
let on = !matches!(on, Value::Nil | Value::Boolean(false));
with_mf(lua, &this, |mf| mf.fading_enabled = on)
})?,
)?;
m.set(
"GetFading",
lua.create_function(|lua, this: Table| with_mf(lua, &this, |mf| mf.fading_enabled))?,
)?;
m.set(
"SetTimeVisible",
lua.create_function(|lua, (this, s): (Table, f32)| {
with_mf(lua, &this, |mf| mf.time_visible = s.max(0.0))
})?,
)?;
m.set(
"GetTimeVisible",
lua.create_function(|lua, this: Table| with_mf(lua, &this, |mf| mf.time_visible))?,
)?;
m.set(
"SetFadeDuration",
lua.create_function(|lua, (this, s): (Table, f32)| {
with_mf(lua, &this, |mf| mf.fade_duration = s.max(0.0))
})?,
)?;
m.set(
"GetFadeDuration",
lua.create_function(|lua, this: Table| with_mf(lua, &this, |mf| mf.fade_duration))?,
)?;
lua.set_named_registry_value(REG_MESSAGEFRAME_METHODS, m)?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
/// How many display lines a named message frame holds. Read off the state rather than through
/// Lua on purpose: `GetNumMessages` is a **ScrollingMessageFrame** binding, and inventing one
/// on this class to make its tests convenient is exactly the drift these tests are pinning
/// against.
fn num_messages(s: &UiScript, name: &str) -> usize {
let m = s.model_ref();
let h = m.arena.lookup(name).expect("named frame");
m.arena
.frame(h)
.and_then(|f| f.kind_state.message_lines())
.map_or(0, |l| l.len())
}
/// The whole point of the arity, pinned: the sixth argument three corpus addons pass is a hold
/// time the real client never had, and it must change **nothing**. The frame's own
/// `displayDuration` is what governs, so the message with a "hold time" of 999 fades on exactly
/// the same tick as the one without.
#[test]
fn the_sixth_addmessage_argument_is_ignored_not_a_holdtime() {
let mut s = UiScript::new().unwrap();
s.run("CreateFrame('MessageFrame', 'MF')").unwrap();
s.run("MF:SetTimeVisible(1)").unwrap();
s.run("MF:SetFadeDuration(0)").unwrap();
// The exact shape of EasyCopy.lua:12 / QuestItem.lua:300 / QuestHistory.lua:1678.
s.run("MF:AddMessage('held', 1.0, 1.0, 1.0, 1.0, 999)")
.unwrap();
s.run("MF:AddMessage('plain', 1.0, 1.0, 1.0, 1.0)").unwrap();
assert_eq!(num_messages(&s, "MF"), 2);
// Past timeVisible with no ramp: both retire together. A honoured holdTime would keep the
// first alive here, which is exactly the divergence this test exists to catch.
s.tick(1.1);
s.tick(0.1);
assert_eq!(
num_messages(&s, "MF"),
0,
"the 6th arg must not extend a message's life"
);
}
/// `SetInsertMode` is a MessageFrame method and **only** a MessageFrame method — a plain Frame
/// and a ScrollingMessageFrame both resolve it to nil, which is what a duck-typing addon reads.
#[test]
fn set_insert_mode_is_messageframe_only() {
let s = UiScript::new().unwrap();
s.run(
"CreateFrame('MessageFrame', 'MF')\n\
CreateFrame('Frame', 'PlainF')\n\
CreateFrame('ScrollingMessageFrame', 'SMF')",
)
.unwrap();
assert_eq!(
s.eval::<String>("return type(MF.SetInsertMode)").unwrap(),
"function"
);
assert_eq!(
s.eval::<String>("return type(PlainF.SetInsertMode)")
.unwrap(),
"nil",
"a plain Frame must not quack like a MessageFrame"
);
assert_eq!(
s.eval::<String>("return type(SMF.SetInsertMode)").unwrap(),
"nil",
"the scrolling class has no SetInsertMode binding at all"
);
// Default BOTTOM (ctor 1), and the round trip.
assert_eq!(
s.eval::<String>("return MF:GetInsertMode()").unwrap(),
"BOTTOM"
);
s.run("MF:SetInsertMode('TOP')").unwrap();
assert_eq!(
s.eval::<String>("return MF:GetInsertMode()").unwrap(),
"TOP"
);
}
/// The two `AddMessage`s are different functions, and the observable tell is the fifth argument:
/// on a MessageFrame it is alpha (0.5 ⇒ a half-transparent line), on a ScrollingMessageFrame it
/// is an id and the line stays opaque.
#[test]
fn messageframe_addmessage_is_not_the_scrolling_ones() {
let s = UiScript::new().unwrap();
s.run(
"CreateFrame('MessageFrame', 'MF')\n\
CreateFrame('ScrollingMessageFrame', 'SMF')",
)
.unwrap();
assert!(
!s.eval::<bool>("return MF.AddMessage == SMF.AddMessage")
.unwrap(),
"one shared AddMessage would give the wrong meaning to the 5th arg on one of them"
);
s.run("MF:AddMessage('half', 1, 1, 1, 0.5)").unwrap();
s.run("SMF:AddMessage('ident', 1, 1, 1, 42)").unwrap();
let alphas = {
let m = s.model_ref();
let pick = |name: &str| {
let h = m.arena.lookup(name).unwrap();
m.arena
.frame(h)
.unwrap()
.kind_state
.message_lines()
.unwrap()[0]
.alpha
};
(pick("MF"), pick("SMF"))
};
assert!(
(alphas.0 - 128.0 / 255.0).abs() < 1e-6,
"MessageFrame's 5th arg is alpha, quantized round-half-up: {alphas:?}"
);
assert_eq!(
alphas.1, 1.0,
"ScrollingMessageFrame's 5th arg is an id; its alpha is forced opaque"
);
}
/// `insertMode` decides which edge the stack grows from: TOP hangs the newest message off the
/// frame's top, BOTTOM (the default) stacks up from its bottom. Same three messages, mirrored.
#[test]
fn insert_mode_picks_the_growth_edge() {
let bands = |mode: &str| {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
s.run(
"local f = CreateFrame('MessageFrame', 'MF')\n\
f:SetPoint('BOTTOMLEFT', 0, 100)\n\
f:SetWidth(400)\n\
f:SetHeight(48)",
)
.unwrap();
s.run(&format!("MF:SetInsertMode('{mode}')")).unwrap();
for t in ["one", "two", "three"] {
s.run(&format!("MF:AddMessage('{t}', 1, 1, 1)")).unwrap();
}
s.resolve();
let mut v: Vec<(String, f32)> = s
.extract()
.iter()
.filter_map(|q| match (&q.content, q.rect) {
(crate::script::QuadContent::Text { text: Some(t), .. }, Some(r)) => {
Some((t.clone(), r.bottom))
}
_ => None,
})
.collect();
v.sort_by(|a, b| b.1.total_cmp(&a.1)); // top row first
v
};
// Default pitch 14, frame [100, 148).
let top = bands("TOP");
assert_eq!(
top.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(),
["three", "two", "one"],
"TOP: newest on the frame's top row, older stepping down"
);
assert!((top[0].1 - 134.0).abs() < 0.01, "newest hangs off fr.top");
let bottom = bands("BOTTOM");
assert_eq!(
bottom.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(),
["one", "two", "three"],
"BOTTOM: newest on the frame's bottom row, older stepping up"
);
assert!(
(bottom[2].1 - 100.0).abs() < 0.01,
"newest sits on fr.bottom"
);
}
/// The class has no `maxLines` — its cap is what fits vertically, applied at the tick. A frame
/// 3 rows tall holds 3 messages however many arrive.
#[test]
fn the_cap_is_what_fits_vertically() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
s.run(
"local f = CreateFrame('MessageFrame', 'MF')\n\
f:SetPoint('BOTTOMLEFT', 0, 0)\n\
f:SetWidth(400)\n\
f:SetHeight(42)\n\
f:SetFading(false)",
)
.unwrap();
for n in 0..9 {
s.run(&format!("MF:AddMessage('m{n}', 1, 1, 1)")).unwrap();
}
s.resolve();
s.tick(0.1);
assert_eq!(num_messages(&s, "MF"), 3, "42px / 14px pitch = 3 rows");
// ...and it is the OLDEST that went.
s.resolve();
let texts: Vec<String> = s
.extract()
.iter()
.filter_map(|q| match &q.content {
crate::script::QuadContent::Text { text: Some(t), .. } => Some(t.clone()),
_ => None,
})
.collect();
assert!(texts.contains(&"m8".to_string()) && !texts.contains(&"m0".to_string()));
}
/// A faded line on this class is **freed**, not left blank holding its rows — the difference
/// from the scrolling class, whose ring slots persist. `Clear` is the immediate form.
#[test]
fn a_finished_line_is_retired_and_clear_empties_now() {
let mut s = UiScript::new().unwrap();
s.run("CreateFrame('MessageFrame', 'MF')").unwrap();
s.run("MF:SetTimeVisible(0.5)").unwrap();
s.run("MF:SetFadeDuration(2)").unwrap();
s.run("MF:AddMessage('x', 1, 1, 1)").unwrap();
s.tick(0.6); // spends phase 1
s.tick(1.0); // mid-ramp: still alive, alpha down
assert_eq!(num_messages(&s, "MF"), 1);
s.tick(1.2); // ramp done → retired
assert_eq!(num_messages(&s, "MF"), 0);
s.run("MF:AddMessage('a', 1, 1, 1)").unwrap();
s.run("MF:AddMessage('b', 1, 1, 1)").unwrap();
assert_eq!(num_messages(&s, "MF"), 2);
s.run("MF:Clear()").unwrap();
assert_eq!(num_messages(&s, "MF"), 0);
}
/// The ctor defaults, straight off msgframe-runtime.md's shared-defaults section, plus the
/// setters' round trip.
#[test]
fn ctor_defaults_and_the_fade_accessors() {
let s = UiScript::new().unwrap();
s.run("CreateFrame('MessageFrame', 'MF')").unwrap();
assert!(s.eval::<bool>("return MF:GetFading()").unwrap());
assert_eq!(s.eval::<f32>("return MF:GetTimeVisible()").unwrap(), 10.0);
assert_eq!(s.eval::<f32>("return MF:GetFadeDuration()").unwrap(), 3.0);
s.run("MF:SetTimeVisible(5) MF:SetFadeDuration(1) MF:SetFading(false)")
.unwrap();
assert_eq!(s.eval::<f32>("return MF:GetTimeVisible()").unwrap(), 5.0);
assert_eq!(s.eval::<f32>("return MF:GetFadeDuration()").unwrap(), 1.0);
assert!(!s.eval::<bool>("return MF:GetFading()").unwrap());
}
}
@@ -0,0 +1,674 @@
//! The `ScrollingMessageFrame` method surface — the chat window's per-kind widget behavior
//! (`CSimpleMessageScrollFrame`, ctor `0x787670`).
//!
//! Grounded in wow-re's byte-verified runtime model (msgframe-runtime.md, §5 pair): a **true ring**
//! of `maxLines` (drop-oldest, `SetMaxLines` destructive), `AddMessage(text[,r,g,b[,id]])` with the
//! `trunc(x*255+0.5)` color quantization + forced-opaque alpha, per-line fade snapshots ticked only
//! while **AtBottom** (scrolled up freezes every alpha), and 1-slot scrollback. The heavy lifting
//! (the ring, the fade phases, the scroll clamps) lives on [`ScrollingMessageState`]
//! (`crate::widget`), unit-tested there; this module is the thin Lua binding over it, plus the
//! host-facing [`UiScript::add_chat_message`]/fade advance the app drives.
//!
//! The methods live in their own registry table, consulted by the frame `__index` dispatcher only
//! for ScrollingMessageFrame frames — so duck-typing addons (`if frame.AddMessage then …`) see `nil`
//! on every other kind, exactly as against the client's per-class method sets. **Its sibling class
//! `MessageFrame` has its own table next door** ([`super::plain`]) carrying a *different*
//! `AddMessage`: merging the two would hand a chat frame a `SetInsertMode` it does not have, and a
//! MessageFrame an id argument where its alpha belongs.
use mlua::{Lua, Table, Value};
use crate::script::object::frame_handle_of;
use crate::script::{Model, UiScript};
use crate::widget::{KindState, ScrollingMessageState};
/// Registry key of the ScrollingMessageFrame method table (the MAXCSTACK discipline: Lua-side root,
/// named key).
pub(crate) const REG_SCROLLINGMESSAGEFRAME_METHODS: &str =
"__benilla_scrollingmessageframe_methods";
/// Run `f` over a frame's message-frame state under one short write borrow. Errors if `this` is not a
/// live ScrollingMessageFrame (unreachable through the kind dispatcher, but the method table is a
/// plain Lua value — a caller could fish it out and misapply it).
fn with_smf<T>(
lua: &Lua,
this: &Table,
f: impl FnOnce(&mut ScrollingMessageState) -> 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::ScrollingMessage(smf) => Ok(f(smf)),
_ => Err(mlua::Error::runtime("not a ScrollingMessageFrame")),
}
}
/// A Lua number-ish → f32 (nil/other → 0.0), for the color args.
fn num_f32(v: &Value) -> f32 {
match v {
Value::Number(n) => *n as f32,
Value::Integer(i) => *i as f32,
_ => 0.0,
}
}
/// `PageUp`/`PageDown`: page by the currently-displayed message count minus one (one message of
/// overlap — msgframe-runtime.md's page law). The viewport's row budget comes from the frame's last
/// resolved rect at the ring font's pitch (the font-height line-step law, the emit pass's basis);
/// an unresolved frame pages by one message.
fn page(lua: &Lua, this: &Table, up: bool) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let viewport_rows = UiScript::message_viewport_rows(&model, h);
let frame = model
.arena
.frame_mut(h)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
match &mut frame.kind_state {
KindState::ScrollingMessage(smf) => {
if up {
smf.page_up(viewport_rows);
} else {
smf.page_down(viewport_rows);
}
Ok(())
}
_ => Err(mlua::Error::runtime("not a ScrollingMessageFrame")),
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
// AddMessage(text [, r, g, b [, id]]) — the id (5th arg) is accepted and ignored (v1 has no
// per-line UpdateColorByID). rgb absent ⇒ white; the state quantizes round-half-up and forces
// the line opaque, then the fade drives its alpha.
m.set(
"AddMessage",
lua.create_function(
|lua, (this, text, r, g, b): (Table, String, Value, Value, Value)| {
let has_rgb = !matches!(
(&r, &g, &b),
(Value::Nil, _, _) | (_, Value::Nil, _) | (_, _, Value::Nil)
);
let (r, g, b) = if has_rgb {
(num_f32(&r), num_f32(&g), num_f32(&b))
} else {
(1.0, 1.0, 1.0)
};
with_smf(lua, &this, |smf| smf.add(text, r, g, b))
},
)?,
)?;
m.set(
"Clear",
lua.create_function(|lua, this: Table| with_smf(lua, &this, ScrollingMessageState::clear))?,
)?;
m.set(
"ScrollUp",
lua.create_function(|lua, this: Table| {
with_smf(lua, &this, ScrollingMessageState::scroll_up)
})?,
)?;
m.set(
"ScrollDown",
lua.create_function(|lua, this: Table| {
with_smf(lua, &this, ScrollingMessageState::scroll_down)
})?,
)?;
m.set(
"ScrollToTop",
lua.create_function(|lua, this: Table| {
with_smf(lua, &this, ScrollingMessageState::scroll_to_top)
})?,
)?;
m.set(
"ScrollToBottom",
lua.create_function(|lua, this: Table| {
with_smf(lua, &this, ScrollingMessageState::scroll_to_bottom)
})?,
)?;
m.set(
"PageUp",
lua.create_function(|lua, this: Table| page(lua, &this, true))?,
)?;
m.set(
"PageDown",
lua.create_function(|lua, this: Table| page(lua, &this, false))?,
)?;
m.set(
"AtTop",
lua.create_function(|lua, this: Table| with_smf(lua, &this, |smf| smf.at_top()))?,
)?;
m.set(
"AtBottom",
lua.create_function(|lua, this: Table| with_smf(lua, &this, |smf| smf.at_bottom()))?,
)?;
// SetMaxLines is destructive (msgframe-runtime.md) — the state handles the wipe.
m.set(
"SetMaxLines",
lua.create_function(|lua, (this, n): (Table, i64)| {
with_smf(lua, &this, |smf| smf.set_max_lines(n.max(1) as usize))
})?,
)?;
m.set(
"GetMaxLines",
lua.create_function(|lua, this: Table| with_smf(lua, &this, |smf| smf.max_lines as i64))?,
)?;
m.set(
"GetNumMessages",
lua.create_function(|lua, this: Table| with_smf(lua, &this, |smf| smf.lines.len() as i64))?,
)?;
m.set(
"SetFading",
lua.create_function(|lua, (this, on): (Table, Value)| {
let on = !matches!(on, Value::Nil | Value::Boolean(false));
with_smf(lua, &this, |smf| smf.fading_enabled = on)
})?,
)?;
m.set(
"GetFading",
lua.create_function(|lua, this: Table| with_smf(lua, &this, |smf| smf.fading_enabled))?,
)?;
// The XML attr is `displayDuration`; the Lua accessors call the same field `TimeVisible`
// (msgframe-runtime.md).
m.set(
"SetTimeVisible",
lua.create_function(|lua, (this, s): (Table, f32)| {
with_smf(lua, &this, |smf| smf.time_visible = s.max(0.0))
})?,
)?;
m.set(
"GetTimeVisible",
lua.create_function(|lua, this: Table| with_smf(lua, &this, |smf| smf.time_visible))?,
)?;
m.set(
"SetFadeDuration",
lua.create_function(|lua, (this, s): (Table, f32)| {
with_smf(lua, &this, |smf| smf.fade_duration = s.max(0.0))
})?,
)?;
m.set(
"GetFadeDuration",
lua.create_function(|lua, this: Table| with_smf(lua, &this, |smf| smf.fade_duration))?,
)?;
lua.set_named_registry_value(REG_SCROLLINGMESSAGEFRAME_METHODS, m)?;
// SubmitChatInput(text) — the chat input EditBox's OnEnterPressed hands the typed line here; it
// queues into the model for the app to parse into a chat command (the outbound Lua→app seam, the
// twin of loot's LootSlot). Empty/whitespace lines are queued as-is; the app treats them as a
// cancel. Not an EditBox method — a bare global, called with the line the handler read.
// BenillaChatTabPressed() — the chat edit box's OnTabPressed queues a flag the app's
// whisper-cycle system drains (UiScript::take_chat_tab), the SubmitChatInput pattern.
lua.globals().set(
"BenillaChatTabPressed",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.chat_tab = true;
Ok(())
})?,
)?;
lua.globals().set(
"SubmitChatInput",
lua.create_function(|lua, text: Option<String>| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.chat_input.push(text.unwrap_or_default());
Ok(())
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
use crate::widget::ScrollingMessageState;
fn white(text: &str) -> (String, f32, f32, f32) {
(text.to_string(), 1.0, 1.0, 1.0)
}
#[test]
fn ring_drops_oldest_past_max_lines() {
let mut s = ScrollingMessageState {
max_lines: 3,
..Default::default()
};
for n in 0..5 {
let (t, r, g, b) = white(&format!("line {n}"));
s.add(t, r, g, b);
}
// Only the last 3 survive, oldest→newest.
assert_eq!(s.lines.len(), 3);
let texts: Vec<&str> = s.lines.iter().map(|l| l.text.as_str()).collect();
assert_eq!(texts, ["line 2", "line 3", "line 4"]);
}
#[test]
fn set_max_lines_is_destructive() {
let mut s = ScrollingMessageState::default();
for n in 0..4 {
s.add(format!("m{n}"), 1.0, 1.0, 1.0);
}
assert_eq!(s.lines.len(), 4);
s.set_max_lines(128);
assert!(s.lines.is_empty(), "SetMaxLines wipes the ring");
assert_eq!(s.max_lines, 128);
assert!(s.at_bottom());
}
#[test]
fn color_is_quantized_round_half_up() {
let mut s = ScrollingMessageState::default();
// 0.5*255 = 127.5 → +0.5 → 128.0 → trunc 128 (round-half-up); 1.0 → 255; clamp past 1.0.
s.add("q".into(), 0.5, 1.0, 2.0);
assert_eq!(s.lines[0].color, [128, 255, 255]);
// FF8040 (EMOTE) from 1.0, 0.5019.., 0.2509.. → 255,128,64 (spot the table's byte round-trip).
s.add("e".into(), 1.0, 128.0 / 255.0, 64.0 / 255.0);
assert_eq!(s.lines[1].color, [255, 128, 64]);
}
#[test]
fn fade_holds_then_ramps_then_retires() {
let mut s = ScrollingMessageState {
time_visible: 1.0,
fade_duration: 2.0,
..Default::default()
};
s.add("x".into(), 1.0, 1.0, 1.0);
// Phase 1: full alpha while timeVisible remains.
s.tick(0.5);
assert_eq!(s.lines[0].alpha, 1.0);
// Cross into phase 2: 0.5 more spends the last of timeVisible; next ticks ramp fadeDuration.
s.tick(0.5);
assert_eq!(s.lines[0].alpha, 1.0, "phase 1 just expired, ramp not yet");
s.tick(1.0); // fade_left 2.0 → 1.0 → alpha = trunc(1.0/2.0*255)/255 = 127/255
assert!((s.lines[0].alpha - 127.0 / 255.0).abs() < 1e-6);
s.tick(1.0); // fade_left → 0 → retired
assert_eq!(s.lines[0].alpha, 0.0);
}
#[test]
fn fade_duration_zero_vanishes_instantly() {
let mut s = ScrollingMessageState {
time_visible: 0.5,
fade_duration: 0.0,
..Default::default()
};
s.add("x".into(), 1.0, 1.0, 1.0);
s.tick(0.6); // spends timeVisible (per-tick phase check: still full this tick)
assert_eq!(s.lines[0].alpha, 1.0);
s.tick(0.1); // now past phase 1, no ramp ⇒ straight to 0
assert_eq!(s.lines[0].alpha, 0.0);
}
#[test]
fn scrolled_up_freezes_the_fade() {
let mut s = ScrollingMessageState {
max_lines: 8,
time_visible: 0.0, // straight into phase 2
fade_duration: 4.0,
..Default::default()
};
s.add("a".into(), 1.0, 1.0, 1.0);
s.add("b".into(), 1.0, 1.0, 1.0);
// Scroll up one line — no longer AtBottom, so ticks must not advance any alpha.
s.scroll_up();
assert!(!s.at_bottom());
s.tick(2.0);
assert_eq!(s.lines[0].alpha, 1.0, "frozen while scrolled up");
assert_eq!(s.lines[1].alpha, 1.0);
// Back to bottom → the fade resumes.
s.scroll_to_bottom();
s.tick(2.0);
assert!(s.lines[1].alpha < 1.0);
}
#[test]
fn scroll_clamps_at_both_ends() {
let mut s = ScrollingMessageState {
max_lines: 8,
..Default::default()
};
for n in 0..3 {
s.add(format!("l{n}"), 1.0, 1.0, 1.0);
}
assert!(s.at_bottom());
s.scroll_down(); // already at bottom — no-op
assert!(s.at_bottom());
// 3 lines → max_scroll = 2; scroll past it clamps.
for _ in 0..10 {
s.scroll_up();
}
assert!(s.at_top());
assert_eq!(s.scroll_offset, 2);
s.scroll_to_bottom();
assert!(s.at_bottom());
}
#[test]
fn scrolled_view_stays_anchored_as_the_ring_grows() {
let mut s = ScrollingMessageState {
max_lines: 8,
..Default::default()
};
for n in 0..4 {
s.add(format!("l{n}"), 1.0, 1.0, 1.0);
}
s.scroll_up(); // viewing one line back (bottom row = l2)
let off = s.scroll_offset;
s.add("l4".into(), 1.0, 1.0, 1.0); // a new line arrives at the bottom
assert_eq!(
s.scroll_offset,
off + 1,
"offset tracks so the same lines stay in view"
);
}
#[test]
fn displayed_count_and_paging_respect_wrapped_rows() {
let mut s = ScrollingMessageState {
max_lines: 8,
..Default::default()
};
for n in 0..3 {
s.add(format!("l{n}"), 1.0, 1.0, 1.0);
}
// Middle line wraps into 3 rows.
s.lines[1].rows = 3;
// Viewport of 4 rows from the bottom: newest (1 row) + middle (3 rows) fill it exactly —
// the oldest is off the top. A partially-fitting message counts (it draws, clipped).
assert_eq!(s.displayed_count(4), 2);
assert_eq!(s.displayed_count(3), 2, "partial middle line still counts");
assert_eq!(s.displayed_count(1), 1);
// Page = displayed − 1 (one message of overlap), never 0.
s.page_up(4);
assert_eq!(s.scroll_offset, 1);
s.page_up(1); // displayed 1 → page clamps to 1 message
assert_eq!(s.scroll_offset, 2);
s.page_down(4);
assert_eq!(s.scroll_offset, 1);
s.page_down(4);
assert!(s.at_bottom());
}
#[test]
fn emit_stacks_wrapped_rows_and_clips_partial_top() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
s.run(
"local f = CreateFrame('ScrollingMessageFrame', 'CF')\n\
f:SetPoint('BOTTOMLEFT', 32, 90)\n\
f:SetWidth(430)\n\
f:SetHeight(34)",
)
.unwrap();
s.run("CF:AddMessage('old', 1, 1, 1)").unwrap();
s.run("CF:AddMessage('wrapped', 1, 1, 1)").unwrap();
s.run("CF:AddMessage('new', 1, 1, 1)").unwrap();
s.resolve();
// The measure round-trip: every fresh line requests its row count at the resolved width.
let reqs = s.message_lines_needing_measure();
assert_eq!(reqs.len(), 3);
assert!(reqs.iter().all(|r| (r.wrap_width - 430.0).abs() < 0.5));
// Host answers: the middle line wraps to 2 rows.
let answers: Vec<(u32, u32, u16, u64)> = reqs
.iter()
.map(|r| (r.frame, r.index, if r.index == 1 { 2 } else { 1 }, r.key))
.collect();
s.set_message_line_rows(&answers);
assert!(
s.message_lines_needing_measure().is_empty(),
"answered keys satisfy the cache"
);
// Default font (no FontString child) ⇒ pitch 14. Frame [90, 124): 'new' band [90,104),
// 'wrapped' 2-row band [104,132) — starts inside, overflows the top, still emitted
// (clipped); 'old' would start at 132 ≥ 124 — not emitted.
let quads = s.extract();
let texts: Vec<(String, f32, f32)> = quads
.iter()
.filter_map(|q| match (&q.content, q.rect) {
(crate::script::QuadContent::Text { text: Some(t), .. }, Some(r)) => {
Some((t.clone(), r.bottom, r.top))
}
_ => None,
})
.collect();
assert_eq!(
texts.len(),
2,
"partial top line draws, off-top line doesn't"
);
assert_eq!(texts[0].0, "new");
assert!((texts[0].1 - 90.0).abs() < 0.01 && (texts[0].2 - 104.0).abs() < 0.01);
assert_eq!(texts[1].0, "wrapped");
assert!((texts[1].1 - 104.0).abs() < 0.01 && (texts[1].2 - 132.0).abs() < 0.01);
// Both clip to the frame rect, so the overflow never inks outside it.
let clip = quads
.iter()
.find_map(|q| match &q.content {
crate::script::QuadContent::Text { text: Some(t), .. } if t == "wrapped" => q.clip,
_ => None,
})
.expect("wrapped line carries a clip");
assert!((clip.top - 124.0).abs() < 0.01 && (clip.bottom - 90.0).abs() < 0.01);
}
/// The ring lines are the frame's ARTWORK content, not its bare draw slot: a BACKGROUND
/// texture of the same frame draws BEHIND them, an OVERLAY one in front. The chat window's
/// hover box is that background texture — at the bare slot it painted over the messages and
/// dimmed them (director, 2026-07-26).
#[test]
fn ring_lines_draw_above_the_frames_background_and_below_its_overlay() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
s.run(
"local f = CreateFrame('ScrollingMessageFrame', 'CF')\n\
f:SetPoint('BOTTOMLEFT', 32, 90)\n\
f:SetWidth(430)\n\
f:SetHeight(60)\n\
local bg = f:CreateTexture('CFBg', 'BACKGROUND')\n\
bg:SetTexture('Interface\\\\Bg')\n\
bg:SetAllPoints(f)\n\
local over = f:CreateTexture('CFOver', 'OVERLAY')\n\
over:SetTexture('Interface\\\\Over')\n\
over:SetAllPoints(f)\n\
f:AddMessage('hello', 1, 1, 1)",
)
.unwrap();
s.resolve();
let quads = s.extract();
let z_of = |path: &str| {
quads
.iter()
.find_map(|q| match &q.content {
crate::script::QuadContent::Texture { path: Some(p), .. } if p == path => {
Some(q.z)
}
_ => None,
})
.expect("texture drew")
};
let line_z = quads
.iter()
.find_map(|q| match &q.content {
crate::script::QuadContent::Text { text: Some(t), .. } if t == "hello" => Some(q.z),
_ => None,
})
.expect("the ring line drew");
let slot_z = quads
.iter()
.find(|q| matches!(q.content, crate::script::QuadContent::Frame))
.map(|q| q.z)
.expect("the frame slot drew");
let (bg_z, over_z) = (z_of("Interface\\Bg"), z_of("Interface\\Over"));
assert!(slot_z < bg_z, "the frame's own slot still leads");
assert!(
bg_z < line_z,
"a BACKGROUND texture draws behind the messages"
);
assert!(line_z < over_z, "an OVERLAY texture draws in front of them");
}
#[test]
fn width_change_invalidates_row_measures() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
s.run(
"local f = CreateFrame('ScrollingMessageFrame', 'CF')\n\
f:SetPoint('BOTTOMLEFT', 0, 0)\n\
f:SetWidth(430)\n\
f:SetHeight(120)",
)
.unwrap();
s.run("CF:AddMessage('x', 1, 1, 1)").unwrap();
s.resolve();
let reqs = s.message_lines_needing_measure();
assert_eq!(reqs.len(), 1);
let answers: Vec<(u32, u32, u16, u64)> =
reqs.iter().map(|r| (r.frame, r.index, 1, r.key)).collect();
s.set_message_line_rows(&answers);
assert!(s.message_lines_needing_measure().is_empty());
s.run("CF:SetWidth(300)").unwrap();
s.resolve();
assert_eq!(
s.message_lines_needing_measure().len(),
1,
"a width change re-requests the row count"
);
}
#[test]
fn faded_line_holds_its_rows_without_drawing() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
s.run(
"local f = CreateFrame('ScrollingMessageFrame', 'CF')\n\
f:SetPoint('BOTTOMLEFT', 0, 0)\n\
f:SetWidth(430)\n\
f:SetHeight(120)\n\
f:SetTimeVisible(0)\n\
f:SetFadeDuration(0)",
)
.unwrap();
s.run("CF:AddMessage('doomed', 1, 1, 1)").unwrap();
// Two ticks retire 'doomed' (phase-1 spend, then the no-ramp snap), then fresh lines land.
s.tick(0.1);
s.tick(0.1);
s.run("CF:SetTimeVisible(120)").unwrap();
s.run("CF:AddMessage('a', 1, 1, 1)").unwrap();
s.run("CF:AddMessage('b', 1, 1, 1)").unwrap();
s.resolve();
let quads = s.extract();
let texts: Vec<(String, f32)> = quads
.iter()
.filter_map(|q| match (&q.content, q.rect) {
(crate::script::QuadContent::Text { text: Some(t), .. }, Some(r)) => {
Some((t.clone(), r.bottom))
}
_ => None,
})
.collect();
// 'doomed' draws nothing but its band (the oldest, topmost) still holds its place: the two
// live lines sit at the two bottom bands, nothing re-packs into the faded slot.
assert_eq!(texts.len(), 2);
assert_eq!(texts[0].0, "b");
assert!((texts[0].1 - 0.0).abs() < 0.01);
assert_eq!(texts[1].0, "a");
assert!((texts[1].1 - 14.0).abs() < 0.01);
}
#[test]
fn hyperlink_span_release_fires_on_hyperlink_click() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
s.run(
"local f = CreateFrame('ScrollingMessageFrame', 'CF')\n\
f:SetPoint('BOTTOMLEFT', 0, 0)\n\
f:SetWidth(430)\n\
f:SetHeight(120)\n\
f:SetScript('OnHyperlinkClick', function(self, link, text, button)\n\
CLICKED = link .. '#' .. text .. '#' .. button\n\
end)",
)
.unwrap();
s.run("CF:AddMessage('x', 1, 1, 1)").unwrap();
s.resolve();
// Find the frame handle the extract loop would hand the app (the message line's target).
let fh = s
.extract()
.iter()
.find_map(|q| match (&q.content, q.target) {
(
crate::script::QuadContent::Text { text: Some(t), .. },
crate::order::ZTarget::Frame(fh),
) if t == "x" => Some(fh),
_ => None,
})
.expect("the ring line extracts");
// The app feeds a span (y-up rect) covering [10..60]x[20..34]; a release inside fires the
// handler with (link, markup, button); one outside does not.
s.set_link_spans(vec![(
fh,
crate::layout::Rect::new(20.0, 10.0, 34.0, 60.0),
"item:7073".to_string(),
"|Hitem:7073|h[Broken Fang]|h".to_string(),
)]);
s.mouse_button(30.0, 25.0, "LeftButton", true);
s.mouse_button(30.0, 25.0, "LeftButton", false);
assert_eq!(
s.eval::<String>("return CLICKED or ''").unwrap(),
"item:7073#|Hitem:7073|h[Broken Fang]|h#LeftButton"
);
// Outside the span: no new fire.
s.run("CLICKED = nil").unwrap();
s.mouse_button(200.0, 100.0, "LeftButton", true);
s.mouse_button(200.0, 100.0, "LeftButton", false);
assert_eq!(
s.eval::<String>("return CLICKED or 'none'").unwrap(),
"none"
);
}
#[test]
fn add_chat_message_targets_the_named_scroll_frame() {
let mut s = UiScript::new().unwrap();
s.run("CreateFrame('ScrollingMessageFrame', 'ChatTest')")
.unwrap();
assert!(s.add_chat_message("ChatTest", "hello", 1.0, 1.0, 1.0));
assert_eq!(
s.eval::<i64>("return ChatTest:GetNumMessages()").unwrap(),
1
);
// A missing frame or a non-message frame is a no-op.
assert!(!s.add_chat_message("Nope", "x", 1.0, 1.0, 1.0));
s.run("CreateFrame('Frame', 'PlainFrame')").unwrap();
assert!(!s.add_chat_message("PlainFrame", "x", 1.0, 1.0, 1.0));
}
#[test]
fn lua_addmessage_and_scroll_surface() {
let s = UiScript::new().unwrap();
s.run("CreateFrame('ScrollingMessageFrame', 'CF')").unwrap();
s.run("CF:SetMaxLines(128)").unwrap();
assert_eq!(s.eval::<i64>("return CF:GetMaxLines()").unwrap(), 128);
s.run("CF:AddMessage('one', 1, 1, 1)").unwrap();
s.run("CF:AddMessage('two')").unwrap(); // rgb-less form defaults white
assert_eq!(s.eval::<i64>("return CF:GetNumMessages()").unwrap(), 2);
assert!(s.eval::<bool>("return CF:AtBottom()").unwrap());
s.run("CF:ScrollUp()").unwrap();
assert!(!s.eval::<bool>("return CF:AtBottom()").unwrap());
s.run("CF:ScrollToBottom()").unwrap();
assert!(s.eval::<bool>("return CF:AtBottom()").unwrap());
s.run("CF:Clear()").unwrap();
assert_eq!(s.eval::<i64>("return CF:GetNumMessages()").unwrap(), 0);
}
}