Files
benilla-octo/crates/benilla-ui/src/script/chat_window.rs
T
SamandClaude Fable 5 9662553528 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>
2026-08-11 18:19:08 +07:00

296 lines
15 KiB
Rust

//! The chat **window** surface: `GetChatWindowInfo` (the engine's per-window settings getter) and
//! `ChatFrame_OpenChat` (the FrameXML verb that opens the edit box prefilled).
//!
//! The windows themselves are frames, declared in the app's shipped `ChatFrame.xml` —
//! `ChatFrame1`..`ChatFrame7`, of which 1 and 2 are docked and 3..7 ship hidden and undocked, the
//! reference's own arrangement. This module is the *settings* half: what an addon learns when it
//! asks about window `i` without touching the frame.
//!
//! ## `GetChatWindowInfo(id)` → `name, fontSize, r, g, b, a, shown, locked, docked`
//!
//! A registered Lua binding in the real client (`0x4a0ba0`, wow-re `system/ui/ledger.tsv`), reading
//! the per-window struct array at `0xb4fe50` — stride 0x98, **10 windows**, colour bytes at
//! +0xd8..+0xdb renormalised by the f32 1/255 (`system/ui/scratch/chat-color-table.md`). Those
//! structs are loaded from the character's `chat-cache.txt`, and a real stock one is on disk
//! (`wow-5875-re/WoW/WTF/Account/ONE/VMaNGOS/Onepaladin/chat-cache.txt`) — every value below is
//! quoted from it:
//!
//! ```text
//! WINDOW 1 SIZE 0 COLOR 0 0 0 0 LOCKED 1 DOCKED 1 SHOWN 1
//! WINDOW 2 SIZE 0 COLOR 0 0 0 0 LOCKED 1 DOCKED 2 SHOWN 0
//! WINDOW 3…10 SIZE 0 COLOR 0 0 0 0 LOCKED 1 DOCKED 0 SHOWN 0
//! ```
//!
//! **Three traps live in that tuple, and all three are load-bearing:**
//!
//! 1. **`name` is the empty string, not "General".** Nothing writes a window name until the user
//! renames a tab, so a stock client answers `""` for every window including the first two. The
//! familiar labels are a *FrameXML fallback*, not data: `FCF_SetWindowName` (FloatingChatFrame.lua
//! l.680-684) reads `if ( not name or name == "" )` and substitutes `GENERAL` / `COMBAT_LOG` /
//! `format(CHAT_NAME_TEMPLATE, id)`. Our own tab labels are that fallback, hardcoded. An addon
//! proves the contract from the other side: `Enchantrix/EnxConfig.lua:113` does
//! `local name = GetChatWindowInfo(i); if ( name == "" ) then if (i == 1) then name =
//! _ENCH('TextGeneral') …` — it would not have been written if the getter answered "General".
//! Answering "General" here would look friendlier and would be a divergence.
//! 2. **`shown`/`locked`/`docked` are numbers or `nil` — never `0`.** FrameXML branches on them
//! bare (`if ( shown ) then chatFrame:Show()`, `if ( docked ) then FCF_DockFrame(…)` —
//! FloatingChatFrame.lua l.59/69), and `0` is TRUE in Lua. A getter that returned the cache's
//! literal `0` would show every hidden window.
//! 3. **`docked` is a dock POSITION, not a flag.** Window 1 answers `1`, window 2 answers `2` —
//! the order tabs sit in the dock, which is why `FCF_DockFrame(frame, docked)` takes it as an
//! index. Reading it as a boolean happens to work; reading it as "is docked" and writing back
//! `1` would silently reorder the dock.
//!
//! `fontSize` is the cache's `SIZE`, and `0` is what a stock client stores — "use the font's own
//! height". No FrameXML path applies it (`FloatingChatFrame_Update` destructures it and never
//! reads it; the options dropdown checks `FCF_GetCurrentChatFrame():GetFont()` instead), and no
//! corpus addon reads it.
//!
//! Measured demand: **3 of the 5 corpus addons that iterate `NUM_CHAT_WINDOWS` call this on the
//! very next line** — `EnhTooltip/Tooltip.lua:1302`, `MikScrollingBattleText.lua:1951` and
//! `Enchantrix/EnxConfig.lua:110`. The first two are the same idiom (look for a window the user
//! named "debug"/"ettdebug"); the third builds a name→index map for its `/enx print-in` config.
//! Without this getter, declaring `NUM_CHAT_WINDOWS` hands those three a loop that raises on its
//! first iteration, so the constant and the getter are one change, not two.
//!
//! ## `ChatFrame_OpenChat(text, chatFrame)`
//!
//! FrameXML in the reference (ChatFrame.lua l.1545), and the same seam as its neighbour
//! `ChatFrame_SendTell` ([`super::party`]): benilla's chat edit machine is app-side
//! (`benilla_app::ui_chat::edit`), so the verb queues the request and the app opens the box.
use mlua::{Lua, MultiValue, Value};
use super::Model;
/// How many chat windows the client's own settings array holds — **10**, both in the engine
/// (`0xb4fe50`, stride 0x98) and in `chat-cache.txt`, which writes `WINDOW 1` … `WINDOW 10`.
/// FrameXML declares `ChatFrame1TabDockRegion`..`ChatFrame10TabDockRegion` to match, and then
/// builds only seven `ChatFrame`s: `NUM_CHAT_WINDOWS = 7` is the *UI's* count, not the engine's.
/// Recorded so the 7 below reads as a deliberate choice rather than a miscount.
const ENGINE_CHAT_WINDOW_SLOTS: usize = 10;
/// The windows benilla actually builds — `ChatFrame1`..`ChatFrame7` in the app's `ChatFrame.xml`,
/// which is also `NUM_CHAT_WINDOWS`. `benilla_app`'s `chat_tests` cross-checks every entry against
/// the shipped XML so the two cannot drift.
const NUM_CHAT_WINDOWS: usize = 7;
/// `(shown, docked)` per window, 1-based, as the tuple's *Lua* values: `None` where the reference
/// answers `nil`. Everything else in the tuple is identical across all seven windows on a stock
/// client — name `""`, size `0`, colour `0,0,0`, alpha `0` — so only the two that differ are
/// tabulated. `locked` is `1` for every window (the cache's `LOCKED 1`), and benilla has no unlock
/// path at all, so it is a constant below rather than a column here.
const WINDOW_STATE: [(Option<i64>, Option<i64>); NUM_CHAT_WINDOWS] = [
(Some(1), Some(1)), // 1 "General" — shown, dock position 1
(None, Some(2)), // 2 "Combat Log" — docked at position 2, not shown (the dock shows one)
(None, None), // 3 — hidden, undocked
(None, None), // 4
(None, None), // 5
(None, None), // 6
(None, None), // 7
];
impl super::UiScript {
/// Drain the `ChatFrame_OpenChat` requests queued since the last call — each is the text the
/// caller wants the chat edit box to open prefilled with. The app opens and focuses the box,
/// applies its own sticky-type law, and lets the live parse take the text from there.
pub fn take_open_chat_requests(&mut self) -> Vec<String> {
std::mem::take(&mut self.model_mut().open_chat_requests)
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// GetChatWindowInfo(id) → name, fontSize, r, g, b, a, shown, locked, docked.
//
// Out of range RAISES rather than answering. The reference's array has 10 slots and benilla
// builds 7 windows; a question about window 8 is a question about a window that does not exist
// here, and the two honest answers are "raise" or "invent a row". Every corpus caller loops
// `1, NUM_CHAT_WINDOWS`, so the raise is unreachable from real code and exists to keep the
// next caller from reading a fabricated window as a real one.
lua.globals().set(
"GetChatWindowInfo",
lua.create_function(|lua, id: i64| {
if id < 1 || id as usize > NUM_CHAT_WINDOWS {
return Err(mlua::Error::runtime(format!(
"GetChatWindowInfo: window {id} out of range — benilla builds \
ChatFrame1..ChatFrame{NUM_CHAT_WINDOWS} (the client's settings array holds \
{ENGINE_CHAT_WINDOW_SLOTS} slots, but only {NUM_CHAT_WINDOWS} have frames)"
)));
}
let (shown, docked) = WINDOW_STATE[id as usize - 1];
let num = |v: Option<i64>| match v {
Some(n) => Value::Integer(n),
None => Value::Nil,
};
Ok(MultiValue::from_vec(vec![
// name: "" — never a label. See the module docs' trap 1.
Value::String(lua.create_string("")?),
Value::Integer(0), // fontSize — the cache's SIZE 0
Value::Number(0.0), // r ┐ COLOR 0 0 0 0, renormalised by the engine's 1/255,
Value::Number(0.0), // g │ and the value ChatFrame.xml's load-time
Value::Number(0.0), // b ┘ SetVertexColor(0, 0, 0) already applies
Value::Number(0.0), // a — window alpha 0: the box only exists on hover
num(shown), // 1 or nil, never 0
Value::Integer(1), // locked — LOCKED 1, and benilla has no unlock path
num(docked), // the dock POSITION (1, 2) or nil
]))
})?,
)?;
// ChatFrame_OpenChat(text, chatFrame) — open the chat edit box prefilled with `text`.
//
// **The second argument is inert, and that is the reference's doing, not a shortcut.** The ref
// reaches the box through `chatFrame.editBox`, and in 1.12 every chat frame's `.editBox` is
// the *same* global `ChatFrameEditBox`: `FloatingChatFrame_OnLoad` sets
// `this.editBox = ChatFrameEditBox` for each frame (FloatingChatFrame.lua l.30), and
// ChatFrame1 — which loads before the edit box exists and so skips that `if` — is patched by
// the box's own OnLoad, `DEFAULT_CHAT_FRAME.editBox = this` (FloatingChatFrame.xml l.742).
// Per-frame edit boxes are a 2.x feature. So `ChatFrame_OpenChat(text, ChatFrame5)` opens the
// one and only edit box on a real 1.12 client too, and accepting-and-ignoring the frame here
// is exact rather than approximate. `benilla_app`'s `chat_tests` states that as a claim.
//
// Measured demand: 3 distinct addons, and all three are the same shape — check the box is not
// already up, then open it prefilled, else fall back to SetText/Insert:
// FuBar_HeyFu/Core.lua:281,292 ChatFrame_OpenChat(reply, DEFAULT_CHAT_FRAME)
// FuBar_FriendsFu/FriendsFu.lua:434 ChatFrame_OpenChat(format("/w %s ", name)) -- 1 arg
// TipBuddy/TipBuddy.xml:2715,2717 ChatFrame_OpenChat("", chatFrame) / ("/", chatFrame)
// Two of the three prefill `/w <name> `, which the app's live parse then turns into whisper
// mode with the target extracted — the same path a human typing those characters takes.
//
// The ref's tail — the PARTY/RAID/BATTLEGROUND sticky downgrade (l.1554-1565) — is not skipped,
// it is the app's: `ui_chat::edit`'s open path already applies exactly that law (a sticky PARTY
// with nobody in the party opens as SAY), so running it here would be running it twice.
lua.globals().set(
"ChatFrame_OpenChat",
lua.create_function(|lua, (text, _chat_frame): (Option<String>, Value)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.open_chat_requests.push(text.unwrap_or_default());
Ok(())
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
/// The tuple is nine values wide and in the reference's own order.
#[test]
fn get_chat_window_info_answers_the_nine_value_tuple() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("return select('#', GetChatWindowInfo(1))")
.unwrap(),
9
);
}
/// Trap 1: a stock client has never been told a window's name, so the getter answers `""` and
/// FrameXML supplies "General"/"Combat Log". Enchantrix's `if ( name == "" )` fallback is only
/// correct because of this.
#[test]
fn every_window_name_is_the_empty_string_not_a_label() {
let s = UiScript::new().unwrap();
for id in 1..=7 {
let name: String = s
.eval(&format!("return (GetChatWindowInfo({id}))"))
.unwrap();
assert_eq!(name, "", "window {id} name");
}
}
/// Trap 2: `shown`/`docked` are `nil` where the cache stores 0, because FrameXML branches on
/// them bare and `0` is true in Lua. Window 1 is shown at dock position 1; window 2 is docked
/// at position 2 but not shown; 3..7 are neither.
#[test]
fn hidden_and_undocked_windows_answer_nil_never_zero() {
let s = UiScript::new().unwrap();
let probe = |id: i32| -> (String, String) {
let shown = s
.eval::<String>(&format!(
"local _,_,_,_,_,_,shown = GetChatWindowInfo({id}) return type(shown)"
))
.unwrap();
let docked = s
.eval::<String>(&format!(
"local _,_,_,_,_,_,_,_,docked = GetChatWindowInfo({id}) return type(docked)"
))
.unwrap();
(shown, docked)
};
assert_eq!(probe(1), ("number".into(), "number".into()));
assert_eq!(probe(2), ("nil".into(), "number".into()));
for id in 3..=7 {
assert_eq!(probe(id), ("nil".into(), "nil".into()), "window {id}");
}
// And the truthiness FrameXML actually branches on.
assert!(s
.eval::<bool>(
"for i = 3, 7 do local _,_,_,_,_,_,shown = GetChatWindowInfo(i) \
if shown then return false end end return true"
)
.unwrap());
}
/// Trap 3: `docked` is the dock POSITION — window 2 answers 2, not 1.
#[test]
fn docked_is_a_dock_position_not_a_flag() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("local _,_,_,_,_,_,_,_,d = GetChatWindowInfo(1) return d")
.unwrap(),
1
);
assert_eq!(
s.eval::<i64>("local _,_,_,_,_,_,_,_,d = GetChatWindowInfo(2) return d")
.unwrap(),
2
);
}
/// The corpus's own debug-window walk (MikScrollingBattleText, EnhTooltip) runs to completion
/// and finds nothing — `string.lower(name)` needs a string, and no window is named "debug".
#[test]
fn the_corpus_debug_window_walk_completes_and_finds_none() {
let s = UiScript::new().unwrap();
let found: i64 = s
.eval(
"local debugWin = 0\n\
for i = 1, 7 do\n\
local name, _, _, _, _, _, shown = GetChatWindowInfo(i)\n\
if string.lower(name) == 'debug' then debugWin = i break end\n\
end\n\
return debugWin",
)
.unwrap();
assert_eq!(found, 0);
}
/// A window benilla has no frame for raises rather than answering with an invented row.
#[test]
fn a_window_past_the_last_frame_raises() {
let s = UiScript::new().unwrap();
assert!(s.eval::<i64>("return (GetChatWindowInfo(8))").is_err());
assert!(s.eval::<i64>("return (GetChatWindowInfo(0))").is_err());
}
/// `ChatFrame_OpenChat` queues the text for the app; the optional frame argument is accepted
/// and dropped (in 1.12 every chat frame shares one edit box — see the install comment).
#[test]
fn chat_frame_open_chat_queues_its_text_and_ignores_the_frame() {
let mut s = UiScript::new().unwrap();
s.run("ChatFrame_OpenChat('/w Bob ')").unwrap();
s.run("ChatFrame_OpenChat('', 'not even a frame')").unwrap();
assert_eq!(
s.take_open_chat_requests(),
vec!["/w Bob ".to_string(), String::new()]
);
assert!(
s.take_open_chat_requests().is_empty(),
"the drain is a take"
);
}
}