From 1efd9a7163da0dd3e114b9a9c87326fb4e286125 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:41:47 +0700 Subject: [PATCH] Lua bindings coerce numbers, flags and colours as 1.12.1 does (#991) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/ui_script/multibar_stance_tests.rs | 30 +- .../src/ui_script/spellbook_tests.rs | 5 +- crates/benilla-ui/src/script/action.rs | 19 +- crates/benilla-ui/src/script/binding_abi.rs | 199 +++++++- crates/benilla-ui/src/script/button.rs | 63 +-- crates/benilla-ui/src/script/channel.rs | 18 +- crates/benilla-ui/src/script/chat_misc.rs | 57 ++- crates/benilla-ui/src/script/chat_window.rs | 115 +++-- crates/benilla-ui/src/script/colorselect.rs | 6 +- .../benilla-ui/src/script/editbox/methods.rs | 14 +- crates/benilla-ui/src/script/editbox/tests.rs | 79 +++ crates/benilla-ui/src/script/follow.rs | 10 +- crates/benilla-ui/src/script/font.rs | 27 +- crates/benilla-ui/src/script/font_block.rs | 22 +- crates/benilla-ui/src/script/guild.rs | 37 +- crates/benilla-ui/src/script/item_stats.rs | 44 +- crates/benilla-ui/src/script/macros.rs | 26 +- .../benilla-ui/src/script/messageframe/mod.rs | 11 + .../src/script/messageframe/plain.rs | 33 +- .../src/script/messageframe/scrolling.rs | 57 +-- crates/benilla-ui/src/script/object.rs | 33 +- .../src/script/object/frame_state.rs | 23 +- .../benilla-ui/src/script/object/movable.rs | 13 +- .../benilla-ui/src/script/object/toplevel.rs | 12 +- crates/benilla-ui/src/script/party.rs | 12 +- crates/benilla-ui/src/script/region/paint.rs | 101 ++-- crates/benilla-ui/src/script/region/text.rs | 13 +- .../benilla-ui/src/script/simplehtml/mod.rs | 3 +- crates/benilla-ui/src/script/social.rs | 14 +- crates/benilla-ui/src/script/statusbar.rs | 12 +- crates/benilla-ui/src/script/tests/button.rs | 59 +++ .../src/script/tests/colour_args.rs | 338 +++++++++++++ .../benilla-ui/src/script/tests/flag_args.rs | 449 ++++++++++++++++++ crates/benilla-ui/src/script/tests/mod.rs | 2 + crates/benilla-ui/src/script/tooltip/mod.rs | 51 +- crates/benilla-ui/src/script/tooltip/verbs.rs | 33 +- crates/benilla-ui/src/script/unit/bindings.rs | 7 +- crates/benilla-ui/src/script/worn_display.rs | 54 ++- 38 files changed, 1599 insertions(+), 502 deletions(-) create mode 100644 crates/benilla-ui/src/script/tests/colour_args.rs create mode 100644 crates/benilla-ui/src/script/tests/flag_args.rs diff --git a/crates/benilla-app/src/ui_script/multibar_stance_tests.rs b/crates/benilla-app/src/ui_script/multibar_stance_tests.rs index 41a55340..d8216634 100644 --- a/crates/benilla-app/src/ui_script/multibar_stance_tests.rs +++ b/crates/benilla-app/src/ui_script/multibar_stance_tests.rs @@ -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()); } diff --git a/crates/benilla-app/src/ui_script/spellbook_tests.rs b/crates/benilla-app/src/ui_script/spellbook_tests.rs index 29d7808a..2a9f0acb 100644 --- a/crates/benilla-app/src/ui_script/spellbook_tests.rs +++ b/crates/benilla-app/src/ui_script/spellbook_tests.rs @@ -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::(&format!( diff --git a/crates/benilla-ui/src/script/action.rs b/crates/benilla-ui/src/script/action.rs index 8e08e734..f3facdfe 100644 --- a/crates/benilla-ui/src/script/action.rs +++ b/crates/benilla-ui/src/script/action.rs @@ -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::().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 { diff --git a/crates/benilla-ui/src/script/binding_abi.rs b/crates/benilla-ui/src/script/binding_abi.rs index 14c8a867..c1783ec3 100644 --- a/crates/benilla-ui/src/script/binding_abi.rs +++ b/crates/benilla-ui/src/script/binding_abi.rs @@ -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)); diff --git a/crates/benilla-ui/src/script/button.rs b/crates/benilla-ui/src/script/button.rs index e3602cbe..450cc8e0 100644 --- a/crates/benilla-ui/src/script/button.rs +++ b/crates/benilla-ui/src/script/button.rs @@ -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)| { + |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)| { + |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)| { + |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::().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)) })?, )?; diff --git a/crates/benilla-ui/src/script/channel.rs b/crates/benilla-ui/src/script/channel.rs index 650f2f0b..af5a3ce5 100644 --- a/crates/benilla-ui/src/script/channel.rs +++ b/crates/benilla-ui/src/script/channel.rs @@ -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(), diff --git a/crates/benilla-ui/src/script/chat_misc.rs b/crates/benilla-ui/src/script/chat_misc.rs index e82dd107..10086f29 100644 --- a/crates/benilla-ui/src/script/chat_misc.rs +++ b/crates/benilla-ui/src/script/chat_misc.rs @@ -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) -> Option { - 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 { @@ -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| { + lua.create_function(move |lua, args: MultiValue| { let mut model = lua.app_data_mut::().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::("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::("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::("return LoggingChat(true)").unwrap(), 1); + assert_eq!(s.arity("LoggingChat(nil)").unwrap(), 1); + assert!(s.eval::("return LoggingChat(nil) == nil").unwrap()); } } diff --git a/crates/benilla-ui/src/script/chat_window.rs b/crates/benilla-ui/src/script/chat_window.rs index 297e605e..d4b6ba58 100644 --- a/crates/benilla-ui/src/script/chat_window.rs +++ b/crates/benilla-ui/src/script/chat_window.rs @@ -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)| { + 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::().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::().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(); diff --git a/crates/benilla-ui/src/script/colorselect.rs b/crates/benilla-ui/src/script/colorselect.rs index 9fedd46c..0e65e84f 100644 --- a/crates/benilla-ui/src/script/colorselect.rs +++ b/crates/benilla-ui/src/script/colorselect.rs @@ -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. diff --git a/crates/benilla-ui/src/script/editbox/methods.rs b/crates/benilla-ui/src/script/editbox/methods.rs index 7f9fa1da..93dce53b 100644 --- a/crates/benilla-ui/src/script/editbox/methods.rs +++ b/crates/benilla-ui/src/script/editbox/methods.rs @@ -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::().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` 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), diff --git a/crates/benilla-ui/src/script/editbox/tests.rs b/crates/benilla-ui/src/script/editbox/tests.rs index a49078b0..95c56ddf 100644 --- a/crates/benilla-ui/src/script/editbox/tests.rs +++ b/crates/benilla-ui/src/script/editbox/tests.rs @@ -656,6 +656,85 @@ fn get_number_parses_the_text() { assert_eq!(s.eval::("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::("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] diff --git a/crates/benilla-ui/src/script/follow.rs b/crates/benilla-ui/src/script/follow.rs index 4fc33bbf..dafdd82f 100644 --- a/crates/benilla-ui/src/script/follow.rs +++ b/crates/benilla-ui/src/script/follow.rs @@ -43,14 +43,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { g.set( "FollowByName", lua.create_function(|lua, (name, exact): (String, Option)| { - // 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::().expect("model app_data"); model .follow_requests diff --git a/crates/benilla-ui/src/script/font.rs b/crates/benilla-ui/src/script/font.rs index 12634e57..f8e99695 100644 --- a/crates/benilla-ui/src/script/font.rs +++ b/crates/benilla-ui/src/script/font.rs @@ -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)| { + |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)| { + |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], }); }) }, diff --git a/crates/benilla-ui/src/script/font_block.rs b/crates/benilla-ui/src/script/font_block.rs index b08e83cd..98a3749f 100644 --- a/crates/benilla-ui/src/script/font_block.rs +++ b/crates/benilla-ui/src/script/font_block.rs @@ -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)| { + 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::().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)| { + 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::().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(()) diff --git a/crates/benilla-ui/src/script/guild.rs b/crates/benilla-ui/src/script/guild.rs index ff8f9ab4..2ead233b 100644 --- a/crates/benilla-ui/src/script/guild.rs +++ b/crates/benilla-ui/src/script/guild.rs @@ -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::().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::().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)| { 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::().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(()) })?, )?; diff --git a/crates/benilla-ui/src/script/item_stats.rs b/crates/benilla-ui/src/script/item_stats.rs index bc78124e..b2898abe 100644 --- a/crates/benilla-ui/src/script/item_stats.rs +++ b/crates/benilla-ui/src/script/item_stats.rs @@ -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)] diff --git a/crates/benilla-ui/src/script/macros.rs b/crates/benilla-ui/src/script/macros.rs index 4ecef239..b7984b3e 100644 --- a/crates/benilla-ui/src/script/macros.rs +++ b/crates/benilla-ui/src/script/macros.rs @@ -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, texture: Option>, body: Option, - local_only: Option, + local_only: bool, ) -> Option { 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 { } } -/// 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), diff --git a/crates/benilla-ui/src/script/messageframe/mod.rs b/crates/benilla-ui/src/script/messageframe/mod.rs index 8c1856a7..162f23d8 100644 --- a/crates/benilla-ui/src/script/messageframe/mod.rs +++ b/crates/benilla-ui/src/script/messageframe/mod.rs @@ -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 { + 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 diff --git a/crates/benilla-ui/src/script/messageframe/plain.rs b/crates/benilla-ui/src/script/messageframe/plain.rs index c3d9926c..bbd92994 100644 --- a/crates/benilla-ui/src/script/messageframe/plain.rs +++ b/crates/benilla-ui/src/script/messageframe/plain.rs @@ -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( } } -/// 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) })?, )?; diff --git a/crates/benilla-ui/src/script/messageframe/scrolling.rs b/crates/benilla-ui/src/script/messageframe/scrolling.rs index 6861f97d..2b58b443 100644 --- a/crates/benilla-ui/src/script/messageframe/scrolling.rs +++ b/crates/benilla-ui/src/script/messageframe/scrolling.rs @@ -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( } } -/// `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::().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) })?, )?; diff --git a/crates/benilla-ui/src/script/object.rs b/crates/benilla-ui/src/script/object.rs index 06729f85..49bf2737 100644 --- a/crates/benilla-ui/src/script/object.rs +++ b/crates/benilla-ui/src/script/object.rs @@ -334,23 +334,26 @@ fn frame_type(s: &str) -> Option { 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())) } // ───────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/benilla-ui/src/script/object/frame_state.rs b/crates/benilla-ui/src/script/object/frame_state.rs index 7c44713d..f72326a7 100644 --- a/crates/benilla-ui/src/script/object/frame_state.rs +++ b/crates/benilla-ui/src/script/object/frame_state.rs @@ -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::() .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::() .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::().expect("model"); let changed = model.arena.is_clamped_to_screen(h) != clamp; @@ -726,10 +732,9 @@ fn backdrop_from_table(t: &Table) -> mlua::Result { if let Ok(Value::String(s)) = t.get::("edgeFile") { bd.edge_file = Some(s.to_str()?.to_string()); } - bd.tile = !matches!( - t.get::("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::("tile").unwrap_or(Value::Nil)), false); if let Ok(Value::Number(n)) = t.get::("tileSize") { bd.tile_size = n as f32; } else if let Ok(Value::Integer(n)) = t.get::("tileSize") { diff --git a/crates/benilla-ui/src/script/object/movable.rs b/crates/benilla-ui/src/script/object/movable.rs index 975fca9c..a6cd1031 100644 --- a/crates/benilla-ui/src/script/object/movable.rs +++ b/crates/benilla-ui/src/script/object/movable.rs @@ -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::().expect("model"); let ok = model diff --git a/crates/benilla-ui/src/script/object/toplevel.rs b/crates/benilla-ui/src/script/object/toplevel.rs index b6072abb..4efd49fb 100644 --- a/crates/benilla-ui/src/script/object/toplevel.rs +++ b/crates/benilla-ui/src/script/object/toplevel.rs @@ -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)| { - 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::() .expect("model") diff --git a/crates/benilla-ui/src/script/party.rs b/crates/benilla-ui/src/script/party.rs index 808bbc57..d8fee7b5 100644 --- a/crates/benilla-ui/src/script/party.rs +++ b/crates/benilla-ui/src/script/party.rs @@ -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| { + let ready = crate::script::binding_abi::bool_or_default(ready.as_ref(), false); let mut model = lua.app_data_mut::().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), ] ); } diff --git a/crates/benilla-ui/src/script/region/paint.rs b/crates/benilla-ui/src/script/region/paint.rs index 13c09d8b..18d9ef5f 100644 --- a/crates/benilla-ui/src/script/region/paint.rs +++ b/crates/benilla-ui/src/script/region/paint.rs @@ -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)| { - 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::().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::().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::().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::().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)| { - let rest: Vec = args.iter().map(as_f32).collect(); + let rest: Vec = 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]])), diff --git a/crates/benilla-ui/src/script/region/text.rs b/crates/benilla-ui/src/script/region/text.rs index a4702874..ad112c25 100644 --- a/crates/benilla-ui/src/script/region/text.rs +++ b/crates/benilla-ui/src/script/region/text.rs @@ -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::().expect("model"); model.region_data.entry(rh).or_default().non_space_wrap = Some(on); diff --git a/crates/benilla-ui/src/script/simplehtml/mod.rs b/crates/benilla-ui/src/script/simplehtml/mod.rs index 0f0c4fa1..588841be 100644 --- a/crates/benilla-ui/src/script/simplehtml/mod.rs +++ b/crates/benilla-ui/src/script/simplehtml/mod.rs @@ -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)?; diff --git a/crates/benilla-ui/src/script/social.rs b/crates/benilla-ui/src/script/social.rs index 0e80379c..4f044536 100644 --- a/crates/benilla-ui/src/script/social.rs +++ b/crates/benilla-ui/src/script/social.rs @@ -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| { + let on = bool_or_default(flag.as_ref(), false); let mut model = lua.app_data_mut::().expect("model app_data"); model.social_requests.push(SocialRequest::SetWhoToUi(on)); Ok(()) diff --git a/crates/benilla-ui/src/script/statusbar.rs b/crates/benilla-ui/src/script/statusbar.rs index e3d2aca5..225f7acb 100644 --- a/crates/benilla-ui/src/script/statusbar.rs +++ b/crates/benilla-ui/src/script/statusbar.rs @@ -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)| { + |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::().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(()) }, )?, diff --git a/crates/benilla-ui/src/script/tests/button.rs b/crates/benilla-ui/src/script/tests/button.rs index 014447d9..9d2310a0 100644 --- a/crates/benilla-ui/src/script/tests/button.rs +++ b/crates/benilla-ui/src/script/tests/button.rs @@ -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::("return C:GetChecked() == 1").unwrap(), + checked, + "SetChecked({arg})" + ); + } +} diff --git a/crates/benilla-ui/src/script/tests/colour_args.rs b/crates/benilla-ui/src/script/tests/colour_args.rs new file mode 100644 index 00000000..52cbbc55 --- /dev/null +++ b/crates/benilla-ui/src/script/tests/colour_args.rs @@ -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::>("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::>("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::(&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::("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::("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::("local _, _, _, a = TTTextLeft1:GetTextColor() return a") + .unwrap(), + 0.5 + ); + s.run(r#"TT:SetText("x", 1, 1, 1, {})"#).unwrap(); + assert_eq!( + s.eval::("local _, _, _, a = TTTextLeft1:GetTextColor() return a") + .unwrap(), + 1.0 + ); +} diff --git a/crates/benilla-ui/src/script/tests/flag_args.rs b/crates/benilla-ui/src/script/tests/flag_args.rs new file mode 100644 index 00000000..7bb4cf16 --- /dev/null +++ b/crates/benilla-ui/src/script/tests/flag_args.rs @@ -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::(&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::("return F:GetBackdrop().tile == 1").unwrap(), + want, + "tile = {arg}" + ); + } + s.run("F:SetBackdrop({})").unwrap(); + assert!(!s.eval::("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::("return GetNumGuildMembers()").unwrap(), 1); + assert_eq!(s.eval::("return GetNumGuildMembers(nil)").unwrap(), 1); + assert_eq!(s.eval::("return GetNumGuildMembers(0)").unwrap(), 1); + assert_eq!(s.eval::("return GetNumGuildMembers('0')").unwrap(), 1); + assert_eq!(s.eval::("return GetNumGuildMembers(1)").unwrap(), 3); + assert_eq!( + s.eval::("return GetNumGuildMembers('yes')").unwrap(), + 3 + ); + assert_eq!(s.eval::("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::(&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::("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::("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::("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"); +} diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs index 07a242e1..4c3fb1ac 100644 --- a/crates/benilla-ui/src/script/tests/mod.rs +++ b/crates/benilla-ui/src/script/tests/mod.rs @@ -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; diff --git a/crates/benilla-ui/src/script/tooltip/mod.rs b/crates/benilla-ui/src/script/tooltip/mod.rs index c3da0944..3597f2dd 100644 --- a/crates/benilla-ui/src/script/tooltip/mod.rs +++ b/crates/benilla-ui/src/script/tooltip/mod.rs @@ -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 { - 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::().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 { + 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)), ) } diff --git a/crates/benilla-ui/src/script/tooltip/verbs.rs b/crates/benilla-ui/src/script/tooltip/verbs.rs index 39ec58d7..4b488965 100644 --- a/crates/benilla-ui/src/script/tooltip/verbs.rs +++ b/crates/benilla-ui/src/script/tooltip/verbs.rs @@ -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)| { 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)| { - 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, ) }, )?, diff --git a/crates/benilla-ui/src/script/unit/bindings.rs b/crates/benilla-ui/src/script/unit/bindings.rs index 13bf261e..5d98fb41 100644 --- a/crates/benilla-ui/src/script/unit/bindings.rs +++ b/crates/benilla-ui/src/script/unit/bindings.rs @@ -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::().expect("model app_data"); model .script_calls diff --git a/crates/benilla-ui/src/script/worn_display.rs b/crates/benilla-ui/src/script/worn_display.rs index 071a0af8..1493298d 100644 --- a/crates/benilla-ui/src/script/worn_display.rs +++ b/crates/benilla-ui/src/script/worn_display.rs @@ -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::().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::().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::("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]