SetCVar and RegisterCVar read their arguments as 1.12.1 does: a number as %.14g text, "0" for a value with no string form, the reference usage text, realmList/realmName/scriptMemory read-only in the world, and CVAR_UPDATE fired inside the call for every token (#557)
`SetCVar` and `RegisterCVar` now read their arguments as the reference's bindings do (`0x488c10`, `0x488b00`): - a number is stored as Lua's `%.14g` text (`luaV_tostring` `0x6f7c80`), so the stock Sound slider's 0.4 stores `"0.40000000596046"`; - a value with no string form (nil, absent, boolean, table) stores `"0"` (`0x488c98`, `0x488b6f`), so unticking a stock Sound options box no longer raises and music can be switched off there; - the reference's usage texts (`0x84235c`, `0x8422e8`); - `realmList`, `realmName` and `scriptMemory` are read-only to Lua `SetCVar` in the world (`0x48f566`-`0x48f584`), and host writes still land; - `CVAR_UPDATE` fires inside the call for every string token, changed or not (`0x488cad`-`0x488cd5`, `SignalEvent2` `0x703f50`). Verified: 7 new VM tests, a stock Sound window test off the chain and a world-entry read-only test, each failing with the change undone; `scripts/gates.sh` green. Built on macOS; no platform seam touched. Fixes #167 Fixes #139 Fixes #547 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
db94ea038e
commit
44fa1813ac
@@ -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
|
||||
|
||||
@@ -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::<bool>("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::<bool>("return MainMenuBarExpText:IsShown()")
|
||||
.unwrap());
|
||||
|
||||
@@ -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::<bool>(checked).unwrap(), "Enable Music starts on");
|
||||
|
||||
s.run("SoundOptionsFrameCheckButton5:Click()").unwrap();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert!(!s.eval::<bool>(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::<String>("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.
|
||||
|
||||
@@ -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::<super::LeavingWorldArmed>();
|
||||
let set = |world: &World, chunk: &str| {
|
||||
let s = world.non_send_resource::<benilla_ui::script::UiScript>();
|
||||
// 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.
|
||||
|
||||
@@ -143,7 +143,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let mut model = lua.app_data_mut::<Model>().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());
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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<String>,
|
||||
) -> 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<String> {
|
||||
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<String> {
|
||||
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<Value>)| {
|
||||
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::<Model>().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::<Model>().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::<Model>().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::<Model>().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::<f64>("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::<String>("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::<f64>("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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
|
||||
/// 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,
|
||||
|
||||
@@ -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::<String>(&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::<f64>("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<String> { 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::<f64>("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::<String>(&format!("return GetCVarDefault({name:?})"))
|
||||
.unwrap(),
|
||||
stored,
|
||||
"the default too"
|
||||
);
|
||||
}
|
||||
assert!(s
|
||||
.take_cvar_registrations()
|
||||
.contains(&("Bare".to_string(), "0".to_string())));
|
||||
assert!(
|
||||
s.eval::<bool>("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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user