Files
benilla-octo/crates/benilla-ui/src/script/messageframe/plain.rs
T
SamandClaude Fable 5 a5c44f367e deleting a good item asks in the reference's words
- deleting a rare-or-better item forks to the reference's typed confirm — "Type \"DELETE\" into the field to confirm": Yes starts disabled, the box takes focus, the button enables only while the field spells DELETE, and a reflexive Enter over a half-typed word is inert; the missing engine piece was one hook, the edit box's on-text-changed; beside it, the loot bind-on-pickup confirm — bind-on-pickup AND uncommon-or-better, two conjuncts and no others, so a white BoP drop is taken with no dialog and a bind-on-equip blue never asks here — with the reference's one-pending-slot latch, and the take split into two verbs the way 1.12 actually has it: the row click stays C-side, and LootSlot is only the confirmation's continuation
- the message frames get their justify quartet, read out of the binary's method tables (with the Button table re-derived as the control that lacks it); Frame:EnableDrawLayer/DisableDrawLayer and Region:GetDrawLayer land — the disabled layers are a five-bit mask on the frame, so re-enabling a layer restores exactly what was showing underneath; and the Skills tab's children take the reference's names, with SKILLS_TO_DISPLAY published — pfUI loops over it and got "'for' limit must be a number"
- the README's roadmap line now names what is genuinely big — battlegrounds and meeting stones, both verified still missing — plus the long tail, because the old line named four features that have since landed and small features go stale on a roadmap within a week

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 20:15:38 +07:00

451 lines
19 KiB
Rust

//! 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()?;
// The justify quartet, shared with the sibling class — see [`super::install_justify`] for the
// method-table bytes that put all four on BOTH tables.
super::install_justify(lua, &m, "MessageFrame")?;
// 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))?,
)?;
// ── the shared font block ───────────────────────────────────────────────────────────────
//
// `Set/GetFontObject · Set/GetFont · Set/GetTextColor · Set/GetShadowColor ·
// Set/GetShadowOffset` are real entries on this class's table, not a courtesy. wow-re's
// registrar carve is explicit about the membership — *"Exposed on: FontString, Font object,
// EditBox, MessageFrame, ScrollingMessageFrame, SimpleHTML. NOT on Button"* — and names this
// class's own shims calling the shared implementations (`GetShadowColor 0x794810`). We shipped the block on two
// of the six and this is the third and fourth; SimpleHTML is a widget kind we do not have at
// all.
//
// Demand is observed, not counted: `BigWigs/Plugins/Messages.lua:212` is
// `self.msgframe:SetFontObject(GameFontNormalLarge)` on a frame it has just given
// `SetInsertMode("TOP")`, and BigWigs dies there every session.
crate::script::font_block::install(
lua,
&m,
|lua, this| {
let h = frame_handle_of(lua, this)?;
super::ensure_font_region(lua, h)
.ok_or_else(|| mlua::Error::runtime("not a MessageFrame"))
},
"MessageFrame",
)?;
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());
}
}