diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index fe453d88..6c64bd76 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -306,6 +306,13 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) { .and_then(|r| r.realm.as_ref().map(|r| r.name.clone())) .unwrap_or_default(); script.set_realm_name(&realm); + // `CGGameUI::InitializeGame` flags these read-only before `UI_Init` loads anything + // (`0x48f566`-`0x48f584`), so an in-game `SetCVar` of one raises, logout handlers included; + // `ShutdownGame` clears them (`0x491240`), and here the flags die with this VM. The host's + // own realm writes are engine writes, which the flag does not stop. + for name in benilla_ui::script::IN_WORLD_READ_ONLY_CVARS { + script.set_cvar_read_only(name, true); + } // The player too, before the addon walk. The record first: the reference's // `UnitName`/`UnitRace`/`UnitClass`/`UnitSex` read only a copy of the char-enum row made at the // Enter World commit (`0x5abd9e`) and never cleared, and each rebuilt VM is told once. The diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index 3c11b97f..5deca590 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -3567,8 +3567,7 @@ fn the_status_bar_text_row_pins_the_numerals_the_moment_it_is_clicked() { s.take_cvar_changes(), vec![("statusBarText".to_string(), "1".to_string())] ); - // No repaint and no XP tick: only the queued `CVAR_UPDATE`. - s.tick(0.0); + // No repaint, no XP tick and no frame: only the `CVAR_UPDATE` the click fired. assert!( s.eval::("return MainMenuBarExpText:IsShown()") .unwrap(), @@ -3577,7 +3576,6 @@ fn the_status_bar_text_row_pins_the_numerals_the_moment_it_is_clicked() { s.run("BenillaOptionsFrameContainerBodyInterfaceRowStatusTextCheck:Click()") .unwrap(); - s.tick(0.0); assert!(!s .eval::("return MainMenuBarExpText:IsShown()") .unwrap()); diff --git a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs index b154d30d..686d5888 100644 --- a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs +++ b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs @@ -1453,6 +1453,54 @@ fn the_stock_sound_options_window_loads_hidden_and_the_alias_is_gone() { ); } +/// The stock Sound window's boxes write `this:GetChecked()` as is (`SoundOptionsFrame.lua:82`), +/// which is nil once unticked, and `SetCVar` stores that as "0" (`0x488c98`); its sliders write +/// `GetValue()`, a single-precision number stored as Lua's `%.14g` text (`0x6f7c80`). +#[test] +fn the_stock_sound_window_turns_a_box_off_and_stores_a_slider_as_lua_text() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut s = benilla_ui::script::UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + s.set_unit( + "player", + Some(benilla_ui::script::UnitState { + exists: true, + name: Some("Probefour".into()), + level: 60, + ..Default::default() + }), + ); + let failures = super::load_default_ui(&s); + assert!(failures.is_empty(), "manifest load errors: {failures:#?}"); + s.set_cvar_host("EnableMusic", "1"); + s.run("this = SoundOptionsFrameOkay SoundOptionsFrame_Load()") + .unwrap(); + let _ = s.take_cvar_changes(); + let checked = "return SoundOptionsFrameCheckButton5:GetChecked() and true or false"; + assert!(s.eval::(checked).unwrap(), "Enable Music starts on"); + + s.run("SoundOptionsFrameCheckButton5:Click()").unwrap(); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); + assert!(!s.eval::(checked).unwrap()); + assert_eq!( + s.take_cvar_changes(), + vec![("EnableMusic".to_string(), "0".to_string())], + "the unticked box turns music off" + ); + s.run("SoundOptionsFrameCheckButton5:Click()").unwrap(); + assert_eq!( + s.take_cvar_changes(), + vec![("EnableMusic".to_string(), "1".to_string())] + ); + + s.run("SoundOptionsFrameSlider3:SetValue(0.4)").unwrap(); + assert_eq!( + s.eval::("return GetCVar(\"MusicVolume\")").unwrap(), + "0.40000000596046" + ); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + /// The stock Video Options window loads hidden (`OptionsFrame.xml:5`) and owns the `OptionsFrame` /// name; ours is `BenillaOptionsFrame`, which the ESC menu opens and the stock "options window /// open?" consumers see only through the `GameMenuFrame.xml` wrappers. diff --git a/crates/benilla-app/src/ui_script/world_entry_tests.rs b/crates/benilla-app/src/ui_script/world_entry_tests.rs index 5f6e7a30..eea5526e 100644 --- a/crates/benilla-app/src/ui_script/world_entry_tests.rs +++ b/crates/benilla-app/src/ui_script/world_entry_tests.rs @@ -270,6 +270,50 @@ fn the_login_arms_the_world_latch_and_the_logout_spends_it() { let _ = std::fs::remove_dir_all(&tmp); } +/// `CGGameUI::InitializeGame` flags `realmList`, `realmName` and `scriptMemory` read-only for the +/// world session (`0x48f566`-`0x48f584`) and `ShutdownGame` clears them (`0x491240`): an +/// in-game `SetCVar` of one raises, through a reload too, and the character screen's does not. +#[test] +fn the_realm_cvars_are_read_only_to_lua_only_in_the_world() { + let _l = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (tmp, _c, _h) = hermetic_probe("readonly"); + let mut world = booted_world(); + world.init_resource::(); + let set = |world: &World, chunk: &str| { + let s = world.non_send_resource::(); + // A bare test world has no registry to seed the VM from. + s.register_cvars(crate::cvars::registered_pairs()); + s.run(chunk).map_err(|e| e.to_string()) + }; + + assert_eq!( + set(&world, "SetCVar(\"realmList\", \"glue.example.org\")"), + Ok(()), + "the character screen's VM may write it" + ); + + log_in_as(&mut world, "Onehunter", 1); + for name in ["realmList", "realmName"] { + let err = set(&world, &format!("SetCVar({name:?}, \"x\")")).expect_err(name); + assert!(err.contains(&format!("\"{name}\" is read-only")), "{err}"); + } + assert_eq!(set(&world, "SetCVar(\"MusicVolume\", 0.5)"), Ok(())); + + reload(&mut world, crate::char_select::ClientState::InWorld); + assert!( + set(&world, "SetCVar(\"realmList\", \"x\")").is_err(), + "a reload keeps the flag, as the reference's rows keep theirs" + ); + + super::end_ui_session(&mut world); + assert_eq!(set(&world, "SetCVar(\"realmList\", \"x\")"), Ok(())); + + drop(world); + let _ = std::fs::remove_dir_all(&tmp); +} + /// The reference's one guard in the shutdown tail: `0x490bd0` tests the active player's GUID pair /// (`0x490bee call 0x468550`, `0x490bf3 or eax,edx`) and with none jumps (`0x490bf5 je 0x490c25`) /// past only the `PLAYER_LEAVING_WORLD` fire, `0x490c20 call 0x490a80`; in-world roots fire both. diff --git a/crates/benilla-ui/src/script/chat_misc.rs b/crates/benilla-ui/src/script/chat_misc.rs index 7703397e..b67b55ee 100644 --- a/crates/benilla-ui/src/script/chat_misc.rs +++ b/crates/benilla-ui/src/script/chat_misc.rs @@ -143,7 +143,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let mut model = lua.app_data_mut::().expect("model app_data"); let is_cvar = model.cvars.contains_key(&name.to_ascii_lowercase()); if is_cvar && !value.is_empty() { - super::cvars::write_cvar(&mut model, name, value.to_string(), None); + super::cvars::write_cvar(&mut model, name, value.to_string()); } else { model.console_lines.push(line.clone()); } diff --git a/crates/benilla-ui/src/script/cvars.rs b/crates/benilla-ui/src/script/cvars.rs index 5a976354..143f1cb1 100644 --- a/crates/benilla-ui/src/script/cvars.rs +++ b/crates/benilla-ui/src/script/cvars.rs @@ -9,6 +9,7 @@ use mlua::{Lua, Value}; +use super::binding_abi::lua_number_text; use super::{Model, ScriptValue}; /// One registered CVar. @@ -202,6 +203,20 @@ impl super::UiScript { std::mem::take(&mut self.model_mut().cvar_registrations) } + /// Flag a CVar read-only to Lua, or clear it: `CVar::SetReadOnly` (`0x63e030`). A flagged name + /// makes `SetCVar` raise `"%s" is read-only` (`0x488c67`-`0x488c78`); host and engine writes, + /// and `ConsoleExec`, still land, as the reference's `CVar::Set` (`0x63df50`) never tests the + /// flag. The name need not be registered yet. + pub fn set_cvar_read_only(&mut self, name: &str, read_only: bool) { + let key = name.to_ascii_lowercase(); + let mut model = self.model_mut(); + if read_only { + model.cvars_read_only.insert(key); + } else { + model.cvars_read_only.remove(&key); + } + } + /// A native widget's write (the glue AddOns screen's "Load out of date AddOns" checkbox): sets /// the value and queues the change like a Lua `SetCVar`, so the config file is dirtied. pub fn set_cvar_engine(&mut self, name: &str, value: &str) { @@ -282,27 +297,15 @@ fn store(model: &mut Model, key: &str, value: String) -> Option { Some(registered) } -/// The `SetCVar` write, shared with `ConsoleExec`: the change is queued only when the value -/// moved, and `CVAR_UPDATE` fires only when a token was passed. `false` for an unknown name. -pub(super) fn write_cvar( - model: &mut Model, - name: &str, - value: String, - token: Option, -) -> bool { +/// A by-name write from a binding (`SetCVar`, `ConsoleExec`, `SetGamma`, `SetWorldDetail`): the +/// change is queued only when the value moved. `false`, with a warning, for an unknown name. +pub(super) fn write_cvar(model: &mut Model, name: &str, value: String) -> bool { let key = name.to_ascii_lowercase(); if !model.cvars.contains_key(&key) { warn_unknown(model, name); return false; } - if store(model, &key, value.clone()).is_some() { - if let Some(token) = token { - model.pending_events.push(( - "CVAR_UPDATE".to_string(), - vec![ScriptValue::Str(token), ScriptValue::Str(value)], - )); - } - } + store(model, &key, value); true } @@ -315,20 +318,36 @@ fn warn_unknown(model: &mut Model, name: &str) { } } -/// Coerce a Lua argument to the stored string; the client takes a number too -/// (`SetCVar("MusicVolume", 0.4)` is the common call). A boolean becomes "1" or "0", where the -/// reference's `SetCVar` and `RegisterCVar` store "0" for anything but a string or a number -/// (`0x488c98`, `0x488b6f`). -fn value_to_string(v: &Value) -> Option { - match v { - Value::String(s) => s.to_str().ok().map(|s| s.to_owned()), - Value::Integer(i) => Some(i.to_string()), - Value::Number(n) => Some(n.to_string()), - Value::Boolean(b) => Some(if *b { "1".into() } else { "0".into() }), +/// An argument as the CVar bindings read it, `lua_tostring` (`0x6f3690`): a string as it is, a +/// number as Lua's `%.14g` text (`luaV_tostring` `0x6f7c80`), so the stock Sound slider's +/// single-precision 0.4 stores `"0.40000000596046"`. `None` for anything `lua_isstring` +/// (`0x6f3510`) refuses: nil, an absent argument, a boolean, a table or a function. +fn cvar_arg(v: Option<&Value>) -> Option { + match v? { + // Lossy: the reference takes bytes, and invalid UTF-8 must not read as absent. + Value::String(s) => Some(s.to_string_lossy()), + Value::Integer(i) => Some(lua_number_text(*i as f64)), + Value::Number(n) => Some(lua_number_text(*n)), _ => None, } } +/// What `SetCVar` and `RegisterCVar` store for a value with no string form (`0x82e570`, loaded +/// at `0x488c98` and `0x488b6f`). +const NO_STRING_FORM: &str = "0"; + +/// `0x84235c`, verbatim, the unbalanced parenthesis included. +const USAGE_SET_CVAR: &str = "Usage: SetCVar(\"cvar\", value [, \"scriptCvar\")"; + +/// `0x8422e8`, verbatim. +const USAGE_REGISTER_CVAR: &str = "Usage: RegisterCVar(\"cvar\" [, default])"; + +/// The CVars `CGGameUI::InitializeGame` flags read-only for the whole world session, the table +/// at `0x83de4c` walked by `0x48f566`-`0x48f584`; `ShutdownGame` clears them again +/// (`0x491240`-`0x49125d`). The host flags them on each in-game VM before its UI loads, as +/// `InitializeGame` does before `UI_Init`. +pub const IN_WORLD_READ_ONLY_CVARS: [&str; 3] = ["realmList", "realmName", "scriptMemory"]; + /// The enemy-plate toggle's CVar, which the app reads rather than re-spelling. Deviation: 1.12 /// registers no nameplate CVar; the toggles persist under the later clients' names rather than /// invented ones. @@ -337,13 +356,18 @@ pub const CVAR_NAMEPLATE_ENEMIES: &str = "nameplateShowEnemies"; pub const CVAR_NAMEPLATE_FRIENDS: &str = "nameplateShowFriends"; pub(super) fn install(lua: &Lua) -> mlua::Result<()> { - // `RegisterCVar(name, value)` declares a CVar the client does not ship, which is how an addon - // persists a setting. Re-declaring a live name is a no-op: the reference never resets a live - // value on re-registration. + // `RegisterCVar(name, value)` (`0x488b00`) declares a CVar the client does not ship, which is + // how an addon persists a setting. Re-declaring a live name is a no-op: the reference never + // resets a live value on re-registration (`0x488b7d`). lua.globals().set( "RegisterCVar", - lua.create_function(|lua, (name, value): (String, Option)| { - let value = value.as_ref().and_then(value_to_string).unwrap_or_default(); + lua.create_function(|lua, args: mlua::MultiValue| { + // `lua_isstring(1)` (`0x488b09`): a string or a number names it. + let Some(name) = cvar_arg(args.front()) else { + return Err(mlua::Error::runtime(USAGE_REGISTER_CVAR)); + }; + // The default only when `lua_isstring(2)` passes (`0x488b56`), else "0". + let value = cvar_arg(args.get(1)).unwrap_or_else(|| NO_STRING_FORM.to_string()); let mut model = lua.app_data_mut::().expect("model app_data"); let key = name.to_ascii_lowercase(); // A saved value outranks the declared one, which stays the default for the saver. @@ -463,24 +487,40 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { install_video_verbs(lua)?; + // `SetCVar(name, value [, token])` (`0x488c10`). lua.globals().set( "SetCVar", lua.create_function(|lua, args: mlua::MultiValue| { - let mut it = args.iter(); - let (Some(Value::String(name)), Some(v)) = (it.next(), it.next()) else { - return Err(mlua::Error::runtime("Usage: SetCVar(\"name\", value)")); + // `lua_isstring(1)` (`0x488c1a`): a string or a number names it. + let Some(name) = cvar_arg(args.front()) else { + return Err(mlua::Error::runtime(USAGE_SET_CVAR)); }; - let name = name.to_str()?.to_owned(); - let Some(value) = value_to_string(v) else { - return Err(mlua::Error::runtime("Usage: SetCVar(\"name\", value)")); - }; - // The third argument is the `CVAR_UPDATE` token, passed through as arg1; without one - // nothing fires. The 1.12 options panels pass their CheckButtons key, such as - // "STATUS_BAR_TEXT" (`UIOptionsFrame.lua` l.335, `OptionsFrame.lua` l.192), which is - // how `UIOptionsFrame_OnEvent` finds its row. - let token = it.next().and_then(value_to_string); - let mut model = lua.app_data_mut::().expect("model app_data"); - write_cvar(&mut model, &name, value, token); + // A value with no string form stores "0" (`0x488c8d`-`0x488c98`): an unchecked + // CheckButton's `GetChecked()` is nil, and `SoundOptionsCheckButton_OnClick` writes + // it as is. + let value = cvar_arg(args.get(1)).unwrap_or_else(|| NO_STRING_FORM.to_string()); + { + let mut model = lua.app_data_mut::().expect("model app_data"); + // Flag bit2 (`0x488c67`), tested before anything is written. + if model.cvars_read_only.contains(&name.to_ascii_lowercase()) { + return Err(mlua::Error::runtime(format!("\"{name}\" is read-only"))); + } + if !write_cvar(&mut model, &name, value.clone()) { + return Ok(()); + } + } + // A third argument that passes `lua_isstring` is the `CVAR_UPDATE` token, arg1, with + // the value as arg2 (`0x488cad`-`0x488cd5`), fired on every such call, changed or not, + // and inside it: `SignalEvent2` (`0x703f50`) walks the listeners in place. The 1.12 + // options panels pass their CheckButtons key, such as "STATUS_BAR_TEXT" + // (`UIOptionsFrame.lua` l.335), which is how `UIOptionsFrame_OnEvent` finds its row. + if let Some(token) = cvar_arg(args.get(2)) { + super::tick::fire_event_into( + lua, + "CVAR_UPDATE", + vec![ScriptValue::Str(token), ScriptValue::Str(value)], + ); + } Ok(()) })?, )?; @@ -709,7 +749,7 @@ fn install_video_verbs(lua: &Lua) -> mlua::Result<()> { let mut written = format!("{:.6}", 1.0 - v); written.truncate(15); let mut model = lua.app_data_mut::().expect("model app_data"); - write_cvar(&mut model, CVAR_GAMMA, written, None); + write_cvar(&mut model, CVAR_GAMMA, written); // Zero return values, not nil (`eax = 0` at every `ret`). Ok(mlua::MultiValue::new()) })?, @@ -777,9 +817,8 @@ fn install_world_detail_verbs(lua: &Lua) -> mlua::Result<()> { &mut model, CVAR_FRILL_DENSITY, WORLD_DETAIL_STOPS[stop].to_string(), - None, ); - write_cvar(&mut model, CVAR_WORLD_DETAIL, stop.to_string(), None); + write_cvar(&mut model, CVAR_WORLD_DETAIL, stop.to_string()); // Zero return values, not nil (`eax = 0` at every `ret`). Ok(mlua::MultiValue::new()) })?, @@ -978,37 +1017,6 @@ mod tests { assert_eq!(s.take_console_lines(), vec!["MusicVolume".to_string()]); } - #[test] - fn the_third_argument_is_the_cvar_update_token() { - let mut s = script_with_volume(); - s.run( - "SEEN = {} \ - f = CreateFrame(\"Frame\") \ - f:RegisterEvent(\"CVAR_UPDATE\") \ - f:SetScript(\"OnEvent\", function() table.insert(SEEN, arg1 .. \"=\" .. arg2) end)", - ) - .unwrap(); - - s.run(r#"SetCVar("MusicVolume", "0.5")"#).unwrap(); - s.tick(0.0); - assert_eq!(s.eval::("return getn(SEEN)").unwrap(), 0.0); - - // With one: arg1 is the token verbatim, not the CVar's name, and arg2 the new value. - s.run(r#"SetCVar("MusicVolume", "0.6", "MUSIC_VOLUME")"#) - .unwrap(); - s.tick(0.0); - assert_eq!( - s.eval::("return SEEN[1]").unwrap(), - "MUSIC_VOLUME=0.6" - ); - - s.run(r#"SetCVar("MusicVolume", "0.6", "MUSIC_VOLUME")"#) - .unwrap(); - s.tick(0.0); - assert_eq!(s.eval::("return getn(SEEN)").unwrap(), 1.0); - assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); - } - #[test] fn host_writes_do_not_echo_and_snapshots_carry_defaults() { let mut s = script_with_volume(); diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 7c99c06f..3ff9dcfe 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -177,8 +177,8 @@ pub use cursor::{ }; pub use cvars::{ MultisampleFormat, ScreenResolution, SeededCvar, VideoCaps, CVAR_FRILL_DENSITY, CVAR_GAMMA, - CVAR_NAMEPLATE_ENEMIES, CVAR_NAMEPLATE_FRIENDS, CVAR_WORLD_DETAIL, VIDEO_DEFAULT_CVARS, - WORLD_DETAIL_STOPS, + CVAR_NAMEPLATE_ENEMIES, CVAR_NAMEPLATE_FRIENDS, CVAR_WORLD_DETAIL, IN_WORLD_READ_ONLY_CVARS, + VIDEO_DEFAULT_CVARS, WORLD_DETAIL_STOPS, }; pub use death::{DeathAction, DeathUiState}; pub use dressup::DressUpIntent; diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index b69e455b..b298c1db 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -379,6 +379,9 @@ pub(crate) struct Model { /// `(name, default)` per addon `RegisterCVar` that created a slot. pub(crate) cvar_registrations: Vec<(String, String)>, pub(crate) cvars_warned: HashSet, + /// The lowercased names a Lua `SetCVar` refuses, the reference's flag bit2 (`rec+0x1c & 4`, + /// `CVar::SetReadOnly 0x63e030`). Kept beside the rows, so a host re-seed never clears it. + pub(crate) cvars_read_only: HashSet, /// The adapter's multisample formats in dropdown order, the reference's list `[0xb4b444]` of /// `{colorBits, depthBits, multisample}` (count `[0xb4b440]`, built by `0x48c3e0`). @@ -1119,6 +1122,7 @@ impl Model { cvar_changes: Vec::new(), cvar_registrations: Vec::new(), cvars_warned: HashSet::new(), + cvars_read_only: HashSet::new(), multisample_formats: Vec::new(), screen_resolutions: Vec::new(), current_resolution: None, diff --git a/crates/benilla-ui/src/script/tests/cvar_bindings.rs b/crates/benilla-ui/src/script/tests/cvar_bindings.rs new file mode 100644 index 00000000..675e5368 --- /dev/null +++ b/crates/benilla-ui/src/script/tests/cvar_bindings.rs @@ -0,0 +1,249 @@ +//! The `SetCVar` (`0x488c10`) and `RegisterCVar` (`0x488b00`) bindings: how each reads its +//! arguments, the read-only flag, and when `CVAR_UPDATE` fires. + +use crate::script::UiScript; + +fn script_with(rows: &[(&str, &str)]) -> UiScript { + let s = UiScript::new().unwrap(); + s.register_cvars(rows.iter().copied()); + s +} + +fn get(s: &UiScript, name: &str) -> String { + s.eval::(&format!("return GetCVar({name:?})")) + .unwrap() +} + +fn raised(s: &UiScript, chunk: &str) -> String { + s.run(chunk) + .expect_err(&format!("{chunk} must raise")) + .to_string() +} + +/// `lua_tostring` on argument 2 returns NULL for anything but a string or a number, and the +/// binding stores `"0"` for it (`0x488c8d`-`0x488c98`): an unticked box's `GetChecked()` is nil. +#[test] +fn set_cvar_stores_zero_for_a_value_with_no_string_form() { + let mut s = script_with(&[("EnableMusic", "1")]); + for value in ["nil", "true", "false", "{}", "print"] { + s.set_cvar_host("EnableMusic", "1"); + s.run(&format!("SetCVar(\"EnableMusic\", {value})")) + .unwrap_or_else(|e| panic!("SetCVar with {value} raised: {e}")); + assert_eq!(get(&s, "EnableMusic"), "0", "SetCVar with {value}"); + assert_eq!( + s.take_cvar_changes(), + vec![("EnableMusic".to_string(), "0".to_string())], + "SetCVar with {value} is a real write" + ); + } + s.set_cvar_host("EnableMusic", "1"); + s.run("SetCVar(\"EnableMusic\")").unwrap(); + assert_eq!(get(&s, "EnableMusic"), "0", "an absent value"); +} + +/// A number goes through `luaV_tostring` (`0x6f7c80`), MSVC's `%.14g`; a string is kept as is. +#[test] +fn set_cvar_stores_a_number_as_lua_text() { + let s = script_with(&[("MusicVolume", "1.0")]); + for (lua, stored) in [ + // The stock Sound slider's single-precision 0.4, widened. + ("0.4000000059604645", "0.40000000596046"), + ("0.7", "0.7"), + ("3", "3"), + ("-2.5", "-2.5"), + ("1e20", "1e+020"), + ("0.00001", "1e-005"), + ("\"0.4000000059604645\"", "0.4000000059604645"), + ] { + s.run(&format!("SetCVar(\"MusicVolume\", {lua})")).unwrap(); + assert_eq!(get(&s, "MusicVolume"), stored, "SetCVar with {lua}"); + } +} + +/// `lua_isstring(1)` fails (`0x488c1a`) and the binding raises the reference's own usage text +/// (`0x84235c`); a number passes and names the CVar by its text. +#[test] +fn set_cvar_raises_the_reference_usage_without_a_name() { + let s = script_with(&[("5", "a")]); + for chunk in [ + "SetCVar()", + "SetCVar(nil, 1)", + "SetCVar({}, 1)", + "SetCVar(true, 1)", + ] { + assert!( + raised(&s, chunk).contains("Usage: SetCVar(\"cvar\", value [, \"scriptCvar\")"), + "{chunk}" + ); + } + s.run("SetCVar(5, \"b\")").unwrap(); + assert_eq!(get(&s, "5"), "b"); +} + +/// Flag bit2 (`0x488c67`-`0x488c78`): the in-game session flags three rows (`0x48f566`), and +/// only the Lua binding tests it; before the flag the same write lands. +#[test] +fn set_cvar_refuses_a_read_only_row_only_once_flagged() { + let mut s = script_with(&[ + ("realmList", "us.logon.worldofwarcraft.com"), + ("realmName", ""), + ("scriptMemory", "0"), + ("MusicVolume", "1.0"), + ]); + s.run( + "SEEN = 0 \ + f = CreateFrame(\"Frame\") \ + f:RegisterEvent(\"CVAR_UPDATE\") \ + f:SetScript(\"OnEvent\", function() SEEN = SEEN + 1 end)", + ) + .unwrap(); + for name in crate::script::IN_WORLD_READ_ONLY_CVARS { + s.run(&format!("SetCVar({name:?}, \"before\")")).unwrap(); + assert_eq!( + get(&s, name), + "before", + "{name} is writable before the flag" + ); + } + s.take_cvar_changes(); + + for name in crate::script::IN_WORLD_READ_ONLY_CVARS { + s.set_cvar_read_only(name, true); + } + for name in crate::script::IN_WORLD_READ_ONLY_CVARS { + let upper = name.to_ascii_uppercase(); + assert_eq!( + raised(&s, &format!("SetCVar({upper:?}, \"after\", \"TOKEN\")")) + .lines() + .next(), + Some(format!("runtime error: \"{upper}\" is read-only").as_str()), + "the name as passed, any case" + ); + assert_eq!(get(&s, name), "before", "{name} kept its value"); + } + assert!(s.take_cvar_changes().is_empty(), "nothing was written"); + assert_eq!( + s.eval::("return SEEN").unwrap(), + 0.0, + "a refused write fires nothing" + ); + s.run("SetCVar(\"MusicVolume\", 0.5)").unwrap(); + assert_eq!(get(&s, "MusicVolume"), "0.5", "other rows stay writable"); + + // The host's and the console's writes go through `CVar::Set` (`0x63df50`), which never + // tests the flag. + s.set_cvar_engine("realmList", "logon.example.org"); + assert_eq!(get(&s, "realmList"), "logon.example.org"); + s.run("ConsoleExec(\"realmName Elsewhere\")").unwrap(); + assert_eq!(get(&s, "realmName"), "Elsewhere"); + + // `ShutdownGame`'s clear (`0x491252`). + s.set_cvar_read_only("realmList", false); + s.run("SetCVar(\"realmList\", \"again\")").unwrap(); + assert_eq!(get(&s, "realmList"), "again"); +} + +/// A re-seed from the host replaces every row, and the flag outlives it, as the reference's +/// rows keep their flags through every write. +#[test] +fn the_read_only_flag_survives_a_host_reseed() { + let mut s = script_with(&[("realmName", "")]); + s.set_cvar_read_only("realmName", true); + s.seed_cvars([crate::script::SeededCvar { + name: "realmName".into(), + value: "Here".into(), + default: String::new(), + latched: false, + }]); + assert!(raised(&s, "SetCVar(\"realmName\", \"x\")").contains("is read-only")); +} + +/// `CVAR_UPDATE` fires whenever argument 3 passes `lua_isstring`, changed or not, with the token +/// as arg1 and the value text as arg2 (`0x488cad`-`0x488cd5`), inside the call: `SignalEvent2` +/// (`0x703f50`) walks its listeners in place. +#[test] +fn set_cvar_fires_cvar_update_inside_the_call_for_every_token() { + let s = script_with(&[("MusicVolume", "1.0")]); + s.run( + "SEEN = {} \ + f = CreateFrame(\"Frame\") \ + f:RegisterEvent(\"CVAR_UPDATE\") \ + f:SetScript(\"OnEvent\", function() table.insert(SEEN, arg1 .. \"=\" .. arg2) end)", + ) + .unwrap(); + let seen = |s: &UiScript| -> Vec { s.eval("return SEEN").unwrap() }; + + // No token, or one with no string form: nothing fires. + for chunk in [ + "SetCVar(\"MusicVolume\", \"0.5\")", + "SetCVar(\"MusicVolume\", \"0.5\", nil)", + "SetCVar(\"MusicVolume\", \"0.5\", true)", + "SetCVar(\"MusicVolume\", \"0.5\", {})", + ] { + s.run(chunk).unwrap(); + assert!(seen(&s).is_empty(), "{chunk} fires nothing"); + } + + // Seen by the next statement of the same chunk, with no tick between. + assert_eq!( + s.eval::("SetCVar(\"MusicVolume\", \"0.6\", \"MUSIC_VOLUME\") return getn(SEEN)") + .unwrap(), + 1.0, + "the handler ran before SetCVar returned" + ); + // The same value again still fires. + s.run("SetCVar(\"MusicVolume\", \"0.6\", \"MUSIC_VOLUME\")") + .unwrap(); + // A number token is its text; a value with no string form is "0". + s.run("SetCVar(\"MusicVolume\", nil, 7)").unwrap(); + s.run("SetCVar(\"MusicVolume\", 0.25, \"VOL\")").unwrap(); + assert_eq!( + seen(&s), + vec!["MUSIC_VOLUME=0.6", "MUSIC_VOLUME=0.6", "7=0", "VOL=0.25"] + ); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + +/// `RegisterCVar` takes argument 2 as the default only when `lua_isstring` passes (`0x488b56`), +/// else `"0"` (`0x488b6f`); a number default is `%.14g` text. Without a name it raises its usage +/// text (`0x8422e8`). +#[test] +fn register_cvar_defaults_to_zero_and_reads_numbers_as_lua_text() { + let mut s = UiScript::new().unwrap(); + for (name, arg, stored) in [ + ("Bare", "", "0"), + ("Nil", ", nil", "0"), + ("True", ", true", "0"), + ("Table", ", {}", "0"), + ("Float", ", 0.4000000059604645", "0.40000000596046"), + ("Int", ", 3", "3"), + ("Text", ", \"on\"", "on"), + ] { + s.run(&format!("RegisterCVar({name:?}{arg})")).unwrap(); + assert_eq!(get(&s, name), stored, "RegisterCVar({name:?}{arg})"); + assert_eq!( + s.eval::(&format!("return GetCVarDefault({name:?})")) + .unwrap(), + stored, + "the default too" + ); + } + assert!(s + .take_cvar_registrations() + .contains(&("Bare".to_string(), "0".to_string()))); + assert!( + s.eval::("return tonumber(GetCVar(\"Bare\")) == 0") + .unwrap(), + "an addon's tonumber of a bare registration is 0, not nil" + ); + for chunk in [ + "RegisterCVar()", + "RegisterCVar(nil, \"1\")", + "RegisterCVar({})", + ] { + assert!( + raised(&s, chunk).contains("Usage: RegisterCVar(\"cvar\" [, default])"), + "{chunk}" + ); + } +} diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs index 53850d17..c4710726 100644 --- a/crates/benilla-ui/src/script/tests/mod.rs +++ b/crates/benilla-ui/src/script/tests/mod.rs @@ -10,6 +10,7 @@ mod channel; mod common; mod cooldown; mod create_frame_template; +mod cvar_bindings; mod dispatch_bench; mod end_to_end; mod events;