Lua bindings coerce numbers, flags and colours as 1.12.1 does (#991)

Lua bindings now read their arguments the way the 1.12.1 client does:

- Numeric strings parse as Storm's `SStrToInt` (`0x64ac60`) and `SStrToFloat` (`0x64aaa0`). That covers channel keys, `GetNumber` and `item:` fields.
- Flag arguments go through `GetBoolOrDefault` (`0x6f1c10`) with each site's default. That is about 40 bindings, `SetChecked()` and `ShowHelm()` among them.
- Colour channels take numeric strings (`lua_tonumber`), and alphas are `isnumber`-gated: no raise, 1.0 by default. A three-argument `SetVertexColor` keeps the current alpha (`0x79ae0a`). So action-bar rings dimmed by a drag stay dimmed, as in 1.12.1.
- `SetTexture("1")` takes the colour form. MessageFrame channels follow `0x795590`.
- `LoggingChat`/`LoggingCombat` return the flag.

Binding-level tests use the reference's values.

Fixes #135
Fixes #144
Fixes #160
Fixes #161

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-12 13:41:47 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent b2926edf53
commit 1efd9a7163
38 changed files with 1599 additions and 502 deletions
@@ -840,9 +840,16 @@ fn a_held_payload_ghosts_the_empty_wells_it_opens() {
let occupied = ring_alphas(&s, quickslot2);
assert_eq!(occupied, vec![0.5], "the occupied ring ghosts too");
// Filling a ghosted well runs `ActionButton_UpdateUsable` (ActionButton.lua:269-283), whose
// three-argument `SetVertexColor(1, 1, 1)` sets alpha 1.0 in this engine; the reference keeps
// the region's alpha (`0x79abd0`), which would leave this ring dim.
// Filling a ghosted well runs `ActionButton_Update` (ActionButton.lua:162, 170). Its
// `SetNormalTexture(path)` (`0x781960` -> `0x781970`) reuses the XML-defined region in
// `[button + 4*state + 0x4b8]` (`0x778f10`) and sets only its file through `0x770200`, which
// writes `[tex+0xcc]` and `[tex+0x128]` and redraws (`0x77fdf0`); the colour array
// (`+0xa4/+0xa8/+0xb4/+0xb8`) is written by `0x77f750` alone, whose callers are the Lua
// colour setters and the XML loader, none a button state change. Then
// `ActionButton_UpdateUsable` (:274-275, :281) calls the three-argument
// `SetVertexColor(1, 1, 1)`, which keeps the region's alpha (`0x79abd0` re-tests the alpha
// gate at `0x79adfe` and copies the old byte back at `0x79ae0a`). So the filled well's ring
// stays at the grid's 0.5 like the occupied one.
s.set_action(
2,
Some(ActionSlot {
@@ -857,9 +864,8 @@ fn a_held_payload_ghosts_the_empty_wells_it_opens() {
s.resolve();
assert_eq!(
ring_alphas(&s, quickslot2),
vec![0.5, 1.0],
"the well that got an action is opaque again (its Update ran UpdateUsable); the other \
occupied ring keeps its ghost, payload still held"
vec![0.5, 0.5],
"the filled well's UpdateUsable keeps the ghost alpha, as the occupied ring's does"
);
s.fire_event("ACTIONBAR_HIDEGRID", vec![]);
@@ -868,12 +874,14 @@ fn a_held_payload_ghosts_the_empty_wells_it_opens() {
let after = ring_alphas(&s, quickslot2);
assert_eq!(
after,
vec![0.5, 1.0],
"HideGrid touches no colour: a ring the grid ghosted stays dim past the payload — the \
reference's own rule (ours used to clear it here)"
vec![0.5, 0.5],
"HideGrid touches no colour (ActionButton.lua:251-258): every ring the grid ghosted stays \
dim past the payload"
);
// The ghosted ring's next `ActionButton_UpdateUsable` is not asserted: this engine resets its
// alpha to 1.0, the reference keeps 0.5 (`0x79abd0`).
// A later `ActionButton_UpdateUsable` (a target change fires it) keeps the dim alpha too.
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
s.resolve();
assert_eq!(ring_alphas(&s, quickslot2), vec![0.5, 0.5]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -346,7 +346,10 @@ fn set_checked_uses_blizzard_bool_coercion() {
("\"false\"", false),
("\"1\"", true),
("\"0\"", false),
("\"junk\"", false),
// An unknown first byte takes the default, 1 (`0x6f1c81`-`0x6f1cdc`).
("\"junk\"", true),
("\"off\"", false),
("", true),
] {
let got = s
.eval::<bool>(&format!(
+4 -15
View File
@@ -7,7 +7,7 @@
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::flag;
use super::binding_abi::{bool_or_default, flag};
use super::Model;
/// The kind byte, bits 24-31 of the packed slot word (vmangos `Player.h:133`). Must match
@@ -137,17 +137,6 @@ impl super::UiScript {
}
}
/// `UseAction`'s flag test: `nil`, `false` and `0` are false, unlike Lua, where `0` is true.
pub(super) fn truthy_nonzero(v: &Value) -> bool {
match v {
Value::Nil => false,
Value::Boolean(b) => *b,
Value::Integer(i) => *i != 0,
Value::Number(n) => *n != 0.0,
_ => true,
}
}
/// Register the action globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
@@ -253,9 +242,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"UseAction",
lua.create_function(|lua, (action, rest): (u32, MultiValue)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let mut rest = rest.iter();
let check_cursor = rest.next().is_some_and(truthy_nonzero);
let on_self = rest.next().is_some_and(truthy_nonzero);
// Both flags go through `GetBoolOrDefault` (`0x4e717e`, `0x4e716f`), default 0.
let check_cursor = bool_or_default(rest.front(), false);
let on_self = bool_or_default(rest.get(1), false);
if check_cursor && model.cursor.is_some() {
super::cursor::place_action(&mut model, action);
} else {
+197 -2
View File
@@ -155,6 +155,118 @@ fn trim_g(s: &str) -> &str {
s.trim_end_matches('0').trim_end_matches('.')
}
/// Storm's `SStrToInt` (`0x64ac60`): an optional leading `-` (and no `+`), then the decimal digits
/// up to the first other byte, with no whitespace skip; none is 0. The accumulator is a 32-bit
/// register that wraps (`0x64ac98`-`0x64aca5`) and `neg` wraps too, so `"4294967297"` is 1.
/// `RemoveChatWindowChannel` and `LeaveChannelByName` key on it (`0x49e880`), and the `item:` link
/// fields and `SStrToFloat`'s exponent read it.
pub(crate) fn s_str_to_int(s: &str) -> i64 {
let b = s.as_bytes();
let (neg, mut i) = match b.first() {
Some(b'-') => (true, 1),
_ => (false, 0),
};
let mut n: i32 = 0;
while let Some(&c) = b.get(i) {
if !c.is_ascii_digit() {
break;
}
n = n.wrapping_mul(10).wrapping_add(i32::from(c - b'0'));
i += 1;
}
i64::from(if neg { n.wrapping_neg() } else { n })
}
/// Storm's `SStrToFloat` (`0x64aaa0`), which `EditBox:GetNumber` (`0x798790`) pushes: a prefix
/// parse that stops at the first byte outside its grammar and skips no whitespace, evaluated with
/// the x87 at 53-bit precision (`_PC_53`), so every operation below rounds as an `f64` does.
///
/// - an optional `-` (no `+`)
/// - the integer digits, which may be none (`".5"`, `"-"`), summed in chunks: a 32-bit register
/// takes digits until it reaches `0x19999999` (`0x64ab19`), then `value = value * 10^n + chunk`
/// (`0x64ab21`-`0x64ab48`) and a new chunk starts
/// - a `.` is consumed whether or not a digit follows it (`0x64ab9f`: `esi` is advanced before the
/// test), so `"5.e3"` is 5000; each fraction digit `d` at place `k` (0 for the first) then adds
/// the table term `d * 10^-(k+1)` (`0x64abe4`, table `0xc52560` built at `0x64a680`; past place
/// 19 the same product is formed on the spot, `0x64abbb`-`0x64abda`), so `"0.12"` is
/// `0.1 + 0.02` = 0.12000000000000001
/// - `e` or `E`, an optional `+`, then [`s_str_to_int`] of what follows scales the value by
/// `pow(10, exp)` (`0x64ac13`-`0x64ac40`): `"1e"` is 1, `"1e+-2"` is 0.01, and the wrapped
/// exponent means `"1e4294967297"` is 10
///
/// Deviation: `pow(10, n)` is the platform's, where the reference's (`0x73f940`, `fyl2x`) rounds
/// its extended-precision steps at 53 bits, so a power or a fraction term can differ in the last
/// place. The reference also keeps the product `value * 10^exp` in the x87's wider exponent range
/// until it is stored, so an exponent past +-308 against a value that pulls it back in range
/// (`"0.000...1e320"`) is reached here only by scaling in steps of 300 digits, which rounds more
/// often; `"0e999"` is 0 as there, and only an exponent past +-4932 makes 0 * inf, NaN.
pub(crate) fn s_str_to_float(s: &str) -> f64 {
let b = s.as_bytes();
let at = |i: usize| b.get(i).copied().unwrap_or(0);
let neg = at(0) == b'-';
let mut i = usize::from(neg);
let mut value = 0.0f64;
let (mut chunk, mut count) = (0u32, 0i32);
while at(i).is_ascii_digit() {
chunk = chunk * 10 + u32::from(at(i) - b'0');
count += 1;
i += 1;
if chunk >= 0x1999_9999 {
value = value * 10f64.powf(f64::from(count)) + f64::from(chunk);
(chunk, count) = (0, 0);
}
}
value = value * 10f64.powf(f64::from(count)) + f64::from(chunk);
if at(i) == b'.' {
i += 1;
let mut place = 1.0;
while at(i).is_ascii_digit() {
value += f64::from(at(i) - b'0') * 10f64.powf(-place);
place += 1.0;
i += 1;
}
}
if matches!(at(i), b'e' | b'E') {
i += 1;
if at(i) == b'+' {
i += 1;
}
value = scale_by_pow10(value, s_str_to_int(&s[i..]) as i32);
}
if neg {
-value
} else {
value
}
}
/// `value * pow(10, exp)` as the x87 forms it: the power keeps the extended exponent range until
/// the product is stored as a double (see [`s_str_to_float`]).
fn scale_by_pow10(value: f64, exp: i32) -> f64 {
let p = 10f64.powf(f64::from(exp));
if p.is_finite() && p != 0.0 {
return value * p;
}
if value == 0.0 || !value.is_finite() {
// 0 * a finite extended power is 0; the power overflows the extended range only past 4932.
return if exp > 4932 || !value.is_finite() {
value * p
} else {
value * 0.0
};
}
let mut v = value;
let mut left = exp;
while left.abs() > 300 {
v *= 10f64.powf(f64::from(300 * left.signum()));
left -= 300 * left.signum();
if v == 0.0 || v.is_infinite() {
return v;
}
}
v * 10f64.powf(f64::from(left))
}
/// `GetBoolOrDefault` (`0x6f1c10`, jump table `0x6f1ce8`), the reference's boolean argument
/// coercion, which is not Lua truthiness:
///
@@ -176,9 +288,10 @@ pub(crate) fn bool_or_default(v: Option<&Value>, default: bool) -> bool {
Value::Nil => false,
Value::Boolean(b) => *b,
Value::LightUserData(_) => default,
// Truncated like `0x40a2b0`, testing only the low dword.
// Truncated like `0x40a2b0` ([`ftol`]), which keeps only the low dword: 2^32 reads 0, and
// an infinity or NaN too.
Value::Integer(i) => (*i as i32) != 0,
Value::Number(n) => (*n as i64 as i32) != 0,
Value::Number(n) => ftol(*n) != 0,
Value::String(s) => {
let bytes = s.as_bytes();
match bytes.first() {
@@ -227,6 +340,75 @@ mod tests {
Lua::new()
}
/// `SStrToInt` (`0x64ac60`): an optional `-`, digits, nothing skipped, a 32-bit wrap.
#[test]
fn s_str_to_int_is_the_references_atoi() {
assert_eq!(s_str_to_int("2589"), 2589);
assert_eq!(s_str_to_int("-5"), -5);
assert_eq!(s_str_to_int(""), 0);
assert_eq!(s_str_to_int("abc"), 0);
assert_eq!(s_str_to_int(" 12"), 0, "no leading-whitespace skip");
assert_eq!(s_str_to_int("+12"), 0, "no unary plus");
assert_eq!(s_str_to_int("12abc"), 12, "stops at the first non-digit");
assert_eq!(s_str_to_int("-"), 0);
assert_eq!(
s_str_to_int("2147483648"),
-2_147_483_648,
"the register wraps"
);
assert_eq!(s_str_to_int("4294967297"), 1);
assert_eq!(s_str_to_int("-4294967297"), -1);
}
/// `SStrToFloat` (`0x64aaa0`): the grammar's quirks, each read off the bytes.
#[test]
fn s_str_to_float_is_the_references_prefix_parse() {
for (text, want) in [
("12", 12.0),
("12abc", 12.0),
("-12.5x", -12.5),
(" 12", 0.0),
("+12", 0.0),
("inf", 0.0),
("1e", 1.0),
("1e3", 1000.0),
("1E+3", 1000.0),
("1e-2", 0.01),
("1e+-2", 0.01),
("1e3.5", 1000.0),
(".5", 0.5),
("-.5", -0.5),
("5.", 5.0),
("5.e2", 500.0),
("5.5.5", 5.5),
("e5", 0.0),
("0x1f", 0.0),
("1234567890123", 1_234_567_890_123.0),
] {
assert_eq!(s_str_to_float(text), want, "{text:?}");
}
// The fraction is a sum of per-digit table terms, not one correctly rounded parse.
assert_eq!(s_str_to_float("0.12"), 0.1 + 0.02);
assert_ne!(s_str_to_float("0.12"), 0.12);
assert_eq!(s_str_to_float("1.5"), 1.5);
// The exponent is `SStrToInt`'s 32-bit wrap.
assert_eq!(s_str_to_float("1e4294967297"), 10.0);
// The integer part sums in chunks of up to ten digits.
assert_eq!(
s_str_to_float("12345678901234567890"),
(1_234_567_890.0 * 1e10) + 1_234_567_890.0
);
// The product keeps the x87's wide exponent until it is stored.
assert_eq!(s_str_to_float("0e999"), 0.0);
assert!(
s_str_to_float("0e5000").is_nan(),
"0 * inf past the extended range"
);
assert_eq!(s_str_to_float("1e-999"), 0.0);
assert_eq!(s_str_to_float("1e999"), f64::INFINITY);
assert!(s_str_to_float("-").is_sign_negative(), "'-' alone is -0");
}
/// `_ftol`'s low dword: chopped toward zero, wrapped to 32 bits, and 0 for whatever the qword
/// `fistp` cannot hold.
#[test]
@@ -260,6 +442,19 @@ mod tests {
assert!(!bool_or_default(Some(&Value::Number(-0.999)), true));
assert!(bool_or_default(Some(&Value::Number(1.5)), false));
assert!(bool_or_default(Some(&Value::Number(-1.5)), false));
// Only the low dword of the truncation counts (`0x40a2b0`): 2^32 reads 0, and so does an
// infinity or NaN, which the `fistp` stores as the integer indefinite.
assert!(!bool_or_default(
Some(&Value::Number(4_294_967_296.0)),
true
));
assert!(bool_or_default(
Some(&Value::Number(4_294_967_297.0)),
false
));
for n in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN, 9.3e18] {
assert!(!bool_or_default(Some(&Value::Number(n)), true), "{n}");
}
assert!(!bool_or_default(Some(&Value::Integer(0)), true));
assert!(bool_or_default(Some(&Value::Integer(7)), false));
+24 -39
View File
@@ -757,16 +757,15 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
m.set(
"SetTextColor",
lua.create_function(
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Option<f32>)| {
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Value)| {
let a = super::object::alpha_f32(lua, &a);
// Shape C on the channels (`Button:SetTextColor 0x780ee0`, `2=C 3=C 4=C 5=B`).
let (r, g, b) = (
super::object::as_f32(&r),
super::object::as_f32(&g),
super::object::as_f32(&b),
super::object::tonumber_f32(lua, &r),
super::object::tonumber_f32(lua, &g),
super::object::tonumber_f32(lua, &b),
);
with_button(lua, &this, |bs| {
bs.normal_color = Some([r, g, b, a.unwrap_or(1.0)])
})
with_button(lua, &this, |bs| bs.normal_color = Some([r, g, b, a]))
},
)?,
)?;
@@ -790,32 +789,30 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
m.set(
"SetHighlightTextColor",
lua.create_function(
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Option<f32>)| {
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Value)| {
let a = super::object::alpha_f32(lua, &a);
// Shape C on the channels (`Button:SetTextColor 0x780ee0`, `2=C 3=C 4=C 5=B`).
let (r, g, b) = (
super::object::as_f32(&r),
super::object::as_f32(&g),
super::object::as_f32(&b),
super::object::tonumber_f32(lua, &r),
super::object::tonumber_f32(lua, &g),
super::object::tonumber_f32(lua, &b),
);
with_button(lua, &this, |bs| {
bs.highlight_color = Some([r, g, b, a.unwrap_or(1.0)])
})
with_button(lua, &this, |bs| bs.highlight_color = Some([r, g, b, a]))
},
)?,
)?;
m.set(
"SetDisabledTextColor",
lua.create_function(
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Option<f32>)| {
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Value)| {
let a = super::object::alpha_f32(lua, &a);
// Shape C on the channels (`Button:SetTextColor 0x780ee0`, `2=C 3=C 4=C 5=B`).
let (r, g, b) = (
super::object::as_f32(&r),
super::object::as_f32(&g),
super::object::as_f32(&b),
super::object::tonumber_f32(lua, &r),
super::object::tonumber_f32(lua, &g),
super::object::tonumber_f32(lua, &b),
);
with_button(lua, &this, |bs| {
bs.disabled_color = Some([r, g, b, a.unwrap_or(1.0)])
})
with_button(lua, &this, |bs| bs.disabled_color = Some([r, g, b, a]))
},
)?,
)?;
@@ -928,24 +925,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
texture_pair(lua, &c, "DisabledChecked", Slot::DisabledChecked)?;
c.set(
"SetChecked",
lua.create_function(|lua, (this, v): (Table, Value)| {
// `SetChecked` (`0x799bf0`) reads its argument through `0x6f1c10` with default 1
// (`0x799c77`), not Lua truthiness: a number truncates toward zero (`0x40a2b0`), so
// `SetChecked(0)` unchecks, and the stock UI passes `"true"`/`"false"`
// (`SpellBookFrame.lua:296-303`). Here a missing argument unchecks and a string checks
// only as `"true"` or a nonzero number, where the reference checks for a missing one
// and reads a string by its first byte, so `"yes"` checks there.
let checked = match v {
Value::Boolean(b) => b,
Value::Integer(i) => i != 0,
Value::Number(n) => n.trunc() != 0.0,
Value::String(s) => s.to_str().ok().is_some_and(|s| {
let t = s.trim();
t.eq_ignore_ascii_case("true")
|| t.parse::<f64>().is_ok_and(|n| n.trunc() != 0.0)
}),
_ => false,
};
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
// `SetChecked` (`0x799bf0`) reads its argument through `GetBoolOrDefault` (`0x6f1c10`)
// with default 1 (`0x799c77`), not Lua truthiness: no argument, `""`, a table or
// `"yes"` check, `nil`, `0` and `"0"` uncheck. The stock UI passes `"true"`/`"false"`
// (`SpellBookFrame.lua:296-303`).
let checked = super::binding_abi::bool_or_default(args.front(), true);
with_button_shown(lua, &this, |bs| bs.refresh_checked(checked, false))
})?,
)?;
+17 -1
View File
@@ -398,7 +398,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
.to_string(),
_ => return Err(mlua::Error::runtime(r#"Usage: LeaveChannelByName("name")"#)),
};
if super::chat_window::leading_int(&key) != 0 {
if super::binding_abi::s_str_to_int(&key) != 0 {
push(lua, ChannelCommand::Leave { name: key });
return Ok(());
}
@@ -711,6 +711,22 @@ mod command_tests {
"…and strips no window: a slot name never equals a window entry"
);
// `SStrToInt` takes no `+`: "+2" is a channel name, so its window entry is stripped, which
// the numeric leg (a slot, never a window entry) does not do.
s.run("AddChatWindowChannel(1, '+2')").unwrap();
s.take_channel_commands();
s.take_chat_window_changes();
s.run("LeaveChannelByName('+2')").unwrap();
assert!(
!s.chat_window_looks()[0]
.channels
.iter()
.any(|(c, _)| c == "+2"),
"a '+'-prefixed key is a name"
);
s.take_channel_commands();
s.take_chat_window_changes();
s.run("LeaveChannelByName('general')").unwrap();
assert_eq!(
s.take_channel_commands(),
+27 -30
View File
@@ -5,6 +5,7 @@
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::bool_or_default;
use super::Model;
/// `DoEmote(token [, target])`: the `EmotesText.dbc` name token (`"WAVE"`, not `/wave`) and an
@@ -31,17 +32,6 @@ fn to_u32(v: &Value) -> u32 {
}
}
fn flag_arg(v: Option<Value>) -> Option<bool> {
match v {
None | Some(Value::Nil) => None,
Some(Value::Boolean(b)) => Some(b),
Some(Value::Integer(n)) => Some(n != 0),
Some(Value::Number(n)) => Some(n != 0.0),
Some(Value::String(s)) => Some(s.to_str().is_ok_and(|s| s != "0" && !s.is_empty())),
Some(_) => Some(true),
}
}
impl super::UiScript {
/// `DoEmote` calls since the last drain.
pub fn take_emote_requests(&mut self) -> Vec<EmoteRequest> {
@@ -167,27 +157,30 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
for (name, chat) in [("LoggingChat", true), ("LoggingCombat", false)] {
g.set(
name,
lua.create_function(move |lua, flag: Option<Value>| {
lua.create_function(move |lua, args: MultiValue| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let slot = if chat {
&mut model.logging_chat
} else {
&mut model.logging_combat
};
match flag_arg(flag) {
None => Ok(MultiValue::from_vec(vec![if *slot {
Value::Integer(1)
// With an argument (`lua_gettop > 0`, `0x49fe57`) the flag is set from
// `GetBoolOrDefault` (`0x49fe69`, default 1), so an explicit nil clears it. Both
// paths then answer the flag as it stands, 1 or nil, one value (`0x49feb2`-`0x49fee1`):
// the reference re-reads it after the log-open attempt, which can clear it
// (`0x49feab`); the app owns that attempt here.
if let Some(on) = args.front().map(|v| bool_or_default(Some(v), true)) {
let slot = if chat {
&mut model.logging_chat
} else {
Value::Nil
}])),
Some(on) => {
if *slot != on {
*slot = on;
model.logging_changed = true;
}
Ok(MultiValue::new())
&mut model.logging_combat
};
if *slot != on {
*slot = on;
model.logging_changed = true;
}
}
let now = if chat {
model.logging_chat
} else {
model.logging_combat
};
Ok(if now { Value::Integer(1) } else { Value::Nil })
})?,
)?;
}
@@ -289,8 +282,8 @@ mod tests {
assert!(s.eval::<bool>("return LoggingCombat() == nil").unwrap());
assert_eq!(
s.arity("LoggingChat(true)").unwrap(),
0,
"the setter returns nothing"
1,
"the setter answers the flag too (`0x49fed3`)"
);
assert_eq!(s.eval::<i64>("return LoggingChat()").unwrap(), 1);
assert_eq!(s.logging_flags(), (true, false));
@@ -303,5 +296,9 @@ mod tests {
!s.take_logging_changes(),
"a write that moves nothing cues nothing"
);
// The setter answers the flag it leaves: 1 or nil, one value either way.
assert_eq!(s.eval::<i64>("return LoggingChat(true)").unwrap(), 1);
assert_eq!(s.arity("LoggingChat(nil)").unwrap(), 1);
assert!(s.eval::<bool>("return LoggingChat(nil) == nil").unwrap());
}
}
+64 -51
View File
@@ -205,28 +205,6 @@ impl ChatWindowLook {
}
}
/// The leading decimal integer after optional whitespace and a sign, 0 when there is none. The
/// reference's `SStrToInt` (`0x64ac60`) takes neither the whitespace nor a `+`.
pub(super) fn leading_int(s: &str) -> i64 {
let s = s.trim_start();
let (neg, digits) = match s.strip_prefix('-') {
Some(rest) => (true, rest),
None => (false, s.strip_prefix('+').unwrap_or(s)),
};
let n: i64 = digits
.chars()
.take_while(|c| c.is_ascii_digit())
.fold(0i64, |acc, c| {
acc.saturating_mul(10)
.saturating_add(i64::from(c as u8 - b'0'))
});
if neg {
-n
} else {
n
}
}
/// The byte `__ftol(x · 255.0)` stores, clamped (the module's deviation); non-finite stores 0.
fn to_byte(x: f64) -> u8 {
if !x.is_finite() {
@@ -407,11 +385,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// SetChatWindowLocked(id, isLocked) (`0x4a1650`): the record's `+0x8c` (`0x4a16a2`), 1 at
// boot (`0x4984e4`). A `bool` holds it, as the cache writer stores it through `setne`
// (`0x499e8b`). The argument is Lua truthiness, where the reference coerces through `0x6f1c10`
// (default 0): `0` and "off" unlock there and lock here. FrameXML passes only 1 and nil.
// (`0x499e8b`). The argument goes through `GetBoolOrDefault` (`0x4a1697`, default 0), so `0`
// and "off" unlock and "yes" locks. FrameXML passes only 1 and nil.
lua.globals().set(
"SetChatWindowLocked",
lua.create_function(|lua, (id, locked): (i64, bool)| {
lua.create_function(|lua, (id, locked): (i64, Option<Value>)| {
let locked = crate::script::binding_abi::bool_or_default(locked.as_ref(), false);
let i = window_index(id)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let look = &mut model.chat_window_looks[i];
@@ -513,31 +492,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// SetChatWindowShown(id [, shown]): `+0x94` through the flag coercion `0x6f1c10`, default 1.
// No argument stores 1; nil, false, 0, "0", "off" and "disabled" store 0. The reference also
// stores 0 for a string opening F or N ("false", "no"), which stores 1 here.
// SetChatWindowShown(id [, shown]): `+0x94` (`0x4a1782`) through `GetBoolOrDefault`
// (`0x4a1777`) with default 1: no argument stores 1; nil, false, `0`, "0", "off", "disabled"
// and anything opening F or N ("false", "no") store 0.
lua.globals().set(
"SetChatWindowShown",
lua.create_function(|lua, (id, rest): (i64, MultiValue)| {
let shown = match rest.front() {
None => true,
Some(Value::Nil) | Some(Value::Boolean(false)) => false,
Some(Value::Boolean(true)) => true,
Some(Value::Integer(n)) => *n != 0,
Some(Value::Number(n)) => n.is_finite() && n.trunc() != 0.0,
Some(Value::String(s)) => {
let s = s
.to_str()
.map(|s| s.trim().to_ascii_lowercase())
.unwrap_or_default();
if s.starts_with(|c: char| c.is_ascii_digit() || c == '-') {
leading_int(&s) != 0
} else {
!(s == "off" || s == "disabled")
}
}
Some(_) => true,
};
let shown = crate::script::binding_abi::bool_or_default(rest.front(), true);
with_look(lua, id, |look| look.shown = shown)
})?,
)?;
@@ -620,7 +581,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// RemoveChatWindowChannel(id, name | n) (`0x4a1260`): a numeric key names the joined channel
// in slot n, and an empty slot removes nothing; the key then resolves to its DBC Shortcut as
// in slot n, and an empty slot or a negative number removes nothing; the key then resolves to its DBC Shortcut as
// above, and the entry of that name is freed.
lua.globals().set(
"RemoveChatWindowChannel",
@@ -630,8 +591,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
};
let key = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let n = leading_int(&key);
let key = if n > 0 {
let n = crate::script::binding_abi::s_str_to_int(&key);
// `0x49e880`: a non-zero `SStrToInt` names a joined slot, and `0x49be50` answers
// NULL below slot 1, so `"-1"` removes nothing (`0x4a12c9`); a zero keeps the
// string raw, untrimmed, for the case-insensitive compares (`0x4a1315`,
// `0x4a1359`).
let key = if n != 0 {
match usize::try_from(n)
.ok()
.and_then(|n| model.joined_channels.get(n - 1))
@@ -641,7 +606,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
None => return Ok(()),
}
} else {
key.trim().to_string()
key
};
match model
.zone_channel_catalog
@@ -1062,6 +1027,54 @@ mod record_tests {
assert_eq!(s.take_chat_window_changes(), vec![0]);
}
/// A channel key is `SStrToInt` (`0x64ac60`, through `0x49e880`): an optional `-` and digits,
/// with no whitespace skip and no `+`, so only a plain number names a joined slot.
#[test]
fn a_channel_key_is_a_slot_only_as_sstrtoint_reads_it() {
let mut s = UiScript::new().unwrap();
s.set_joined_channels(vec![Some("MyChan".into())]);
s.run("AddChatWindowChannel(1, 'MyChan')").unwrap();
for key in ["+1", " 1", "x1"] {
s.run(&format!("RemoveChatWindowChannel(1, {key:?})"))
.unwrap();
assert_eq!(
s.chat_window_looks()[0].channels,
vec![("MyChan".to_string(), 0)],
"{key:?} is a name, not slot 1"
);
}
// A negative number is a slot too (non-zero, `0x49e889`) and no slot is below 1.
s.run("AddChatWindowChannel(1, '-1')").unwrap();
s.run("RemoveChatWindowChannel(1, '-1')").unwrap();
assert_eq!(
s.chat_window_looks()[0].channels,
vec![("MyChan".to_string(), 0), ("-1".to_string(), 0)],
"'-1' removes nothing"
);
// A name is raw: the blanks around it are part of it.
s.set_chat_window_looks([(
4,
ChatWindowLook {
channels: vec![(" pad ".into(), 0)],
..Default::default()
},
)]);
s.run("RemoveChatWindowChannel(5, 'pad')").unwrap();
assert_eq!(
s.chat_window_looks()[4].channels.len(),
1,
"'pad' is not ' pad '"
);
s.run("RemoveChatWindowChannel(5, ' pad ')").unwrap();
assert!(s.chat_window_looks()[4].channels.is_empty());
s.run("RemoveChatWindowChannel(1, '1abc')").unwrap();
assert_eq!(
s.chat_window_looks()[0].channels,
vec![("-1".to_string(), 0)],
"'1abc' reads 1 and frees the channel joined in slot 1"
);
}
#[test]
fn a_host_loaded_record_is_what_the_verbs_read() {
let mut s = UiScript::new().unwrap();
+3 -3
View File
@@ -239,9 +239,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// Shape C on r, g, b (`ColorSelect:SetColorRGB 0x78eae0`, `2=C 3=C 4=C 5=B`).
lua.create_function(|lua, (this, r, g, b): (Table, Value, Value, Value)| {
let (r, g, b) = (
crate::script::object::as_f64(&r),
crate::script::object::as_f64(&g),
crate::script::object::as_f64(&b),
crate::script::object::tonumber_f64(lua, &r),
crate::script::object::tonumber_f64(lua, &g),
crate::script::object::tonumber_f64(lua, &b),
);
// Store through the client's quantizer, then fire with what the widget now holds: the
// round-tripped values, identical to the next `GetColorRGB`, not the raw arguments.
@@ -57,13 +57,13 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(())
})?,
)?;
// GetNumber: the real text as a number, else 0. The reference (`0x798790`) is atof, which also
// reads the leading number of a text like "12abc".
// GetNumber (`0x798790`): the real text through Storm's `SStrToFloat` (`0x64aaa0`), a prefix
// parse, so "12abc" is 12 and " 12", "+12" and "inf" are 0.
m.set(
"GetNumber",
lua.create_function(|lua, this: Table| {
let text = with_editbox(lua, &this, |eb| eb.text.clone())?;
Ok(text.trim().parse::<f64>().unwrap_or(0.0))
Ok(crate::script::binding_abi::s_str_to_float(&text))
})?,
)?;
// Insert: a nil is a no-op, not an error, since the reference reads the argument through
@@ -293,8 +293,10 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
let refresh_text = name == "SetMultiLine";
m.set(
name,
lua.create_function(move |lua, (this, v): (Table, Value)| {
let on = !matches!(v, Value::Nil | Value::Boolean(false));
lua.create_function(move |lua, (this, args): (Table, mlua::MultiValue)| {
// `GetBoolOrDefault` (`0x6f1c10`) with default 1 (`0x797d1e`, `0x797e8e`,
// `0x797ffe`, `0x79816e`): a bare call enables, `"0"` and `nil` disable.
let on = crate::script::binding_abi::bool_or_default(args.front(), true);
with_editbox(lua, &this, |eb| set(eb, on))?;
if refresh_text {
// A multi-line box seats its text TOP and as tall as the text, a single-line
@@ -372,7 +374,7 @@ fn install_font_block(lua: &Lua, m: &Table) -> mlua::Result<()> {
/// A `Set<Flag>` method name and the field write it drives.
type FlagSetter = (&'static str, fn(&mut EditBoxState, bool));
/// The four config-flag setters, each taking Lua truthiness.
/// The four config-flag setters, each reading its flag with `GetBoolOrDefault`.
fn flag_setters() -> [FlagSetter; 4] {
[
("SetAutoFocus", |eb, on| eb.auto_focus = on),
@@ -656,6 +656,85 @@ fn get_number_parses_the_text() {
assert_eq!(s.eval::<f64>("return E:GetNumber()").unwrap(), 0.0);
}
/// `GetNumber` pushes `SStrToFloat` of the text (`0x798790` → `0x64aaa0`): a prefix parse with no
/// whitespace skip and no `+`.
#[test]
fn get_number_is_the_references_prefix_parse() {
let s = script();
s.run(r#"E = CreateFrame("EditBox", "E")"#).unwrap();
for (text, want) in [
("12abc", 12.0),
(" 12", 0.0),
("+12", 0.0),
("inf", 0.0),
("nan", 0.0),
("-12.5x", -12.5),
(".5", 0.5),
("5.", 5.0),
("1e3", 1000.0),
("1E+2", 100.0),
("0x10", 0.0),
("", 0.0),
] {
s.run(&format!("E:SetText({text:?})")).unwrap();
assert_eq!(
s.eval::<f64>("return E:GetNumber()").unwrap(),
want,
"{text:?}"
);
}
}
/// The four config flags are `GetBoolOrDefault` reads with default 1 (`0x797d1e`, `0x797e8e`,
/// `0x797ffe`, `0x79816e`): a bare call enables, nil, `"0"` and `"no"` disable.
#[test]
fn editbox_flag_setters_read_get_bool_or_default_with_default_one() {
use crate::script::object::frame_handle_of;
use crate::widget::EditBoxState;
type Pick = fn(&EditBoxState) -> bool;
let s = script();
s.run(r#"E = CreateFrame("EditBox", "E")"#).unwrap();
let state = |pick: Pick| {
let t: mlua::Table = s.lua().globals().get("E").unwrap();
let h = frame_handle_of(s.lua(), &t).unwrap();
super::with_eb(s.lua(), h, |eb| pick(eb)).unwrap()
};
for (set, pick) in [
("SetAutoFocus", (|eb| eb.auto_focus) as Pick),
("SetNumeric", |eb| eb.numeric),
("SetPassword", |eb| eb.password),
("SetMultiLine", |eb| eb.multi_line),
] {
let put = |arg: &str| s.run(&format!("E:{set}({arg})")).unwrap();
put("true");
put("false");
put("");
assert!(state(pick), "{set}: no argument takes the default 1");
for (arg, want) in [
("nil", false),
("true", true),
("false", false),
("0", false),
("0.5", false),
("1", true),
("2^32", false),
(r#""0""#, false),
(r#""1""#, true),
(r#""no""#, false),
(r#""yes""#, true),
(r#""off""#, false),
(r#""on""#, true),
(r#""""#, true),
("{}", true),
] {
put(if want { "false" } else { "true" });
assert_eq!(state(pick), !want, "{set}: setup");
put(arg);
assert_eq!(state(pick), want, "{set}({arg})");
}
}
}
// ── the generic OnChar and OnKeyDown ─────────────────────────────────────────────────────────
#[test]
+2 -8
View File
@@ -43,14 +43,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"FollowByName",
lua.create_function(|lua, (name, exact): (String, Option<mlua::Value>)| {
// The reference reads this with `GetBoolOrDefault` (`0x6f1c10`), default false; here
// nil, false and 0 are false and any other value is true.
let exact = match exact {
None | Some(mlua::Value::Nil) | Some(mlua::Value::Boolean(false)) => false,
Some(mlua::Value::Integer(n)) => n != 0,
Some(mlua::Value::Number(n)) => n != 0.0,
Some(_) => true,
};
// `GetBoolOrDefault` (`0x6f1c10`, `0x489f14`) with default false.
let exact = super::binding_abi::bool_or_default(exact.as_ref(), false);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
.follow_requests
+14 -13
View File
@@ -285,16 +285,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
m.set(
"SetTextColor",
lua.create_function(
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Option<f32>)| {
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Value)| {
let a = super::object::alpha_f32(lua, &a);
let (r, g, b) = (
super::object::as_f32(&r),
super::object::as_f32(&g),
super::object::as_f32(&b),
super::object::tonumber_f32(lua, &r),
super::object::tonumber_f32(lua, &g),
super::object::tonumber_f32(lua, &b),
);
edit(lua, &this, |fo| {
let keep = fo.color.map_or(1.0, |c| c[3]);
fo.color = Some([r, g, b, a.unwrap_or(keep)]);
})
// Alpha is staged 1.0 (`0x79f59b`) and never read back, so a bare colour call
// resets it.
edit(lua, &this, |fo| fo.color = Some([r, g, b, a]))
},
)?,
)?;
@@ -326,17 +326,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"SetShadowColor",
lua.create_function(
// Shape C on r, g, b (`Font:SetShadowColor 0x79f730`): never raises.
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Option<f32>)| {
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Value)| {
let a = super::object::alpha_f32(lua, &a);
let (r, g, b) = (
crate::script::object::as_f32(&r),
crate::script::object::as_f32(&g),
crate::script::object::as_f32(&b),
crate::script::object::tonumber_f32(lua, &r),
crate::script::object::tonumber_f32(lua, &g),
crate::script::object::tonumber_f32(lua, &b),
);
edit(lua, &this, |fo| {
let offset = fo.shadow.map_or([0.0, 0.0], |s| s.offset);
fo.shadow = Some(FontShadow {
offset,
color: [r, g, b, a.unwrap_or(1.0)],
color: [r, g, b, a],
});
})
},
+12 -10
View File
@@ -314,16 +314,17 @@ pub(super) fn install(
m.set(
"SetTextColor",
lua.create_function(
move |lua, (this, r, g, b, a): (Table, Value, Value, Value, Option<f32>)| {
move |lua, (this, r, g, b, a): (Table, Value, Value, Value, Value)| {
let a = super::object::alpha_f32(lua, &a);
let (r, g, b) = (
super::object::as_f32(&r),
super::object::as_f32(&g),
super::object::as_f32(&b),
super::object::tonumber_f32(lua, &r),
super::object::tonumber_f32(lua, &g),
super::object::tonumber_f32(lua, &b),
);
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)]);
d.vertex_color = Some([r, g, b, a]);
// Clears the colour inherit bit (`0x79dbd0`): it survives a font-object repaint.
d.font_explicit.color = true;
Ok(())
@@ -353,11 +354,12 @@ pub(super) fn install(
"SetShadowColor",
lua.create_function(
// r, g and b are a bare `lua_tonumber` (`0x79dd40`), as in `SetTextColor`.
move |lua, (this, r, g, b, a): (Table, Value, Value, Value, Option<f32>)| {
move |lua, (this, r, g, b, a): (Table, Value, Value, Value, Value)| {
let a = super::object::alpha_f32(lua, &a);
let (r, g, b) = (
super::object::as_f32(&r),
super::object::as_f32(&g),
super::object::as_f32(&b),
super::object::tonumber_f32(lua, &r),
super::object::tonumber_f32(lua, &g),
super::object::tonumber_f32(lua, &b),
);
let rh = resolve(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
@@ -365,7 +367,7 @@ pub(super) fn install(
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)],
color: [r, g, b, a],
});
d.font_explicit.shadow = true;
Ok(())
+15 -22
View File
@@ -247,18 +247,6 @@ fn member_at(roster: &[GuildMemberInfo], index: i64) -> Option<&GuildMemberInfo>
.and_then(|i| roster.get(i))
}
/// A flag argument: `nil`, `false` and `0` are off, anything else on. The reference reads both
/// flags with `GetBoolOrDefault` (`0x6f1c10`, `binding_abi::bool_or_default`), which differs on
/// strings and fractions; the FrameXML's checkbox states, `1` or `nil`, read the same in both.
fn truthy(value: &Value) -> bool {
match value {
Value::Nil | Value::Boolean(false) => false,
Value::Integer(0) => false,
Value::Number(n) => *n != 0.0,
_ => true,
}
}
/// Whether the player's own rank holds right `index`, 1-based into [`RANK_RIGHT_BITS`].
fn has_right(model: &Model, index: usize) -> bool {
model.guild.in_guild && model.guild.rights & RANK_RIGHT_BITS[index - 1] != 0
@@ -335,12 +323,17 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// ── The roster ───────────────────────────────────────────────────────────────────────────
// GetNumGuildMembers() (`0x4d1190`): the loop bound (`FriendsFrame.lua:446`), which respects
// show-offline, not the addressable range (`GuildState::num_members`).
// GetNumGuildMembers([includeOffline]) (`0x4d1190`): the loop bound (`FriendsFrame.lua:446`),
// which respects show-offline, not the addressable range (`GuildState::num_members`).
g.set(
"GetNumGuildMembers",
lua.create_function(|lua, ()| {
lua.create_function(|lua, args: MultiValue| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
// The flag is `GetBoolOrDefault` (`0x4d11a0`, default 0): a set one counts the whole
// roster (`[0xb73118]`) whatever show-offline says, else the loop bound.
if binding_abi::bool_or_default(args.front(), false) {
return Ok(model.guild.roster.len() as i64);
}
Ok(model.guild.num_members as i64)
})?,
)?;
@@ -438,14 +431,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(binding_abi::flag(model.guild.show_offline))
})?,
)?;
// With no argument it turns show-offline on, unlike an explicit `nil`, hence `MultiValue`.
// `GetBoolOrDefault` (`0x4d1e17`) with default 1: no argument turns show-offline on, unlike an
// explicit `nil`, hence `MultiValue`.
g.set(
"SetGuildRosterShowOffline",
lua.create_function(|lua, args: MultiValue| {
let on = match args.into_iter().next() {
None => true,
Some(flag) => truthy(&flag),
};
let on = binding_abi::bool_or_default(args.front(), true);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.guild.show_offline = on;
model.guild_requests.push(GuildRequest::SetShowOffline(on));
@@ -653,7 +644,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// GuildControlSetRankFlag(index, enabled) (`0x4d2070`): stage one checkbox, 1 to 13.
g.set(
"GuildControlSetRankFlag",
lua.create_function(|lua, (index, enabled): (i64, Value)| {
lua.create_function(|lua, (index, enabled): (i64, Option<Value>)| {
let Some(slot) = usize::try_from(index)
.ok()
.and_then(|i| i.checked_sub(1))
@@ -662,7 +653,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
return Ok(());
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.guild_control.staged[slot] = truthy(&enabled);
// `GetBoolOrDefault` (`0x4d20b7`), default 0.
model.guild_control.staged[slot] =
binding_abi::bool_or_default(enabled.as_ref(), false);
Ok(())
})?,
)?;
+4 -40
View File
@@ -315,33 +315,6 @@ fn equip_loc_token(inventory_type: u32) -> &'static str {
TOKENS.get(inventory_type as usize).copied().unwrap_or("")
}
/// The reference's `SStrToInt` (`0x64ac60`) over an `item:` field: an optional `-`, then digits up
/// to the first non-digit, with no whitespace skip and no `+`. Deviation: an overflow saturates at
/// `u32::MAX` where the reference wraps, because only a script can overflow it.
fn reference_atoi(s: &str) -> i64 {
let b = s.as_bytes();
let (neg, mut i) = match b.first() {
Some(b'-') => (true, 1),
_ => (false, 0),
};
let mut n: i64 = 0;
while let Some(&c) = b.get(i) {
if !c.is_ascii_digit() {
break;
}
n = n
.saturating_mul(10)
.saturating_add(i64::from(c - b'0'))
.min(i64::from(u32::MAX));
i += 1;
}
if neg {
-n
} else {
n
}
}
/// `GetItemInfo`'s argument as the reference parses it (`0x48e0a3`-`0x48e16d`): a number, or a
/// string `lua_isnumber` (`0x6f34d0`) coerces, is the id alone; another string (`0x6f3510`) must
/// pass `strnicmp(s, "item:", 5)` (`0x64a4c0`) for its four fields, else it is id 0, so a name or
@@ -372,7 +345,9 @@ fn parse_item_arg(v: &Value) -> mlua::Result<(i64, u32, u32, u32)> {
else {
return Ok((0, 0, 0, 0));
};
let mut fields = rest.splitn(4, ':').map(reference_atoi);
let mut fields = rest
.splitn(4, ':')
.map(crate::script::binding_abi::s_str_to_int);
let id = fields.next().unwrap_or(0);
let mut next = || u32::try_from(fields.next().unwrap_or(0)).unwrap_or(0);
Ok((id, next(), next(), next()))
@@ -577,7 +552,7 @@ mod tests {
#[cfg(test)]
mod get_item_info_tests {
use super::{equip_loc_token, reference_atoi, ItemTemplateView};
use super::{equip_loc_token, ItemTemplateView};
use crate::script::UiScript;
/// Worn Shortsword, item 25, as vmangos `item_template` holds it (item level 2).
@@ -806,17 +781,6 @@ mod get_item_info_tests {
assert_eq!(equip_loc_token(29), "", "the array's trailing \"\"");
assert_eq!(equip_loc_token(9999), "", "past the array");
}
#[test]
fn the_field_parser_is_the_references_atoi() {
assert_eq!(reference_atoi("2589"), 2589);
assert_eq!(reference_atoi("-5"), -5);
assert_eq!(reference_atoi(""), 0);
assert_eq!(reference_atoi("abc"), 0);
assert_eq!(reference_atoi(" 12"), 0, "no leading-whitespace skip");
assert_eq!(reference_atoi("+12"), 0, "no unary plus");
assert_eq!(reference_atoi("12abc"), 12, "stops at the first non-digit");
}
}
#[cfg(test)]
+11 -15
View File
@@ -204,16 +204,17 @@ fn create_macro(
Some(MacroIndex { per_character, pos }.lua_index())
}
/// `EditMacro(index, name, icon, body, local)`: an omitted argument leaves its field alone, since
/// the stock UI calls it with disjoint halves, `(sel, name, icon)` from the rename popup and
/// `(sel, nil, nil, text)` from `MacroFrame_SaveMacro`.
/// `EditMacro(index, name, icon, body, local)`: an omitted name, icon or body leaves its field
/// alone, since the stock UI calls it with disjoint halves, `(sel, name, icon)` from the rename
/// popup and `(sel, nil, nil, text)` from `MacroFrame_SaveMacro`. The `local` flag is always
/// written (`0x4f12dc`, `[macro+0x20]`), so an omitted one clears it.
fn edit_macro(
model: &mut Model,
index: usize,
name: Option<String>,
texture: Option<Option<String>>,
body: Option<String>,
local_only: Option<bool>,
local_only: bool,
) -> Option<usize> {
let at = MacroIndex::split(index)?;
let entry = model.macros.list_mut(at.per_character).get_mut(at.pos)?;
@@ -230,9 +231,7 @@ fn edit_macro(
if let Some(body) = body {
entry.body = clamp_chars(&body, MAX_MACRO_BODY);
}
if let Some(local_only) = local_only {
entry.local_only = local_only;
}
entry.local_only = local_only;
model.macros_dirty = true;
model.macros_generation += 1;
Some(index)
@@ -280,11 +279,11 @@ fn opt_string(v: Option<&Value>) -> Option<String> {
}
}
/// The `local` and `perCharacter` flags: `nil`, `false` and `0` are false, as in `UseAction`. The
/// reference reads them with `GetBoolOrDefault` (`0x6f1c10`), which also takes `"0"` and any
/// number that truncates to 0 as false; the stock popup passes a Lua boolean and never `local`.
/// The `local` and `perCharacter` flags: `GetBoolOrDefault` (`0x6f1c10`) with default 0
/// (`CreateMacro` `0x4f167b`, `0x4f168a`; `EditMacro` `0x4f19ad`), so `"0"` and `0.5` are false and
/// `"yes"` true. The stock popup passes a Lua boolean and never `local`.
fn flag(v: Option<&Value>) -> bool {
v.is_some_and(super::action::truthy_nonzero)
super::binding_abi::bool_or_default(v, false)
}
/// Register the macro globals.
@@ -393,10 +392,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
};
let name = opt_string(a.get(1));
let body = opt_string(a.get(3));
let local_only = match a.get(4) {
None | Some(Value::Nil) => None,
other => Some(flag(other)),
};
let local_only = flag(a.get(4));
Ok(
match edit_macro(&mut model, index, name, texture, body, local_only) {
Some(i) => Value::Integer(i as i64),
@@ -43,6 +43,17 @@ pub(super) fn update(model: &mut Model, h: FrameHandle, elapsed: f32) {
}
}
/// `lua_isnumber` (`0x6f34d0`) then `lua_tonumber` (`0x6f3620`): a number, or a string Lua reads
/// as one. The message-frame verbs gate every colour channel and id on it.
pub(super) fn is_number(lua: &mlua::Lua, v: &mlua::Value) -> Option<f64> {
lua.coerce_number(v.clone()).ok().flatten()
}
/// A channel the verb has already gated with [`is_number`], as the `f32` it stores.
pub(super) fn channel(lua: &mlua::Lua, v: &mlua::Value) -> f32 {
is_number(lua, v).map_or(0.0, |n| n as f32)
}
/// `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
@@ -5,8 +5,9 @@
//! there and forces alpha opaque. `SetInsertMode` and `GetInsertMode` (`0x794ed0`, `0x794ff0`)
//! exist on this class only.
use mlua::{Lua, Table, Value};
use mlua::{Lua, MultiValue, Table, Value};
use super::{channel, is_number};
use crate::script::object::frame_handle_of;
use crate::script::Model;
use crate::widget::{InsertMode, KindState, MessageFrameState};
@@ -33,15 +34,6 @@ fn with_mf<T>(
}
}
/// 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,
Value::Integer(i) => *i as f32,
_ => 0.0,
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
@@ -60,18 +52,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let Some(text) = super::message_text(lua, &text) else {
return Ok(());
};
let has_rgb = !matches!(
(&r, &g, &b),
(Value::Nil, _, _) | (_, Value::Nil, _) | (_, _, Value::Nil)
);
// Each of r, g and b is `lua_isnumber`-gated (`0x79565f`-`0x795687`) and the trio is
// all or nothing; a failed gate still adds the line, in white (`0x79581c`).
let has_rgb = [&r, &g, &b].iter().all(|v| is_number(lua, v).is_some());
let (r, g, b) = if has_rgb {
(num_f32(&r), num_f32(&g), num_f32(&b))
(channel(lua, &r), channel(lua, &g), channel(lua, &b))
} else {
(1.0, 1.0, 1.0)
};
// 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),
// The alpha is its own `lua_isnumber` gate (`0x795759`), default 1.0, read with
// the trio.
let a = match (is_number(lua, &a), has_rgb) {
(Some(n), true) => n as f32,
_ => 1.0,
};
with_mf(lua, &this, |mf| mf.add(text, r, g, b, a))
@@ -116,8 +108,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// The fade accessors, as on the scrolling class; XML's `displayDuration` is `TimeVisible`.
m.set(
"SetFading",
lua.create_function(|lua, (this, on): (Table, Value)| {
let on = !matches!(on, Value::Nil | Value::Boolean(false));
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
// `GetBoolOrDefault` (`0x6f1c10`, `0x7950d0`) with default 1: a bare call enables.
let on = crate::script::binding_abi::bool_or_default(args.front(), true);
with_mf(lua, &this, |mf| mf.fading_enabled = on)
})?,
)?;
@@ -4,8 +4,9 @@
//! 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};
use mlua::{Lua, MultiValue, Table, Value};
use super::{channel, is_number};
use crate::script::object::frame_handle_of;
use crate::script::{Model, UiScript};
use crate::widget::{KindState, ScrollingMessageState};
@@ -33,24 +34,6 @@ fn with_smf<T>(
}
}
/// `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,
Value::String(s) => s.to_str().is_ok_and(|s| s.trim().parse::<f64>().is_ok()),
_ => false,
}
}
/// 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,
Value::Integer(i) => *i as f32,
_ => 0.0,
}
}
/// 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(
@@ -94,15 +77,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let Some(text) = super::message_text(lua, &text) else {
return Ok(());
};
let has_rgb = is_number(&r) && is_number(&g) && is_number(&b);
// 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,
_ => 0,
};
let has_rgb = [&r, &g, &b].iter().all(|v| is_number(lua, v).is_some());
// The chat-type index (`ChatFrame.lua` passes `info.id`), stack index 6 or 3, a
// `lua_isnumber`-gated `_ftol` (`0x792b1b`, `0x792b4f`); 0 when the gate fails.
let id = is_number(lua, if has_rgb { &id } else { &r })
.map_or(0, |n| crate::script::binding_abi::ftol(n) as u32);
let (r, g, b) = if has_rgb {
(num_f32(&r), num_f32(&g), num_f32(&b))
(channel(lua, &r), channel(lua, &g), channel(lua, &b))
} else {
(1.0, 1.0, 1.0)
};
@@ -117,12 +98,17 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"UpdateColorByID",
lua.create_function(
|lua, (this, id, r, g, b): (Table, Value, Value, Value, Value)| {
let id = match &id {
Value::Integer(i) => u32::try_from(*i).unwrap_or(0),
Value::Number(n) if n.is_finite() && *n >= 0.0 => *n as u32,
_ => return Ok(()),
};
let (r, g, b) = (num_f32(&r), num_f32(&g), num_f32(&b));
// All four arguments are `lua_isnumber`-gated (`0x793345`-`0x793388`); any failure
// is a silent no-op, and the id is an `_ftol` (`0x7933a1`).
if ![&id, &r, &g, &b]
.iter()
.all(|v| is_number(lua, v).is_some())
{
return Ok(());
}
let id =
crate::script::binding_abi::ftol(is_number(lua, &id).unwrap_or(0.0)) as u32;
let (r, g, b) = (channel(lua, &r), channel(lua, &g), channel(lua, &b));
with_smf(lua, &this, |smf| {
smf.update_color_by_id(id, r, g, b);
})
@@ -195,8 +181,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
m.set(
"SetFading",
lua.create_function(|lua, (this, on): (Table, Value)| {
let on = !matches!(on, Value::Nil | Value::Boolean(false));
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
// `GetBoolOrDefault` (`0x6f1c10`, `0x7939a0`) with default 1: a bare call enables.
let on = crate::script::binding_abi::bool_or_default(args.front(), true);
with_smf(lua, &this, |smf| smf.fading_enabled = on)
})?,
)?;
+18 -15
View File
@@ -334,23 +334,26 @@ fn frame_type(s: &str) -> Option<FrameType> {
Some(FrameType { kind, factory })
}
/// A Lua number-ish → f32 (nil/other → 0.0), for offset/color args.
pub(super) fn as_f32(v: &Value) -> f32 {
match v {
Value::Number(n) => *n as f32,
Value::Integer(i) => *i as f32,
_ => 0.0,
}
/// A colour or coordinate argument by the reference's bare `lua_tonumber` (`0x6f3620`): a number,
/// or a string `luaO_str2d` (`0x6f5900`) reads as one, and 0.0 for anything else, with no type gate
/// and no raise. [`super::binding_abi::coerced_number`] narrowed to the `f32` the widgets store.
pub(super) fn tonumber_f32(lua: &Lua, v: &Value) -> f32 {
super::binding_abi::coerced_number(lua, Some(v.clone())) as f32
}
/// [`as_f32`] in `f64`, for `ColorSelect:SetColorRGB`, whose quantizer an `f32` detour could push
/// across a rounding boundary.
pub(super) fn as_f64(v: &Value) -> f64 {
match v {
Value::Number(n) => *n,
Value::Integer(i) => *i as f64,
_ => 0.0,
}
/// A colour's alpha argument: `lua_isnumber`-gated (`0x781037`, `0x79f5a2`, ...), so a numeric
/// string counts and anything else, a table or boolean included, stages 1.0 without raising.
pub(super) fn alpha_f32(lua: &Lua, v: &Value) -> f32 {
lua.coerce_number(v.clone())
.ok()
.flatten()
.map_or(1.0, |n| n as f32)
}
/// [`tonumber_f32`] in `f64`, for `ColorSelect:SetColorRGB`, whose quantizer an `f32` detour could
/// push across a rounding boundary.
pub(super) fn tonumber_f64(lua: &Lua, v: &Value) -> f64 {
super::binding_abi::coerced_number(lua, Some(v.clone()))
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -1,6 +1,8 @@
//! Frame methods: visibility, hierarchy, strata, level, scale, alpha, backdrop and input flags.
use mlua::{Lua, Table, Value};
use mlua::{Lua, MultiValue, Table, Value};
use crate::script::binding_abi::bool_or_default;
use crate::script::region_map::{set_shared, Side};
@@ -578,7 +580,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// Mouse interaction: `EnableMouse` gates hit-testing.
m.set(
"EnableMouse",
lua.create_function(|lua, (this, enable): (Table, bool)| {
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
let enable = bool_or_default(args.front(), true); // `0x7770fe`
let h = frame_handle_of(lua, &this)?;
visibility::set_mouse_enabled(lua, h, enable);
Ok(())
@@ -598,7 +601,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// (`script::keyboard`), separate from having a handler (`0x76af00`).
m.set(
"EnableKeyboard",
lua.create_function(|lua, (this, enable): (Table, bool)| {
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
let enable = bool_or_default(args.front(), true); // `0x776f4e`
let h = frame_handle_of(lua, &this)?;
lua.app_data_mut::<Model>()
.expect("model")
@@ -647,7 +651,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// in the reference, and the one the wheel hit test gates on.
m.set(
"EnableMouseWheel",
lua.create_function(|lua, (this, enable): (Table, bool)| {
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
let enable = bool_or_default(args.front(), true); // `0x77727e`
let h = frame_handle_of(lua, &this)?;
lua.app_data_mut::<Model>()
.expect("model")
@@ -670,7 +675,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// frame's rect inside the window, size preserved.
m.set(
"SetClampedToScreen",
lua.create_function(|lua, (this, clamp): (Table, bool)| {
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
let clamp = bool_or_default(args.front(), true); // `0x776c8e`
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let changed = model.arena.is_clamped_to_screen(h) != clamp;
@@ -726,10 +732,9 @@ fn backdrop_from_table(t: &Table) -> mlua::Result<Backdrop> {
if let Ok(Value::String(s)) = t.get::<Value>("edgeFile") {
bd.edge_file = Some(s.to_str()?.to_string());
}
bd.tile = !matches!(
t.get::<Value>("tile").unwrap_or(Value::Nil),
Value::Nil | Value::Boolean(false)
);
// `GetBoolOrDefault` (`0x7778a3`) with default 0 on the `tile` key's value, so `tile = "0"`
// and `tile = 0` are off and `tile = "yes"` is on.
bd.tile = bool_or_default(Some(&t.get::<Value>("tile").unwrap_or(Value::Nil)), false);
if let Ok(Value::Number(n)) = t.get::<Value>("tileSize") {
bd.tile_size = n as f32;
} else if let Ok(Value::Integer(n)) = t.get::<Value>("tileSize") {
@@ -14,6 +14,7 @@
use mlua::{Lua, MultiValue, Table, Value};
use crate::layout::{Anchor, Anchors, Point};
use crate::script::binding_abi::bool_or_default;
use crate::script::{Model, UiScript, SCREEN};
use crate::widget::FrameHandle;
@@ -37,10 +38,12 @@ pub(crate) struct FrameMove {
/// Populate `m`'s movable and resizable methods.
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// SetMovable / IsMovable (`0x776420`/`0x7764d0`): a pure flag write, so clearing it mid-drag
// stops nothing. The argument is Lua truthiness, as the reference's `toboolean`.
// stops nothing. The argument goes through `GetBoolOrDefault` (`0x6f1c10`) with default 1
// (`0x7764ae`), as every flag setter of the Frame table does.
m.set(
"SetMovable",
lua.create_function(|lua, (this, flag): (Table, bool)| {
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
let flag = bool_or_default(args.front(), true);
set_flag(lua, &this, Flag::Movable, flag)
})?,
)?;
@@ -51,7 +54,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// SetResizable / IsResizable (`0x776590`/`0x776640`): the same pure flag write.
m.set(
"SetResizable",
lua.create_function(|lua, (this, flag): (Table, bool)| {
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
let flag = bool_or_default(args.front(), true); // `0x77661e`
set_flag(lua, &this, Flag::Resizable, flag)
})?,
)?;
@@ -132,7 +136,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// nor resizable (`0x776adb`).
m.set(
"SetUserPlaced",
lua.create_function(|lua, (this, flag): (Table, bool)| {
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
let flag = bool_or_default(args.front(), true); // `0x776b29`
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let ok = model
@@ -15,7 +15,7 @@
//! per-tick pass that keeps it (`0x7657d0`). The wired triggers are `Raise` (`0x775b0a`), Show
//! (`0x76aeec`), drag start (`0x7652d7`) and mouse-down (`0x766392`).
use mlua::{Lua, Table, Value};
use mlua::{Lua, MultiValue, Table};
use crate::layout::Rect;
use crate::script::{Model, UiScript};
@@ -25,14 +25,12 @@ use super::frame_handle_of;
/// Populate `m`'s toplevel and raise methods.
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// SetToplevel's argument is optional and defaults to true (`0x775440`), unlike `SetMovable`'s.
// SetToplevel's argument goes through `GetBoolOrDefault` (`0x6f1c10`) with default 1
// (`0x775440`, `0x7754ce`): a bare call sets it, an explicit nil clears it.
m.set(
"SetToplevel",
lua.create_function(|lua, (this, flag): (Table, Option<Value>)| {
let on = match flag {
None => true,
Some(v) => !matches!(v, Value::Nil | Value::Boolean(false)),
};
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
let on = crate::script::binding_abi::bool_or_default(args.front(), true);
let h = frame_handle_of(lua, &this)?;
lua.app_data_mut::<Model>()
.expect("model")
+6 -6
View File
@@ -625,12 +625,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(())
})?,
)?;
// ConfirmReadyCheck(ready) reads its argument as truth: the reference's No button passes no
// argument, Yes passes `1`.
// ConfirmReadyCheck(ready) (`0x4bc090`) reads its argument through `GetBoolOrDefault`
// (`0x4bc0ad`) with default 0: the reference's No button passes no argument, Yes passes `1`.
g.set(
"ConfirmReadyCheck",
lua.create_function(|lua, ready: Value| {
let ready = !matches!(ready, Value::Nil | Value::Boolean(false));
lua.create_function(|lua, ready: Option<Value>| {
let ready = crate::script::binding_abi::bool_or_default(ready.as_ref(), false);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
.party_requests
@@ -1339,8 +1339,8 @@ mod tests {
PartyRequest::ReadyCheckAnswer(true),
PartyRequest::ReadyCheckAnswer(false),
PartyRequest::ReadyCheckAnswer(false),
// `0` is true in Lua.
PartyRequest::ReadyCheckAnswer(true),
// `0` truncates to 0 (`GetBoolOrDefault`, `0x4bc0ad`), where Lua calls it true.
PartyRequest::ReadyCheckAnswer(false),
]
);
}
+57 -44
View File
@@ -3,7 +3,7 @@
use mlua::{Lua, MultiValue, Table, Value};
use crate::script::object::{as_f32, draw_layer_from_str, draw_layer_name};
use crate::script::object::{draw_layer_from_str, draw_layer_name, tonumber_f32};
use crate::script::{BlendMode, Model, TexCoords};
use super::region_handle_of;
@@ -36,18 +36,26 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
)?;
// SetVertexColor (`0x79abd0`) reads r, g and b with a bare `lua_tonumber`, so nil or a table is
// 0 and never raises; stock `QuestLogFrame.lua:337` can pass three nils. A missing alpha is 1
// here; the reference keeps the region's current alpha (read back at `0x79ac81`, copied over
// at `0x79adfe`/`0x79ae0a`), so there a three-argument call cannot restore opacity.
// 0 and never raises; stock `QuestLogFrame.lua:337` can pass three nils. The alpha is staged
// 1.0 (`0x79ad43`) but re-tested at the end (`0x79adfe`): when it is not a `lua_isnumber` the
// colour read back at the start (`0x79ac81`) lends its alpha byte (`0x79ae0a`), so a
// three-argument call keeps the region's alpha. Stock `ActionButton_UpdateUsable`
// (`ActionButton.lua:274-281`) leans on it: a ring `ActionButton_ShowGrid` dimmed stays dim.
m.set(
"SetVertexColor",
lua.create_function(
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Option<f32>)| {
let (r, g, b) = (as_f32(&r), as_f32(&g), as_f32(&b));
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Value)| {
let a = lua.coerce_number(a).ok().flatten().map(|n| n as f32);
let (r, g, b) = (
tonumber_f32(lua, &r),
tonumber_f32(lua, &g),
tonumber_f32(lua, &b),
);
let rh = region_handle_of(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)]);
let kept = d.vertex_color.map_or(1.0, |c| c[3]);
d.vertex_color = Some([r, g, b, a.unwrap_or(kept)]);
// The slot `SetTextColor` writes on a FontString, so it overrides the font object.
d.font_explicit.color = true;
Ok(())
@@ -90,8 +98,21 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
};
let n = if with_alpha { 8 } else { 6 };
let mut c = [0.0f32; 8];
for slot in c.iter_mut().take(n) {
*slot = it.next().as_ref().map(as_f32).unwrap_or(0.0);
for (i, slot) in c.iter_mut().take(n).enumerate() {
let v = it.next();
// Every channel is a bare `lua_tonumber` (`0x79aef3`-`0x79b083`, `0x79b242`-
// `0x79b490`) except the two alphas, which are `lua_isnumber`-gated with
// default 1 (`0x79b30d`, `0x79b480`).
*slot = match v {
Some(v) if with_alpha && matches!(i, 3 | 7) => lua
.coerce_number(v)
.ok()
.flatten()
.map_or(1.0, |n| n as f32),
Some(v) => tonumber_f32(lua, &v),
None if with_alpha && matches!(i, 3 | 7) => 1.0,
None => 0.0,
};
}
let (start, end) = if with_alpha {
([c[0], c[1], c[2], c[3]], [c[4], c[5], c[6], c[7]])
@@ -121,6 +142,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(
|lua, (this, arg, g, b, a): (Table, Value, Value, Value, Value)| {
let rh = region_handle_of(lua, &this)?;
// `lua_isnumber` (`0x79bbd5`) picks the colour form, a numeric string included.
let colour_form = lua.coerce_number(arg.clone()).ok().flatten().is_some();
// Scoped so the model borrow ends before the layout touch; the path arm drops it
// mid-way to call the host probe.
let loaded = {
@@ -135,7 +158,6 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// the colour form (`0x770360`) never writes it.
let same_path = matches!((&arg, &data.texture),
(Value::String(s), Some(cur)) if s.to_str().is_ok_and(|s| *s == **cur));
let colour_form = matches!(&arg, Value::Number(_) | Value::Integer(_));
if !same_path && !colour_form {
data.desaturated = false;
}
@@ -143,6 +165,24 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// each clears the other; neither touches the vertex colour (`+0xb8`), so a
// tint outlives its art.
let loaded = match &arg {
// The colour form (`lua_isnumber`, `0x79bbd5`, so a numeric string too): g
// and b are a bare `lua_tonumber`, and alpha is `lua_isnumber`-gated with
// default 1.
_ if colour_form => {
let alpha = lua
.coerce_number(a.clone())
.ok()
.flatten()
.map_or(1.0, |n| n as f32);
data.fill = Some([
tonumber_f32(lua, &arg),
tonumber_f32(lua, &g),
tonumber_f32(lua, &b),
alpha,
]);
data.texture = None;
true
}
// "" clears, as nil does (stock `QuestLogFrame.lua:166`), and answers nil
// like a failed load; the reference's answer for "" is untraced.
Value::String(s) if s.to_str()?.is_empty() => {
@@ -172,23 +212,6 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
}
resolvable && had_probe
}
// The colour form, the only one to read the trailing three; a non-number
// there takes the default a missing one does.
Value::Number(_) | Value::Integer(_) => {
let chan = |v: &Value, dflt: f32| match v {
Value::Number(_) | Value::Integer(_) => as_f32(v),
Value::String(s) => s
.to_str()
.ok()
.and_then(|s| s.parse::<f32>().ok())
.unwrap_or(dflt),
_ => dflt,
};
data.fill =
Some([as_f32(&arg), chan(&g, 0.0), chan(&b, 0.0), chan(&a, 1.0)]);
data.texture = None;
true
}
// nil clears, so the region draws nothing, and answers 1 (`0x79bb40`).
Value::Nil => {
data.texture = None;
@@ -213,10 +236,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// SetDesaturated(flag) (`0x79c1e0`), Texture only, answers shaderSupported, which stock
// `ItemButtonTemplate.lua:69` reads to fall back to a grey tint; the renderer greys the texel,
// so this answers 1. The flag is `GetBoolOrDefault` (`0x6f1c10`, default 1, jump table
// `0x6f1ce8`): no argument is on, hence the `MultiValue`, nil is off, a number truncating to 0
// is off (`0x6f3620`, `0x40a2b0`), and a table, function or userdata is on. Every string is on
// here; the reference's string arm (`0x6f1c51`, against `0x871460` and `0x853758`) is not.
// so this answers 1. The flag is `GetBoolOrDefault` (`0x6f1c10`, default 1, `0x79c27e`): no
// argument is on, hence the `MultiValue`, nil is off, and a string goes by its first byte.
m.set(
"SetDesaturated",
lua.create_function(|lua, args: MultiValue| {
@@ -225,14 +246,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
Some(Value::Table(t)) => t,
_ => return Ok(Value::Nil),
};
let on = match args.next() {
None => true,
Some(Value::Nil) => false,
Some(Value::Boolean(b)) => b,
Some(Value::Integer(i)) => i != 0,
Some(Value::Number(n)) => n.trunc() != 0.0,
Some(_) => true,
};
let on = crate::script::binding_abi::bool_or_default(args.next().as_ref(), true);
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
model.region_data.entry(rh).or_default().desaturated = on;
@@ -312,11 +326,10 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// flag makes `SetTexCoord` re-derive the rect from the UV quad (`0x770462`).
m.set(
"SetTexCoordModifiesRect",
lua.create_function(|lua, (this, arg): (Table, Value)| {
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
let rh = region_handle_of(lua, &this)?;
// `GetBoolOrDefault` (`0x6f1c10`) with default 1 (`0x79c105`), but a missing argument
// arrives here as nil and reads false, where the reference sets the flag.
let on = crate::script::binding_abi::bool_or_default(Some(&arg), false);
// `GetBoolOrDefault` (`0x6f1c10`) with default 1 (`0x79c10e`): a bare call sets it.
let on = crate::script::binding_abi::bool_or_default(args.front(), true);
let mut model = lua.app_data_mut::<Model>().expect("model");
model
.region_data
@@ -397,7 +410,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// Each coordinate is a bare `lua_tonumber` (`0x79beb0`), so nil or a table is 0; only the
// count is gated, 4 or 8 (`0x79bf5d`).
lua.create_function(|lua, (this, args): (Table, mlua::Variadic<Value>)| {
let rest: Vec<f32> = args.iter().map(as_f32).collect();
let rest: Vec<f32> = args.iter().map(|v| tonumber_f32(lua, v)).collect();
let rh = region_handle_of(lua, &this)?;
let coords = match rest.len() {
4 => Some(TexCoords::Rect([rest[0], rest[1], rest[2], rest[3]])),
+5 -8
View File
@@ -1,7 +1,7 @@
//! The FontString-only methods: the string, its measured width, justification, non-space wrap and
//! text height. The font block it shares with EditBox comes from [`crate::script::font_block`].
use mlua::{Lua, Table, Value};
use mlua::{Lua, MultiValue, Table, Value};
use crate::justify;
use crate::script::Model;
@@ -160,15 +160,12 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
)?;
// SetNonSpaceWrap/CanNonSpaceWrap (`0x79e9f0`/`0x79ead0`), FontString only: the getter
// answers 1 or nil, and a call with no argument enables.
// answers 1 or nil. The flag is `GetBoolOrDefault` (`0x6f1c10`, default 1, `0x79ea7e`): a call
// with no argument enables, an explicit nil disables.
m.set(
"SetNonSpaceWrap",
lua.create_function(|lua, (this, enable): (Table, Value)| {
let on = match &enable {
Value::Nil => true,
Value::Boolean(b) => *b,
_ => true,
};
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
let on = crate::script::binding_abi::bool_or_default(args.front(), true);
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
model.region_data.entry(rh).or_default().non_space_wrap = Some(on);
@@ -734,7 +734,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let raw = match &text {
// Bytes, lossily; never a raise.
Value::String(s) => s.to_string_lossy(),
Value::Number(_) | Value::Integer(_) => super::object::as_f32(&text).to_string(),
Value::Number(n) => (*n as f32).to_string(),
Value::Integer(i) => (*i as f32).to_string(),
_ => String::new(),
};
let h = html_handle(lua, &this)?;
+5 -9
View File
@@ -6,7 +6,7 @@
use mlua::{Lua, Value};
use super::binding_abi::string_arg;
use super::binding_abi::{bool_or_default, string_arg};
use super::who_sort::WhoSortChain;
use super::Model;
@@ -434,16 +434,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// SetWhoToUI(flag): the Who frame passes 1 on show and 0 on hide; nil, false and 0 are off.
// SetWhoToUI(flag) (`0x5ad870`): the Who frame passes 1 on show and 0 on hide; the flag goes
// through `GetBoolOrDefault` (`0x5ad877`) with default 0.
g.set(
"SetWhoToUI",
lua.create_function(|lua, flag: Value| {
let on = match flag {
Value::Nil | Value::Boolean(false) => false,
Value::Integer(0) => false,
Value::Number(n) => n != 0.0,
_ => true,
};
lua.create_function(|lua, flag: Option<Value>| {
let on = bool_or_default(flag.as_ref(), false);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.social_requests.push(SocialRequest::SetWhoToUi(on));
Ok(())
+6 -6
View File
@@ -243,11 +243,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
m.set(
"SetStatusBarColor",
lua.create_function(
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Option<f32>)| {
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Value)| {
let a = crate::script::object::alpha_f32(lua, &a);
let (r, g, b) = (
crate::script::object::as_f32(&r),
crate::script::object::as_f32(&g),
crate::script::object::as_f32(&b),
crate::script::object::tonumber_f32(lua, &r),
crate::script::object::tonumber_f32(lua, &g),
crate::script::object::tonumber_f32(lua, &b),
);
// `0x78fc20` hands the colour to the bar (`0x782dd0`), which sets its vertex colour
// only if a bar exists and keeps nothing otherwise.
@@ -255,8 +256,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
return Ok(());
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.region_data.entry(rh).or_default().vertex_color =
Some([r, g, b, a.unwrap_or(1.0)]);
model.region_data.entry(rh).or_default().vertex_color = Some([r, g, b, a]);
Ok(())
},
)?,
@@ -1375,3 +1375,62 @@ fn button_get_text_color_answers_four_values_through_the_state_font() {
.unwrap();
assert_eq!(s.arity("ChkColorBtn:GetTextColor()").unwrap(), 4);
}
/// `CheckButton:SetChecked` (`0x799bf0`) reads its argument through `GetBoolOrDefault` (`0x6f1c10`)
/// with default 1 (`0x799c77`): absent, a table, a function and an unknown string check; nil and
/// zero do not; a string goes by its first byte, then by `on`/`off`/`enabled`/`disabled`; a number
/// keeps only the low dword of its truncation.
#[test]
fn set_checked_reads_its_argument_as_get_bool_or_default() {
let s = script();
s.run(r#"C = CreateFrame("CheckButton", "C")"#).unwrap();
for (arg, checked) in [
("", true),
("nil", false),
("true", true),
("false", false),
("0", false),
("0.5", false),
("-0.5", false),
("1", true),
("-1", true),
("2^32", false),
("2^32 + 1", true),
("1/0", false),
("{}", true),
("function() end", true),
(r#""yes""#, true),
(r#""Yes""#, true),
(r#""y""#, true),
(r#""t""#, true),
(r#""on""#, true),
(r#""enabled""#, true),
(r#""1abc""#, true),
(r#""01""#, false),
(r#"" 0""#, true),
("\"\"", true),
(r#""abc""#, true),
(r#""off""#, false),
(r#""Disabled""#, false),
(r#""no""#, false),
(r#""false""#, false),
(r#""true""#, true),
(r#""0""#, false),
(r#""1""#, true),
(r#"" 1""#, true),
(r#""-1""#, true),
] {
// Start from the opposite state so a call that changes nothing reads as a failure.
s.run(&format!(
"C:SetChecked({})",
if checked { "nil" } else { "1" }
))
.unwrap();
s.run(&format!("C:SetChecked({arg})")).unwrap();
assert_eq!(
s.eval::<bool>("return C:GetChecked() == 1").unwrap(),
checked,
"SetChecked({arg})"
);
}
}
@@ -0,0 +1,338 @@
//! Colour and coordinate channels are a bare `lua_tonumber` (`0x6f3620`) with no type gate: a
//! numeric string is its number (`luaV_tonumber` `0x6f7c20`, `luaO_str2d` `0x6f5900`), anything
//! else 0, and the setter never raises. Sites: `Button:SetTextColor` `0x780ee0`,
//! `SetHighlightTextColor` `0x7815e0`, `SetDisabledTextColor` `0x781260`, the Font object setters
//! `0x79f4d0` and `0x79f730`, `FontString:SetTextColor` `0x79d9c0` and `SetShadowColor` `0x79dd40`,
//! `Texture:SetVertexColor` `0x79abd0`, `SetTexCoord` `0x79beb0`, `SetGradient` `0x79ae30`,
//! `SetGradientAlpha` `0x79b180`, `StatusBar:SetStatusBarColor` `0x78fc20` and
//! `ColorSelect:SetColorRGB` `0x78eae0`.
use super::common::script;
/// Numeric strings and a hostile one; each reads as the number on the right.
const CHANNELS: &str = r#""1", "0.5", "abc""#;
/// `set(r, g, b)` with string channels, read back through `get` as three numbers.
fn check(setup: &str, call: &str, get: &str) {
check_as(setup, call, get, (1.0, 0.5, 0.0));
}
fn check_as(setup: &str, call: &str, get: &str, want: (f64, f64, f64)) {
let s = script();
s.run(setup).unwrap();
s.run(&call.replace("ARGS", CHANNELS)).unwrap();
let (r, g, b) = s
.eval::<(f64, f64, f64)>(&format!("local r, g, b = {get} return r, g, b"))
.unwrap();
assert_eq!((r, g, b), want, "{call}");
}
#[test]
fn text_and_shadow_colour_setters_coerce_numeric_strings() {
check(
r#"B = CreateFrame("Button", "B")"#,
"B:SetTextColor(ARGS)",
"B:GetTextColor()",
);
check(
r#"CreateFont("Fnt")"#,
"Fnt:SetTextColor(ARGS)",
"Fnt:GetTextColor()",
);
check(
r#"CreateFont("Fnt")"#,
"Fnt:SetShadowColor(ARGS)",
"Fnt:GetShadowColor()",
);
let setup = r#"F = CreateFrame("Frame", "F") FS = F:CreateFontString("FS")"#;
check(setup, "FS:SetTextColor(ARGS)", "FS:GetTextColor()");
check(setup, "FS:SetShadowColor(ARGS)", "FS:GetShadowColor()");
}
#[test]
fn button_highlight_and_disabled_colours_coerce_numeric_strings() {
let s = script();
s.run(r#"B = CreateFrame("Button", "B")"#).unwrap();
s.run(&format!(
"B:SetHighlightTextColor({CHANNELS}) B:SetDisabledTextColor({CHANNELS})"
))
.unwrap();
let t: mlua::Table = s.lua().globals().get("B").unwrap();
let h = crate::script::object::frame_handle_of(s.lua(), &t).unwrap();
let model = s.model_ref();
let Some(crate::widget::KindState::Button(bs)) = model.arena.frame(h).map(|f| &f.kind_state)
else {
panic!("not a button");
};
assert_eq!(bs.highlight_color, Some([1.0, 0.5, 0.0, 1.0]));
assert_eq!(bs.disabled_color, Some([1.0, 0.5, 0.0, 1.0]));
}
#[test]
fn vertex_bar_and_picker_colours_coerce_numeric_strings() {
check(
r#"F = CreateFrame("Frame", "F") T = F:CreateTexture("T")"#,
"T:SetVertexColor(ARGS)",
"T:GetVertexColor()",
);
check(
r#"S = CreateFrame("StatusBar", "S") S:SetStatusBarTexture("Interface\\Bar")"#,
"S:SetStatusBarColor(ARGS)",
"S:GetStatusBarColor()",
);
// The widget quantizes each channel to a byte: 0.5 is 128 / 255.
check_as(
r#"C = CreateFrame("ColorSelect", "C")"#,
"C:SetColorRGB(ARGS)",
"C:GetColorRGB()",
(1.0, 128.0 / 255.0, 0.0),
);
}
#[test]
fn tex_coord_and_gradients_coerce_numeric_strings() {
let s = script();
s.run(r#"F = CreateFrame("Frame", "F") T = F:CreateTexture("T")"#)
.unwrap();
s.run(r#"T:SetTexCoord("0.25", "0.75", "0.5", "1")"#)
.unwrap();
assert_eq!(
s.eval::<Vec<f64>>("return {T:GetTexCoord()}").unwrap(),
vec![0.25, 0.5, 0.25, 1.0, 0.75, 0.5, 0.75, 1.0]
);
s.run(r#"T:SetTexCoord("0", "1", "abc", "{}")"#).unwrap();
assert_eq!(
s.eval::<Vec<f64>>("return {T:GetTexCoord()}").unwrap(),
vec![0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0]
);
let t: mlua::Table = s.lua().globals().get("T").unwrap();
let rh = crate::script::region::region_handle_of(s.lua(), &t).unwrap();
let gradient = || {
let g = s
.model_ref()
.region_data
.get(&rh)
.and_then(|d| d.gradient)
.expect("a gradient");
(g.start, g.end)
};
s.run(r#"T:SetGradient("VERTICAL", "1", "0.5", "abc", "0.25", "0.75", "1")"#)
.unwrap();
assert_eq!(
gradient(),
([1.0, 0.5, 0.0, 1.0], [0.25, 0.75, 1.0, 1.0]),
"SetGradient"
);
s.run(r#"T:SetGradientAlpha("HORIZONTAL", "1", "0.5", "abc", "0.5", "0.25", "0.75", "1", "0.25")"#)
.unwrap();
assert_eq!(
gradient(),
([1.0, 0.5, 0.0, 0.5], [0.25, 0.75, 1.0, 0.25]),
"SetGradientAlpha"
);
// The alphas are `lua_isnumber`-gated with default 1 (`0x79b30d`), not a bare read.
s.run(r#"T:SetGradientAlpha("VERTICAL", 0, 0, 0, nil, 1, 1, 1, "x")"#)
.unwrap();
assert_eq!(gradient(), ([0.0, 0.0, 0.0, 1.0], [1.0, 1.0, 1.0, 1.0]));
}
/// `SetTexture`'s colour form (`0x79bb40`): g and b are a bare read, alpha `lua_isnumber`-gated,
/// and `luaO_str2d` skips the blanks around a number.
#[test]
fn texture_colour_form_coerces_numeric_strings() {
let s = script();
s.run(r#"F = CreateFrame("Frame", "F") T = F:CreateTexture("T")"#)
.unwrap();
s.run(r#"T:SetTexture(1, " 0.5 ", "abc", "0.25")"#).unwrap();
let t: mlua::Table = s.lua().globals().get("T").unwrap();
let rh = crate::script::region::region_handle_of(s.lua(), &t).unwrap();
assert_eq!(
s.model_ref().region_data.get(&rh).and_then(|d| d.fill),
Some([1.0, 0.5, 0.0, 0.25])
);
}
/// A frame's message lines as `(text, rgb bytes, id, alpha)`.
fn lines(s: &crate::script::UiScript, name: &str) -> Vec<(String, [u8; 3], u32, f32)> {
let m = s.model_ref();
let h = m.arena.lookup(name).expect("named frame");
m.arena
.frame(h)
.and_then(|f| f.kind_state.message_lines())
.map(|l| {
l.iter()
.map(|l| (l.text.clone(), l.color, l.id, l.alpha))
.collect()
})
.unwrap_or_default()
}
/// `MessageFrame:AddMessage` (`0x795590`) gates r, g and b with `lua_isnumber` (`0x79565f`-
/// `0x795687`) and reads the alpha under its own gate (`0x795759`): "abc" or a table is white and
/// opaque, a numeric string is its number.
#[test]
fn message_frame_add_message_gates_its_channels_with_is_number() {
let s = script();
s.run(r#"CreateFrame("MessageFrame", "MF")"#).unwrap();
s.run(
r#"
MF:AddMessage("a", "abc", 1, 1)
MF:AddMessage("b", "0.5", "1", "0", "0.5")
MF:AddMessage("c", 1, 1, 1, {})
MF:AddMessage("d", {}, 1, 1)
"#,
)
.unwrap();
let got = lines(&s, "MF");
assert_eq!(got[0].1, [255, 255, 255], "a failed gate is white");
assert_eq!(got[1].1, [128, 255, 0]);
assert_eq!(
got[1].3,
128.0 / 255.0,
"a numeric alpha string counts, byte-quantized"
);
assert_eq!(got[2].3, 1.0, "a table alpha stages 1.0");
assert_eq!(got[3].1, [255, 255, 255]);
}
/// `ScrollingMessageFrame:AddMessage` (`0x792900`) gates the same three, then reads the id at 6
/// (or r's slot at 3 when the gate failed) through `lua_isnumber` and `_ftol`; `UpdateColorByID`
/// (`0x7932b0`) gates all four and does nothing if any fails.
#[test]
fn scrolling_message_frame_gates_colour_and_id_with_is_number() {
let s = script();
s.run(r#"CreateFrame("ScrollingMessageFrame", "SMF")"#)
.unwrap();
s.run(
r#"
SMF:AddMessage("a", "0.5", "1", "0", "7")
SMF:AddMessage("b", "abc", 1, 1, 8)
SMF:AddMessage("c", "9")
SMF:AddMessage("d", 1, 1, 1, 2.9)
"#,
)
.unwrap();
let got = lines(&s, "SMF");
assert_eq!((got[0].1, got[0].2), ([128, 255, 0], 7), "numeric strings");
assert_eq!((got[1].1, got[1].2), ([255, 255, 255], 0), "id is r's slot");
assert_eq!((got[2].1, got[2].2), ([255, 255, 255], 9));
assert_eq!(got[3].2, 2, "the id is chopped by _ftol");
s.run(r#"SMF:UpdateColorByID(7, 0, "abc", 1)"#).unwrap();
assert_eq!(
lines(&s, "SMF")[0].1,
[128, 255, 0],
"a failed gate is a no-op"
);
s.run(r#"SMF:UpdateColorByID("7", "0", "0.5", 0)"#).unwrap();
assert_eq!(lines(&s, "SMF")[0].1, [0, 128, 0]);
}
/// The text-colour alpha is `lua_isnumber`-gated with default 1.0 (`0x781037`, `0x79f5a2`,
/// `0x78fd77`, `0x79db17`, `0x79de97`, `0x79f802`): a table or a boolean neither raises nor
/// sticks, and a numeric string is its number.
#[test]
fn colour_alphas_are_gated_not_raised() {
let s = script();
s.run(
r#"
B = CreateFrame("Button", "B")
F = CreateFrame("Frame", "F")
FS = F:CreateFontString("FS")
CreateFont("Fnt")
S = CreateFrame("StatusBar", "S") S:SetStatusBarTexture("Interface\\Bar")
T = F:CreateTexture("T")
"#,
)
.unwrap();
for (call, get) in [
("B:SetTextColor(1, 1, 1, ARG)", "B:GetTextColor()"),
("FS:SetTextColor(1, 1, 1, ARG)", "FS:GetTextColor()"),
("FS:SetShadowColor(1, 1, 1, ARG)", "FS:GetShadowColor()"),
("Fnt:SetTextColor(1, 1, 1, ARG)", "Fnt:GetTextColor()"),
("Fnt:SetShadowColor(1, 1, 1, ARG)", "Fnt:GetShadowColor()"),
("S:SetStatusBarColor(1, 1, 1, ARG)", "S:GetStatusBarColor()"),
] {
for (arg, want) in [
(r#""0.5""#, 0.5),
("{}", 1.0),
("true", 1.0),
(r#""abc""#, 1.0),
("nil", 1.0),
("0.25", 0.25),
] {
s.run(&call.replace("ARG", arg))
.unwrap_or_else(|e| panic!("{call} with {arg} raised: {e}"));
let a = s
.eval::<f64>(&format!("local _, _, _, a = {get} return a"))
.unwrap();
assert_eq!(a, want, "{call} with {arg}");
}
}
// A Font object's colour is replaced whole (`0x79f5f5`...`0x79f65f`): a bare call resets the
// alpha rather than keeping it.
s.run("Fnt:SetTextColor(1, 1, 1, 0.25) Fnt:SetTextColor(1, 1, 1)")
.unwrap();
assert_eq!(
s.eval::<f64>("local _, _, _, a = Fnt:GetTextColor() return a")
.unwrap(),
1.0
);
}
/// `SetVertexColor` (`0x79abd0`) stages alpha 1.0 but re-tests the gate at the end (`0x79adfe`):
/// anything but a number lends the region's current alpha back (`0x79ae0a`), and never raises.
#[test]
fn set_vertex_color_keeps_the_alpha_unless_given_a_number() {
let s = script();
s.run(r#"F = CreateFrame("Frame", "F") T = F:CreateTexture("T")"#)
.unwrap();
let alpha = || {
s.eval::<f64>("local _, _, _, a = T:GetVertexColor() return a")
.unwrap()
};
s.run("T:SetVertexColor(1, 1, 1, 0.5)").unwrap();
assert_eq!(alpha(), 0.5);
for arg in ["", ", nil", ", {}", ", true", r#", "x""#] {
s.run(&format!("T:SetVertexColor(0, 0, 0{arg})"))
.unwrap_or_else(|e| panic!("SetVertexColor(0, 0, 0{arg}) raised: {e}"));
assert_eq!(alpha(), 0.5, "SetVertexColor(0, 0, 0{arg}) keeps it");
}
s.run(r#"T:SetVertexColor(0, 0, 0, "0.25")"#).unwrap();
assert_eq!(alpha(), 0.25, "a numeric string is a number");
}
/// `lua_isnumber` on the first argument picks `SetTexture`'s colour form (`0x79bbd5`), so a
/// numeric string is a fill, not a path.
#[test]
fn set_texture_takes_a_numeric_string_as_a_colour() {
let s = script();
s.run(r#"F = CreateFrame("Frame", "F") T = F:CreateTexture("T")"#)
.unwrap();
s.run(r#"T:SetTexture("1", "0.5", 0)"#).unwrap();
let t: mlua::Table = s.lua().globals().get("T").unwrap();
let rh = crate::script::region::region_handle_of(s.lua(), &t).unwrap();
let m = s.model_ref();
let d = m.region_data.get(&rh).unwrap();
assert_eq!(d.fill, Some([1.0, 0.5, 0.0, 1.0]));
assert_eq!(d.texture, None, "a numeric string is not a path");
}
/// `GameTooltip:SetText`'s alpha (`0x531d23`) is `lua_isnumber`-gated too.
#[test]
fn tooltip_set_text_alpha_accepts_a_numeric_string() {
let s = script();
s.run(r#"TT = CreateFrame("GameTooltip", "TT")"#).unwrap();
s.run(r#"TT:SetText("x", 1, 1, 1, "0.5")"#).unwrap();
assert_eq!(
s.eval::<f64>("local _, _, _, a = TTTextLeft1:GetTextColor() return a")
.unwrap(),
0.5
);
s.run(r#"TT:SetText("x", 1, 1, 1, {})"#).unwrap();
assert_eq!(
s.eval::<f64>("local _, _, _, a = TTTextLeft1:GetTextColor() return a")
.unwrap(),
1.0
);
}
@@ -0,0 +1,449 @@
//! The flag arguments the reference reads through `GetBoolOrDefault` (`0x6f1c10`): absent takes the
//! binding's default, nil is false, a boolean is itself, a number is its truncation's low dword, a
//! string goes by its first byte and then by `on`/`off`/`enabled`/`disabled`, and a table or
//! function takes the default. Each binding below is one call site of the helper, with the default
//! its site pushes.
use super::common::script;
use crate::script::*;
/// Every argument form with the answer the helper gives it for `default`.
fn cases(default: bool) -> Vec<(&'static str, bool)> {
vec![
("nil", false),
("true", true),
("false", false),
("0", false),
("0.5", false),
("-0.5", false),
("1", true),
("-1", true),
("2^32", false),
("2^32 + 1", true),
("1/0", false),
(r#""0""#, false),
(r#""1""#, true),
(r#""0.5""#, false),
(r#""9lives""#, true),
(r#""f""#, false),
(r#""False""#, false),
(r#""n""#, false),
(r#""no""#, false),
(r#""t""#, true),
(r#""True""#, true),
(r#""y""#, true),
(r#""yes""#, true),
(r#""off""#, false),
(r#""OFF""#, false),
(r#""Disabled""#, false),
(r#""on""#, true),
(r#""Enabled""#, true),
(r#""""#, default),
(r#""zzz""#, default),
(r#"" 0""#, default),
(r#""-1""#, default),
("{}", default),
("function() end", default),
]
}
/// Drive one flag binding: `set(arg)` writes it, `get()` reads it back as a Lua boolean. Each case
/// starts from the opposite of its answer, so an ignored argument fails.
fn check(s: &UiScript, what: &str, set: &str, get: &str, default: bool) {
let put = |arg: &str| {
let call = set.replace("ARG", arg);
s.run(&call)
.unwrap_or_else(|e| panic!("{what}: {call}: {e}"));
};
let read = || s.eval::<bool>(&format!("return ({get}) == true")).unwrap();
// The bare call takes the default.
put("true");
put("false");
s.run(&set.replace(", ARG", "").replace("ARG", "")).unwrap();
assert_eq!(read(), default, "{what}: no argument");
for (arg, want) in cases(default) {
put(if want { "false" } else { "true" });
assert_eq!(read(), !want, "{what}: setup");
put(arg);
assert_eq!(read(), want, "{what}({arg})");
}
}
/// The Frame methods, all default 1 (`0x775440` ... `0x77727e`).
#[test]
fn frame_flag_setters_read_get_bool_or_default_with_default_one() {
let s = script();
s.run(r#"F = CreateFrame("Frame", "F")"#).unwrap();
for (set, get) in [
("SetToplevel", "IsToplevel"),
("SetMovable", "IsMovable"),
("SetResizable", "IsResizable"),
("SetClampedToScreen", "IsClampedToScreen"),
("EnableKeyboard", "IsKeyboardEnabled"),
("EnableMouse", "IsMouseEnabled"),
("EnableMouseWheel", "IsMouseWheelEnabled"),
] {
// The predicates answer the number 1 or nil, never a boolean.
check(
&s,
set,
&format!("F:{set}(ARG)"),
&format!("F:{get}() == 1"),
true,
);
}
// `SetUserPlaced` (`0x776a50`) refuses a frame that is neither movable nor resizable.
s.run("F:SetMovable(true)").unwrap();
check(
&s,
"SetUserPlaced",
"F:SetUserPlaced(ARG)",
"F:IsUserPlaced() == 1",
true,
);
}
/// `SetFading` on both message-frame classes (`0x7939a0`, `0x7950d0`), default 1.
#[test]
fn set_fading_reads_get_bool_or_default_with_default_one() {
let s = script();
s.run(
r#"
S = CreateFrame("ScrollingMessageFrame", "S")
M = CreateFrame("MessageFrame", "M")
"#,
)
.unwrap();
for f in ["S", "M"] {
check(
&s,
&format!("{f}:SetFading"),
&format!("{f}:SetFading(ARG)"),
&format!("{f}:GetFading() == 1"),
true,
);
}
}
/// `SetTexCoordModifiesRect`, `SetDesaturated` (`0x79c10e`, `0x79c27e`) and `SetNonSpaceWrap`
/// (`0x79ea7e`), default 1.
#[test]
fn texture_and_fontstring_flags_read_get_bool_or_default_with_default_one() {
let s = script();
s.run(
r#"
F = CreateFrame("Frame", "F")
T = F:CreateTexture("T")
FS = F:CreateFontString("FS")
"#,
)
.unwrap();
check(
&s,
"SetTexCoordModifiesRect",
"T:SetTexCoordModifiesRect(ARG)",
"T:GetTexCoordModifiesRect() == 1",
true,
);
check(
&s,
"SetNonSpaceWrap",
"FS:SetNonSpaceWrap(ARG)",
"FS:CanNonSpaceWrap() == 1",
true,
);
// Desaturation has no getter; read it off the region.
let desat = || {
let t: mlua::Table = s.lua().globals().get("T").unwrap();
let rh = crate::script::region::region_handle_of(s.lua(), &t).unwrap();
s.model_ref()
.region_data
.get(&rh)
.is_some_and(|d| d.desaturated)
};
s.run("T:SetDesaturated(false) T:SetDesaturated()").unwrap();
assert!(desat(), "SetDesaturated: no argument takes the default 1");
for (arg, want) in cases(true) {
s.run(&format!(
"T:SetDesaturated({})",
if want { "false" } else { "true" }
))
.unwrap();
s.run(&format!("T:SetDesaturated({arg})")).unwrap();
assert_eq!(desat(), want, "SetDesaturated({arg})");
}
}
/// `SetBackdrop`'s `tile` key (`0x7778a3`), default 0.
#[test]
fn backdrop_tile_reads_get_bool_or_default_with_default_zero() {
let s = script();
s.run(r#"F = CreateFrame("Frame", "F")"#).unwrap();
for (arg, want) in cases(false) {
if arg == "nil" {
continue; // an absent key and a nil value are the same table slot
}
s.run(&format!("F:SetBackdrop({{ tile = {arg} }})"))
.unwrap();
assert_eq!(
s.eval::<bool>("return F:GetBackdrop().tile == 1").unwrap(),
want,
"tile = {arg}"
);
}
s.run("F:SetBackdrop({})").unwrap();
assert!(!s.eval::<bool>("return F:GetBackdrop().tile == 1").unwrap());
}
/// The chat-window records (`0x4a1777` default 1, `0x4a1697` default 0).
#[test]
fn chat_window_flags_read_get_bool_or_default() {
let s = script();
// `GetChatWindowInfo` answers shown 7th and locked 8th, each 1 or nil; Lua 5.0 has no `select`.
s.run(
"function ChatShown() local _, _, _, _, _, _, v = GetChatWindowInfo(2) return v end \
function ChatLocked() local _, _, _, _, _, _, _, v = GetChatWindowInfo(2) return v end",
)
.unwrap();
check(
&s,
"SetChatWindowShown",
"SetChatWindowShown(2, ARG)",
"ChatShown() == 1",
true,
);
check(
&s,
"SetChatWindowLocked",
"SetChatWindowLocked(2, ARG)",
"ChatLocked() == 1",
false,
);
}
/// The guild flags: show-offline (`0x4d1e17`, default 1), the rank checkbox (`0x4d20b7`, default 0).
#[test]
fn guild_flags_read_get_bool_or_default() {
let s = script();
check(
&s,
"SetGuildRosterShowOffline",
"SetGuildRosterShowOffline(ARG)",
"GetGuildRosterShowOffline() == 1",
true,
);
let staged = || s.model_ref().guild_control.staged[0];
s.run("GuildControlSetRankFlag(1, true) GuildControlSetRankFlag(1)")
.unwrap();
assert!(!staged(), "no argument takes the default 0");
for (arg, want) in cases(false) {
s.run(&format!(
"GuildControlSetRankFlag(1, {})",
if want { "false" } else { "true" }
))
.unwrap();
s.run(&format!("GuildControlSetRankFlag(1, {arg})"))
.unwrap();
assert_eq!(staged(), want, "GuildControlSetRankFlag(1, {arg})");
}
}
/// `GetNumGuildMembers`' flag (`0x4d11a0`, default 0) counts the whole roster.
#[test]
fn get_num_guild_members_flag_counts_the_whole_roster() {
let mut s = script();
let member = |name: &str, online: bool| GuildMemberInfo {
name: name.into(),
online,
..Default::default()
};
s.set_guild(GuildState {
in_guild: true,
roster: vec![member("A", true), member("B", false), member("C", false)],
num_members: 1,
..Default::default()
});
assert_eq!(s.eval::<i64>("return GetNumGuildMembers()").unwrap(), 1);
assert_eq!(s.eval::<i64>("return GetNumGuildMembers(nil)").unwrap(), 1);
assert_eq!(s.eval::<i64>("return GetNumGuildMembers(0)").unwrap(), 1);
assert_eq!(s.eval::<i64>("return GetNumGuildMembers('0')").unwrap(), 1);
assert_eq!(s.eval::<i64>("return GetNumGuildMembers(1)").unwrap(), 3);
assert_eq!(
s.eval::<i64>("return GetNumGuildMembers('yes')").unwrap(),
3
);
assert_eq!(s.eval::<i64>("return GetNumGuildMembers(true)").unwrap(), 3);
}
/// The request-queueing verbs, each reading one flag through the helper.
#[test]
fn queued_verbs_read_their_flags_through_get_bool_or_default() {
let mut s = script();
s.set_guild(GuildState::default());
// SetWhoToUI(flag) 0x5ad877, default 0.
for (arg, want) in cases(false) {
s.run(&format!("SetWhoToUI({arg})")).unwrap();
assert_eq!(
s.take_social_requests(),
vec![SocialRequest::SetWhoToUi(want)],
"SetWhoToUI({arg})"
);
}
s.run("SetWhoToUI()").unwrap();
assert_eq!(
s.take_social_requests(),
vec![SocialRequest::SetWhoToUi(false)]
);
// TargetByName / FollowByName exact-match flag, default 0 (`0x489d8e`, `0x489f14`).
for (arg, want) in cases(false) {
s.run(&format!("FollowByName('Bob', {arg})")).unwrap();
assert_eq!(
s.take_follow_requests(),
vec![FollowRequest::ByName {
name: "Bob".into(),
exact: want
}],
"FollowByName('Bob', {arg})"
);
s.run(&format!("TargetByName('Bob', {arg})")).unwrap();
assert_eq!(
s.take_script_calls(),
vec![ScriptCall::TargetByName {
name: "Bob".into(),
exact: want
}],
"TargetByName('Bob', {arg})"
);
}
// ConfirmReadyCheck(ready) 0x4bc0ad, default 0.
for (arg, want) in cases(false) {
s.run(&format!("ConfirmReadyCheck({arg})")).unwrap();
assert_eq!(
s.take_party_requests(),
vec![PartyRequest::ReadyCheckAnswer(want)],
"ConfirmReadyCheck({arg})"
);
}
s.run("ConfirmReadyCheck()").unwrap();
assert_eq!(
s.take_party_requests(),
vec![PartyRequest::ReadyCheckAnswer(false)]
);
// UseAction(action, checkCursor, onSelf) 0x4e717e, 0x4e716f, defaults 0.
for (arg, want) in cases(false) {
s.run(&format!("UseAction(1, 0, {arg})")).unwrap();
assert_eq!(
s.take_script_calls(),
vec![ScriptCall::UseAction(ActionUse {
action: 1,
on_self: want
})],
"UseAction(1, 0, {arg})"
);
}
}
/// `LoggingChat(flag)` (`0x49fe69`, default 1) sets whenever an argument is passed, so an explicit
/// nil clears; a bare call is the getter.
#[test]
fn logging_flags_read_get_bool_or_default_with_default_one() {
let s = script();
for name in ["LoggingChat", "LoggingCombat"] {
for (arg, want) in cases(true) {
s.run(&format!("{name}({})", if want { "false" } else { "true" }))
.unwrap();
s.run(&format!("{name}({arg})")).unwrap();
assert_eq!(
s.eval::<bool>(&format!("return {name}() == 1")).unwrap(),
want,
"{name}({arg})"
);
}
}
}
/// `CreateMacro`'s `local` and `perCharacter` flags (`0x4f167b`, `0x4f168a`) and `EditMacro`'s
/// `local` (`0x4f19ad`), default 0; `EditMacro` always writes it.
#[test]
fn macro_flags_read_get_bool_or_default_with_default_zero() {
let s = script();
s.run("function MacroLocal(i) local _, _, _, v = GetMacroInfo(i) return v end")
.unwrap();
for (arg, want) in cases(false) {
s.run(&format!(
"i = CreateMacro('m', 1, '', {arg}) _, _, _, loc = GetMacroInfo(i)"
))
.unwrap();
assert_eq!(
s.eval::<bool>("return loc == 1").unwrap(),
want,
"CreateMacro(name, icon, body, {arg})"
);
s.run("DeleteMacro(i)").unwrap();
}
s.run("i = CreateMacro('m', 1, '', true) EditMacro(i, nil, nil, 'body')")
.unwrap();
assert!(
!s.eval::<bool>("return MacroLocal(i) == 1").unwrap(),
"an omitted EditMacro `local` is stored as 0"
);
for (arg, want) in cases(false) {
s.run(&format!("EditMacro(i, nil, nil, nil, {arg})"))
.unwrap();
assert_eq!(
s.eval::<bool>("return MacroLocal(i) == 1").unwrap(),
want,
"EditMacro(i, nil, nil, nil, {arg})"
);
}
}
/// The GameTooltip wrap flag (`0x531811`, `0x531dd3`, `0x531b60`), default 0: a wrapped line pins
/// its left cell to the wrap width. A double line wraps only with an empty right side, as the line
/// core clears the flag otherwise (`0x530284`).
#[test]
fn tooltip_wrap_flags_read_get_bool_or_default_with_default_zero() {
let s = script();
s.run(r#"TT = CreateFrame("GameTooltip", "TT")"#).unwrap();
let pinned = |line: u32| {
let t: mlua::Table = s
.lua()
.globals()
.get(format!("TTTextLeft{line}"))
.expect("left line");
let rh = crate::script::region::region_handle_of(s.lua(), &t).unwrap();
s.model_ref()
.region_data
.get(&rh)
.is_some_and(|d| d.size.is_some())
};
for (arg, want) in cases(false) {
s.run("TT:ClearLines()").unwrap();
s.run(&format!("TT:AddLine('line', 1, 1, 1, {arg})"))
.unwrap();
assert_eq!(pinned(1), want, "AddLine(text, r, g, b, {arg})");
s.run(&format!("TT:SetText('line', 1, 1, 1, 1, {arg})"))
.unwrap();
assert_eq!(pinned(1), want, "SetText(text, r, g, b, a, {arg})");
s.run(&format!(
"TT:ClearLines() TT:AddDoubleLine('line', '', 1, 1, 1, 1, 1, 1, {arg})"
))
.unwrap();
assert_eq!(
pinned(1),
want,
"AddDoubleLine(.., {arg}) with an empty right"
);
s.run(&format!(
"TT:ClearLines() TT:AddDoubleLine('line', 'right', 1, 1, 1, 1, 1, 1, {arg})"
))
.unwrap();
assert!(!pinned(1), "a double line with a right side never wraps");
}
s.run("TT:ClearLines() TT:AddLine('line')").unwrap();
assert!(!pinned(1), "no flag takes the default 0");
}
@@ -12,6 +12,7 @@ mod capture;
mod channel;
mod chat_bubble;
mod clear_points;
mod colour_args;
mod common;
mod cooldown;
mod create_frame_template;
@@ -23,6 +24,7 @@ mod dying_target;
mod end_to_end;
mod engine_updates;
mod events;
mod flag_args;
mod font_object;
mod frame_api;
mod generic_for;
+23 -28
View File
@@ -567,39 +567,34 @@ pub(in crate::script) fn plural_template(
crate::strings::plural(token, Some(n), get)
}
/// A colour component by `lua_tonumber`'s coercion: a numeric string counts, other strings do not.
fn color_num(v: Option<&Value>) -> Option<f32> {
match v {
Some(Value::Number(n)) => Some(*n as f32),
Some(Value::Integer(i)) => Some(*i as f32),
Some(Value::String(s)) => s.to_str().ok().and_then(|s| s.trim().parse::<f32>().ok()),
_ => None,
}
/// A colour component by `lua_tonumber`'s coercion: a number or a string `luaO_str2d` reads as
/// one, else none.
fn color_num(lua: &Lua, v: Option<&Value>) -> Option<f32> {
lua.coerce_number(v?.clone())
.ok()
.flatten()
.map(|n| n as f32)
}
/// A wrap flag: nil, false, 0 and the strings "0", "off" and "disabled" are false, anything else
/// true. The reference reads it with `GetBoolOrDefault` (`0x6f1c10`,
/// `binding_abi::bool_or_default`), where "false", "no" and 0.5 are false too.
fn bool_arg(v: Option<&Value>) -> bool {
match v {
None | Some(Value::Nil) | Some(Value::Boolean(false)) => false,
Some(Value::Integer(i)) => *i != 0,
Some(Value::Number(n)) => *n != 0.0,
Some(Value::String(s)) => !s.to_str().ok().is_some_and(|s| {
s == "0" || s.eq_ignore_ascii_case("off") || s.eq_ignore_ascii_case("disabled")
}),
_ => true,
}
/// A line's wrap flag, `GetBoolOrDefault` (`0x6f1c10`) with default 0 at `AddLine`'s sixth
/// argument (`0x53181a`), `SetText`'s seventh (`0x531ddc`) and `AddDoubleLine`'s tenth (`0x531b69`).
fn wrap_arg(v: Option<&Value>) -> bool {
crate::script::binding_abi::bool_or_default(v, false)
}
/// An `AddLine`-family colour, gated on the r-slot alone (`lua_isnumber`, `0x6f34d0`), else
/// [`DEFAULT_TEXT_GOLD`]; past the gate a non-number g or b reads 0, and alpha is always opaque.
fn parse_line_color(r: Option<&Value>, g: Option<&Value>, b: Option<&Value>) -> [f32; 4] {
match color_num(r) {
fn parse_line_color(
lua: &Lua,
r: Option<&Value>,
g: Option<&Value>,
b: Option<&Value>,
) -> [f32; 4] {
match color_num(lua, r) {
Some(r) => [
r,
color_num(g).unwrap_or(0.0),
color_num(b).unwrap_or(0.0),
color_num(lua, g).unwrap_or(0.0),
color_num(lua, b).unwrap_or(0.0),
1.0,
],
None => DEFAULT_TEXT_GOLD,
@@ -608,10 +603,10 @@ fn parse_line_color(r: Option<&Value>, g: Option<&Value>, b: Option<&Value>) ->
/// An `AddLine` tail, positional `r, g, b, wrap` (`0x531630`); the wrap flag is read whatever the
/// colour gate decides.
fn parse_line_tail(args: &[Value]) -> ([f32; 4], bool) {
fn parse_line_tail(lua: &Lua, args: &[Value]) -> ([f32; 4], bool) {
(
parse_line_color(args.first(), args.get(1), args.get(2)),
bool_arg(args.get(3)),
parse_line_color(lua, args.first(), args.get(1), args.get(2)),
wrap_arg(args.get(3)),
)
}
+17 -16
View File
@@ -10,8 +10,8 @@ use crate::script::Model;
use crate::widget::{KindState, TooltipAnchor, TOOLTIP_LINE_GAP, TOOLTIP_PAD};
use super::{
append_line, bool_arg, clear, parse_line_color, parse_line_tail, set_owner_core, set_shown,
text_of, tip_mut, with_tip, write_cell, REG_TOOLTIP_METHODS,
append_line, clear, color_num, parse_line_color, parse_line_tail, set_owner_core, set_shown,
text_of, tip_mut, with_tip, wrap_arg, write_cell, REG_TOOLTIP_METHODS,
};
pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
@@ -115,13 +115,11 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
));
}
let text = text_of(args.first());
let [r, g, b, _] = parse_line_color(args.get(1), args.get(2), args.get(3));
let a = match args.get(4) {
Some(Value::Number(n)) => *n as f32,
Some(Value::Integer(i)) => *i as f32,
_ => 1.0,
};
let wrap = bool_arg(args.get(5));
let [r, g, b, _] = parse_line_color(lua, args.get(1), args.get(2), args.get(3));
// The alpha is its own `lua_isnumber` gate (`0x531ddc`'s neighbour at idx 6), so a
// numeric string counts.
let a = color_num(lua, args.get(4)).unwrap_or(1.0);
let wrap = wrap_arg(args.get(5));
clear(lua, h);
append_line(lua, &this, (text, [r, g, b, a]), None, wrap)?;
set_shown(lua, h, true);
@@ -161,25 +159,28 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
"AddLine",
lua.create_function(|lua, (this, args): (Table, Variadic<Value>)| {
let text = text_of(args.first());
let (color, wrap) = parse_line_tail(&args[1.min(args.len())..]);
let (color, wrap) = parse_line_tail(lua, &args[1.min(args.len())..]);
append_line(lua, &this, (text, color), None, wrap)
})?,
)?;
// AddDoubleLine(textL, textR, rL, gL, bL, rR, gR, bR [, wrap]) (`0x531840`): each side's
// colour gates on its own r-slot. The core forces wrap off when the right text is non-empty;
// this never wraps.
// colour gates on its own r-slot, and the wrap flag is the tenth argument.
m.set(
"AddDoubleLine",
lua.create_function(
|lua, (this, tl, tr, cl): (Table, Value, Value, Variadic<Value>)| {
let left_color = parse_line_color(cl.first(), cl.get(1), cl.get(2));
let right_color = parse_line_color(cl.get(3), cl.get(4), cl.get(5));
let left_color = parse_line_color(lua, cl.first(), cl.get(1), cl.get(2));
let right_color = parse_line_color(lua, cl.get(3), cl.get(4), cl.get(5));
let right = text_of(Some(&tr));
// The line core (`0x530270`) clears the flag when the right text is non-empty
// (`0x530284`), so only a double line with an empty right side can wrap.
let wrap = right.is_empty() && wrap_arg(cl.get(6));
append_line(
lua,
&this,
(text_of(Some(&tl)), left_color),
Some((text_of(Some(&tr)), right_color)),
false,
Some((right, right_color)),
wrap,
)
},
)?,
@@ -1045,12 +1045,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
let name =
super::super::binding_abi::string_arg(lua, name, "Usage: TargetByName(\"name\")")?;
// Argument 2 comes from `0x6f1c10` with default 0: it never raises, and 0 is absent.
let exact = match exact {
None | Some(Value::Nil) | Some(Value::Boolean(false)) => false,
Some(Value::Integer(n)) => n != 0,
Some(Value::Number(n)) => n != 0.0,
Some(_) => true,
};
let exact = super::super::binding_abi::bool_or_default(exact.as_ref(), false);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
.script_calls
+33 -21
View File
@@ -4,8 +4,9 @@
//! `ToggleFlag`), so a set queues a flip only when it differs from a belief that moves at the call
//! and is overwritten when the wire bit moves.
use mlua::{Lua, Value};
use mlua::{Lua, MultiValue};
use super::binding_abi::bool_or_default;
use super::Model;
/// A queued flip: one `CMSG_TOGGLE_HELM` or `CMSG_TOGGLE_CLOAK` for the app to send.
@@ -38,24 +39,6 @@ impl super::UiScript {
}
}
/// Numeric, not Lua-truthy: the stock panel passes the string `"0"` for off
/// (`UIOptionsFrame_Save`), which Lua 5.0 holds truthy. Nil, false, 0 and `"0"` hide.
fn shown_arg(v: &Value) -> bool {
match v {
Value::Nil => false,
Value::Boolean(b) => *b,
Value::Integer(i) => *i != 0,
Value::Number(n) => *n != 0.0,
// A non-numeric string is never handed to this API; it shows rather than hiding gear.
Value::String(s) => s
.to_str()
.ok()
.and_then(|t| t.trim().parse::<f64>().ok())
.is_none_or(|n| n != 0.0),
_ => true,
}
}
/// Ask for a state, and queue the flip only if we are not already in it.
fn want(model: &mut Model, which: WornDisplay, show: bool) {
let held = match which {
@@ -78,9 +61,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
] {
g.set(
name,
lua.create_function(move |lua, v: Value| {
// `GetBoolOrDefault` (`0x6f1c10`) with default 1 (`0x48de4e`, `0x48de9e`): a bare call
// shows, and the stock panel's string `"0"` hides where Lua 5.0 holds it truthy.
lua.create_function(move |lua, args: MultiValue| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
want(&mut model, which, shown_arg(&v));
want(&mut model, which, bool_or_default(args.front(), true));
Ok(())
})?,
)?;
@@ -140,6 +125,33 @@ mod tests {
);
}
/// The flag is `GetBoolOrDefault` with default 1 (`0x48de4e`): a bare call shows, a table
/// takes the default, and a string goes by its first byte.
#[test]
fn the_setter_takes_the_flag_with_default_one() {
let mut s = UiScript::new().unwrap();
for (arg, shown) in [
("", true),
("nil", false),
("{}", true),
(r#""no""#, false),
(r#""yes""#, true),
(r#""off""#, false),
(r#""""#, true),
("0.5", false),
] {
s.run(&format!("ShowHelm({})", if shown { "0" } else { "1" }))
.unwrap();
s.take_worn_display_toggles();
s.run(&format!("ShowHelm({arg})")).unwrap();
assert_eq!(
s.eval::<bool>("return ShowingHelm() == 1").unwrap(),
shown,
"ShowHelm({arg})"
);
}
}
/// The Options window's Defaults button writes every row, so a flip on no difference would
/// invert a preference already at its default.
#[test]