Comments in benilla-ui: one line of fact each, no history, no record numbers (#143)
Part of #41: the comments of `benilla-ui` (the Lua UI engine, its FrameXML loader and its widgets) are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 43,140 comment lines become 15,161. - **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal. - **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them: - `0x64a480` is the case-sensitive compare (a byte `repz cmpsb`), and `0x64a4c0` the case-insensitive one. - A frame's id sits at `+0xb0`, not `+0xb4`. `EnableKeyboard`/`IsKeyboardEnabled` are `0x776ec0`/`0x776f90`. - A region's unpinned edge has no fallback to its owner's rect: the owner supplies only the scale. - `$parent` expands to the nearest named ancestor, or "Top" when none is named (`0x76c5b0`), not simply to the parent. - A StatusBar fills from its internal normalised fraction, not from `GetValue()`. - The addresses of `StartDuel` and `StartDuelUnit` were swapped. - Many "untraced" notes now give the traced reference fact: the drag threshold, the per-grip sizing edges, the empty-`autoFocus` skip, and `SetTexCoordModifiesRect`'s default. - Stock FrameXML line citations are checked against the stock 1.12 files and corrected. `ref-` file names become the stock file names. - **Deviation labels.** "Deviation:" now marks only a choice with a reason. A surface that exists only for the retired Era addon target says so plainly: it is not 1.12. - **Verification.** Every file strips to identical code, and `cargo fmt --check` is clean. Nine second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the code, the stock files, vmangos and the reference. Behaviour differences the rewrite surfaced are filed as their own issues (#131–#142), and more are being validated. The comments here state what the code does. String literals are untouched; they are #92. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8505187668
commit
fd386e75ae
@@ -1,22 +1,12 @@
|
||||
//! The two message-frame widget classes, and the display machinery they share.
|
||||
//! The two message-frame classes and the display code they share. In 1.12 they are siblings, not
|
||||
//! parent and child, each with its own method table, so a method only one carries is nil on the
|
||||
//! other:
|
||||
//!
|
||||
//! 1.12 ships **two** of them, and 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.
|
||||
//! - [`plain`]: `CSimpleMessageFrame` (ctor `0x785640`), `UIErrorsFrame`'s class.
|
||||
//!
|
||||
//! - [`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.
|
||||
//! Both display a stack of [`MessageLine`] records, so the row measure, the band emit and the font
|
||||
//! read are shared here.
|
||||
|
||||
use crate::layout::Rect;
|
||||
use crate::order::ZTarget;
|
||||
@@ -34,62 +24,20 @@ 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).
|
||||
/// **`AddMessage`'s text argument, and the three ways it silently swallows the whole call.**
|
||||
/// `None` means *do nothing at all* — no line, no record, no error.
|
||||
///
|
||||
/// `CSimpleMessageFrame::AddMessage 0x795590` fetches Lua index 2 through `lua_isstring 0x6f3510`
|
||||
/// and then `lua_tostring 0x6f3690`, and **three** results jump to the function's own epilogue at
|
||||
/// `0x79582b` (`pop edi / pop esi / xor eax,eax / pop ebx / leave / ret` — zero Lua values, no
|
||||
/// push, and the virtual enqueue at `0x795825 call [eax+0x90]` never reached):
|
||||
///
|
||||
/// ```text
|
||||
/// 79561b mov edx,2
|
||||
/// 795625 call 0x6f3510 ; lua_isstring — tags 4 (string) and 3 (number) only
|
||||
/// 79562c je 0x79582b ; nil / boolean / table / function -> silent
|
||||
/// 795642 je 0x79582b ; lua_tostring returned NULL -> silent
|
||||
/// 79564b je 0x79582b ; cmp byte ptr [ebx],0 — EMPTY string -> silent
|
||||
/// ```
|
||||
///
|
||||
/// `ScrollingMessageFrame:AddMessage 0x792900` is the same shape (gate `0x79299c`, `je 0x792b81` =
|
||||
/// its own epilogue), so `DEFAULT_CHAT_FRAME:AddMessage(nil)` is silent too.
|
||||
///
|
||||
/// **This is not a general argument law and must not be read as one** (1717's rule: the shape is
|
||||
/// settled per binding). The *colour* arguments beside it behave the opposite way — a bad `r`/`g`/
|
||||
/// `b`/`a` jumps to `0x79581c`, which still calls the enqueue with the `0xffffffff` opaque white
|
||||
/// staged at `0x795658`. Only a bad **text** shows nothing. The three *receiver* guards
|
||||
/// (`0x847ef8` "used '.' instead of ':'", `0x847ec0`, `0x847e98`) do still raise, and they are
|
||||
/// upstream of this.
|
||||
///
|
||||
/// **Why it is load-bearing rather than pedantry.** Stock FrameXML takes this path: 1.12's
|
||||
/// `ContainerFrame.lua:753` reports a full keyring with
|
||||
/// `UIErrorsFrame:AddMessage(NO_EMPTY_KEYRING_SLOTS, 1.0, 0.1, 0.1, 1.0)` and **no such
|
||||
/// GlobalString exists** — the shipped `GlobalStrings.lua` defines only
|
||||
/// `NO_EMPTY_KEYRING_SLOTS_ERROR`, a rename that updated the definition and not the use. So a
|
||||
/// player dropping a key onto a full keyring gets a dead click on a real client. Taking a `String`
|
||||
/// here (which is what this did until 1751's fourth window swapped the bag bar in) raised instead,
|
||||
/// putting a script-error dialog on an ordinary player gesture that the reference never shows.
|
||||
///
|
||||
/// A **number** is accepted and rendered as its decimal text — `0x6f7c80` retags the slot in
|
||||
/// place — which is exactly [`super::binding_abi::optional_string`]'s coercion.
|
||||
/// `AddMessage`'s text; `None` means the whole call does nothing, no line and no error. Both
|
||||
/// bindings (`0x795590`, `0x792900`) jump to their epilogue (`0x79582b`, `0x792b81`) when
|
||||
/// `lua_isstring` (`0x6f3510`) fails, `lua_tostring` (`0x6f3690`) gives NULL or the string is empty
|
||||
/// (`0x79564b`). Stock `ContainerFrame.lua:753` passes the undefined `NO_EMPTY_KEYRING_SLOTS` on a
|
||||
/// full keyring and gets a silent click. Only the text is gated so: a bad colour still adds the
|
||||
/// line in white (`0x79581c`), and the receiver checks (`0x847ef8`) still raise. A number becomes
|
||||
/// its decimal text (`0x6f7c80`).
|
||||
pub(super) fn message_text(lua: &mlua::Lua, v: &mlua::Value) -> Option<String> {
|
||||
super::binding_abi::optional_string(lua, v).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Install `SetJustifyH`/`GetJustifyH`/`SetJustifyV`/`GetJustifyV` on a message-frame method
|
||||
/// table. Both classes carry all four (see [`crate::widget::ScrollingMessageState::justify`] for
|
||||
/// the method-table bytes that settled it), and the law they obey is
|
||||
/// [`crate::justify`]'s — shared with FontString, `<Font>` and EditBox since 1237, so the two
|
||||
/// verified traps cannot drift apart per widget:
|
||||
///
|
||||
/// · an **unrecognised token RAISES** `Usage: %s:SetJustifyH("justify")` rather than defaulting;
|
||||
/// · a token from the **other axis** parses and then masks to nothing — `SetJustifyH("TOP")`
|
||||
/// CLEARS the horizontal axis, and `GetJustifyH()` answers `"UNKNOWN"` while the glyphs keep
|
||||
/// drawing at the paint fallback.
|
||||
///
|
||||
/// `GetJustify*` reports the EFFECTIVE value — the frame's own once set, else the font object's —
|
||||
/// so an untouched frame answers what it actually draws with rather than a ctor constant its
|
||||
/// `<FontString>` may have overridden.
|
||||
/// Install `SetJustifyH`, `GetJustifyH`, `SetJustifyV` and `GetJustifyV`, which both classes carry,
|
||||
/// under [`crate::justify`]'s rules: an unknown token raises the usage line, and a token of the
|
||||
/// other axis clears this one, so `GetJustifyH()` then answers `"UNKNOWN"`.
|
||||
use crate::script::object::frame_handle_of;
|
||||
|
||||
pub(super) fn install_justify(
|
||||
@@ -146,13 +94,8 @@ pub(super) fn install_justify(
|
||||
)?;
|
||||
}
|
||||
|
||||
// The two getters differ ONLY in what an untouched frame falls back to, and the asymmetry is
|
||||
// real rather than an oversight: horizontally there IS another source — the font object the
|
||||
// lines actually draw with — so reporting anything else would have `GetJustifyH` disagree with
|
||||
// the glyphs on our own chat window, which declares `justifyH="LEFT"` on its `<FontString>`.
|
||||
// Vertically there is no such source: `emit_message_lines` pins each line to the top of its own
|
||||
// band deliberately (the band, not a FontString rect, owns the vertical rhythm), so an untouched
|
||||
// frame reports the ctor default rather than a value invented from our band math.
|
||||
// On an untouched frame `GetJustifyH` answers its font's `justifyH`, what the lines draw with,
|
||||
// and `GetJustifyV` the ctor default, as each line sits at the top of its own band.
|
||||
m.set(
|
||||
"GetJustifyH",
|
||||
lua.create_function(move |lua, this: mlua::Table| {
|
||||
@@ -189,39 +132,21 @@ pub(super) fn install(lua: &mlua::Lua) -> mlua::Result<()> {
|
||||
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.
|
||||
/// The font a message frame's lines draw with: its declared `<FontString>` child, as the stock
|
||||
/// chat frames and `UIErrorsFrame.xml` declare it, else the default face at 14 px.
|
||||
#[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.
|
||||
/// The child's `justifyH`, which centres `UIErrorsFrame`'s lines and keeps chat flush left;
|
||||
/// LEFT without a declared FontString, not the FontString default of CENTER.
|
||||
pub(super) justify_h: JustifyH,
|
||||
}
|
||||
|
||||
/// The FontString region a message frame's **style** lives on — found, or created on demand.
|
||||
///
|
||||
/// Both classes draw their lines through one font instance, and [`UiScript::message_frame_font`]
|
||||
/// reads it off the frame's first `FontString` region. That region normally comes from the
|
||||
/// declared direct-child `<FontString>` (LoadXML's "special" font string — our ChatFrame has one).
|
||||
/// A frame built by `CreateFrame("MessageFrame")` has none, so the ten shared font-block verbs
|
||||
/// need somewhere to write: this is that somewhere, and it is created **only** when one of them is
|
||||
/// actually called, so a frame nobody styles never grows a region.
|
||||
///
|
||||
/// **It is seated LEFT deliberately.** A frame with no declared FontString falls back to LEFT (the
|
||||
/// chat shape) per [`MessageFont::justify_h`]'s law — but a freshly created `RegionData` defaults
|
||||
/// to the *FontString* default of CENTER. Creating the region at the default would silently
|
||||
/// re-justify a frame the moment an addon touched its font, which is a visible change nobody
|
||||
/// asked for.
|
||||
/// The FontString region holding a message frame's font, made on the first font-block call when
|
||||
/// the frame declared none. It is made LEFT, not CENTER, so styling a frame never re-justifies it.
|
||||
pub(super) fn ensure_font_region(
|
||||
lua: &mlua::Lua,
|
||||
fh: FrameHandle,
|
||||
@@ -288,11 +213,8 @@ impl UiScript {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// How many rows frame `fh`'s resolved rect holds at its font's pitch, the client's
|
||||
/// `numLinesDisplayed`: the scrolling class's page size and the plain class's capacity.
|
||||
pub(super) fn message_viewport_rows(model: &Model, fh: FrameHandle) -> usize {
|
||||
let pitch = Self::message_frame_font(model, fh)
|
||||
.height
|
||||
@@ -303,16 +225,12 @@ impl UiScript {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Message lines whose wrapped row count needs a host measure. Call after
|
||||
/// [`UiScript::resolve`]; answer with [`UiScript::set_message_line_rows`] before extract.
|
||||
pub fn message_lines_needing_measure(&mut self) -> Vec<LineMeasureRequest> {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut model = self.model_mut();
|
||||
// A plain reborrow so the arena borrow inside the loop and the sweep-token field writes
|
||||
// split by FIELD instead of fighting over the RefMut.
|
||||
// A plain reborrow, so the arena reads and the sweep-token writes borrow disjoint fields.
|
||||
let model = &mut *model;
|
||||
let mut out = Vec::new();
|
||||
let frames: Vec<(FrameHandle, Rect)> = model
|
||||
@@ -329,7 +247,7 @@ impl UiScript {
|
||||
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
|
||||
continue; // no width to wrap against
|
||||
}
|
||||
let scale = model
|
||||
.arena
|
||||
@@ -344,13 +262,9 @@ impl UiScript {
|
||||
let Some(lines) = frame.kind_state.message_lines() else {
|
||||
continue;
|
||||
};
|
||||
// The skip token (the W4 fix, 1410's lane one door over): a frame whose line set
|
||||
// (lines_gen) and measure environment (font/wrap/scale/outline) both match its last
|
||||
// CLEAN sweep can produce nothing — every line's rows_key was minted under exactly
|
||||
// these inputs and matched then. Environment changes (a resize, a font swap, a
|
||||
// SetScale) miss the env hash; text changes miss the generation. The token is only
|
||||
// stored by a zero-request sweep (below), so an unanswered request keeps
|
||||
// re-requesting — the same rule the region ledger runs under.
|
||||
// Skip a frame whose line generation and measure environment match its last clean
|
||||
// sweep. Only a sweep with no requests stores the token, so an unanswered request
|
||||
// keeps re-requesting.
|
||||
let lines_gen = frame.kind_state.lines_gen().unwrap_or(0);
|
||||
let env = {
|
||||
use std::hash::{Hash, Hasher};
|
||||
@@ -400,9 +314,8 @@ impl UiScript {
|
||||
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.
|
||||
/// Store host row counts, `(frame, index, rows, key)` echoed from each [`LineMeasureRequest`].
|
||||
/// The key is stored beside the rows, so a line that changed since its request re-requests.
|
||||
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 {
|
||||
@@ -422,31 +335,14 @@ impl UiScript {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 not fully traced — 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
|
||||
/// not settled at the bytes, 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.
|
||||
/// Push one [`QuadContent::Text`] per visible message of either class, each `rows × pitch`
|
||||
/// tall. The pitch is the font height, the client's line step (`LayoutLines` `0x5cdc20`: size
|
||||
/// plus a spacing of 0); the message frame's own relayout (`0x788750`, `0x788c00`) is not fully
|
||||
/// traced. A scrolling frame stacks up from the bottom, `scroll_offset` choosing the bottom
|
||||
/// message. A MessageFrame does too for `insertMode` BOTTOM, and for TOP hangs the newest off
|
||||
/// the top edge (`UIErrorsFrame.xml:4`); the reference's anchor per mode is untraced, and this
|
||||
/// reading reproduces both stock uses. A message that partly fits draws clipped; a faded
|
||||
/// scrolling line keeps its rows, as the reference never re-packs chat.
|
||||
pub(super) fn emit_message_lines(
|
||||
model: &Model,
|
||||
fh: FrameHandle,
|
||||
@@ -483,23 +379,20 @@ impl UiScript {
|
||||
KindState::Message(s) => s.justify,
|
||||
_ => None,
|
||||
};
|
||||
// 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).
|
||||
// The resolved rect is scaled and the font height is frame-local, so the pitch scales.
|
||||
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).
|
||||
// Clip to the frame, intersected with any ScrollFrame ancestor's 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
|
||||
let mut used = 0.0f32; // px used 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
|
||||
break; // the next band starts outside the frame
|
||||
}
|
||||
let line = &lines[idx];
|
||||
let band_h = f32::from(line.rows.max(1)) * pitch;
|
||||
@@ -510,7 +403,7 @@ impl UiScript {
|
||||
};
|
||||
used += band_h;
|
||||
if line.alpha <= 0.0 {
|
||||
continue; // fully faded — holds its place, draws nothing
|
||||
continue; // faded: holds its rows, draws nothing
|
||||
}
|
||||
out.push(ExtractedQuad {
|
||||
target: ZTarget::Frame(fh),
|
||||
@@ -526,27 +419,21 @@ impl UiScript {
|
||||
f32::from(line.color[2]) / 255.0,
|
||||
line.alpha,
|
||||
]),
|
||||
// The frame's own justify wins once SetJustifyH has been called; until then
|
||||
// the font object's governs, which is what `justifyH=` on the frame's
|
||||
// `<FontString>` set and what this drew with before the verbs existed.
|
||||
// The frame's own justify once set, else its font's.
|
||||
justify_h: match own_justify {
|
||||
Some(j) => j.paint_h(),
|
||||
None => 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".
|
||||
// The band is exactly the wrapped block's height, so Top fills it.
|
||||
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.
|
||||
// The font's own shadow, ChatFontNormal's black at `(1,-1)`.
|
||||
shadow: font.shadow,
|
||||
font_height: font.height,
|
||||
text_height: None, // message lines are never SetTextHeight'd
|
||||
text_height: None, // message lines have no SetTextHeight
|
||||
outline: font.outline,
|
||||
alpha_gradient: None,
|
||||
world_seat: false, // a chat frame is interface, on the interface's grid
|
||||
world_seat: false, // interface, not world
|
||||
},
|
||||
scale: paint.scale,
|
||||
});
|
||||
|
||||
@@ -1,25 +1,9 @@
|
||||
//! The `MessageFrame` method surface — `CSimpleMessageFrame` (ctor `0x785640`), the class
|
||||
//! `UIErrorsFrame` is and the one `CreateFrame("MessageFrame")` makes.
|
||||
//!
|
||||
//! 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`].
|
||||
//! The `MessageFrame` methods over [`MessageFrameState`](crate::widget::MessageFrameState):
|
||||
//! `CSimpleMessageFrame` (ctor `0x785640`), `UIErrorsFrame`'s class and what
|
||||
//! `CreateFrame("MessageFrame")` makes. Its `AddMessage(text [, r, g, b [, a]])` (`0x795590`) takes
|
||||
//! a real alpha and reads nothing after it, where the scrolling class's (`0x792900`) takes an id
|
||||
//! there and forces alpha opaque. `SetInsertMode` and `GetInsertMode` (`0x794ed0`, `0x794ff0`)
|
||||
//! exist on this class only.
|
||||
|
||||
use mlua::{Lua, Table, Value};
|
||||
|
||||
@@ -27,13 +11,11 @@ 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).
|
||||
/// Registry key of the MessageFrame method table (the MAXCSTACK discipline).
|
||||
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).
|
||||
/// Run `f` over a MessageFrame's state under one short borrow; any other frame, reachable only
|
||||
/// through a misapplied method table, is an error.
|
||||
fn with_mf<T>(
|
||||
lua: &Lua,
|
||||
this: &Table,
|
||||
@@ -51,7 +33,7 @@ fn with_mf<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/// A Lua number-ish → f32 (nil/other → 0.0), for the colour args.
|
||||
/// A colour argument as f32, 0 for anything but a number.
|
||||
fn num_f32(v: &Value) -> f32 {
|
||||
match v {
|
||||
Value::Number(n) => *n as f32,
|
||||
@@ -63,30 +45,14 @@ fn num_f32(v: &Value) -> f32 {
|
||||
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.
|
||||
// The justify quartet, on both classes ([`super::install_justify`]).
|
||||
super::install_justify(lua, &m, "MessageFrame")?;
|
||||
|
||||
// AddMessage(text [, r, g, b [, a]]) — binding 0x795590.
|
||||
//
|
||||
// The TEXT is `super::message_text` — an `lua_isstring` gate whose failure is a silent jump to
|
||||
// this function's own epilogue, not a raise, and the same for a NULL coercion and for the
|
||||
// empty string. Its doc carries the bytes and the reason it matters; the short version is that
|
||||
// stock `ContainerFrame.lua` really does reach `AddMessage(nil, …)` on a full keyring, and
|
||||
// taking a `String` here (which this did until 1751's fourth window) put a script-error dialog
|
||||
// on a gesture the reference answers with a dead click.
|
||||
//
|
||||
// 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`). **Deliberately NOT the same shape as the text:** a
|
||||
// bad colour jumps to `0x79581c`, which still enqueues the line in opaque white. Only a bad
|
||||
// text shows nothing.
|
||||
//
|
||||
// **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.
|
||||
// AddMessage(text [, r, g, b [, a]]) (`0x795590`). An unusable text is a silent no-op
|
||||
// ([`super::message_text`]). r, g and b count only as a trio, else white, and a bad colour
|
||||
// still adds the line in white (`0x79581c`); the alpha defaults to 1.0 (`0x795752`). The
|
||||
// closure's arity drops a sixth argument, as the binding reads five: addons that pass a hold
|
||||
// time there get the frame's `displayDuration`, as on the reference.
|
||||
m.set(
|
||||
"AddMessage",
|
||||
lua.create_function(
|
||||
@@ -103,9 +69,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
} 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.
|
||||
// The alpha counts only with the trio, read in the same parse.
|
||||
let a = match (&a, has_rgb) {
|
||||
(Value::Number(_) | Value::Integer(_), true) => num_f32(&a),
|
||||
_ => 1.0,
|
||||
@@ -120,9 +84,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
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.
|
||||
// SetInsertMode (`0x794ed0`) compares against "BOTTOM" (`0x871404`), so anything else is
|
||||
// TOP; the ctor default is BOTTOM.
|
||||
m.set(
|
||||
"SetInsertMode",
|
||||
lua.create_function(|lua, (this, mode): (Table, Value)| {
|
||||
@@ -134,8 +97,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
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.
|
||||
// A non-string fails the compare too.
|
||||
_ => InsertMode::Top,
|
||||
};
|
||||
with_mf(lua, &this, |mf| mf.insert_mode = mode)
|
||||
@@ -151,8 +113,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// 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.
|
||||
// The fade accessors, as on the scrolling class; XML's `displayDuration` is `TimeVisible`.
|
||||
m.set(
|
||||
"SetFading",
|
||||
lua.create_function(|lua, (this, on): (Table, Value)| {
|
||||
@@ -160,8 +121,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
with_mf(lua, &this, |mf| mf.fading_enabled = on)
|
||||
})?,
|
||||
)?;
|
||||
// 1/nil, the reference's predicate shape (`0x795170`): `(nil) | (number)`, like every other
|
||||
// 1.12 predicate (decision 2118).
|
||||
// 1 or nil, the 1.12 predicate shape (`0x795170`).
|
||||
m.set(
|
||||
"GetFading",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
@@ -192,19 +152,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
|
||||
// ── 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. The
|
||||
// registrar map is explicit about the membership — *"Exposed on: FontString, Font object,
|
||||
// EditBox, MessageFrame, ScrollingMessageFrame, SimpleHTML. NOT on Button"* (table
|
||||
// `0x879d00` has none) — and names this class's own shims calling the shared
|
||||
// implementations (`GetShadowColor 0x794810`). All six carry the block now —
|
||||
// `SimpleHTML`'s is its own copy rather than `font_block::install`'s
|
||||
// (`script/simplehtml/mod.rs`), because that class computes its own inter-block step.
|
||||
//
|
||||
// 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.
|
||||
// The ten font verbs are on this class's own table (`GetShadowColor` `0x794810`), as on
|
||||
// FontString, Font, EditBox, ScrollingMessageFrame and SimpleHTML; Button's (`0x879d00`) has
|
||||
// none.
|
||||
crate::script::font_block::install(
|
||||
lua,
|
||||
&m,
|
||||
@@ -224,10 +174,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
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.
|
||||
/// A named frame's line count, off the state: `GetNumMessages` is not a MessageFrame method.
|
||||
fn num_messages(s: &UiScript, name: &str) -> usize {
|
||||
let m = s.model_ref();
|
||||
let h = m.arena.lookup(name).expect("named frame");
|
||||
@@ -237,23 +184,18 @@ mod tests {
|
||||
.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.
|
||||
/// A sixth argument, which some addons pass as a hold time, changes nothing.
|
||||
#[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.
|
||||
// Past timeVisible with no ramp, both retire together.
|
||||
s.tick(1.1);
|
||||
s.tick(0.1);
|
||||
assert_eq!(
|
||||
@@ -263,31 +205,19 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **A text `AddMessage` cannot use is swallowed whole — silently, with nothing added and
|
||||
/// nothing raised** (`0x795590`'s three jumps to its own epilogue; see [`super::super`]'s
|
||||
/// `message_text`).
|
||||
///
|
||||
/// The defect this pins is not hypothetical and not ours to have guessed at: our binding took
|
||||
/// its text as an mlua `String`, which raises on nil, and 1.12's own `ContainerFrame.lua:753`
|
||||
/// reports a full keyring through `UIErrorsFrame:AddMessage(NO_EMPTY_KEYRING_SLOTS, …)` — a
|
||||
/// GlobalString the shipped `GlobalStrings.lua` never defines. So dropping a key onto a full
|
||||
/// keyring put a script-error dialog on screen where the reference gives a dead click. It
|
||||
/// surfaced the moment 1751's third window made the bag bar the reference's own file.
|
||||
///
|
||||
/// The three silent cases and the two that are NOT silent are pinned together, because the
|
||||
/// pair is the finding: a bad **colour** still adds the line (opaque white), and the receiver
|
||||
/// guards still raise. Reading the text gate as a general argument law is exactly 1717's error.
|
||||
/// Only an unusable text is silent (`0x795590` jumps to its epilogue): a bad colour still adds
|
||||
/// the line, and the receiver check still raises.
|
||||
#[test]
|
||||
fn addmessage_swallows_a_text_it_cannot_use_and_only_the_text() {
|
||||
let s = UiScript::new().unwrap();
|
||||
s.run("CreateFrame('MessageFrame', 'MF')").unwrap();
|
||||
|
||||
// `lua_isstring 0x6f3510` accepts tags 4 and 3 only — everything else jumps to 0x79582b.
|
||||
// `lua_isstring` (`0x6f3510`) passes strings and numbers only; the rest jump to `0x79582b`.
|
||||
for bad in ["nil", "", "true", "{}", "print"] {
|
||||
s.run(&format!("MF:AddMessage({bad})"))
|
||||
.unwrap_or_else(|e| panic!("AddMessage({bad}) must not raise: {e}"));
|
||||
}
|
||||
// …and so does the empty string, on its own `cmp byte ptr [ebx],0` at 0x79564b.
|
||||
// The empty string has its own check (`0x79564b`).
|
||||
s.run("MF:AddMessage('')").unwrap();
|
||||
assert_eq!(
|
||||
num_messages(&s, "MF"),
|
||||
@@ -295,14 +225,12 @@ mod tests {
|
||||
"nil, absent, boolean, table, function and \"\" each add NOTHING"
|
||||
);
|
||||
|
||||
// A number IS a usable text — tag 3 passes the gate and `0x6f7c80` retags the slot in
|
||||
// place, so it renders as its decimal form.
|
||||
// A number is a text, retagged in place (`0x6f7c80`).
|
||||
s.run("MF:AddMessage(42)").unwrap();
|
||||
assert_eq!(num_messages(&s, "MF"), 1, "a number is a text");
|
||||
|
||||
// The colour arguments are the OPPOSITE shape and that is the load-bearing negative: a bad
|
||||
// r/g/b jumps to 0x79581c, which still enqueues with the 0xffffffff white staged at
|
||||
// 0x795658. Suppressing the line here would be the same over-generalisation in reverse.
|
||||
// A bad colour jumps to `0x79581c`, which still adds the line in the white staged at
|
||||
// `0x795658`.
|
||||
s.run("MF:AddMessage('kept', {}, nil, 'x')").unwrap();
|
||||
assert_eq!(
|
||||
num_messages(&s, "MF"),
|
||||
@@ -310,16 +238,13 @@ mod tests {
|
||||
"a bad colour still adds the line"
|
||||
);
|
||||
|
||||
// The receiver guards are upstream of all of it and DO raise (0x847ef8 — "used '.' instead
|
||||
// of ':'").
|
||||
// The receiver check still raises (`0x847ef8`).
|
||||
assert!(
|
||||
s.run("MF.AddMessage('dot')").is_err(),
|
||||
"a '.'-instead-of-':' call still raises"
|
||||
);
|
||||
}
|
||||
|
||||
/// `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();
|
||||
@@ -344,7 +269,6 @@ mod tests {
|
||||
"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"
|
||||
@@ -356,9 +280,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// The fifth argument is alpha on a MessageFrame and an id on a ScrollingMessageFrame.
|
||||
#[test]
|
||||
fn messageframe_addmessage_is_not_the_scrolling_ones() {
|
||||
let s = UiScript::new().unwrap();
|
||||
@@ -398,8 +320,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `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| {
|
||||
@@ -450,8 +370,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// No `maxLines`: the cap is what fits vertically, applied at the tick.
|
||||
#[test]
|
||||
fn the_cap_is_what_fits_vertically() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
@@ -470,7 +389,6 @@ mod tests {
|
||||
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()
|
||||
@@ -483,8 +401,7 @@ mod tests {
|
||||
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.
|
||||
/// A faded line is freed, where the scrolling class keeps its rows.
|
||||
#[test]
|
||||
fn a_finished_line_is_retired_and_clear_empties_now() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
@@ -505,8 +422,7 @@ mod tests {
|
||||
assert_eq!(num_messages(&s, "MF"), 0);
|
||||
}
|
||||
|
||||
/// The ctor defaults (`0x81cc2c`/`0x81cc30`: timeVisible, fadeDuration), plus the setters'
|
||||
/// round trip.
|
||||
/// The ctor defaults: timeVisible (`0x81cc2c`) and fadeDuration (`0x81cc30`).
|
||||
#[test]
|
||||
fn ctor_defaults_and_the_fade_accessors() {
|
||||
let s = UiScript::new().unwrap();
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
//! The `ScrollingMessageFrame` method surface — the chat window's per-kind widget behavior
|
||||
//! (`CSimpleMessageScrollFrame`, ctor `0x787670`).
|
||||
//!
|
||||
//! 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),
|
||||
//! 1-slot scrollback, and — the other half of that fade — **every scroll entry re-arming the
|
||||
//! displayed lines** (`0x788b80` direct when the scroll is refused at an end, `0x788af0` via the
|
||||
//! relayout when it moves), which is what brings a faded-out chat back. 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.
|
||||
//! The `ScrollingMessageFrame` methods over [`ScrollingMessageState`]: `CSimpleMessageScrollFrame`
|
||||
//! (ctor `0x787670`), the chat window's class. A ring of `maxLines` that drops the oldest, colours
|
||||
//! quantized `trunc(x*255+0.5)` with alpha forced opaque, a fade that ticks only at the bottom, and
|
||||
//! every scroll call re-arming the displayed lines' fade (`0x788b80` when refused at an end,
|
||||
//! `0x788af0` when it moves), which is what brings a faded chat back.
|
||||
|
||||
use mlua::{Lua, Table, Value};
|
||||
|
||||
@@ -24,14 +10,12 @@ 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).
|
||||
/// Registry key of the ScrollingMessageFrame method table (the MAXCSTACK discipline).
|
||||
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).
|
||||
/// Run `f` over a ScrollingMessageFrame's state under one short borrow; any other frame, reachable
|
||||
/// only through a misapplied method table, is an error.
|
||||
fn with_smf<T>(
|
||||
lua: &Lua,
|
||||
this: &Table,
|
||||
@@ -49,9 +33,7 @@ fn with_smf<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/// `lua_isnumber` semantics — a number, or a string Lua would coerce to one. The rgb trio's
|
||||
/// presence test is three of these (decision 2125), so a caller passing `"1"` still colours the
|
||||
/// line and a caller passing `nil` or a table does not.
|
||||
/// `lua_isnumber`: a number or a numeric string. The rgb presence test is three of these.
|
||||
fn is_number(v: &Value) -> bool {
|
||||
match v {
|
||||
Value::Integer(_) | Value::Number(_) => true,
|
||||
@@ -60,7 +42,7 @@ fn is_number(v: &Value) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Lua number-ish → f32 (nil/other → 0.0), for the color args.
|
||||
/// A colour argument as f32, 0 for anything but a number.
|
||||
fn num_f32(v: &Value) -> f32 {
|
||||
match v {
|
||||
Value::Number(n) => *n as f32,
|
||||
@@ -69,12 +51,8 @@ fn num_f32(v: &Value) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a scroll entry over a frame's state with the viewport's row budget in hand.
|
||||
///
|
||||
/// Every scroll method needs it, not just the pages: each one re-arms the **displayed** lines'
|
||||
/// fade ([`ScrollingMessageState::reset_all_fade_times`]), and which lines those are depends on the
|
||||
/// row budget. It 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 reads as one row.
|
||||
/// Run a scroll call with the viewport's row count, which every scroll needs: each re-arms the
|
||||
/// fade of the lines on display ([`ScrollingMessageState::reset_all_fade_times`]).
|
||||
fn scroll(
|
||||
lua: &Lua,
|
||||
this: &Table,
|
||||
@@ -99,27 +77,16 @@ fn scroll(
|
||||
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.
|
||||
// The justify quartet, on both classes ([`super::install_justify`]).
|
||||
super::install_justify(lua, &m, "ScrollingMessageFrame")?;
|
||||
|
||||
// AddMessage(text [, r, g, b [, id]]) — binding 0x792900. rgb absent ⇒ white; the state
|
||||
// quantizes round-half-up and forces the line opaque, then the fade drives its alpha.
|
||||
//
|
||||
// **The rgb gate is three separate `lua_isnumber` calls on indices 3/4/5, and the id's index
|
||||
// is LEG-DEPENDENT** (decision 2125): 6 when rgb are
|
||||
// present (`0x792b13 mov edx,6`), **3** when they are absent (`0x792b48 mov edx,3`) — which is
|
||||
// what makes the documented `AddMessage(text, id)` shorthand work. So `AceConsole-2.0`'s
|
||||
// `AddMessage(text, nil, nil, nil, nil, 5)` fails the R gate at index 3, takes the absent leg,
|
||||
// re-reads index 3 as the id — still nil — and stores **0**; its trailing `5` sits at index 7
|
||||
// and is never read at all.
|
||||
//
|
||||
// Absent or non-numeric is id 0, and 0 is a line `UpdateColorByID` can never find: that guard
|
||||
// is the reference's own, at `0x788250`'s first branch.
|
||||
//
|
||||
// The TEXT gate is the sibling class's, verbatim — gate `0x79299c`, `je 0x792b81` = this
|
||||
// function's own epilogue — so `DEFAULT_CHAT_FRAME:AddMessage(nil)` is silent too. See
|
||||
// `super::message_text`.
|
||||
// AddMessage(text [, r, g, b [, id]]) (`0x792900`): white without rgb; the state quantizes
|
||||
// the colour and makes the line opaque. The rgb test is three `lua_isnumber` checks; the id
|
||||
// is stack index 6 when they pass (`0x792b13`) and index 3, `r`'s, when they fail
|
||||
// (`0x792b48`), which makes `AddMessage(text, id)` work and gives
|
||||
// `AddMessage(text, nil, nil, nil, nil, 5)` id 0. A missing id is 0, which `UpdateColorByID`
|
||||
// never matches (`0x788250`). The text gate (`0x79299c`) is the plain class's
|
||||
// ([`super::message_text`]).
|
||||
m.set(
|
||||
"AddMessage",
|
||||
lua.create_function(
|
||||
@@ -128,10 +95,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
return Ok(());
|
||||
};
|
||||
let has_rgb = is_number(&r) && is_number(&g) && is_number(&b);
|
||||
// The id is the chat-type index (`GetChatTypeIndex`) the line is printed under —
|
||||
// `ChatFrame.lua` passes `info.id` — read from the leg's OWN stack slot: the
|
||||
// sixth argument when rgb are present, the third (i.e. where `r` would be) when
|
||||
// they are not.
|
||||
// The chat-type index (`ChatFrame.lua` passes `info.id`), stack index 6 or 3.
|
||||
let id = match if has_rgb { &id } else { &r } {
|
||||
Value::Integer(i) => u32::try_from(*i).unwrap_or(0),
|
||||
Value::Number(n) if n.is_finite() && *n >= 0.0 => *n as u32,
|
||||
@@ -147,9 +111,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?,
|
||||
)?;
|
||||
|
||||
// UpdateColorByID(id, r, g, b) — `0x7932b0`. `ChatFrame_OnEvent`'s UPDATE_CHAT_COLOR arm
|
||||
// calls it with the type's index and the event's floats so the lines already in the
|
||||
// window take the new colour; lines printed under another id are untouched.
|
||||
// UpdateColorByID(id, r, g, b) (`0x7932b0`) recolours the lines printed under `id`; stock
|
||||
// `ChatFrame_OnEvent` calls it on `UPDATE_CHAT_COLOR`.
|
||||
m.set(
|
||||
"UpdateColorByID",
|
||||
lua.create_function(
|
||||
@@ -214,7 +177,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
lua.create_function(|lua, this: Table| with_smf(lua, &this, |smf| smf.at_bottom()))?,
|
||||
)?;
|
||||
|
||||
// SetMaxLines is destructive (`0x7938a0`) — the state handles the wipe.
|
||||
// SetMaxLines wipes the ring (`0x7938a0`).
|
||||
m.set(
|
||||
"SetMaxLines",
|
||||
lua.create_function(|lua, (this, n): (Table, i64)| {
|
||||
@@ -237,8 +200,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
with_smf(lua, &this, |smf| smf.fading_enabled = on)
|
||||
})?,
|
||||
)?;
|
||||
// 1/nil, the reference's predicate shape (`0x793a40`): `(nil) | (number)`, like every other
|
||||
// 1.12 predicate (decision 2118).
|
||||
// 1 or nil, the 1.12 predicate shape (`0x793a40`).
|
||||
m.set(
|
||||
"GetFading",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
@@ -247,8 +209,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
// The XML attr is `displayDuration`; the Lua accessors call the same field `TimeVisible`
|
||||
// (`0x788090`).
|
||||
// XML's `displayDuration` is the Lua `TimeVisible` (`0x788090`).
|
||||
m.set(
|
||||
"SetTimeVisible",
|
||||
lua.create_function(|lua, (this, s): (Table, f32)| {
|
||||
@@ -271,19 +232,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
|
||||
// ── 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. The
|
||||
// registrar map is explicit about the membership — *"Exposed on: FontString, Font object,
|
||||
// EditBox, MessageFrame, ScrollingMessageFrame, SimpleHTML. NOT on Button"* (table
|
||||
// `0x879d00` has none) — and names this class's own shims calling the shared
|
||||
// implementations (`GetShadowColor 0x792240`). All six carry the block now —
|
||||
// `SimpleHTML`'s is its own copy rather than `font_block::install`'s
|
||||
// (`script/simplehtml/mod.rs`), because that class computes its own inter-block step.
|
||||
//
|
||||
// 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.
|
||||
// The ten font verbs are on this class's own table (`GetShadowColor` `0x792240`), as on
|
||||
// FontString, Font, EditBox, MessageFrame and SimpleHTML; Button's (`0x879d00`) has none.
|
||||
crate::script::font_block::install(
|
||||
lua,
|
||||
&m,
|
||||
@@ -297,12 +247,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
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.
|
||||
// BenillaChatTabPressed(): the chat edit box's OnTabPressed sets a flag for the app's whisper
|
||||
// cycle (`UiScript::take_chat_tab`).
|
||||
lua.globals().set(
|
||||
"BenillaChatTabPressed",
|
||||
lua.create_function(|lua, ()| {
|
||||
@@ -311,6 +257,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
// SubmitChatInput(text): the chat edit box's OnEnterPressed queues the line for the app to
|
||||
// parse as a chat command; an empty line is queued too, and the app takes it as a cancel.
|
||||
lua.globals().set(
|
||||
"SubmitChatInput",
|
||||
lua.create_function(|lua, text: Option<String>| {
|
||||
@@ -341,7 +289,6 @@ mod tests {
|
||||
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"]);
|
||||
@@ -363,10 +310,10 @@ mod tests {
|
||||
#[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.
|
||||
// trunc(0.5 * 255 + 0.5) = 128; 1.0 is 255, and past 1.0 clamps.
|
||||
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).
|
||||
// EMOTE's FF8040 survives the 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]);
|
||||
}
|
||||
@@ -382,7 +329,7 @@ mod tests {
|
||||
// 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.
|
||||
// 0.5 more spends timeVisible; the ticks after ramp over 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
|
||||
@@ -399,15 +346,13 @@ mod tests {
|
||||
..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)
|
||||
s.tick(0.6); // spends timeVisible, 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
|
||||
s.tick(0.1); // past phase 1 with no ramp: straight to 0
|
||||
assert_eq!(s.lines[0].alpha, 0.0);
|
||||
}
|
||||
|
||||
/// The tick's AtBottom gate, isolated from the scroll re-arm that now sits next to it: with a
|
||||
/// one-row viewport only the newest displayed line is re-armed, so the two lines either side of
|
||||
/// it are the ones that show the freeze.
|
||||
/// With a one-row viewport the scroll re-arms one line; the lines either side show the freeze.
|
||||
#[test]
|
||||
fn scrolled_up_freezes_the_fade() {
|
||||
let mut s = ScrollingMessageState {
|
||||
@@ -422,8 +367,7 @@ mod tests {
|
||||
s.tick(2.0); // half-way down the ramp: trunc(2.0/4.0*255) = 127
|
||||
let half = 127.0 / 255.0;
|
||||
assert_eq!(s.lines[0].alpha, half);
|
||||
// Scroll up one line — no longer AtBottom, so ticks must not advance any alpha. The line
|
||||
// that lands in the one-row viewport (l1) is re-armed by the scroll; l0 and l2 are not.
|
||||
// Scrolled up, ticks stop; the scroll re-arms only l1, the line now in view.
|
||||
s.scroll_up(1);
|
||||
assert!(!s.at_bottom());
|
||||
assert_eq!(s.lines[1].alpha, 1.0, "the displayed line was re-armed");
|
||||
@@ -431,15 +375,14 @@ mod tests {
|
||||
assert_eq!(s.lines[0].alpha, half, "frozen while scrolled up");
|
||||
assert_eq!(s.lines[2].alpha, half);
|
||||
assert_eq!(s.lines[1].alpha, 1.0);
|
||||
// Back to bottom → the fade resumes (from the re-arm this scroll performs).
|
||||
// Back at the bottom, the fade resumes.
|
||||
s.scroll_to_bottom(1);
|
||||
s.tick(2.0);
|
||||
assert!(s.lines[2].alpha < 1.0);
|
||||
}
|
||||
|
||||
/// **The director's bug (2026-08-29): a fully-faded chat could never be brought back.** Every
|
||||
/// scroll entry re-arms the displayed lines — including the ones that move no cursor at all,
|
||||
/// which is what clicking the arrows on an already-at-bottom chat does.
|
||||
/// Every scroll call re-arms the displayed lines, even one that moves no cursor, as clicking
|
||||
/// the arrows on a chat already at the bottom does.
|
||||
#[test]
|
||||
fn any_scroll_brings_faded_lines_back() {
|
||||
let armed = |op: fn(&mut ScrollingMessageState, usize)| {
|
||||
@@ -453,7 +396,7 @@ mod tests {
|
||||
s.add(format!("l{n}"), 1.0, 1.0, 1.0);
|
||||
}
|
||||
s.tick(1.5); // spend phase 1
|
||||
s.tick(1.5); // spend phase 2 — everything is gone
|
||||
s.tick(1.5); // spend phase 2: all gone
|
||||
assert!(s.lines.iter().all(|l| l.alpha == 0.0), "faded out first");
|
||||
op(&mut s, 8);
|
||||
s
|
||||
@@ -470,8 +413,7 @@ mod tests {
|
||||
("PageDown", ScrollingMessageState::page_down),
|
||||
] {
|
||||
let s = armed(op);
|
||||
// Whatever the op left in view is what the engine re-arms — `ScrollUp` carries the
|
||||
// newest line off the bottom of the viewport, so the set is the op's, not the ring.
|
||||
// The re-armed set is what the op left in view, not the whole ring.
|
||||
let shown = s.displayed_range(8);
|
||||
assert!(!shown.is_empty(), "{name} displayed nothing");
|
||||
for i in shown {
|
||||
@@ -483,8 +425,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `0x788b80` walks the display vector, not the ring: a line scrolled out of view keeps
|
||||
/// whatever fade state it had.
|
||||
/// `0x788b80` walks the lines on display, not the ring.
|
||||
#[test]
|
||||
fn the_re_arm_reaches_only_the_displayed_lines() {
|
||||
let mut s = ScrollingMessageState {
|
||||
@@ -514,7 +455,7 @@ mod tests {
|
||||
s.add(format!("l{n}"), 1.0, 1.0, 1.0);
|
||||
}
|
||||
assert!(s.at_bottom());
|
||||
s.scroll_down(8); // already at bottom — no cursor move
|
||||
s.scroll_down(8); // already at the bottom: no move
|
||||
assert!(s.at_bottom());
|
||||
// 3 lines → max_scroll = 2; scroll past it clamps.
|
||||
for _ in 0..10 {
|
||||
@@ -537,7 +478,7 @@ mod tests {
|
||||
}
|
||||
s.scroll_up(8); // 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
|
||||
s.add("l4".into(), 1.0, 1.0, 1.0);
|
||||
assert_eq!(
|
||||
s.scroll_offset,
|
||||
off + 1,
|
||||
@@ -554,14 +495,12 @@ mod tests {
|
||||
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).
|
||||
// 4 rows: the newest (1) and the middle (3) fill it; a partly fitting message counts.
|
||||
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.
|
||||
// A page is 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
|
||||
@@ -587,11 +526,9 @@ mod tests {
|
||||
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))
|
||||
@@ -601,9 +538,8 @@ mod tests {
|
||||
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.
|
||||
// Default font, pitch 14. Frame [90, 124): 'new' is [90, 104), 'wrapped' [104, 132)
|
||||
// overflows and draws clipped, and 'old' would start at 132, outside, so does not draw.
|
||||
let quads = s.extract();
|
||||
let texts: Vec<(String, f32, f32)> = quads
|
||||
.iter()
|
||||
@@ -623,7 +559,6 @@ mod tests {
|
||||
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 {
|
||||
@@ -634,10 +569,8 @@ mod tests {
|
||||
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).
|
||||
/// The lines draw at the frame's ARTWORK layer: a BACKGROUND texture, such as the chat
|
||||
/// window's hover box, behind them, an OVERLAY one in front.
|
||||
#[test]
|
||||
fn ring_lines_draw_above_the_frames_background_and_below_its_overlay() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
@@ -732,7 +665,7 @@ mod tests {
|
||||
)
|
||||
.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.
|
||||
// Two ticks retire 'doomed' (phase 1, then the snap with no ramp); then fresh lines land.
|
||||
s.tick(0.1);
|
||||
s.tick(0.1);
|
||||
s.run("CF:SetTimeVisible(120)").unwrap();
|
||||
@@ -749,8 +682,7 @@ mod tests {
|
||||
_ => 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.
|
||||
// 'doomed' draws nothing but keeps its band at the top; the live lines hold the bottom two.
|
||||
assert_eq!(texts.len(), 2);
|
||||
assert_eq!(texts[0].0, "b");
|
||||
assert!((texts[0].1 - 0.0).abs() < 0.01);
|
||||
@@ -774,7 +706,6 @@ mod tests {
|
||||
.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()
|
||||
@@ -786,8 +717,8 @@ mod tests {
|
||||
_ => 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.
|
||||
// The app feeds a span (a y-up rect) over [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),
|
||||
@@ -826,14 +757,8 @@ mod tests {
|
||||
assert!(!s.add_chat_message("PlainFrame", "x", 1.0, 1.0, 1.0));
|
||||
}
|
||||
|
||||
/// **`AddMessage`'s id lives at a different stack index on each leg** (decision 2125): the
|
||||
/// sixth argument when r,g,b are present, the THIRD when they are not — which is what makes
|
||||
/// the `AddMessage(text, id)` shorthand work at all (`0x792b13 mov edx,6` /
|
||||
/// `0x792b48 mov edx,3`).
|
||||
///
|
||||
/// `AceConsole-2.0`'s `Print` is the case that matters: `AddMessage(text, nil, nil, nil, nil,
|
||||
/// 5)` fails the R gate at index 3, takes the absent leg, re-reads index 3 as the id — still
|
||||
/// nil — and stores 0. Its trailing `5` sits at index 7 and is never read.
|
||||
/// The id is stack index 6 with rgb and index 3 without (`0x792b13`, `0x792b48`), so
|
||||
/// `AceConsole-2.0`'s `AddMessage(text, nil, nil, nil, nil, 5)` stores 0.
|
||||
#[test]
|
||||
fn the_addmessage_id_index_follows_the_rgb_leg() {
|
||||
let s = UiScript::new().unwrap();
|
||||
@@ -856,8 +781,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `UpdateColorByID` recolours exactly the lines printed under that id — the stock
|
||||
/// `ChatFrame_OnEvent` repaints a type's history when its colour changes, and nothing else.
|
||||
#[test]
|
||||
fn update_color_by_id_recolours_only_that_ids_lines() {
|
||||
let mut smf = ScrollingMessageState::default();
|
||||
@@ -880,10 +803,8 @@ mod tests {
|
||||
assert_ne!(smf.lines_gen, gen, "a recolour is a redraw");
|
||||
assert_eq!(smf.update_color_by_id(11, 0.0, 0.5, 1.0), 0, "idempotent");
|
||||
assert_eq!(smf.update_color_by_id(99, 0.0, 0.0, 0.0), 0, "no such id");
|
||||
// **Id 0 matches nothing** — `0x788250`'s own opening guard (decision 2125). Line "d" was
|
||||
// printed with no id, so it carries 0; so does `ChatTypeInfo["REPLY"]`, which the
|
||||
// `UPDATE_CHAT_COLOR` handler mirrors WHISPER into. Without the guard that pair repainted
|
||||
// every colourless line in the window whisper-pink at every login.
|
||||
// Id 0 matches nothing (`0x788250`). REPLY's chat type has id 0 and `UPDATE_CHAT_COLOR`
|
||||
// mirrors WHISPER into it, so without the guard every id-less line turns whisper pink.
|
||||
assert_eq!(
|
||||
smf.update_color_by_id(0, 1.0, 0.5, 1.0),
|
||||
0,
|
||||
@@ -895,7 +816,7 @@ mod tests {
|
||||
"the colourless line stays the frame's own colour"
|
||||
);
|
||||
|
||||
// And the Lua surface: the fifth argument tags, the method recolours, silently.
|
||||
// Through Lua: argument 5 tags the line, and a bad id is silent.
|
||||
let s = UiScript::new().unwrap();
|
||||
s.run(
|
||||
"local f = CreateFrame('ScrollingMessageFrame') \
|
||||
@@ -923,11 +844,8 @@ mod tests {
|
||||
assert_eq!(s.eval::<i64>("return CF:GetNumMessages()").unwrap(), 0);
|
||||
}
|
||||
|
||||
/// The W4 skip token: a settled message frame's sweep hashes ZERO lines (the counter is the
|
||||
/// claim — 0735's counts rule), and each side of the token catches its own change class:
|
||||
/// text through the generation, the resolved width through the environment hash. The token
|
||||
/// is only stored by a zero-request sweep, so an UNANSWERED request keeps re-requesting —
|
||||
/// the region ledger's own rule (1410), inherited.
|
||||
/// A settled frame's sweep hashes no lines; a text change reopens it through the line
|
||||
/// generation, a width change through the environment hash.
|
||||
#[test]
|
||||
fn a_settled_message_frame_hashes_no_lines_until_something_moves() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
@@ -951,7 +869,7 @@ mod tests {
|
||||
again.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());
|
||||
// Settled: the NEXT sweep must skip the frame whole — zero lines hashed.
|
||||
// Settled: the next sweep skips the frame whole.
|
||||
let hashed_before = s.model_mut().msg_lines_hashed;
|
||||
assert!(s.message_lines_needing_measure().is_empty());
|
||||
assert_eq!(
|
||||
@@ -959,7 +877,7 @@ mod tests {
|
||||
hashed_before,
|
||||
"a settled frame's sweep must hash no lines"
|
||||
);
|
||||
// Text change reopens through the GENERATION…
|
||||
// A text change reopens through the generation.
|
||||
s.run("CF:AddMessage('three', 1, 1, 1)").unwrap();
|
||||
let reqs = s.message_lines_needing_measure();
|
||||
assert_eq!(reqs.len(), 1, "the new line re-surfaces");
|
||||
@@ -968,7 +886,7 @@ mod tests {
|
||||
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());
|
||||
// …and a resolved-width change reopens through the ENVIRONMENT: every line re-keys.
|
||||
// A width change reopens through the environment: every line re-keys.
|
||||
s.run("CF:SetWidth(300)").unwrap();
|
||||
s.resolve();
|
||||
let reqs = s.message_lines_needing_measure();
|
||||
|
||||
Reference in New Issue
Block a user