Files
benilla-octo/crates/benilla-ui/src/script/font_block.rs
T
SamandClaude Fable 5 251daa14c5 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

352 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! **The font block** — the ten font methods a text-bearing widget re-declares in its own method
//! table, implemented once over the [`RegionData`](super::RegionData) the glyphs actually paint
//! from, and installed onto any widget's table by [`install`].
//!
//! ## Why this is a module and not a base class
//!
//! **There is no `FontInstance` class in the 1.12.1 Lua chain.** wow-re's registrar map
//! (`system/ui/scratch/widget-api-batch-benilla.md`, and the per-table carve in
//! `font-object-lua-surface.md` §2) shows 23 flat `{const char* name, void* fn}` `.data` tables and
//! a per-class `vtable[+0x8]` lookup that tail-calls **exactly one** base class's lookup on a miss.
//! The six text-bearing types each *re-declare* the same font names in their own flat table; the
//! sharing happens one level **down**, in C++, where every binding is a thin type-guard shim that
//! tail-calls one shared implementation (`0x79f210` SetFont · `0x79f3b0` GetFont · `0x79f4d0` /
//! `0x79f680` Set/GetTextColor · `0x79f730` / `0x79f910` Set/GetShadowColor · `0x79f9c0` /
//! `0x79fad0` Set/GetShadowOffset · `0x79ef10` / `0x79f090` Set/GetFontObject).
//!
//! So: **which font methods a type has is a per-table fact, never derivable from "it draws text"** —
//! and this module is that shared C++ layer, with each widget's own table naming the subset it
//! really carries. Adding a name a table does not carry is exactly as wrong as missing one.
//!
//! ## The membership, read off the binary rather than assumed
//!
//! `EditBox`'s table is `.data 0x87bb68`, **48 entries** — the count read from the `mov edx,0x30` at
//! the registering site `0x799ab5`, never from a run-length scan (adjacent tables are contiguous and
//! a scan merges them into a bogus blob). Its **first sixteen entries are the font block**, in this
//! order, and each one is a shim that `call`s the shared implementation and returns **without
//! touching `eax`** — so every return arity below is the shared implementation's, verbatim:
//!
//! | # | EditBox entry | binding | tail-calls | returns |
//! |---|---|---|---|---|
//! | 0 | `SetFontObject` | `0x797090` | `0x79ef10` | **0 values** |
//! | 1 | `GetFontObject` | `0x797150` | `0x79f090` | **1 value** (the handle, or nil) |
//! | 2 | `SetFont` | `0x797210` | `0x79f210` | **1 value: the NUMBER 1, or nil** |
//! | 3 | `GetFont` | `0x7972d0` | `0x79f3b0` | **3 values** (path, height, flags) |
//! | 4 | `SetTextColor` | `0x797390` | `0x79f4d0` | **0 values** |
//! | 5 | `GetTextColor` | `0x797450` | `0x79f680` | **4 values** |
//! | 6 | `SetShadowColor` | `0x797510` | `0x79f730` | **0 values** |
//! | 7 | `GetShadowColor` | `0x7975d0` | `0x79f910` | **4 values** (`0x79f9b3 mov eax,0x4`) |
//! | 8 | `SetShadowOffset` | `0x797690` | `0x79f9c0` | **0 values** |
//! | 9 | `GetShadowOffset` | `0x797750` | `0x79fad0` | **2 values** |
//! | 10–11 | `Set/GetSpacing` | `0x797810` / `0x7978d0` | `0x79fb40` / `0x79fbe0` | *not installed — below* |
//! | 12–15 | `Set/GetJustifyH`, `Set/GetJustifyV` | `0x797990`… | `0x79fc20`… | *EditBox-side — below* |
//!
//! **`SetFont` is the trap here, and it points the opposite way to the one this codebase already
//! pinned.** `Button:SetFont` (`0x780880`) ends `xor eax,eax` and returns **nothing** — that
//! divergence has its own test. `EditBox:SetFont` does **not**: `0x7972b2 call 0x79f210` is followed
//! by `pop edi; pop esi; pop ebx; ret` with no `eax` write, so the shared implementation's single
//! value (`lua_pushnumber(1.0)` at `0x79f345`, or `lua_pushnil` at `0x79f361`) passes straight
//! through. It is the number `1`, not `true`, and not zero values.
//!
//! The table above was read off the PE `.data` section directly (with Font's 22-entry table as a
//! positive control) and **independently re-derived by a wow-re §5 trio**, which agrees on all 48
//! names, all 48 binding addresses and all 16 arities —
//! `system/ui/scratch/editbox-font-surface.md`. Its own control for the "nothing is discarded"
//! claim is `SetMultiLine`'s binding, which *does* emit `xor eax,eax` (`0x797e9d`) and returns 0.
//! Entry 47 ends at `0x87bce8`, the first byte of the string `"GetAltArrowKeyMode"` — the table
//! abuts its own string pool, which pins the count at 48 a second way.
//!
//! **A stated divergence, verified and deliberately left consistent rather than fixed on one table:**
//! the reference's `SetTextColor` and `SetShadowColor` read r, g and b with a **bare `lua_tonumber`**
//! (a non-number silently becomes `0.0`) and carry **no usage string at all**, so they *cannot
//! raise* — `SetTextColor()` sets opaque black. Ours take `f32`, so a missing or non-numeric channel
//! errors instead. Alpha matches (`lua_isnumber`-gated, default 1.0). `script::region`'s and
//! `script::font`'s copies are strict in the same way, so correcting only this one would put two
//! answers in the codebase for one contract; it belongs with the collapse below. No corpus site is
//! known to depend on it.
//!
//! ## Where it lands, and why that is the mechanism rather than an approximation
//!
//! Each shim loads **`[this+0x324]`** and hands it to the shared implementation as the target. On a
//! `CSimpleEditBox` that offset is the box's implicit FontString — the same field
//! [`EditBoxState::text_region`](crate::widget::EditBoxState::text_region) models (RF-0082, and it is
//! the EditBox's analogue of `ButtonText`). So `editBox:SetFontObject(ChatFontNormal)` really does
//! paint *the box's font string*, which is exactly what this module writes: resolve the widget to
//! the region its glyphs come from, then set the same [`RegionData`](super::RegionData) fields a
//! `FontString` sets for itself.
//!
//! ## Not installed here, and the reason is not laziness
//!
//! - **`SetSpacing` / `GetSpacing`.** We model no line spacing anywhere — neither `RegionData` nor
//! the text layout carries the field, and the line pitch is the font height — so a setter here
//! could only store a number nobody draws. That is the silently-ignored-setter failure this
//! codebase keeps being bitten by (1203, 1205, 1211), and the alternative failure is the good one:
//! a missing method raises `attempt to call method 'SetSpacing' (a nil value)`, which names itself.
//! Demand is **zero** — `grep -r ':SetSpacing(\|:GetSpacing('` over the 218-addon corpus finds
//! nothing at all. `script::font`'s own table withholds the pair for the same reason; when spacing
//! becomes something the renderer honours, both land together.
//! - **The justify four.** They are real EditBox entries and they *are* wired — but on the EditBox's
//! own state rather than on its text region, because our EditBox draw law forces the region's
//! justification (see `script::editbox::methods`). They live there for that reason, not here.
//!
//! ## The one duplicate this leaves, named rather than left to be found
//!
//! `script::region`'s FontString table still carries its own hand-written copies of these ten. They
//! are the same contract over the same fields and should collapse onto this module — one
//! `font_block::install(lua, &m, region_handle_of)` — when `region.rs` is split (it is over the
//! file-size budget, which is why that half is not done here). The collapse carries one correction
//! with it: that copy's `SetFont` answers the **boolean `true`**, where the bytes above say the
//! number `1` or nil.
use mlua::{Lua, Table, Value};
use super::object::as_f32;
use super::{FontShadow, Model, Outline};
use crate::widget::RegionHandle;
/// How a widget's method table resolves `this` to the region its glyphs are painted from.
///
/// A `FontString` *is* that region; an `EditBox` owns one and creates it on demand. A resolver
/// returning an error is how a method called on the wrong receiver reports it.
pub(super) type ResolveRegion = fn(&Lua, &Table) -> mlua::Result<RegionHandle>;
/// Install the ten shared font-block methods onto `m`, reading `this` through `resolve`.
///
/// The caller names the subset: this installs exactly the ten the module doc tabulates, and a
/// widget whose real table lacks one of them must not use this installer for it.
pub(super) fn install(lua: &Lua, m: &Table, resolve: ResolveRegion) -> mlua::Result<()> {
// ── the font object, and the live link to it ────────────────────────────────────────────
// SetFontObject(font | "font" | nil) → 0 values. All three argument forms the reference's own
// usage string names (`.rdata 0x87c5cc`: `Usage: %s:SetFontObject(font or "font" or nil)`).
// `Dewdrop-2.0.lua:1675` — `editBox:SetFontObject(ChatFontNormal)`, two lines after
// `CreateFrame("EditBox", nil, editBoxFrame)` — passes the OBJECT, and that one line is
// replicated into 63 of the corpus's 218 addons.
//
// A frame, a number, or an unknown name is an ERROR, never a silent no-op: every rejection in
// the reference is `luaL_error 0x6f4940`, which longjmps and aborts the call (1203/1205/1211's
// silent-drop class).
m.set(
"SetFontObject",
lua.create_function(move |lua, (this, font): (Table, Value)| {
let name = super::font::resolve("SetFontObject", &font)?;
let rh = resolve(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
// The nil form severs the link and leaves the paint standing — the reference stores a
// null parent, and nothing re-reads or clears the resolved values.
let Some(name) = name else {
model.region_data.entry(rh).or_default().font_object = None;
return Ok(());
};
let Some(fo) = model.font_objects.get(&name).cloned() else {
return Err(mlua::Error::runtime(format!(
"SetFontObject: no font object named '{name}' is registered"
)));
};
let d = model.region_data.entry(rh).or_default();
d.font_object = Some(name);
// The severance mask is deliberately NOT reset. §5-verified: the real "stop inheriting
// this property" signal is a CLEARED bit in the inheritMask (`FONTINSTANCE+0x2c`),
// cleared by each local setter and never restored — so a property the widget set for
// itself stays severed across a later `SetFontObject`.
super::font::repaint(d, &fo);
Ok(())
})?,
)?;
// GetFontObject() → exactly 1 value: the font OBJECT last resolved, or nil. The object, never
// its name — `Dewdrop-2.0.lua:2181` indexes the result immediately.
m.set(
"GetFontObject",
lua.create_function(move |lua, this: Table| {
let rh = resolve(lua, &this)?;
let name = {
let model = lua.app_data_ref::<Model>().expect("model");
model
.region_data
.get(&rh)
.and_then(|d| d.font_object.clone())
.filter(|n| model.font_objects.contains_key(n))
};
match name {
Some(n) => Ok(Value::Table(super::font::wrapper(lua, &n)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// ── the face ────────────────────────────────────────────────────────────────────────────
// SetFont(path, height [, flags]) → **the NUMBER 1, or nil** — one value either way, never a
// boolean and never zero values (the module doc's table; `0x7972b2` does not clobber `eax` the
// way `Button:SetFont` does). The shared impl gates arg2 on `lua_isstring` and arg3 on
// `lua_isnumber`, raising `Usage: %s:SetFont("font", fontHeight [, flags])` (`0x87c69c`)
// otherwise — both accept a numeric string, since `lua_isstring`/`lua_isnumber` coerce.
//
// nil is the *load-failure* answer, not an argument error: `!OmniCC/main.lua:41`'s
// `if not Font:SetFont(saved, size) then revert end` is a font-file validity probe, so an empty
// path must come back falsey rather than raising.
m.set(
"SetFont",
lua.create_function(
move |lua, (this, file, height, flags): (Table, Value, Value, Option<String>)| {
let usage = || {
mlua::Error::runtime(
"Usage: <EditBox>:SetFont(\"font\", fontHeight [, flags])".to_string(),
)
};
let path = match &file {
Value::String(s) => s.to_str()?.to_string(),
Value::Number(_) | Value::Integer(_) => as_f32(&file).to_string(),
_ => return Err(usage()),
};
let height = match &height {
Value::Number(_) | Value::Integer(_) => as_f32(&height),
Value::String(s) => s.to_str()?.parse::<f32>().map_err(|_| usage())?,
_ => return Err(usage()),
};
let rh = resolve(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
// Every argument supplied is an EXPLICIT set: it must survive a later mutation of
// the font object this region inherits.
let ok = !path.is_empty();
if ok {
d.font_path = Some(path);
d.font_explicit.face = true;
}
d.font_height = Some(height);
d.font_explicit.height = true;
if let Some(f) = flags {
// The LUA flags spelling ("OUTLINE"/"THICKOUTLINE"), not the XML attribute's
// ("NORMAL"/"THICK") — the shared parse `0x6f1a90` is a case-insensitive
// SUBSTRING scan, which is why "THICKOUTLINE" yields both bits.
d.outline = Outline::flags(&f);
d.font_explicit.outline = true;
}
Ok(if ok { Value::Number(1.0) } else { Value::Nil })
},
)?,
)?;
// GetFont() → 3 values: path, height, flagsString (`mov eax,3` at `0x79f407`). The flags string
// is `""` when there are none — built into a zeroed static buffer at `0xceea60` — never nil.
m.set(
"GetFont",
lua.create_function(move |lua, this: Table| {
let rh = resolve(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let d = model.region_data.get(&rh);
let path = match d.and_then(|d| d.font_path.clone()) {
Some(p) => Value::String(lua.create_string(&p)?),
None => Value::Nil,
};
let height = d.and_then(|d| d.font_height);
let flags = d.map(|d| d.outline).unwrap_or_default().as_str();
Ok((path, height, flags))
})?,
)?;
// ── the text colour ─────────────────────────────────────────────────────────────────────
// SetTextColor(r, g, b [, a]) → 0 values; alpha defaults to 1.0 (`lua_isnumber(L,5)`-gated,
// `0x3f800000`). A FontString has no texel of its own, so its vertex colour IS the colour it
// draws — the same `+0xb8` slot `SetVertexColor` writes.
m.set(
"SetTextColor",
lua.create_function(
move |lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
let rh = resolve(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
d.vertex_color = Some([r, g, b, a.unwrap_or(1.0)]);
// The shared impl clears the inheritMask's colour bit on whatever instance it ran
// on (`0x79dbd0`'s `and [edi+0xd4],0xfffffffb`), so an explicit colour survives a
// later repaint from the font object.
d.font_explicit.color = true;
Ok(())
},
)?,
)?;
// GetTextColor() → 4 values, r, g, b, a. Never set = the white every region draws at.
m.set(
"GetTextColor",
lua.create_function(move |lua, this: Table| {
let rh = resolve(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let c = model
.region_data
.get(&rh)
.and_then(|d| d.vertex_color)
.unwrap_or([1.0, 1.0, 1.0, 1.0]);
Ok((c[0], c[1], c[2], c[3]))
})?,
)?;
// ── the shadow ──────────────────────────────────────────────────────────────────────────
// **`GetShadowColor` returns FOUR values, not three** (`0x79f9b3`, `mov eax,0x4`). Three is the
// plausible wrong answer and it silently drops the alpha that
// `FuBar_NavigatorFu/NavigatorFu.lua:31` round-trips. `GetShadowOffset` returns two, in UI
// units (the same units as `SetWidth`/`SetPoint`), not device pixels.
//
// Either half may be set before the other, so each starts from whatever is already there —
// shadow colour and offset share one inherit slot in the reference too.
m.set(
"SetShadowColor",
lua.create_function(
move |lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
let rh = resolve(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
let offset = d.font_shadow.map_or([0.0, 0.0], |s| s.offset);
d.font_shadow = Some(FontShadow {
offset,
color: [r, g, b, a.unwrap_or(1.0)],
});
d.font_explicit.shadow = true;
Ok(())
},
)?,
)?;
m.set(
"GetShadowColor",
lua.create_function(move |lua, this: Table| {
let rh = resolve(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let c = model
.region_data
.get(&rh)
.and_then(|d| d.font_shadow)
.map_or([0.0, 0.0, 0.0, 1.0], |s| s.color);
Ok((c[0], c[1], c[2], c[3]))
})?,
)?;
// SetShadowOffset(x, y) → 0 values, and **both arguments are required** — the shared impl
// raises `Usage: %s:SetShadowOffset(x, y)` (`0x87c6e8`) rather than defaulting the missing one.
m.set(
"SetShadowOffset",
lua.create_function(move |lua, (this, x, y): (Table, f32, f32)| {
let rh = resolve(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
let color = d.font_shadow.map_or([0.0, 0.0, 0.0, 1.0], |s| s.color);
d.font_shadow = Some(FontShadow {
offset: [x, y],
color,
});
d.font_explicit.shadow = true;
Ok(())
})?,
)?;
m.set(
"GetShadowOffset",
lua.create_function(move |lua, this: Table| {
let rh = resolve(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let o = model
.region_data
.get(&rh)
.and_then(|d| d.font_shadow)
.map_or([0.0, 0.0], |s| s.offset);
Ok((o[0], o[1]))
})?,
)?;
Ok(())
}