channel lines get their warm color, and a /wave gets its sentence

- a channel line renders in the channel family's color, not the grey notice row the client consults and throws away
- a text emote composes its sentence — gender and target picked from the game's own tables — so a /wave is a line in chat, not just an animation
- a FontString's `font=` resolves a font object by name and gates the whole block, `<Gradient>` tints the art it sits on, and buttons' state fonts read `font=` too — Bagnon's white boxes and bare stack counts are fixed
- the money frame sizes its coins from the digits themselves, so Bagnon's gold stops opening cramped

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-14 19:25:00 +07:00
co-authored by Claude Fable 5
parent a8718ad62c
commit a075a2ef44
23 changed files with 1495 additions and 129 deletions
+62 -7
View File
@@ -519,10 +519,12 @@ impl UiScript {
worldmap::install(&lua)?;
net_stats::install(&lua)?;
Ok(UiScript {
let s = UiScript {
lua,
instructions: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
})
};
s.publish_number_width()?;
Ok(s)
}
/// The embedded VM — for the Bevy plugin / TOC-XML loader to add the game-state API bindings
@@ -879,12 +881,18 @@ impl UiScript {
}
/// Feed the number font's per-digit advance widths (logical px, `'0'..='9'` in order) as the
/// Lua global table `BENILLA_DIGIT_W` (keyed by digit *string*, `["0"].."["9"]`). The
/// synchronous half of the money layout's metrics (the real `SmallMoneyFrame` calls
/// `GetTextWidth` mid-update — MoneyFrame.lua l.202 — while our `GetStringWidth` measure
/// round-trip is a frame late): the app measures NumberFontNormal's digits once per atlas
/// scale and `BenillaNumberWidth` (MerchantFrame.xml) sums them per price.
/// Lua global table `BENILLA_DIGIT_W` (keyed by digit *string*, `["0"].."["9"]`).
///
/// The synchronous half of the money layout's metrics. The real `SmallMoneyFrame` calls
/// `GetTextWidth` mid-update (MoneyFrame.lua l.202) and the real client answers it inline;
/// our `GetStringWidth` is served from the measure round-trip, which lands at extract — a frame
/// AFTER the tick that set the text. A kit that sizes itself from its own label therefore reads
/// 0 the first time and stays wrong until something redraws it.
///
/// The sum lives in [`Self::publish_number_width`] beside this, not in XML: its two callers are
/// in different shipped files and no third file is a dependency of both.
pub fn set_digit_advances(&mut self, advances: &[f32; 10]) {
self.model_mut().digit_advances = Some(*advances);
let set = || -> mlua::Result<()> {
let t = self.lua.create_table()?;
for (i, w) in advances.iter().enumerate() {
@@ -899,6 +907,53 @@ impl UiScript {
}
}
/// Publish `BenillaNumberWidth(n)` — the laid-out width of a number in NumberFontNormal, the
/// sum of its digits' advances from the [`Self::set_digit_advances`] feed.
///
/// **Exact, not an approximation.** The client's width law is a floor-and-add over a per-glyph
/// advance with no kerning term, so a number's width IS the sum of its digits'. That is equally
/// why it stops at numbers: arbitrary text still needs the real measure, and a caller that wants
/// one still waits a frame.
///
/// **Published from here rather than written in XML** — which is where it started, and the move
/// is the point. Its callers are `BenillaMoney_Set` (MerchantFrame.xml) and `ShowCoin`
/// (MoneyFrame.xml), and there is no third shipped file both of them load: fixtures take
/// arbitrary subsets, so whichever file owned it, some fixture met it as nil. Seated at VM
/// construction it is simply always there, and the headless fallback lives in one place.
///
/// The 7px fallback is for a VM whose host never fed the advances — every engine test, since
/// there is no font atlas without the app. In-app the feed lands on the first frame the atlas
/// exists.
fn publish_number_width(&self) -> mlua::Result<()> {
let f = self.lua.create_function(|lua, n: mlua::Value| {
// `tostring(n)` semantics: the callers pass a number, but a string is as valid an
// argument here as it is to the Lua original this replaces.
let s = match &n {
mlua::Value::Integer(i) => i.to_string(),
mlua::Value::Number(x) => {
if *x == x.trunc() && x.is_finite() {
format!("{}", *x as i64)
} else {
x.to_string()
}
}
mlua::Value::String(s) => s.to_str()?.to_string(),
_ => String::new(),
};
let model = lua.app_data_ref::<Model>().expect("model");
let adv = model.digit_advances;
Ok(s.chars()
.map(|c| match (c.to_digit(10), adv) {
(Some(d), Some(a)) => a[d as usize],
// A non-digit (a minus sign, a separator) has no advance in the feed, and
// neither does anything before the host has fed one.
_ => 7.0,
})
.sum::<f32>())
})?;
self.lua.globals().set("BenillaNumberWidth", f)
}
// ── Input: pointer-leaves-window cleanup (decision 0216 §3; the hit-test/mouse dispatch that
// used to sit here now lives in [`pointer`]) ─────────────────────────────────────────────────
+5
View File
@@ -24,6 +24,10 @@ pub(crate) struct Model {
pub(crate) addons: Vec<super::addon::AddOnInfo>,
/// The AddOns folder, so `LoadAddOn` can read an addon's files from inside a Lua binding.
pub(crate) addons_root: Option<std::path::PathBuf>,
/// NumberFontNormal's per-digit advances, `'0'..='9'` ([`super::UiScript::set_digit_advances`])
/// — the synchronous stand-in for a text measure, read by the `BenillaNumberWidth` binding.
/// `None` until the host feeds them, which is every VM without a font atlas (every engine test).
pub(crate) digit_advances: Option<[f32; 10]>,
/// Where per-addon saved variables live: the account-scoped folder, then this character's.
/// Both are directories holding one `<Addon>.lua` per declaring addon.
pub(crate) addons_saved_account: Option<std::path::PathBuf>,
@@ -1010,6 +1014,7 @@ impl Model {
Model {
addons: Vec::new(),
addons_root: None,
digit_advances: None,
addons_saved_account: None,
addons_saved_character: None,
framexml_templates: Default::default(),