The stock video options window (`OptionsFrame`, off the player's chain) now opens, saves, restores defaults and cancels with no Lua error, as on 1.12.1. - The CVars it reads are registered at the reference's defaults: `SmallCull` "0.04" (`0x68854a`), `shadowLevel` "1" (`0x6885bc`), `baseMip` "0" (`0x6887aa`), `DesktopGamma` "0" (`0x402d4d`), `spellEffectLevel` "2" (`0x688900`), plus `doodadAnim` and `texLodBias`, which the new getters read. `SetWorldDetail` keeps `SmallCull` in step (`0x488e56`), and the wrong comment that called the getter its only reader is gone. - `spellEffectLevel`'s handler (`0x689510`): the value is clamped to 0-2, "Spell effect level set to %d." is printed, the particle emission scalar is set to 0.33/0.66/1.0, and the shard emitter's rate follows the level (`0x6eb95e`). - The 21 functions #337 lists: the six video getter/setter pairs (`0x488ec0`-`0x489300`), `GetNumFrames`/`EnumerateFrames` in creation order (`0x705f00`, `0x705f60`), and the client queries, including `GetDebugStats` and `OpeningCinematic`'s empty `CMSG_OPENING_CINEMATIC` (0xf9, `0x48c8c9`). Verified: a headless test walks the stock window's 18 boxes and 9 sliders, then Okay, Defaults and Cancel, with no error (it fails on main at `OptionsFrame.lua:146`), plus unit tests for each binding and handler; 12 mutations each fail a test. A live run opened the window, saved, restored defaults and cancelled with no Lua error. `scripts/gates.sh` green. Built on macOS; no platform seam touched. Fixes #548 Fixes #337 Refs #329 (benilla's own options window has no Spell Detail row yet) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
378 lines
15 KiB
Rust
378 lines
15 KiB
Rust
//! The client-identity and script verbs: build, locale, realm, bind location and framerate, with
|
|
//! `RunScript` and `FrameXML_Debug`. The realm, bind location and framerate read slots the app
|
|
//! pushes.
|
|
|
|
use mlua::{Lua, MultiValue, Value};
|
|
|
|
use super::Model;
|
|
|
|
/// The 1.12.1 client's build identity, as the strings in its binary spell it.
|
|
const VERSION: &str = "1.12.1";
|
|
const BUILD: &str = "5875";
|
|
const BUILD_DATE: &str = "Sep 19 2006";
|
|
|
|
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
|
let g = lua.globals();
|
|
|
|
// `GetLocale()` (glue `0x46ce40`, in-game `0x48d8b0`) returns one string, `enUS`, as benilla
|
|
// is enUS-only. `IsMacClient()` (`0x48c980`) returns one nil, the PC build's answer, which
|
|
// takes FrameXML's PC key-label arm.
|
|
g.set(
|
|
"GetLocale",
|
|
lua.create_function(|lua, ()| lua.create_string("enUS"))?,
|
|
)?;
|
|
g.set("IsMacClient", lua.create_function(|_, ()| Ok(Value::Nil))?)?;
|
|
// The PC build's other two platform answers: `IsWindowsClient()` (`0x48c960`) pushes the
|
|
// number 1 and `IsLinuxClient()` (`0x48c990`) one nil. Arguments are ignored.
|
|
g.set(
|
|
"IsWindowsClient",
|
|
lua.create_function(|_, _: MultiValue| Ok(1.0))?,
|
|
)?;
|
|
g.set(
|
|
"IsLinuxClient",
|
|
lua.create_function(|_, _: MultiValue| Ok(Value::Nil))?,
|
|
)?;
|
|
|
|
// `GetDebugStats()` (`0x488af0`, `xor eax,eax; ret`): zero values, which `StatsFrame.lua:20`
|
|
// hands straight to `SetText`.
|
|
g.set(
|
|
"GetDebugStats",
|
|
lua.create_function(|_, _: MultiValue| Ok(MultiValue::new()))?,
|
|
)?;
|
|
|
|
// `OpeningCinematic()` (`0x48c8c0`): sends an empty `CMSG_OPENING_CINEMATIC` (`0xf9`,
|
|
// `0x48c8c9` into `0x5ab630`) and returns nothing; the server may answer with
|
|
// `SMSG_TRIGGER_CINEMATIC`. Arguments are ignored.
|
|
g.set(
|
|
"OpeningCinematic",
|
|
lua.create_function(|lua, _: MultiValue| {
|
|
lua.app_data_mut::<Model>()
|
|
.expect("model app_data")
|
|
.opening_cinematic_asks += 1;
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// Three setters whose state has no reader here, so each takes its argument as the reference
|
|
// does, never raising, and stores nothing; all return zero values.
|
|
// - `SetEuropeanNumbers(flag)` (`0x48dec0`): `GetBoolOrDefault(1, true)` into `[0xcf4d24]`,
|
|
// which turns on `FontString:SetText`'s digit-group pass (`0x771daa` into `0x771c50`).
|
|
// benilla's `SetText` has no such pass.
|
|
// - `SetLayoutMode(mode)` (`0x488540`): a number truncates into the UI root's `+0xcf8`, anything
|
|
// else stores 1; read by the root's mouse handler (`0x766301`) for a frame-dragging mode
|
|
// benilla does not build.
|
|
// - `SetConsoleKey(key)` (`0x488640`): a one-character or named key becomes the console's
|
|
// toggle key (`0x63cb00`), a non-string the default `0x30e`. benilla has no console screen.
|
|
for name in ["SetEuropeanNumbers", "SetLayoutMode", "SetConsoleKey"] {
|
|
g.set(
|
|
name,
|
|
lua.create_function(|_, _: MultiValue| Ok(MultiValue::new()))?,
|
|
)?;
|
|
}
|
|
|
|
// `FrameXML_Debug([v])` (`0x488440`): get-or-set of the XML loader's trace flag `[0xceea30]`,
|
|
// which gates `crate::loader::LoadReport::traces`. A Lua-truthy argument sets it, truncated
|
|
// toward zero (`0x40a2b0`), so `FrameXML_Debug(0)` clears it; nil or false only reads. It
|
|
// returns the flag after the call.
|
|
g.set(
|
|
"FrameXML_Debug",
|
|
lua.create_function(|lua, v: Value| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let truthy = !matches!(v, Value::Nil | Value::Boolean(false));
|
|
if truthy {
|
|
// A value that will not coerce is 0, as `lua_tonumber` (`0x6f3620`) answers.
|
|
let n = lua.coerce_number(v)?.unwrap_or(0.0);
|
|
model.framexml_debug.set(n.trunc() as i32);
|
|
}
|
|
Ok(model.framexml_debug.get())
|
|
})?,
|
|
)?;
|
|
|
|
// Version, build and date, and no fourth value (the TOC number is a later expansion's): the
|
|
// in-game `GetBuildInfo` (`0x4884a0`) pushes three strings, the glue one (`0x46cd70`) five.
|
|
g.set(
|
|
"GetBuildInfo",
|
|
lua.create_function(|lua, ()| {
|
|
Ok(MultiValue::from_vec(vec![
|
|
Value::String(lua.create_string(VERSION)?),
|
|
Value::String(lua.create_string(BUILD)?),
|
|
Value::String(lua.create_string(BUILD_DATE)?),
|
|
]))
|
|
})?,
|
|
)?;
|
|
|
|
// `RunScript(text)` (`0x48b980`) runs a chunk in the shared global state with full API
|
|
// access, no `setfenv`. Its contract:
|
|
// 1. The chunk name is the source itself: the one `lua_tostring` result is passed as both
|
|
// code and name to `FrameScript_Execute` (`0x704cd0`), so an error renders
|
|
// `[string "…"]`, as `loadstring`'s default does.
|
|
// 2. An argument that is neither a string nor a number, or is empty, is a silent no-op, never
|
|
// a raise (`0x48b98f`, `0x48b99f`, `0x48b9a1`); `lua_isstring` (`0x6f3510`) passes a number.
|
|
// 3. An error never reaches the caller: `0x704ae0` runs the chunk under `lua_pcall` with the
|
|
// registered error handler (`0x704b68`), and a compile failure goes to the same handler
|
|
// (`0x704b42`).
|
|
// 4. It returns zero values on every path.
|
|
g.set(
|
|
"RunScript",
|
|
lua.create_function(|lua, text: Value| {
|
|
// `lua_isstring` and `lua_tostring` in one: strings and numbers coerce, anything else
|
|
// is the silent return (2).
|
|
let Some(s) = lua.coerce_string(text)? else {
|
|
return Ok(());
|
|
};
|
|
let raw = s.as_bytes();
|
|
if raw.is_empty() {
|
|
return Ok(());
|
|
}
|
|
// The chunk name is the source bytes as given (1).
|
|
let name = String::from_utf8_lossy(&raw).into_owned();
|
|
if let Err(e) = lua
|
|
.load(&*raw)
|
|
.set_name(name)
|
|
.set_mode(mlua::ChunkMode::Text)
|
|
.exec()
|
|
{
|
|
lua.app_data_mut::<crate::script::Model>()
|
|
.expect("model")
|
|
.record_script_error(super::stdlib::lua_message(&e));
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// `GetRealmName()`: the realm as the realm list spells it; `""` until the app pushes one, the
|
|
// glue screen's answer, never nil, as addons index saved variables with it at file scope.
|
|
g.set(
|
|
"GetRealmName",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
lua.create_string(&model.realm_name)
|
|
})?,
|
|
)?;
|
|
|
|
// `RequestTimePlayed()` queues a `CMSG_PLAYED_TIME` and returns nothing; the answer is the
|
|
// `TIME_PLAYED_MSG(total, level)` the app fires when `SMSG_PLAYED_TIME` lands.
|
|
g.set(
|
|
"RequestTimePlayed",
|
|
lua.create_function(|lua, ()| {
|
|
lua.app_data_mut::<Model>()
|
|
.expect("model app_data")
|
|
.played_time_asks += 1;
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// `GetBindLocation()`: the hearthstone's bind location name, which the instance-boot dialog
|
|
// formats into its text (`StaticPopup.lua:1742`). `""` until `SMSG_BINDPOINTUPDATE` lands, as
|
|
// the reference's empty buffer, never nil, since addons concatenate it.
|
|
g.set(
|
|
"GetBindLocation",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
lua.create_string(&model.bind_location)
|
|
})?,
|
|
)?;
|
|
|
|
// `GetFramerate()`: frames per second, a smoothed value the app pushes each tick; 0, a number,
|
|
// before the first push.
|
|
g.set(
|
|
"GetFramerate",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(model.framerate)
|
|
})?,
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
impl super::UiScript {
|
|
/// Drain the `RequestTimePlayed()` asks, each a `CMSG_PLAYED_TIME`: a count, as the request
|
|
/// body is empty.
|
|
pub fn take_played_time_asks(&mut self) -> u32 {
|
|
std::mem::take(&mut self.model_mut().played_time_asks)
|
|
}
|
|
|
|
/// Drain the `OpeningCinematic()` calls, each an empty `CMSG_OPENING_CINEMATIC`.
|
|
pub fn take_opening_cinematic_asks(&mut self) -> u32 {
|
|
std::mem::take(&mut self.model_mut().opening_cinematic_asks)
|
|
}
|
|
|
|
/// Push the bind location's name, resolved from `SMSG_BINDPOINTUPDATE`'s AreaTable id through
|
|
/// the catalog the hearthstone's `$z` uses, so the two agree; empty until the packet lands.
|
|
pub fn set_bind_location(&mut self, area_name: &str) {
|
|
let mut model = self.model_mut();
|
|
if model.bind_location != area_name {
|
|
model.bind_location = area_name.to_string();
|
|
}
|
|
}
|
|
|
|
/// Seed the local player record (the reference's `0xc27d80`, [`super::PlayerRecord`]) from the
|
|
/// char-enum row at Enter World, before any addon file runs, as `UnitName("player")` at file
|
|
/// scope is common. A record with no name is refused: nothing empties the reference's record
|
|
/// once it holds a character, so the last one stands.
|
|
pub fn set_player_record(&mut self, record: super::PlayerRecord) {
|
|
if record.name.is_empty() {
|
|
return;
|
|
}
|
|
let mut model = self.model_mut();
|
|
if model.player_record != record {
|
|
model.player_record = record;
|
|
}
|
|
}
|
|
|
|
/// Push the realm name, set at world entry from the realm the session connected to; the empty
|
|
/// string means no realm yet, not a clear.
|
|
pub fn set_realm_name(&mut self, realm: &str) {
|
|
{
|
|
let mut model = self.model_mut();
|
|
if model.realm_name != realm {
|
|
model.realm_name = realm.to_string();
|
|
}
|
|
// The persisted `realmName` CVar (`0x83f2d0`) is the same fact, set here so it cannot
|
|
// drift from `GetRealmName()`; the engine write queues the change, so it is saved.
|
|
super::cvars::set_from_engine(&mut model, "realmName", realm.to_string());
|
|
}
|
|
}
|
|
|
|
/// Push the current framerate, from the app's frame clock each tick; this crate has no clock.
|
|
pub fn set_framerate(&mut self, fps: f64) {
|
|
self.model_mut().framerate = fps;
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::script::UiScript;
|
|
|
|
/// Three strings and no fourth, as the in-game registrar pushes (`0x4884a0`); this VM is the
|
|
/// in-game one, not the glue (`0x46cd70`, five).
|
|
#[test]
|
|
fn get_build_info_answers_as_the_1_12_1_client() {
|
|
let s = UiScript::new().unwrap();
|
|
assert_eq!(
|
|
s.eval::<Vec<String>>("return { GetBuildInfo() }").unwrap(),
|
|
vec!["1.12.1", "5875", "Sep 19 2006"]
|
|
);
|
|
assert_eq!(
|
|
s.arity("GetBuildInfo()").unwrap(),
|
|
3,
|
|
"three values, never a fourth"
|
|
);
|
|
}
|
|
|
|
/// Never nil: addons index with it at file scope, and a nil index errors far from the cause.
|
|
#[test]
|
|
fn get_realm_name_is_empty_before_it_is_known_and_the_host_pushes_it() {
|
|
let mut s = UiScript::new().unwrap();
|
|
assert_eq!(s.eval::<String>("return GetRealmName()").unwrap(), "");
|
|
assert!(
|
|
s.eval::<bool>("local t = {} t[GetRealmName()] = 1 return t[''] == 1")
|
|
.unwrap(),
|
|
"the empty string is a usable table key; nil is not, and that is the whole point"
|
|
);
|
|
s.set_realm_name("Whitemane");
|
|
assert_eq!(
|
|
s.eval::<String>("return GetRealmName()").unwrap(),
|
|
"Whitemane"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn get_framerate_is_a_number_before_the_first_push() {
|
|
let mut s = UiScript::new().unwrap();
|
|
assert_eq!(s.eval::<f64>("return GetFramerate()").unwrap(), 0.0);
|
|
assert_eq!(
|
|
s.eval::<String>("return format(\"%.1f\", GetFramerate())")
|
|
.unwrap(),
|
|
"0.0"
|
|
);
|
|
s.set_framerate(59.94);
|
|
assert_eq!(
|
|
s.eval::<String>("return format(\"%.1f\", GetFramerate())")
|
|
.unwrap(),
|
|
"59.9"
|
|
);
|
|
}
|
|
|
|
/// A failure is recorded, not raised: `0x704ae0` runs the chunk under `lua_pcall(L, 0, 0, -2)`
|
|
/// with the registry's error handler.
|
|
#[test]
|
|
fn run_script_evaluates_in_the_shared_state_and_reports_without_raising() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.run(r#"RunScript("RunScriptProbe = 41 + 1")"#).unwrap();
|
|
assert_eq!(s.eval::<i64>("return RunScriptProbe").unwrap(), 42);
|
|
s.run(r#"RunScript("this is not lua")"#)
|
|
.expect("a malformed script is caught inside RunScript, not raised at its caller");
|
|
let errs = s.take_errors();
|
|
assert!(
|
|
errs.iter()
|
|
.any(|e| e.starts_with("[string \"this is not lua\"]:1:")),
|
|
"it must not vanish either — the handler channel gets it: {errs:?}"
|
|
);
|
|
}
|
|
|
|
/// The reference's arities: `GetLocale` (`0x48d8b0`) one string, `IsMacClient` (`0x48c980`)
|
|
/// one nil.
|
|
#[test]
|
|
fn the_client_identity_pair_answers_its_reference_arity() {
|
|
let s = UiScript::new().unwrap();
|
|
assert_eq!(
|
|
s.arity("GetLocale()").unwrap(),
|
|
1,
|
|
"GetLocale pushes exactly one value"
|
|
);
|
|
assert_eq!(s.eval::<String>("return GetLocale()").unwrap(), "enUS");
|
|
assert_eq!(
|
|
s.arity("IsMacClient()").unwrap(),
|
|
1,
|
|
"IsMacClient pushes one value, and it is nil"
|
|
);
|
|
assert!(
|
|
s.eval::<bool>("return IsMacClient() == nil").unwrap(),
|
|
"nil is the PC arm, which is the arm this client wants"
|
|
);
|
|
}
|
|
|
|
/// `IsWindowsClient` (`0x48c960`) pushes 1 and `IsLinuxClient` (`0x48c990`) nil, one value
|
|
/// each; `GetDebugStats` (`0x488af0`) pushes none.
|
|
#[test]
|
|
fn the_platform_queries_and_debug_stats_answer_the_pc_build() {
|
|
let s = UiScript::new().unwrap();
|
|
assert_eq!(s.arity("IsWindowsClient()").unwrap(), 1);
|
|
assert_eq!(s.eval::<f64>("return IsWindowsClient(0)").unwrap(), 1.0);
|
|
assert_eq!(s.arity("IsLinuxClient()").unwrap(), 1);
|
|
assert!(s.eval::<bool>("return IsLinuxClient() == nil").unwrap());
|
|
assert_eq!(s.arity("GetDebugStats()").unwrap(), 0);
|
|
}
|
|
|
|
/// Each `OpeningCinematic()` queues one send and returns nothing.
|
|
#[test]
|
|
fn opening_cinematic_queues_one_send_per_call() {
|
|
let mut s = UiScript::new().unwrap();
|
|
assert_eq!(s.arity("OpeningCinematic()").unwrap(), 0);
|
|
s.run("OpeningCinematic(1)").unwrap();
|
|
assert_eq!(s.take_opening_cinematic_asks(), 2);
|
|
assert_eq!(
|
|
s.take_opening_cinematic_asks(),
|
|
0,
|
|
"the drain empties the queue"
|
|
);
|
|
}
|
|
|
|
/// The three setters never raise, whatever they are handed, and return zero values.
|
|
#[test]
|
|
fn the_stateless_setters_take_any_argument() {
|
|
let s = UiScript::new().unwrap();
|
|
for name in ["SetEuropeanNumbers", "SetLayoutMode", "SetConsoleKey"] {
|
|
for arg in ["", "nil", "1", "\"x\"", "{}", "\"ESCAPE\""] {
|
|
assert_eq!(
|
|
s.arity(&format!("{name}({arg})")).unwrap(),
|
|
0,
|
|
"{name}({arg})"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|