From a9dd325bb70be2813e7f422c613b7c0be7d6f9e7 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:38:02 +0700 Subject: [PATCH] A glue Lua state with the reference glue surface, events and ModelFFX (#963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first piece of running the stock GlueXML: benilla had no glue Lua state, and no reference data for its surface. In 1.12.1 the glue state holds the base set (`0x7039e0`) and seven registrars (`0x46abb0`): 119 functions, 105 of them glue-only, with 26 glue events (`0xb41e70`, `0x46a88f`). `RegisterEvent` matches names case-insensitively and drops an unknown one (`0x702140`). The glue builds the 13 base frame types plus `ModelFFX` (`0x46a4fe`), a Model with its own show and paint (`0x46fa00`); the in-game types come only at world entry (`0x495940`). `reference/` gains the glue event and surface tables, generated and checked against the shapes table. `UiScript::new_glue()` builds that state; a gate holds its globals to the table, with the 108 unbuilt names listed. Nothing in the app changes yet. Part of #856 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 --- crates/benilla-ui/src/loader/mod.rs | 7 +- crates/benilla-ui/src/script/chat_misc.rs | 52 +- crates/benilla-ui/src/script/glue.rs | 82 +++ crates/benilla-ui/src/script/mod.rs | 261 +++++---- crates/benilla-ui/src/script/model.rs | 3 + crates/benilla-ui/src/script/modelframe.rs | 7 +- crates/benilla-ui/src/script/object.rs | 113 ++-- .../src/script/object/events_regions.rs | 8 + crates/benilla-ui/src/script/region.rs | 25 +- crates/benilla-ui/src/script/stdlib.rs | 53 +- .../src/script/tests/glue_surface.rs | 524 ++++++++++++++++++ crates/benilla-ui/src/script/tests/mod.rs | 1 + crates/benilla-ui/src/script/tick.rs | 3 + crates/benilla-ui/src/widget/kinds/mod.rs | 23 +- crates/benilla-ui/src/widget/mod.rs | 13 +- docs/MAP.md | 1 + reference/1.12-glue-events.tsv | 42 ++ reference/1.12-glue-surface.tsv | 212 +++++++ reference/README.md | 21 + scripts/gen-reference-glue-surface.py | 212 +++++++ 20 files changed, 1449 insertions(+), 214 deletions(-) create mode 100644 crates/benilla-ui/src/script/glue.rs create mode 100644 crates/benilla-ui/src/script/tests/glue_surface.rs create mode 100644 reference/1.12-glue-events.tsv create mode 100644 reference/1.12-glue-surface.tsv create mode 100755 scripts/gen-reference-glue-surface.py diff --git a/crates/benilla-ui/src/loader/mod.rs b/crates/benilla-ui/src/loader/mod.rs index 76f90f82..acebc976 100644 --- a/crates/benilla-ui/src/loader/mod.rs +++ b/crates/benilla-ui/src/loader/mod.rs @@ -235,11 +235,10 @@ pub fn join_ref(base: &str, path: &str) -> String { out.join("/") } -/// A `CSimpleModel` kind, whose `LoadXML` (`0x76cac0`) reads `scale=` as the model's scale. +/// A `CSimpleModel` kind, whose `LoadXML` (`0x76cac0`) reads `scale=` as the model's scale; +/// `ModelFFX` shares it (its second vtable's slot 2). pub(super) fn model_kind_tag(tag: &str) -> bool { - ["Model", "PlayerModel", "DressUpModel", "TabardModel"] - .iter() - .any(|k| k.eq_ignore_ascii_case(tag)) + crate::script::frame_kind_from_tag(tag).is_some_and(crate::widget::FrameKind::is_model) } impl Loader<'_> { diff --git a/crates/benilla-ui/src/script/chat_misc.rs b/crates/benilla-ui/src/script/chat_misc.rs index e54dc116..e82dd107 100644 --- a/crates/benilla-ui/src/script/chat_misc.rs +++ b/crates/benilla-ui/src/script/chat_misc.rs @@ -75,6 +75,35 @@ impl super::UiScript { } } +/// `ConsoleExec` (`0x515f10`) is in the registrar both states call (`0x51bb20`, from the glue +/// registrar `0x46abb0` and the in-game `0x51bb60`), so it installs apart from this file's in-game +/// verbs. +pub(super) fn install_console_exec(lua: &Lua) -> mlua::Result<()> { + // `ConsoleExec("name value")`: a registered CVar with a value is written at once, as `SetCVar` + // writes it; anything else, a bare CVar name included (the reference answers it with + // `CVar "%s" is "%s"`, `0x63dde0`), goes to the app's command registry. + lua.globals().set( + "ConsoleExec", + lua.create_function(|lua, line: Option| { + let Some(line) = line.map(|l| l.trim().to_string()).filter(|l| !l.is_empty()) else { + return Ok(()); + }; + let (name, value) = line + .split_once(char::is_whitespace) + .map_or((line.as_str(), ""), |(n, v)| (n, v.trim())); + 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()); + } else { + model.console_lines.push(line.clone()); + } + Ok(()) + })?, + )?; + Ok(()) +} + pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let g = lua.globals(); @@ -135,29 +164,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // `ConsoleExec("name value")`: a registered CVar with a value is written at once, as `SetCVar` - // writes it; anything else, a bare CVar name included (the reference answers it with - // `CVar "%s" is "%s"`, `0x63dde0`), goes to the app's command registry. - g.set( - "ConsoleExec", - lua.create_function(|lua, line: Option| { - let Some(line) = line.map(|l| l.trim().to_string()).filter(|l| !l.is_empty()) else { - return Ok(()); - }; - let (name, value) = line - .split_once(char::is_whitespace) - .map_or((line.as_str(), ""), |(n, v)| (n, v.trim())); - 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()); - } else { - model.console_lines.push(line.clone()); - } - Ok(()) - })?, - )?; - for (name, chat) in [("LoggingChat", true), ("LoggingCombat", false)] { g.set( name, diff --git a/crates/benilla-ui/src/script/glue.rs b/crates/benilla-ui/src/script/glue.rs new file mode 100644 index 00000000..3be4d523 --- /dev/null +++ b/crates/benilla-ui/src/script/glue.rs @@ -0,0 +1,82 @@ +//! The glue screens' FrameScript state, which [`UiScript::new_glue`] builds: what the glue builder +//! (`0x46a7b0`) gives Lua where `UI_Init` (`0x48fbf0`) gives it the world's surface. +//! +//! The reference keeps one Lua state (`0xceef74`). The glue builder starts from the bare base set +//! (`0x7039e0`): on a rebuild it resets the state (`0x703b80` at `0x46a87b`, re-running the base +//! init), while the first build (`0x46a505`, passing 0) skips the reset (`je` at `0x46a879`) and +//! takes the state the startup init left (`0x402aee`). Either way it then runs the glue registrar +//! `0x46abb0` and loads the 26-event table below in place of the in-game one +//! (`0x703d90` at `0x46a88f`). The frame types are a process-wide registry instead +//! ([`super::object::registered_frame_kind`]). +//! +//! [`UiScript::new_glue`]: super::UiScript::new_glue + +/// Which of the reference's two FrameScript states a VM is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VmKind { + /// `UI_Init`'s: the in-game surface over FrameXML. + InGame, + /// The glue builder's: the base set, the three shared registrars and the glue verbs, over + /// GlueXML. + Glue, +} + +/// The glue event table by id (`0xb41e70`, written by `0x46cc60`), `reference/1.12-glue-events.tsv`. +/// While the glue state runs these are the only events: `RegisterEvent` matches a name against +/// them case-insensitively (`0x702140` with `0x64a4c0`) and drops one they lack without an error. +pub const GLUE_EVENTS: [&str; 26] = [ + "SET_GLUE_SCREEN", + "START_GLUE_MUSIC", + "DISCONNECTED_FROM_SERVER", + "OPEN_STATUS_DIALOG", + "UPDATE_STATUS_DIALOG", + "CLOSE_STATUS_DIALOG", + "ADDON_LIST_UPDATE", + "CHARACTER_LIST_UPDATE", + "UPDATE_SELECTED_CHARACTER", + "OPEN_REALM_LIST", + "GET_PREFERRED_REALM_INFO", + "UPDATE_SELECTED_RACE", + "SELECT_LAST_CHARACTER", + "SELECT_FIRST_CHARACTER", + "GLUE_SCREENSHOT_SUCCEEDED", + "GLUE_SCREENSHOT_FAILED", + "PATCH_UPDATE_PROGRESS", + "PATCH_DOWNLOADED", + "SUGGEST_REALM", + "SUGGEST_REALM_WRONG_PVP", + "SUGGEST_REALM_WRONG_CATEGORY", + "SHOW_SERVER_ALERT", + "FRAMES_LOADED", + "FORCE_RENAME_CHARACTER", + "SHOW_SURVEY_NOTIFICATION", + "PLAYER_ENTER_PIN", +]; + +impl VmKind { + /// The event table's own spelling of `name` for `RegisterEvent`/`UnregisterEvent` (`0x702140`, + /// `0x702280`), or `None` for a name the table lacks, which registers nothing. + /// + /// The in-game VM answers every name as given: benilla's in-game events register by name and + /// carry no table, so a name off the reference's table registers and never fires, and only the + /// exact spelling matches. + pub(crate) fn event_key(self, name: &str) -> Option<&str> { + match self { + VmKind::InGame => Some(name), + VmKind::Glue => GLUE_EVENTS + .iter() + .copied() + .find(|e| e.eq_ignore_ascii_case(name)), + } + } + + /// Whether the host may fire `event` into this VM. The reference fires by id + /// (`0x703e50`), so the glue state can only ever see one of its 26; a host's in-game event sent + /// to the glue VM reaches no frame, `RegisterAllEvents` ones included. + pub(crate) fn fires(self, event: &str) -> bool { + match self { + VmKind::InGame => true, + VmKind::Glue => GLUE_EVENTS.contains(&event), + } + } +} diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index c2ae0a3d..773ef10a 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -64,6 +64,8 @@ mod follow; pub(crate) mod font; mod font_block; mod frame_enum; +mod glue; +pub use glue::{VmKind, GLUE_EVENTS}; mod gm_ticket; mod gossip; mod guild; @@ -499,117 +501,168 @@ pub fn addon_chunk_name(folder: &str, file: &str) -> String { format!("@Interface\\AddOns\\{folder}\\{}", file.replace('/', "\\")) } +/// What both FrameScript states hold: FrameScript's Lua init (`0x7039e0`), the bare state the glue +/// builder starts from (left by startup `0x402aee` on its first build, re-run by its reset +/// `0x703b80` on a later one) and `UI_Init` rebuilds through its own, and the three registrars +/// the glue registrar `0x46abb0` shares with `UI_Init`: `0x51bb20` (the clock, `ConsoleExec` and +/// the file trio), `0x706350` (the frame factory globals and the 17 widget method tables) and +/// `0x4587f0` (sound). +fn install_framescript(vm: VmKind) -> mlua::Result { + let lua = Lua::new(); + let mut model = Model::new(); + model.vm = vm; + lua.set_app_data(model); + // Before anything can fire a handler and while no app-data borrow is held, as the + // profiler's slot requires. + handler_prof::install(&lua); + + // `0x7039e0`: the base array `0x811e28`, the four libraries and the compat chunk `0x8722e8`. + stdlib::sandbox(&lua)?; + // Before the stdlib layer, so its aliases bind the 5.0-shaped functions. + lua50::install(&lua)?; + stdlib::install(&lua)?; + + // `0x51bb20` (table `0x8503f4`): `GetTime`, `GetGameTime` and `ConsoleExec`. `ReadFile`, + // `DeleteFile` and `AppendToFile` are not built. + clock::install(&lua)?; + chat_misc::install_console_exec(&lua)?; + + // `0x706350`: `CreateFrame`, `CreateFont`, `GetNumFrames`, `EnumerateFrames` (table + // `0x872e74`), then the widget method tables `0x7a2e70`..`0x78dc60`. + object::install(&lua)?; + frame_enum::install(&lua)?; + // After `object`, whose `publish_global` it reuses, and before any FrameXML loads: + // `Loader::do_font` publishes into the tables this builds. + font::install(&lua)?; + messageframe::install(&lua)?; + scrollframe::install(&lua)?; + simplehtml::install(&lua)?; + slider::install(&lua)?; + colorselect::install(&lua)?; + modelframe::install(&lua)?; + + // `0x4587f0` (table `0x835a50`): `PlaySound`, `PlayMusic`, `PlaySoundFile`, `StopMusic`. + sound::install(&lua)?; + Ok(lua) +} + +/// The in-game surface: everything `UI_Init` registers past the shared registrars (`0x490250` and +/// the calls around it), the in-game widget types' method tables among it. +fn install_in_game(lua: &Lua) -> mlua::Result<()> { + addon::install(lua)?; + addon_message::install(lua)?; + chat_send::install(lua)?; + chat_types::install(lua)?; + chat_misc::install(lua)?; + channel::install(lua)?; + chat_window::install(lua)?; + client::install(lua)?; + screenshot::install(lua)?; + zone_text::install(lua)?; + object::install_in_game(lua)?; + region::install_in_game(lua)?; + modelframe::install_player_model(lua)?; + unit::install(lua)?; + party::install(lua)?; + social::install(lua)?; + guild::install(lua)?; + petition::install(lua)?; + binder::install(lua)?; + summon::install(lua)?; + bind_confirm::install(lua)?; + instance::install(lua)?; + gm_ticket::install(lua)?; + duel::install(lua)?; + follow::install(lua)?; + camera_view::install(lua)?; + input_verbs::install(lua)?; + session::install(lua)?; + pvp::install(lua)?; + worn_display::install(lua)?; + death::install(lua)?; + aura::install(lua)?; + cvars::install(lua)?; + nameplate::install_verbs(lua)?; + saved::install(lua)?; + keybind::install(lua)?; + ui_errors::install(lua)?; + pointer::install(lua)?; + action::install(lua)?; + action_bar_toggles::install(lua)?; + container::install(lua)?; + cursor::install(lua)?; + spellbook::install(lua)?; + macros::install(lua)?; + talent::install(lua)?; + dialog_verbs::install(lua)?; + battlefield_score::install(lua)?; + battlefield_queue::install(lua)?; + battlefield_positions::install(lua)?; + tutorial::install(lua)?; + worldmap_arrow::install(lua)?; + shapeshift::install(lua)?; + pet::install(lua)?; + gossip::install(lua)?; + merchant::install(lua)?; + bank::install(lua)?; + stable::install(lua)?; + item_text::install(lua)?; + mail::install(lua)?; + auction::install(lua)?; + trainer::install(lua)?; + taxi::install(lua)?; + trade::install(lua)?; + inspect::install(lua)?; + dressup::install(lua)?; + tabard::install(lua)?; + tradeskill::install(lua)?; + craft::install(lua)?; + reputation::install(lua)?; + skills::install(lua)?; + item_stats::install(lua)?; + char_stats::install(lua)?; + inventory_verbs::install(lua)?; + weapon_enchant::install(lua)?; + loot::install(lua)?; + loot_roll::install(lua)?; + quest::install(lua)?; + quest_log::install(lua)?; + minimap::install(lua)?; + tooltip::install(lua)?; + worldmap::install(lua)?; + worldstate::install(lua)?; + net_stats::install(lua)?; + Ok(()) +} + impl UiScript { - /// Build a fully sandboxed, stdlib- and object-model-equipped host. + /// The in-game VM: FrameScript's base set and the registrars both states share + /// ([`install_framescript`]), then the in-game surface `UI_Init` (`0x48fbf0`) registers. pub fn new() -> mlua::Result { - let lua = Lua::new(); - lua.set_app_data(Model::new()); - // Before anything can fire a handler and while no app-data borrow is held, as the - // profiler's slot requires. - handler_prof::install(&lua); + let lua = install_framescript(VmKind::InGame)?; + install_in_game(&lua)?; + Ok(Self::host(lua)) + } - addon::install(&lua)?; - addon_message::install(&lua)?; - chat_send::install(&lua)?; - chat_types::install(&lua)?; - chat_misc::install(&lua)?; - channel::install(&lua)?; - chat_window::install(&lua)?; - client::install(&lua)?; - screenshot::install(&lua)?; - stdlib::sandbox(&lua)?; - // Before the stdlib layer, so its aliases bind the 5.0-shaped functions. - lua50::install(&lua)?; - stdlib::install(&lua)?; - object::install(&lua)?; - frame_enum::install(&lua)?; - // After `object`, whose `publish_global` it reuses, and before any FrameXML loads: - // `Loader::do_font` publishes into the tables this builds. - font::install(&lua)?; - unit::install(&lua)?; - party::install(&lua)?; - social::install(&lua)?; - guild::install(&lua)?; - petition::install(&lua)?; - binder::install(&lua)?; - summon::install(&lua)?; - bind_confirm::install(&lua)?; - instance::install(&lua)?; - gm_ticket::install(&lua)?; - duel::install(&lua)?; - follow::install(&lua)?; - camera_view::install(&lua)?; - input_verbs::install(&lua)?; - session::install(&lua)?; - pvp::install(&lua)?; - worn_display::install(&lua)?; - death::install(&lua)?; - aura::install(&lua)?; - cvars::install(&lua)?; - nameplate::install_verbs(&lua)?; - saved::install(&lua)?; - keybind::install(&lua)?; - sound::install(&lua)?; - ui_errors::install(&lua)?; - pointer::install(&lua)?; - action::install(&lua)?; - action_bar_toggles::install(&lua)?; - container::install(&lua)?; - cursor::install(&lua)?; - spellbook::install(&lua)?; - macros::install(&lua)?; - talent::install(&lua)?; - dialog_verbs::install(&lua)?; - battlefield_score::install(&lua)?; - battlefield_queue::install(&lua)?; - battlefield_positions::install(&lua)?; - tutorial::install(&lua)?; - worldmap_arrow::install(&lua)?; - shapeshift::install(&lua)?; - pet::install(&lua)?; - gossip::install(&lua)?; - merchant::install(&lua)?; - bank::install(&lua)?; - stable::install(&lua)?; - item_text::install(&lua)?; - mail::install(&lua)?; - auction::install(&lua)?; - trainer::install(&lua)?; - taxi::install(&lua)?; - trade::install(&lua)?; - inspect::install(&lua)?; - dressup::install(&lua)?; - tabard::install(&lua)?; - tradeskill::install(&lua)?; - craft::install(&lua)?; - reputation::install(&lua)?; - skills::install(&lua)?; - item_stats::install(&lua)?; - char_stats::install(&lua)?; - inventory_verbs::install(&lua)?; - weapon_enchant::install(&lua)?; - loot::install(&lua)?; - loot_roll::install(&lua)?; - quest::install(&lua)?; - quest_log::install(&lua)?; - messageframe::install(&lua)?; - scrollframe::install(&lua)?; - simplehtml::install(&lua)?; - slider::install(&lua)?; - colorselect::install(&lua)?; - minimap::install(&lua)?; - modelframe::install(&lua)?; - tooltip::install(&lua)?; - worldmap::install(&lua)?; - worldstate::install(&lua)?; - net_stats::install(&lua)?; + /// The glue screens' VM, as the glue builder (`0x46a7b0`) leaves its state before GlueXML + /// loads: the base set and the shared registrars, with the glue event table and the glue frame + /// types ([`glue`]), and none of the in-game surface. The glue registrar's own four tables + /// (`0x46ddf0`, `0x4736c0`, `0x471a20`, `0x46f480`) are not built yet. + pub fn new_glue() -> mlua::Result { + Ok(Self::host(install_framescript(VmKind::Glue)?)) + } - let s = UiScript { + fn host(lua: Lua) -> UiScript { + UiScript { lua, instructions: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)), session: NEXT_SESSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed), - }; - Ok(s) + } + } + + /// Which FrameScript state this VM is. + pub fn vm_kind(&self) -> VmKind { + self.model_ref().vm } /// The embedded VM, for the app and the loader to add their bindings over this object model. diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 5893f627..6e03caf4 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -61,6 +61,8 @@ impl IdMap { /// The Rust-side model behind the Lua VM, in `lua.app_data`. It holds no mlua handles, whose /// persistent count the vendored `LUAI_MAXCSTACK` caps. pub(crate) struct Model { + /// Which FrameScript state this VM is: it picks the event table and the frame types. + pub(crate) vm: super::glue::VmKind, /// Every discovered addon in load order: the AddOn API's registry, filled at world entry. pub(crate) addons: Vec, /// The Lua index space into `addons`, a title-sorted, hidden-filtered list (`0x51da70`). @@ -1098,6 +1100,7 @@ impl Model { pub(crate) fn new() -> Model { Model { + vm: super::glue::VmKind::InGame, addons: Vec::new(), addon_index: Vec::new(), addon_info_hidden: None, diff --git a/crates/benilla-ui/src/script/modelframe.rs b/crates/benilla-ui/src/script/modelframe.rs index 285b1d5d..ae653d15 100644 --- a/crates/benilla-ui/src/script/modelframe.rs +++ b/crates/benilla-ui/src/script/modelframe.rs @@ -527,12 +527,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - lua.set_named_registry_value(REG_MODEL_METHODS, m)?; - playermodel_install(lua) + lua.set_named_registry_value(REG_MODEL_METHODS, m) } /// `PlayerModel`'s own three methods (table `0x84f1fc`); the rest are `Model`'s, reached on a miss. -fn playermodel_install(lua: &Lua) -> mlua::Result<()> { +/// Only the in-game state registers it (`0x506230`, from `0x490260`), where `Model`'s is in the +/// widget set both states install (`0x76f840`, from `0x706350`). +pub(super) fn install_player_model(lua: &Lua) -> mlua::Result<()> { let m = lua.create_table()?; // `SetUnit(unit)` (`0x84f1fc[0]` → `0x505d70`) clears the `SetModel` path; the unit token is diff --git a/crates/benilla-ui/src/script/object.rs b/crates/benilla-ui/src/script/object.rs index b2f3734b..850d79c0 100644 --- a/crates/benilla-ui/src/script/object.rs +++ b/crates/benilla-ui/src/script/object.rs @@ -11,10 +11,11 @@ use std::ffi::c_void; use mlua::{LightUserData, Lua, ObjectLike, Table, Value}; use super::binding_abi::optional_string; +use super::glue::VmKind; use super::{Model, REG_FRAME_META, REG_FRAME_METHODS, REG_SCRIPTS, REG_WRAPPERS}; use crate::layout::Point; use crate::order::{DrawLayer, Strata}; -use crate::widget::{FrameHandle, FrameKind}; +use crate::widget::{FrameHandle, FrameKind, KindState}; // The frame method clusters, split out for size. pub(crate) mod anchor_args; @@ -248,10 +249,10 @@ pub(super) fn draw_layer_name(l: DrawLayer) -> &'static str { } } -/// A widget tag or `CreateFrame` type → its [`FrameKind`] ([`enum_token`]). Public so the app asks -/// this one mapping rather than keeping a copy. +/// A widget tag or `CreateFrame` type → its [`FrameKind`] ([`enum_token`]), whichever registration +/// makes it known. Public so the app asks this one mapping rather than keeping a copy. pub fn frame_kind_from_tag(s: &str) -> Option { - frame_kind_from_str(s) + frame_type(s).map(|t| t.kind) } /// The type registry lookup `CreateFrame` and the XML loader share (`0x6ee280`, table @@ -259,47 +260,78 @@ pub fn frame_kind_from_tag(s: &str) -> Option { /// record is released once the first one is made (`0x6ee439`), so a second one misses. On a miss /// Lua raises and XML logs `Unknown frame type: %s` and skips the node. pub(crate) fn registered_frame_kind(lua: &Lua, kind: &str) -> Option { - let frame_kind = frame_kind_from_str(kind)?; - let one_shot_spent = frame_kind == FrameKind::WorldFrame - && lua - .app_data_ref::() - .expect("model app_data") - .world_frame_made; - (!one_shot_spent).then_some(frame_kind) + let t = frame_type(kind)?; + let model = lua.app_data_ref::().expect("model app_data"); + let registered = match t.factory { + Factory::Startup => true, + Factory::InGame => model.vm == VmKind::InGame, + }; + let one_shot_spent = t.kind == FrameKind::WorldFrame && model.world_frame_made; + (registered && !one_shot_spent).then_some(t.kind) } -fn frame_kind_from_str(s: &str) -> Option { - Some(match enum_token(s).as_str() { - "FRAME" => FrameKind::Frame, +/// `ModelFFX`, the glue's `CSimpleModelFFX` (factory `0x46fa00`): a `CSimpleModel` (its ctor +/// `0x46fa30` runs `0x76c8e0`, then swaps the vtable) answering as `Model` (`0x470030`) with +/// `Model`'s methods and `LoadXML` (`0x76cac0`), which differs in its OnShow (`0x46fa60`) and its +/// paint (`0x46fa90`, [`crate::widget::ModelState::ffx`]). +pub(crate) fn is_model_ffx(kind: &str) -> bool { + enum_token(kind) == "MODELFFX" +} + +/// When a frame type's factory is registered (`0x6ee880`) into the process-wide registry, which +/// nothing clears before shutdown (`0x6ee800`, from `0x4036fc`). +#[derive(Clone, Copy)] +enum Factory { + /// Before any FrameScript state is built: the 13 base types (`0x6ef070`, from `0x402af3`), and + /// `ModelFFX`, which the glue manager's init registers (`0x46a4fe`) before its first build + /// (`0x46a505`), so the in-game state has it too. + Startup, + /// `UI_Init`'s eight (`0x495940`, from `0x48feb5`). They stay registered after a logout, so + /// only a glue state that never entered the world lacks them, which is the one benilla builds. + InGame, +} + +/// A registered type: the [`FrameKind`] its factory builds, and its registration. +struct FrameType { + kind: FrameKind, + factory: Factory, +} + +fn frame_type(s: &str) -> Option { + use Factory::{InGame, Startup}; + let (kind, factory) = match enum_token(s).as_str() { + "FRAME" => (FrameKind::Frame, Startup), // Registered at `0x495948`, the one row passing `1` as its third argument. - "WORLDFRAME" => FrameKind::WorldFrame, + "WORLDFRAME" => (FrameKind::WorldFrame, InGame), // A plain `CSimpleFrame`: factory `0x495ba0` allocates `0x314` like ``'s `0x6eec10`, // the ctor `0x506950` adds no field, and its paint `0x506a90` does nothing, so // `GetObjectType()` is "Frame" and `IsObjectType("TaxiRouteFrame")` false (`0x484800` is // not overridden). The flight lines are the FrameXML's own rotated textures. - "TAXIROUTEFRAME" => FrameKind::Frame, - "BUTTON" => FrameKind::Button, - "CHECKBUTTON" => FrameKind::CheckButton, + "TAXIROUTEFRAME" => (FrameKind::Frame, InGame), + "BUTTON" => (FrameKind::Button, Startup), + "CHECKBUTTON" => (FrameKind::CheckButton, Startup), // Its own registered type (`0x4959a6`), not a Button: 1.12 has no Lua verb to take loot // slot N (`LootSlot` only continues a bind confirm), so a Button row could loot nothing. - "LOOTBUTTON" => FrameKind::LootButton, - "EDITBOX" => FrameKind::EditBox, - "STATUSBAR" => FrameKind::StatusBar, - "SLIDER" => FrameKind::Slider, - "SCROLLFRAME" => FrameKind::ScrollFrame, - "MODEL" => FrameKind::Model, - "PLAYERMODEL" => FrameKind::PlayerModel, - "DRESSUPMODEL" => FrameKind::DressUpModel, - "TABARDMODEL" => FrameKind::TabardModel, - "MESSAGEFRAME" => FrameKind::MessageFrame, - "SCROLLINGMESSAGEFRAME" => FrameKind::ScrollingMessageFrame, - "COLORSELECT" => FrameKind::ColorSelect, - "SIMPLEHTML" => FrameKind::SimpleHtml, - "MOVIEFRAME" => FrameKind::MovieFrame, - "GAMETOOLTIP" => FrameKind::GameTooltip, - "MINIMAP" => FrameKind::Minimap, + "LOOTBUTTON" => (FrameKind::LootButton, InGame), + "EDITBOX" => (FrameKind::EditBox, Startup), + "STATUSBAR" => (FrameKind::StatusBar, Startup), + "SLIDER" => (FrameKind::Slider, Startup), + "SCROLLFRAME" => (FrameKind::ScrollFrame, Startup), + "MODEL" => (FrameKind::Model, Startup), + "MODELFFX" => (FrameKind::Model, Startup), + "PLAYERMODEL" => (FrameKind::PlayerModel, InGame), + "DRESSUPMODEL" => (FrameKind::DressUpModel, InGame), + "TABARDMODEL" => (FrameKind::TabardModel, InGame), + "MESSAGEFRAME" => (FrameKind::MessageFrame, Startup), + "SCROLLINGMESSAGEFRAME" => (FrameKind::ScrollingMessageFrame, Startup), + "COLORSELECT" => (FrameKind::ColorSelect, Startup), + "SIMPLEHTML" => (FrameKind::SimpleHtml, Startup), + "MOVIEFRAME" => (FrameKind::MovieFrame, Startup), + "GAMETOOLTIP" => (FrameKind::GameTooltip, InGame), + "MINIMAP" => (FrameKind::Minimap, InGame), _ => return None, - }) + }; + Some(FrameType { kind, factory }) } /// A Lua number-ish → f32 (nil/other → 0.0), for offset/color args. @@ -351,7 +383,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let create_frame = lua.create_function(create_frame)?; lua.globals().set("CreateFrame", create_frame)?; + Ok(()) +} +/// The frame globals only the in-game state registers (table `0x83de68`, `UI_Init`'s): the glue +/// state's `GetScreenWidth`/`GetScreenHeight` are other bindings (`0x46dd30`/`0x46dd80`), and it +/// has no modifier-key or fullscreen-scale verb. +pub(super) fn install_in_game(lua: &Lua) -> mlua::Result<()> { // `GetScreenWidth`/`GetScreenHeight` (`0x48b480`/`0x48b4d0`): the screen root's size divided by // `UIParent`'s scale (`GetUiScale`, `0x494590`), so they answer in `UIParent`'s units. lua.globals().set( @@ -584,6 +622,11 @@ pub(super) fn create_frame( if frame_kind == FrameKind::WorldFrame { model.world_frame_made = true; } + if is_model_ffx(&kind) { + if let Some(KindState::Model(m)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) { + m.ffx = true; + } + } // A child enters its parent's stratum at the parent's level + 1; an explicit // `frameStrata` or `frameLevel` still overrides it afterwards. if let Some(p) = parent_handle { diff --git a/crates/benilla-ui/src/script/object/events_regions.rs b/crates/benilla-ui/src/script/object/events_regions.rs index 723ec352..f680d634 100644 --- a/crates/benilla-ui/src/script/object/events_regions.rs +++ b/crates/benilla-ui/src/script/object/events_regions.rs @@ -19,6 +19,10 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { lua.create_function(|lua, (this, event): (Table, String)| { let h = frame_handle_of(lua, &this)?; let mut model = lua.app_data_mut::().expect("model"); + // A name the VM's event table lacks registers nothing and raises nothing (`0x702140`). + let Some(event) = model.vm.event_key(&event).map(str::to_string) else { + return Ok(()); + }; // Dispatch follows registration order, as the reference's listener list does; a // re-register keeps its place and never fires twice. let list = model.event_to_frames.entry(event.clone()).or_default(); @@ -34,6 +38,10 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { lua.create_function(|lua, (this, event): (Table, String)| { let h = frame_handle_of(lua, &this)?; let mut model = lua.app_data_mut::().expect("model"); + // Matched as `RegisterEvent` matches (`0x702280`). + let Some(event) = model.vm.event_key(&event).map(str::to_string) else { + return Ok(()); + }; if let Some(list) = model.event_to_frames.get_mut(&event) { list.retain(|x| x != &h); } diff --git a/crates/benilla-ui/src/script/region.rs b/crates/benilla-ui/src/script/region.rs index d1c22ee1..dbbba05f 100644 --- a/crates/benilla-ui/src/script/region.rs +++ b/crates/benilla-ui/src/script/region.rs @@ -337,10 +337,24 @@ pub(super) fn region_wrapper(lua: &Lua, id: u32) -> mlua::Result { Ok(t) } -/// Install the region method tables and the region globals. +/// Install the region method tables. pub(super) fn install(lua: &Lua) -> mlua::Result<()> { install_region_methods(lua)?; + // `__index` is the method table itself, here and on the leaf metatables, not a function + // (~9 ns against ~195 ns per method access); sound while nothing replaces these tables. + let region_meta = lua.create_table()?; + region_meta.set( + "__index", + lua.named_registry_value::
(REG_REGION_METHODS)?, + )?; + lua.set_named_registry_value(REG_REGION_META, region_meta)?; + Ok(()) +} + +/// The two portrait globals, which only the in-game state registers (`SetPortraitTexture` in +/// table `0x850438`, `SetPortraitToTexture` in `0x83de68`). +pub(super) fn install_in_game(lua: &Lua) -> mlua::Result<()> { // SetPortraitTexture(texture, unit) (`UnitFrame.lua:26`): binds the region to a unit whose // model the app bakes off-screen, drawn masked to its inscribed circle; a later `SetTexture` // or `SetPortraitToTexture` clears it. @@ -392,15 +406,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { Ok(()) })?, )?; - - // `__index` is the method table itself, here and on the leaf metatables, not a function - // (~9 ns against ~195 ns per method access); sound while nothing replaces these tables. - let region_meta = lua.create_table()?; - region_meta.set( - "__index", - lua.named_registry_value::
(REG_REGION_METHODS)?, - )?; - lua.set_named_registry_value(REG_REGION_META, region_meta)?; Ok(()) } diff --git a/crates/benilla-ui/src/script/stdlib.rs b/crates/benilla-ui/src/script/stdlib.rs index 5b7b28d2..414ec587 100644 --- a/crates/benilla-ui/src/script/stdlib.rs +++ b/crates/benilla-ui/src/script/stdlib.rs @@ -4,10 +4,10 @@ //! `loadfile`, `load`, `module`, `newproxy` and `debug` removed), the reference's `debugstack`, //! and a text-only `loadstring`. //! - [`install`]: the 1.12 bare globals (string, table and math aliases, the `debug*` family, the -//! error-handler pair), `time` and `date`, the clock and zone getters ([`super::clock`], -//! [`super::zone_text`]), and a `string.format` that accepts positional `%N$` specs as the 1.12 -//! client's does, rewriting them into a sequential format. Mixing positional and sequential -//! specs raises, as in the reference, whose single argument cursor a positional spec desyncs. +//! error-handler pair), `time` and `date`, and a `string.format` that accepts positional `%N$` +//! specs as the 1.12 client's does, rewriting them into a sequential format. Mixing positional +//! and sequential specs raises, as in the reference, whose single argument cursor a positional +//! spec desyncs. use mlua::{Lua, Value, Variadic}; @@ -101,9 +101,7 @@ pub(super) fn sandbox(lua: &Lua) -> mlua::Result<()> { pub(super) fn install(lua: &Lua) -> mlua::Result<()> { install_error_handler(lua)?; install_time(lua)?; - // Before the Lua layer, whose `debugprofile*` pair keeps its own `GetTime`. - super::clock::install(lua)?; - super::zone_text::install(lua)?; + install_profile(lua)?; lua.load(WOW_STDLIB) .set_name("=[benilla wow stdlib]") .set_mode(mlua::ChunkMode::Text) @@ -111,6 +109,32 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { Ok(()) } +/// `debugprofilestart`/`debugprofilestop` (`0x702840`/`0x702860`), the two real `debug*` verbs (RDTSC +/// via `0x4293d0`): start latches, stop answers the milliseconds since. Ours read the `GetTime()` +/// clock ([`super::clock::now`]), the monotonic session seconds the tick advances, as benilla has +/// no cycle counter; read in Rust, so an addon that replaces the `GetTime` global cannot bend it. +fn install_profile(lua: &Lua) -> mlua::Result<()> { + use std::sync::atomic::{AtomicU64, Ordering}; + let start = std::sync::Arc::new(AtomicU64::new(0f64.to_bits())); + let g = lua.globals(); + let latch = start.clone(); + g.set( + "debugprofilestart", + lua.create_function(move |lua, ()| { + latch.store(super::clock::now(lua).to_bits(), Ordering::Relaxed); + Ok(()) + })?, + )?; + g.set( + "debugprofilestop", + lua.create_function(move |lua, ()| { + let since = f64::from_bits(start.load(Ordering::Relaxed)); + Ok((super::clock::now(lua) - since) * 1000.0) + })?, + )?; + Ok(()) +} + /// The handler `seterrorhandler` holds, `None` until one is given. pub(super) fn error_handler(lua: &Lua) -> Option { lua.named_registry_value::>(super::REG_ERRORHANDLER) @@ -223,13 +247,9 @@ ldexp = math.ldexp -- That is what lets `BasicControls.xml` stop guarding its `debuginfo()` call and transcribe -- `_ERRORMESSAGE` verbatim, which it now does. -- --- `debugprofilestart`/`debugprofilestop` are the two that are REAL (RDTSC via `0x4293d0`): start --- latches, stop answers the elapsed milliseconds since it. Modelled on `GetTime`'s own clock — --- the same monotonic session seconds the tick advances — because benilla has no cycle counter and --- an addon uses these to time its own work, which milliseconds answer honestly. The clock is --- captured, so an addon that replaces the `GetTime` global cannot bend it. +-- `debugprofilestart`/`debugprofilestop` are the two that are REAL; they are Rust +-- (`install_profile`). do - local GetTime = GetTime local function noop() end debuginfo = noop debugload = noop @@ -237,19 +257,12 @@ do debugdump = noop debugbreak = noop debugtimestamp = noop - - local profileStart = 0 - function debugprofilestart() profileStart = GetTime() end - function debugprofilestop() return (GetTime() - profileStart) * 1000 end end -- ── _G accessors (getglobal/setglobal: _G[name] get/set) ─────────────────────────────── function getglobal(name) return _G[name] end function setglobal(name, value) _G[name] = value end --- ── GetLocale: benilla ships/reads enUS data only (the 5875 MPQs + Spell.dbc enUS column) ────── -function GetLocale() return "enUS" end - -- ── NOT here: wipe / tostringall / strsplit / strjoin / strconcat / strtrim ──────────────────── -- Six 2.0+ names benilla used to define. 1.12 has none of them (absent from -- `reference/1.12-globals.tsv`, from the stock chain, and from every registrar table), and the diff --git a/crates/benilla-ui/src/script/tests/glue_surface.rs b/crates/benilla-ui/src/script/tests/glue_surface.rs new file mode 100644 index 00000000..f9dd545d --- /dev/null +++ b/crates/benilla-ui/src/script/tests/glue_surface.rs @@ -0,0 +1,524 @@ +//! The glue screens' Lua surface (`reference/1.12-glue-surface.tsv`) held to its source, the shapes +//! table, and the glue VM ([`UiScript::new_glue`]) held to the surface: every function it puts in +//! `_G` is a glue-state one, and every glue-state function is there or on [`NOT_YET`]. Then the glue +//! state's frame types and event table. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::script::{UiScript, GLUE_EVENTS}; +use crate::widget::KindState; + +/// A vendored `reference/` table's data rows, split on tabs, the header row dropped. +fn reference_rows(file: &str, header: &str) -> Vec> { + let path = format!("{}/../../reference/{file}", env!("CARGO_MANIFEST_DIR")); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("reading {path}: {e}")); + text.lines() + .filter(|l| !l.starts_with('#') && !l.is_empty()) + .map(|l| l.split('\t').map(str::to_string).collect::>()) + .filter(|f| f[0] != header) + .collect() +} + +/// One row of the glue surface table. +pub(super) struct GlueRow { + pub scope: String, + pub name: String, + pub fn_va: String, + pub table_va: String, + pub arity: String, +} + +/// `reference/1.12-glue-surface.tsv`. +pub(super) fn glue_surface() -> Vec { + let rows: Vec = reference_rows("1.12-glue-surface.tsv", "scope") + .into_iter() + .map(|f| GlueRow { + scope: f[0].clone(), + name: f[2].clone(), + fn_va: f[3].clone(), + table_va: f[4].clone(), + arity: f[5].clone(), + }) + .collect(); + assert!( + rows.len() > 150, + "the glue surface table looks truncated ({} rows) — regenerate it with \ + scripts/gen-reference-glue-surface.py", + rows.len() + ); + rows +} + +/// The tables the glue state installs, as the glue registrar `0x46abb0` and the Lua init +/// `0x7039e0` loop them: each `cmp` bound over the 8-byte pair stride. +const GLUE_TABLES: [(&str, usize); 8] = [ + ("0x811e28", 36), // the base array, 0x7039e0 + ("0x8503f4", 6), // 0x51bb20 + ("0x872e74", 4), // 0x706350 + ("0x8373b8", 60), // 0x46ddf0 + ("0x838578", 11), // 0x4736c0 + ("0x8380f8", 24), // 0x471a20 + ("0x837ea0", 10), // 0x46f480 + ("0x835a50", 4), // 0x4587f0 +]; + +/// Each registrar table's rows are exactly the shapes table's rows for that table, binding and +/// arity alike; a compat alias names a library binding the shapes table has. +#[test] +fn the_glue_surface_matches_the_shapes_table() { + // (table_va, name) -> (fn, arity): a name is not a key in the shapes table. + let shapes: BTreeMap<(String, String), (String, String)> = + reference_rows("1.12-shapes.tsv", "name") + .into_iter() + .map(|f| ((f[3].clone(), f[0].clone()), (f[1].clone(), f[7].clone()))) + .collect(); + let surface = glue_surface(); + + for (table_va, rows) in GLUE_TABLES { + let in_shapes: BTreeSet<&str> = shapes + .keys() + .filter(|(t, _)| t == table_va) + .map(|(_, n)| n.as_str()) + .collect(); + let in_surface: BTreeSet<&str> = surface + .iter() + .filter(|r| r.table_va == table_va) + .map(|r| r.name.as_str()) + .collect(); + assert_eq!( + in_shapes.len(), + rows, + "the shapes table's {table_va} has {} rows", + in_shapes.len() + ); + assert_eq!( + in_surface, in_shapes, + "table {table_va}: the glue surface and the shapes table differ" + ); + } + for r in &surface { + if r.fn_va == "lua" { + continue; + } + if r.table_va == "-" { + // `__pow`, set by `luaopen_math` outside any table. + assert_eq!(r.name, "__pow", "{} has no table", r.name); + continue; + } + let shape = shapes + .iter() + .find(|((t, _), (f, _))| *t == r.table_va && *f == r.fn_va) + .map(|(_, s)| s); + assert_eq!( + shape.map(|(f, a)| (f.as_str(), a.as_str())), + Some((r.fn_va.as_str(), r.arity.as_str())), + "{}: no shapes row in table {} with that binding and arity", + r.name, + r.table_va + ); + } + let names: BTreeSet<&str> = surface.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(names.len(), surface.len(), "a name appears twice"); + // 36 base-array rows, the compat chunk's 39 functions and `__pow`; 6 + 4 + 4 shared; the four + // glue tables' 105. + for (scope, n) in [("base", 76), ("shared", 14), ("glue", 105)] { + assert_eq!( + surface.iter().filter(|r| r.scope == scope).count(), + n, + "{scope} rows" + ); + } +} + +/// Glue-state functions the glue VM does not answer yet, by registrar table, each with why. The +/// list only shrinks: a verb is absent until built, never stubbed. +const NOT_YET: &[(&str, &[&str])] = &[ + ( + "0x51bb20's file trio, each only `luaL_error(\"Access Denied\")`; unbuilt in the in-game \ + VM too, tracked by #338", + &["ReadFile", "DeleteFile", "AppendToFile"], + ), + ( + "the glue core (0x46ddf0, table 0x8373b8): login, the notices, the addon list, the cursor \ + and the glue music; each a binding of its own, the 14 with an in-game namesake included", + &[ + "AcceptContest", + "AcceptEULA", + "AcceptScanning", + "AcceptTOS", + "ContestAccepted", + "DefaultServerLogin", + "DisableAddOn", + "DisableAllAddOns", + "DisconnectFromServer", + "EULAAccepted", + "EnableAddOn", + "EnableAllAddOns", + "EnterWorld", + "GetAddOnDependencies", + "GetAddOnEnableState", + "GetAddOnInfo", + "GetBillingPlan", + "GetBillingTimeRemaining", + "GetBillingTimeRested", + "GetBuildInfo", + "GetCursorPosition", + "GetLocale", + "GetMovieResolution", + "GetMovieSubtitles", + "GetNumAddOns", + "GetSavedAccountName", + "GetScreenHeight", + "GetScreenWidth", + "GetScriptMemory", + "GetServerName", + "HideCursor", + "IsAddonVersionCheckEnabled", + "IsConnectedToServer", + "LaunchAddOnURL", + "LaunchURL", + "PINEntered", + "PatchDownloadApply", + "PatchDownloadCancel", + "PatchDownloadProgress", + "PlayCreditsMusic", + "PlayGlueMusic", + "QuitGame", + "ResetAddOns", + "SaveAddOns", + "ScanningAccepted", + "Screenshot", + "SetAddonVersionCheck", + "SetCurrentScreen", + "SetMovieSubtitles", + "SetSavedAccountName", + "SetScriptMemory", + "ShowContestNotice", + "ShowCursor", + "ShowEULANotice", + "ShowScanningNotice", + "ShowTOSNotice", + "StatusDialogClick", + "StopGlueMusic", + "SurveyNotificationDone", + "TOSAccepted", + ], + ), + ( + "character select (0x4736c0, table 0x838578)", + &[ + "DeleteCharacter", + "GetCharacterInfo", + "GetCharacterListUpdate", + "GetCharacterSelectFacing", + "GetNumCharacters", + "RenameCharacter", + "SelectCharacter", + "SetCharSelectBackground", + "SetCharSelectModelFrame", + "SetCharacterSelectFacing", + "UpdateSelectionCustomizationScene", + ], + ), + ( + "character create (0x471a20, table 0x8380f8)", + &[ + "CreateCharacter", + "CycleCharCustomization", + "GetAvailableRaces", + "GetCharacterCreateFacing", + "GetClassesForRace", + "GetFacialHairCustomization", + "GetFactionForRace", + "GetHairCustomization", + "GetNameForRace", + "GetRandomName", + "GetSelectedClass", + "GetSelectedRace", + "GetSelectedSex", + "HasCharCustomization", + "RandomizeCharCustomization", + "ResetCharCustomize", + "SetCharCustomizeBackground", + "SetCharCustomizeFrame", + "SetCharacterCreateFacing", + "SetSelectedClass", + "SetSelectedRace", + "SetSelectedSex", + "UpdateCustomizationBackground", + "UpdateCustomizationScene", + ], + ), + ( + "the realm list (0x46f480, table 0x837ea0)", + &[ + "CancelRealmListQuery", + "ChangeRealm", + "GetNumRealms", + "GetRealmCategories", + "GetRealmInfo", + "GetSelectedCategory", + "RealmListDialogCancelled", + "RequestRealmList", + "SetPreferredInfo", + "SortRealms", + ], + ), +]; + +/// The glue VM's globals by Lua type name. +fn glue_globals() -> BTreeMap { + UiScript::new_glue() + .expect("construct the glue VM") + .eval::>>( + "local out = {} \ + for k, v in pairs(getfenv(0)) do \ + if type(k) == 'string' then table.insert(out, {k, type(v)}) end \ + end \ + return out", + ) + .expect("dump _G") + .into_iter() + .map(|kv| (kv[0].clone(), kv[1].clone())) + .collect() +} + +/// The glue VM's functions are the glue surface less [`NOT_YET`], exactly. +#[test] +fn the_glue_vm_answers_the_glue_surface() { + let surface: BTreeSet = glue_surface().into_iter().map(|r| r.name).collect(); + let not_yet: BTreeSet<&str> = NOT_YET + .iter() + .flat_map(|(_, n)| n.iter().copied()) + .collect(); + let ours: BTreeSet = glue_globals() + .into_iter() + .filter(|(_, t)| t == "function") + .map(|(k, _)| k) + .collect(); + + let beyond: Vec<&String> = ours.iter().filter(|n| !surface.contains(*n)).collect(); + assert!( + beyond.is_empty(), + "the glue VM exposes {} function(s) the glue state does not have: {beyond:?}. The \ + in-game surface stays out of it (`UiScript::new` installs that).", + beyond.len() + ); + let missing: Vec<&String> = surface + .iter() + .filter(|n| !ours.contains(*n) && !not_yet.contains(n.as_str())) + .collect(); + assert!( + missing.is_empty(), + "glue-state functions the glue VM lacks and NOT_YET does not name: {missing:?}" + ); + let stale: Vec<&&str> = not_yet + .iter() + .filter(|n| ours.contains(**n) || !surface.contains(**n)) + .collect(); + assert!( + stale.is_empty(), + "NOT_YET names that are built, or that are not glue-state functions; take them off: \ + {stale:?}" + ); + assert_eq!( + not_yet.len(), + NOT_YET.iter().map(|(_, n)| n.len()).sum::(), + "a NOT_YET name is listed twice" + ); +} + +/// Past its functions the glue `_G` holds the four library tables, the frame metatable +/// `0x7039e0` publishes, and the compat chunk's `PI`; `_G` itself is benilla's, as in-game +/// (`reference_surface::BEYOND_1_12`). +#[test] +fn the_glue_vm_holds_no_other_global() { + let others: BTreeMap = glue_globals() + .into_iter() + .filter(|(_, t)| t != "function") + .collect(); + let want: BTreeMap = [ + ("PI", "number"), + ("__framescript_meta", "table"), + ("_G", "table"), + ("bit", "table"), + ("math", "table"), + ("string", "table"), + ("table", "table"), + ] + .into_iter() + .map(|(k, t)| (k.to_string(), t.to_string())) + .collect(); + assert_eq!(others, want); +} + +/// The glue event table in code is `reference/1.12-glue-events.tsv`, by id. +#[test] +fn the_glue_event_table_matches_the_reference() { + let rows = reference_rows("1.12-glue-events.tsv", "id"); + assert_eq!(rows.len(), GLUE_EVENTS.len()); + for (id, row) in rows.iter().enumerate() { + assert_eq!(row[0], id.to_string(), "ids run 0..26 in order"); + assert_eq!(row[1], GLUE_EVENTS[id], "event {id}"); + } +} + +/// `RegisterEvent` matches a name against the glue table case-insensitively (`0x702140` with +/// `0x64a4c0`) and drops one the table lacks without raising; `UnregisterEvent` matches the same +/// way (`0x702280`). An in-game event fired at the glue VM reaches nothing, an all-events frame +/// included, since the reference fires by id. +#[test] +fn glue_events_register_by_the_glue_table() { + let mut s = UiScript::new_glue().unwrap(); + s.run( + r#" + heard = {} + f = CreateFrame("Frame") + f:SetScript("OnEvent", function() table.insert(heard, "f:" .. event) end) + f:RegisterEvent("set_glue_screen") + f:RegisterEvent("PLAYER_LOGIN") + all = CreateFrame("Frame") + all:SetScript("OnEvent", function() table.insert(heard, "all:" .. event) end) + all:RegisterAllEvents() + "#, + ) + .expect("an event off the glue table raises nothing"); + s.fire_event("SET_GLUE_SCREEN", vec![]); + s.queue_event("PLAYER_LOGIN", vec![]); + // The per-frame advance runs over the glue VM, its queued events included. + s.tick(0.016); + assert_eq!( + s.eval::>("return heard").unwrap(), + ["f:SET_GLUE_SCREEN", "all:SET_GLUE_SCREEN"], + "the glue event reaches both frames under the table's spelling; the in-game one neither" + ); + s.run(r#"heard = {} f:UnregisterEvent("Set_Glue_Screen")"#) + .unwrap(); + s.fire_event("SET_GLUE_SCREEN", vec![]); + assert_eq!( + s.eval::>("return heard").unwrap(), + ["all:SET_GLUE_SCREEN"] + ); +} + +/// The in-game VM keeps its by-name registry: an in-game event registers and fires there. +#[test] +fn the_in_game_vm_keeps_its_events() { + let mut s = UiScript::new().unwrap(); + s.run( + r#"heard = 0 f = CreateFrame("Frame") f:RegisterEvent("PLAYER_LOGIN") + f:SetScript("OnEvent", function() heard = heard + 1 end)"#, + ) + .unwrap(); + s.fire_event("PLAYER_LOGIN", vec![]); + assert_eq!(s.eval::("return heard").unwrap(), 1); +} + +/// The frame types: the 13 registered at startup (`0x6ef070`) and `ModelFFX` (`0x46a4fe`) in both +/// states; `UI_Init`'s eight (`0x495940`) only in-game. +#[test] +fn the_glue_vm_makes_the_startup_frame_types_only() { + const STARTUP: [&str; 14] = [ + "Button", + "CheckButton", + "EditBox", + "Frame", + "MessageFrame", + "Model", + "ScrollFrame", + "ScrollingMessageFrame", + "Slider", + "SimpleHTML", + "StatusBar", + "ColorSelect", + "MovieFrame", + "ModelFFX", + ]; + const IN_GAME: [&str; 8] = [ + "WorldFrame", + "GameTooltip", + "Minimap", + "PlayerModel", + "DressUpModel", + "TabardModel", + "LootButton", + "TaxiRouteFrame", + ]; + let glue = UiScript::new_glue().unwrap(); + let world = UiScript::new().unwrap(); + for t in STARTUP { + for (vm, s) in [("glue", &glue), ("in-game", &world)] { + s.run(&format!(r#"CreateFrame("{t}")"#)) + .unwrap_or_else(|e| panic!("{vm} CreateFrame(\"{t}\"): {e}")); + } + } + for t in IN_GAME { + let e = glue + .run(&format!(r#"CreateFrame("{t}")"#)) + .expect_err(t) + .to_string(); + assert!(e.contains(&format!("unknown frame type '{t}'")), "{t}: {e}"); + world.run(&format!(r#"CreateFrame("{t}")"#)).unwrap(); + } +} + +/// `ModelFFX` is a `Model` to Lua (`0x470030`, `0x46ffd0`) with `Model`'s methods, flagged FFX for +/// the renderer; its XML is `Model`'s (`0x76cac0`), `file=` and `scale=` included. +#[test] +fn model_ffx_is_a_model_flagged_ffx() { + let s = UiScript::new_glue().unwrap(); + s.run( + r#" + a = CreateFrame("ModelFFX", "FfxPane") + b = CreateFrame("Model", "PlainPane") + a:SetModel("Test/Scene.mdx") + "#, + ) + .unwrap(); + assert_eq!( + s.eval::<(String, bool, bool, String)>( + r#"return a:GetObjectType(), a:IsObjectType("Model") == 1, + a:IsObjectType("ModelFFX") == 1, a:GetModel()"# + ) + .unwrap(), + ( + "Model".to_string(), + true, + false, + "Test/Scene.mdx".to_string() + ) + ); + let ffx = |name: &str| { + let model = s.model_ref(); + let h = model.arena.lookup(name).unwrap(); + match &model.arena.frame(h).unwrap().kind_state { + KindState::Model(m) => m.ffx, + other => panic!("{name}: {other:?}"), + } + }; + assert!(ffx("FfxPane")); + assert!(!ffx("PlainPane")); + + let doc = crate::framexml::parse( + r#" + + + "#, + ) + .unwrap(); + let report = crate::loader::load(&s, &doc, &|_| None); + assert!( + report + .warnings + .iter() + .any(|w| w == "Unknown frame type: PlayerModel"), + "the glue state has no PlayerModel factory: {:?}", + report.warnings + ); + assert!(ffx("XmlFfx")); + assert_eq!( + s.eval::<(String, f64)>("return XmlFfx:GetModel(), XmlFfx:GetModelScale()") + .unwrap(), + ("Test/Xml.mdx".to_string(), 2.0) + ); +} diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs index 41ac60a2..9eae7b2d 100644 --- a/crates/benilla-ui/src/script/tests/mod.rs +++ b/crates/benilla-ui/src/script/tests/mod.rs @@ -25,6 +25,7 @@ mod events; mod font_object; mod frame_api; mod generic_for; +mod glue_surface; mod gm_ticket; mod handler_prof; mod host_state; diff --git a/crates/benilla-ui/src/script/tick.rs b/crates/benilla-ui/src/script/tick.rs index a97f1d40..ca767233 100644 --- a/crates/benilla-ui/src/script/tick.rs +++ b/crates/benilla-ui/src/script/tick.rs @@ -36,6 +36,9 @@ impl UiScript { /// [`UiScript::fire_event`] for a caller holding `&Lua`, such as a Lua binding. pub(crate) fn fire_event_into(lua: &Lua, event: &str, args: Vec) { let model_mut = || lua.app_data_mut::().expect("model app_data set"); + if !model_mut().vm.fires(event) { + return; + } { let mut at = { let model = model_mut(); diff --git a/crates/benilla-ui/src/widget/kinds/mod.rs b/crates/benilla-ui/src/widget/kinds/mod.rs index d123cf38..dbae78e8 100644 --- a/crates/benilla-ui/src/widget/kinds/mod.rs +++ b/crates/benilla-ui/src/widget/kinds/mod.rs @@ -71,6 +71,17 @@ pub enum KindUpdate { } impl FrameKind { + /// A `CSimpleModel` kind, carrying a [`ModelState`]. + pub fn is_model(self) -> bool { + matches!( + self, + FrameKind::Model + | FrameKind::PlayerModel + | FrameKind::DressUpModel + | FrameKind::TabardModel + ) + } + /// The kind's own update, run in the walk after the frame's `OnUpdate`. The reference /// overrides the slot on more kinds; benilla models these. pub fn kind_update(self) -> Option { @@ -80,10 +91,7 @@ impl FrameKind { FrameKind::MessageFrame => Some(KindUpdate::MessageFrame), FrameKind::ScrollingMessageFrame => Some(KindUpdate::ScrollingMessageFrame), FrameKind::ScrollFrame => Some(KindUpdate::ScrollFrame), - FrameKind::Model - | FrameKind::PlayerModel - | FrameKind::DressUpModel - | FrameKind::TabardModel => Some(KindUpdate::Model), + k if k.is_model() => Some(KindUpdate::Model), _ => None, } } @@ -334,6 +342,12 @@ pub struct ModelState { pub fog_near: f32, /// Fog far (`+0x3b0`), shaped like [`Self::fog_near`]; the ctor's is `1.0`. pub fog_far: f32, + /// A `ModelFFX`, the glue's `CSimpleModelFFX` (`0x46fa00`). Its vtable differs from + /// `CSimpleModel`'s in two slots past the destructor: its OnShow (`0x46fa60`) installs the glue + /// glow pass (`0x6cde60` with `[0xb414c4]`) and pins `LightParams.glow` to `*0x8380b4` (0.30), + /// and its layer-2 paint callback (`0x46fad0`, registered by `0x46fa90`) brackets the model + /// paint `0x76d240` with the full-screen-effect pass pair `0x6cd890`/`0x6cda70`. + pub ffx: bool, } /// A model pane's armed fog, all the renderer needs ([`ModelState::armed_fog`]). @@ -406,6 +420,7 @@ impl Default for ModelState { fog: false, fog_near: 0.0, fog_far: 1.0, + ffx: false, } } } diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index 3075ba5a..cfbe82a4 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -556,10 +556,7 @@ impl WidgetArena { } // `CGCharacterModelBase` extends `CSimpleModel`, adding only a turn-animation pair, // which is not modelled. - FrameKind::Model - | FrameKind::PlayerModel - | FrameKind::DressUpModel - | FrameKind::TabardModel => KindState::Model(kinds::ModelState::default()), + k if k.is_model() => KindState::Model(kinds::ModelState::default()), FrameKind::Minimap => KindState::Minimap(kinds::MinimapState::default()), FrameKind::GameTooltip => KindState::Tooltip(kinds::TooltipState::default()), _ => KindState::None, @@ -570,13 +567,7 @@ impl WidgetArena { if matches!(kind, FrameKind::Minimap) { self.minimap_created += 1; } - let model_kind = matches!( - kind, - FrameKind::Model - | FrameKind::PlayerModel - | FrameKind::DressUpModel - | FrameKind::TabardModel - ); + let model_kind = kind.is_model(); let visible = frame.effective_visible; let (strata, level) = (frame.strata, frame.level); let (index, generation) = self.frames.insert(frame); diff --git a/docs/MAP.md b/docs/MAP.md index 8238fdec..4f3f7305 100644 --- a/docs/MAP.md +++ b/docs/MAP.md @@ -530,6 +530,7 @@ - `gen-message-catalog.py` — Generate `crates/benilla-ui/src/messages/catalog.rs` from `reference/1.12-message-catalog.tsv`. - `gen-reference-events.py` — Regenerate `reference/1.12-events.tsv`: each 1.12 FrameScript event and the arguments it carries. - `gen-reference-globals.py` — Regenerate `reference/1.12-globals.tsv`: the 1.12.1 client's global namespace, by origin. +- `gen-reference-glue-surface.py` — Regenerate `reference/1.12-glue-surface.tsv`: every Lua function the glue screens' VM has in `_G`. - `gen-reference-verb-events.py` — Regenerate `reference/1.12-verb-events.tsv`: the events 1.12 Lua verbs fire, keyed by verb. - `mixsum.py` — Read a `$WOW_MIX_TAP` capture and say, in numbers, what the mix was doing when. - `pass-span-lint.py` — Refuse a second open `pass_span` on the same render pass (a wgpu validation error on Vulkan). diff --git a/reference/1.12-glue-events.tsv b/reference/1.12-glue-events.tsv new file mode 100644 index 00000000..6f244003 --- /dev/null +++ b/reference/1.12-glue-events.tsv @@ -0,0 +1,42 @@ +# The glue screens' FrameScript event table of the 1.12.1 client (WoW.exe build 5875): eventId -> name. +# Source: the 26 `mov dword [0xb41e70 + 4*id], imm32` writes of 0x46cc60 (0x46cc60-0x46cd5a, called +# once at startup from 0x402af8), each imm32 the address of the event's name string. The glue +# builder 0x46a7b0 hands the array to 0x703d90 (`mov edx,0x1a; mov ecx,0xb41e70; call 0x703d90` +# at 0x46a885-0x46a88f), which rebuilds the runtime dispatch table [0xceef68] from it wholesale, +# as UI_Init does with the in-game array 0xbe1198 (`1.12-event-catalog.tsv`). So while the glue +# state runs, these 26 are the only events, the id fired by FrameScript_SignalEvent 0x703e50 / +# 0x703f50 indexes this table, and the same integer names a different event in-game. +# Registration: `RegisterEvent` 0x774a40 -> 0x702140 walks the table and compares names with +# 0x64a4c0 (-> 0x414310, case-insensitive); a name off the table matches nothing and is dropped +# without an error. `UnregisterEvent` 0x774b30 -> 0x702280 matches the same way. +# Vendored from the client's bytes; no generator for it lives in this repo. +# +# name_va the name string's address (the imm32). +# write_va the instruction in 0x46cc60 that stores it. +id name name_va write_va +0 SET_GLUE_SCREEN 0x837bec 0x46cc60 +1 START_GLUE_MUSIC 0x837bd8 0x46cc6a +2 DISCONNECTED_FROM_SERVER 0x837bbc 0x46cc74 +3 OPEN_STATUS_DIALOG 0x837ba8 0x46cc7e +4 UPDATE_STATUS_DIALOG 0x837b90 0x46cc88 +5 CLOSE_STATUS_DIALOG 0x837b7c 0x46cc92 +6 ADDON_LIST_UPDATE 0x837b68 0x46cc9c +7 CHARACTER_LIST_UPDATE 0x837b50 0x46cca6 +8 UPDATE_SELECTED_CHARACTER 0x837b34 0x46ccb0 +9 OPEN_REALM_LIST 0x837b24 0x46ccba +10 GET_PREFERRED_REALM_INFO 0x837b08 0x46ccc4 +11 UPDATE_SELECTED_RACE 0x837af0 0x46ccce +12 SELECT_LAST_CHARACTER 0x837ad8 0x46ccd8 +13 SELECT_FIRST_CHARACTER 0x837ac0 0x46cce2 +14 GLUE_SCREENSHOT_SUCCEEDED 0x837aa4 0x46ccec +15 GLUE_SCREENSHOT_FAILED 0x837a8c 0x46ccf6 +16 PATCH_UPDATE_PROGRESS 0x837a74 0x46cd00 +17 PATCH_DOWNLOADED 0x837a60 0x46cd0a +18 SUGGEST_REALM 0x837a50 0x46cd14 +19 SUGGEST_REALM_WRONG_PVP 0x837a38 0x46cd1e +20 SUGGEST_REALM_WRONG_CATEGORY 0x837a18 0x46cd28 +21 SHOW_SERVER_ALERT 0x837a04 0x46cd32 +22 FRAMES_LOADED 0x8379f4 0x46cd3c +23 FORCE_RENAME_CHARACTER 0x8379dc 0x46cd46 +24 SHOW_SURVEY_NOTIFICATION 0x8379c0 0x46cd50 +25 PLAYER_ENTER_PIN 0x8379ac 0x46cd5a diff --git a/reference/1.12-glue-surface.tsv b/reference/1.12-glue-surface.tsv new file mode 100644 index 00000000..10ede5e7 --- /dev/null +++ b/reference/1.12-glue-surface.tsv @@ -0,0 +1,212 @@ +# The glue screens' Lua surface in the 1.12.1 client (build 5875): every function in `_G` +# once the glue builder 0x46a7b0 has installed its registrars, before GlueXML loads. +# Generated by scripts/gen-reference-glue-surface.py from reference/1.12-shapes.tsv and +# reference/1.12-globals.tsv; the generator's header carries the registrars and the method. +# +# scope base = FrameScript's Lua init 0x7039e0, which both states run; shared = a +# registrar the in-game state calls too (0x51bb20, 0x706350, 0x4587f0); glue = +# a glue-only registrar table (0x46ddf0, 0x4736c0, 0x471a20, 0x46f480). +# fn, table_va, arity as 1.12-shapes.tsv has them. A base alias the compat chunk +# 0x8722e8 defines carries the library binding it names (`tinsert` is table +# 0x822d40's `insert`), or fn `lua` for a degree-converting closure; math's +# global `__pow` (0x7fb050) has no table. +# world_fn a glue row's in-game namesake, a different binding; `-` if none. +# note shape-differs = both shapes exact, and the glue binding takes or returns a +# different count than its in-game namesake. +# 195 functions: 76 base, 14 shared, 105 glue; 6 glue rows differ in shape from their in-game namesake. +scope registrar name fn table_va arity world_fn note +base 0x7039e0 assert 0x7031a0 0x811e28 1 - +base 0x7039e0 collectgarbage 0x703250 0x811e28 0 - +base 0x7039e0 date 0x7033a0 0x811e28 1 - +base 0x7039e0 debugbreak 0x702820 0x811e28 0 - +base 0x7039e0 debugdump 0x702810 0x811e28 0 - +base 0x7039e0 debuginfo 0x7027f0 0x811e28 0 - +base 0x7039e0 debugload 0x7027e0 0x811e28 0 - +base 0x7039e0 debugprint 0x702800 0x811e28 0 - +base 0x7039e0 debugprofilestart 0x702840 0x811e28 0 - +base 0x7039e0 debugprofilestop 0x702860 0x811e28 1 - +base 0x7039e0 debugstack 0x703760 0x811e28 1 - +base 0x7039e0 debugtimestamp 0x702830 0x811e28 0 - +base 0x7039e0 error 0x702970 0x811e28 ? - +base 0x7039e0 gcinfo 0x703200 0x811e28 2 - +base 0x7039e0 geterrorhandler 0x702950 0x811e28 1 - +base 0x7039e0 getfenv 0x702ac0 0x811e28 1 - +base 0x7039e0 getglobal 0x7027b0 0x811e28 1 - +base 0x7039e0 getmetatable 0x7029f0 0x811e28 1 - +base 0x7039e0 ipairs 0x702cd0 0x811e28 ? - +base 0x7039e0 loadstring 0x703280 0x811e28 1|2 - +base 0x7039e0 next 0x702c90 0x811e28 1|2 - +base 0x7039e0 pairs 0x702da0 0x811e28 3 - +base 0x7039e0 pcall 0x7032f0 0x811e28 ? - +base 0x7039e0 rawequal 0x7030f0 0x811e28 1 - +base 0x7039e0 rawget 0x703130 0x811e28 1 - +base 0x7039e0 rawset 0x703160 0x811e28 1 - +base 0x7039e0 seterrorhandler 0x702900 0x811e28 0 - +base 0x7039e0 setfenv 0x702be0 0x811e28 0 - +base 0x7039e0 setglobal 0x702770 0x811e28 0 - +base 0x7039e0 setmetatable 0x702a40 0x811e28 1 - +base 0x7039e0 time 0x7035a0 0x811e28 1 - +base 0x7039e0 tonumber 0x702e30 0x811e28 1 - +base 0x7039e0 tostring 0x702f40 0x811e28 1 - +base 0x7039e0 type 0x702df0 0x811e28 1 - +base 0x7039e0 unpack 0x7030a0 0x811e28 ? - +base 0x7039e0 xpcall 0x703340 0x811e28 ? - +shared 0x51bb20 AppendToFile 0x515f90 0x8503f4 ? - +shared 0x51bb20 ConsoleExec 0x515f10 0x8503f4 0 - +shared 0x51bb20 DeleteFile 0x515f70 0x8503f4 ? - +shared 0x51bb20 GetGameTime 0x515ee0 0x8503f4 2 - +shared 0x51bb20 GetTime 0x515ea0 0x8503f4 1 - +shared 0x51bb20 ReadFile 0x515f50 0x8503f4 ? - +shared 0x706350 CreateFont 0x706040 0x872e74 1 - +shared 0x706350 CreateFrame 0x7060b0 0x872e74 1 - +shared 0x706350 EnumerateFrames 0x705f60 0x872e74 1 - +shared 0x706350 GetNumFrames 0x705f00 0x872e74 1 - +glue 0x46ddf0 AcceptContest 0x46d150 0x8373b8 0 - +glue 0x46ddf0 AcceptEULA 0x46d070 0x8373b8 0 - +glue 0x46ddf0 AcceptScanning 0x46d0e0 0x8373b8 0 - +glue 0x46ddf0 AcceptTOS 0x46d000 0x8373b8 0 - +glue 0x46ddf0 ContestAccepted 0x46d120 0x8373b8 1 - +glue 0x46ddf0 DefaultServerLogin 0x46d160 0x8373b8 0 - +glue 0x46ddf0 DisableAddOn 0x46d8a0 0x8373b8 0 0x48e760 shape-differs: argc 2 here, 1 in-game (0x48e760) +glue 0x46ddf0 DisableAllAddOns 0x46d940 0x8373b8 0 0x48e7f0 shape-differs: argc 1 here, 0 in-game (0x48e7f0) +glue 0x46ddf0 DisconnectFromServer 0x46d340 0x8373b8 0 - +glue 0x46ddf0 EULAAccepted 0x46d040 0x8373b8 1 - +glue 0x46ddf0 EnableAddOn 0x46d7b0 0x8373b8 0 0x48e690 shape-differs: argc 2 here, 1 in-game (0x48e690) +glue 0x46ddf0 EnableAllAddOns 0x46d850 0x8373b8 0 0x48e720 shape-differs: argc 1 here, 0 in-game (0x48e720) +glue 0x46ddf0 EnterWorld 0x46d3c0 0x8373b8 0 - +glue 0x46ddf0 GetAddOnDependencies 0x46d650 0x8373b8 ? 0x48e5e0 +glue 0x46ddf0 GetAddOnEnableState 0x46d6f0 0x8373b8 1 - +glue 0x46ddf0 GetAddOnInfo 0x46d460 0x8373b8 8 0x48e390 shape-differs: arity 8 here, 7 in-game (0x48e390) +glue 0x46ddf0 GetBillingPlan 0x46dc40 0x8373b8 3 - +glue 0x46ddf0 GetBillingTimeRemaining 0x46dbf0 0x8373b8 1 - +glue 0x46ddf0 GetBillingTimeRested 0x46dcf0 0x8373b8 1 0x48ec50 +glue 0x46ddf0 GetBuildInfo 0x46cd70 0x8373b8 5 0x4884a0 shape-differs: arity 5 here, 3 in-game (0x4884a0) +glue 0x46ddf0 GetCursorPosition 0x46dad0 0x8373b8 2 0x48b820 +glue 0x46ddf0 GetLocale 0x46ce40 0x8373b8 1 0x48d8b0 +glue 0x46ddf0 GetMovieResolution 0x46cf10 0x8373b8 1 - +glue 0x46ddf0 GetMovieSubtitles 0x46dbc0 0x8373b8 1 - +glue 0x46ddf0 GetNumAddOns 0x46d420 0x8373b8 1 0x48e350 +glue 0x46ddf0 GetSavedAccountName 0x46cdd0 0x8373b8 1 - +glue 0x46ddf0 GetScreenHeight 0x46dd80 0x8373b8 1 0x48b4d0 +glue 0x46ddf0 GetScreenWidth 0x46dd30 0x8373b8 1 0x48b480 +glue 0x46ddf0 GetScriptMemory 0x46da10 0x8373b8 1 - +glue 0x46ddf0 GetServerName 0x46d280 0x8373b8 3 - +glue 0x46ddf0 HideCursor 0x46db70 0x8373b8 0 - +glue 0x46ddf0 IsAddonVersionCheckEnabled 0x46d9b0 0x8373b8 1 - +glue 0x46ddf0 IsConnectedToServer 0x46d380 0x8373b8 1 - +glue 0x46ddf0 LaunchAddOnURL 0x46d5e0 0x8373b8 0 - +glue 0x46ddf0 LaunchURL 0x46cf60 0x8373b8 0 - +glue 0x46ddf0 PINEntered 0x46d1c0 0x8373b8 0 - +glue 0x46ddf0 PatchDownloadApply 0x46d410 0x8373b8 0 - +glue 0x46ddf0 PatchDownloadCancel 0x46d400 0x8373b8 0 - +glue 0x46ddf0 PatchDownloadProgress 0x46d3e0 0x8373b8 1 - +glue 0x46ddf0 PlayCreditsMusic 0x46cef0 0x8373b8 0 - +glue 0x46ddf0 PlayGlueMusic 0x46ceb0 0x8373b8 0 - +glue 0x46ddf0 QuitGame 0x46cea0 0x8373b8 0 - +glue 0x46ddf0 ResetAddOns 0x46d9a0 0x8373b8 0 - +glue 0x46ddf0 SaveAddOns 0x46d990 0x8373b8 0 - +glue 0x46ddf0 ScanningAccepted 0x46d0b0 0x8373b8 1 - +glue 0x46ddf0 Screenshot 0x46d3d0 0x8373b8 0 0x4893d0 +glue 0x46ddf0 SetAddonVersionCheck 0x46d9e0 0x8373b8 0 - +glue 0x46ddf0 SetCurrentScreen 0x46ce60 0x8373b8 0 - +glue 0x46ddf0 SetMovieSubtitles 0x46db80 0x8373b8 0 - +glue 0x46ddf0 SetSavedAccountName 0x46cdf0 0x8373b8 0 - +glue 0x46ddf0 SetScriptMemory 0x46da50 0x8373b8 0 - +glue 0x46ddf0 ShowContestNotice 0x46d0f0 0x8373b8 1 - +glue 0x46ddf0 ShowCursor 0x46db60 0x8373b8 0 - +glue 0x46ddf0 ShowEULANotice 0x46d010 0x8373b8 1 - +glue 0x46ddf0 ShowScanningNotice 0x46d080 0x8373b8 1 - +glue 0x46ddf0 ShowTOSNotice 0x46cfa0 0x8373b8 1 - +glue 0x46ddf0 StatusDialogClick 0x46d270 0x8373b8 0 - +glue 0x46ddf0 StopGlueMusic 0x46cf00 0x8373b8 0 - +glue 0x46ddf0 SurveyNotificationDone 0x46ddd0 0x8373b8 1 - +glue 0x46ddf0 TOSAccepted 0x46cfd0 0x8373b8 1 - +glue 0x4736c0 DeleteCharacter 0x4734c0 0x838578 0 - +glue 0x4736c0 GetCharacterInfo 0x4732a0 0x838578 8 - +glue 0x4736c0 GetCharacterListUpdate 0x473270 0x838578 0 - +glue 0x4736c0 GetCharacterSelectFacing 0x473650 0x838578 1 - +glue 0x4736c0 GetNumCharacters 0x473280 0x838578 1 - +glue 0x4736c0 RenameCharacter 0x473520 0x838578 0|1 - +glue 0x4736c0 SelectCharacter 0x473470 0x838578 0 - +glue 0x4736c0 SetCharSelectBackground 0x473230 0x838578 0 - +glue 0x4736c0 SetCharSelectModelFrame 0x4731c0 0x838578 0 - +glue 0x4736c0 SetCharacterSelectFacing 0x473670 0x838578 1 - +glue 0x4736c0 UpdateSelectionCustomizationScene 0x473640 0x838578 0 - +glue 0x471a20 CreateCharacter 0x4719e0 0x8380f8 0 - +glue 0x471a20 CycleCharCustomization 0x471890 0x8380f8 0 - +glue 0x471a20 GetAvailableRaces 0x471520 0x8380f8 ? - +glue 0x471a20 GetCharacterCreateFacing 0x471910 0x8380f8 1 - +glue 0x471a20 GetClassesForRace 0x471560 0x8380f8 ? - +glue 0x471a20 GetFacialHairCustomization 0x471620 0x8380f8 1 - +glue 0x471a20 GetFactionForRace 0x471450 0x8380f8 2 - +glue 0x471a20 GetHairCustomization 0x4715d0 0x8380f8 1 - +glue 0x471a20 GetNameForRace 0x4713f0 0x8380f8 2 - +glue 0x471a20 GetRandomName 0x471980 0x8380f8 1 - +glue 0x471a20 GetSelectedClass 0x4716e0 0x8380f8 2 - +glue 0x471a20 GetSelectedRace 0x471680 0x8380f8 1 - +glue 0x471a20 GetSelectedSex 0x4716b0 0x8380f8 1 - +glue 0x471a20 HasCharCustomization 0x471820 0x8380f8 1 - +glue 0x471a20 RandomizeCharCustomization 0x471900 0x8380f8 0 - +glue 0x471a20 ResetCharCustomize 0x4713e0 0x8380f8 0 - +glue 0x471a20 SetCharCustomizeBackground 0x4713a0 0x8380f8 0 - +glue 0x471a20 SetCharCustomizeFrame 0x471330 0x8380f8 0 - +glue 0x471a20 SetCharacterCreateFacing 0x471930 0x8380f8 1 - +glue 0x471a20 SetSelectedClass 0x4717c0 0x8380f8 0 - +glue 0x471a20 SetSelectedRace 0x471740 0x8380f8 0 - +glue 0x471a20 SetSelectedSex 0x471780 0x8380f8 0 - +glue 0x471a20 UpdateCustomizationBackground 0x471800 0x8380f8 0 - +glue 0x471a20 UpdateCustomizationScene 0x471810 0x8380f8 0 - +glue 0x46f480 CancelRealmListQuery 0x46ed10 0x837ea0 0 - +glue 0x46f480 ChangeRealm 0x46f0e0 0x837ea0 0 - +glue 0x46f480 GetNumRealms 0x46ed30 0x837ea0 1 - +glue 0x46f480 GetRealmCategories 0x46f1f0 0x837ea0 ? - +glue 0x46f480 GetRealmInfo 0x46ee40 0x837ea0 8 - +glue 0x46f480 GetSelectedCategory 0x46f400 0x837ea0 1 - +glue 0x46f480 RealmListDialogCancelled 0x46ed20 0x837ea0 0 - +glue 0x46f480 RequestRealmList 0x46ecf0 0x837ea0 0 - +glue 0x46f480 SetPreferredInfo 0x46f2c0 0x837ea0 0 - +glue 0x46f480 SortRealms 0x46f330 0x837ea0 0 - +shared 0x4587f0 PlayMusic 0x458720 0x835a50 0 - +shared 0x4587f0 PlaySound 0x4586d0 0x835a50 0 - +shared 0x4587f0 PlaySoundFile 0x458780 0x835a50 1 - +shared 0x4587f0 StopMusic 0x458770 0x835a50 0 - +base 0x7039e0 foreach 0x7fb590 0x822d40 0|1 - +base 0x7039e0 foreachi 0x7fb4e0 0x822d40 0|1 - +base 0x7039e0 getn 0x7fb630 0x822d40 1 - +base 0x7039e0 tinsert 0x7fb6b0 0x822d40 0 - +base 0x7039e0 tremove 0x7fb770 0x822d40 0|1 - +base 0x7039e0 sort 0x7fb920 0x822d40 0 - +base 0x7039e0 abs 0x7fade0 0x822c60 1 - +base 0x7039e0 acos lua - ? - +base 0x7039e0 asin lua - ? - +base 0x7039e0 atan lua - ? - +base 0x7039e0 atan2 lua - ? - +base 0x7039e0 ceil 0x7faf70 0x822c60 1 - +base 0x7039e0 cos lua - ? - +base 0x7039e0 deg 0x7fb140 0x822c60 1 - +base 0x7039e0 exp 0x7fb100 0x822c60 1 - +base 0x7039e0 floor 0x7fafa0 0x822c60 1 - +base 0x7039e0 frexp 0x7fb1a0 0x822c60 2 - +base 0x7039e0 ldexp 0x7fb1f0 0x822c60 1 - +base 0x7039e0 log 0x7fb0a0 0x822c60 1 - +base 0x7039e0 log10 0x7fb0d0 0x822c60 1 - +base 0x7039e0 max 0x7fb2a0 0x822c60 1 - +base 0x7039e0 min 0x7fb230 0x822c60 1 - +base 0x7039e0 mod 0x7fafd0 0x822c60 1 - +base 0x7039e0 rad 0x7fb170 0x822c60 1 - +base 0x7039e0 random 0x7fb310 0x822c60 1 - +base 0x7039e0 randomseed 0x7fb450 0x822c60 0 - +base 0x7039e0 sin lua - ? - +base 0x7039e0 sqrt 0x7fb020 0x822c60 1 - +base 0x7039e0 tan lua - ? - +base 0x7039e0 strbyte 0x7fc210 0x822d88 0|1 - +base 0x7039e0 strchar 0x7fc280 0x822d88 1 - +base 0x7039e0 strfind 0x7fc3b0 0x822d88 ? - +base 0x7039e0 format 0x7fd3d0 0x822d88 1 - +base 0x7039e0 gsub 0x7fd0e0 0x822d88 2 - +base 0x7039e0 strlen 0x7fbcf0 0x822d88 1 - +base 0x7039e0 strlower 0x7fbdf0 0x822d88 1 - +base 0x7039e0 strrep 0x7fc1a0 0x822d88 1 - +base 0x7039e0 strsub 0x7fbd30 0x822d88 1 - +base 0x7039e0 strupper 0x7fc0c0 0x822d88 1 - +base 0x7039e0 __pow 0x7fb050 - 1 - diff --git a/reference/README.md b/reference/README.md index 61273c26..657a399e 100644 --- a/reference/README.md +++ b/reference/README.md @@ -81,3 +81,24 @@ Reading it: `crates/benilla-app/src/ui_script/verb_event_gate.rs` is the gate. F verb benilla registers, the module that registers the verb fires the event, or the pair is declared there: `ELSEWHERE` (a feed fires it on the state the verb changes, and the named file is checked too) or `GAP` (nothing does, with the reason). + +## `1.12-glue-events.tsv` and `1.12-glue-surface.tsv` + +The glue screens' own FrameScript state. The glue builder (`0x46a7b0`) gives Lua a different +surface and a different event table from the in-game state's, and GlueXML runs against those. + +`1.12-glue-events.tsv` is the 26-entry event table (`0xb41e70`) the builder loads in place of the +in-game one (`0x703d90` at `0x46a88f`), so a glue event's id names a different event in-game. It is +vendored from the client's bytes, as `1.12-event-catalog.tsv` is. + +`1.12-glue-surface.tsv` is every function in the glue state's `_G` before GlueXML loads: the base +set both states share (`0x7039e0`), the three registrars the in-game state calls too, and the four +glue-only tables, with each glue verb's in-game namesake and the six whose shape differs from it. +Regenerating: `scripts/gen-reference-glue-surface.py`, from the shapes and globals tables, so it +reproduces the committed file from any clone. + +Reading them: `crates/benilla-ui/src/script/tests/glue_surface.rs` holds the surface table to the +shapes table, row for row by `table_va`, and the glue VM (`UiScript::new_glue`) to the surface table: +every function it exposes is a glue-state one, and every glue-state function it lacks is on the +file's `NOT_YET` list, which only shrinks. The same file holds the VM's event table to +`1.12-glue-events.tsv`. diff --git a/scripts/gen-reference-glue-surface.py b/scripts/gen-reference-glue-surface.py new file mode 100755 index 00000000..292b4f42 --- /dev/null +++ b/scripts/gen-reference-glue-surface.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Regenerate `reference/1.12-glue-surface.tsv`: every Lua function the glue screens' VM has in `_G`. + + scripts/gen-reference-glue-surface.py [--shapes FILE] [--globals FILE] [--out FILE] + +The inputs are vendored under `reference/`, so this runs from any clone and reproduces the +committed table byte for byte. + +The glue builder (`0x46a7b0`) starts from the bare base set `0x7039e0`: a rebuild resets the Lua +state (`0x703b80` -> `0x7039e0`, at `0x46a87b`), and the first build (`0x46a505`, passing 0) skips +that reset (`je` at `0x46a879`) and takes the state the startup init left (`0x402aee`). It then +calls the glue registrar `0x46abb0`, which installs seven registrar tables and nothing else: + + 0x51bb20 table 0x8503f4 6 shared: in-game, `0x51bb60` calls it before its own table + 0x706350 table 0x872e74 4 shared: in-game at `0x490251`; then the 17 widget method tables + 0x46ddf0 table 0x8373b8 60 glue core + 0x4736c0 table 0x838578 11 character select + 0x471a20 table 0x8380f8 24 character create + 0x46f480 table 0x837ea0 10 realm list + 0x4587f0 table 0x835a50 4 shared: in-game at `0x4902a6` + +Each registrar is a `0x704120` loop over its table with the bound read from its own `cmp`, the +counts above. `0x7039e0`, FrameScript's Lua init, is the base set both states share: the 36-entry +base array `0x811e28` (`1.12-shapes.tsv`'s `baselib` table), the `string`, `table`, `math` and +`bit` libraries (fields, not globals, so not rows here), `math`'s global `__pow` ([`POW`]), and +the compat chunk `0x8722e8` run at `0x703b26`. The chunk's 39 function assignments are +[`COMPAT`]: 32 bind a library function under a bare name (`tinsert = table.insert`), each row +taking that binding's `fn` and table, and 7 wrap one in a degree-converting Lua closure (`sin`, +`acos`, ...), `fn` `lua`. Its one other assignment, `PI = math.pi`, is a number. Every engine +function `1.12-globals.tsv` has that no `global` or `baselib` row of the shapes table names must be +one of these, so a chunk the list missed fails the generator. + +Columns: + scope base (0x7039e0) | shared (a registrar the in-game state calls too) | glue. + registrar the function that installs the row. + fn, table_va the binding and its table; a compat alias carries the library binding it names, + or fn `lua` and table `-` for a closure; `__pow` has no table. + arity the shapes table's return arity, `?` for a closure. + world_fn a glue row's in-game namesake from any other `global` table, else `-`. The in-game + state never sees a glue row, but the name is shared: a glue verb benilla builds is a + different binding from the in-game one of the same name. + note `shape-differs` where both shapes are exact and the argument count or the return + arity differs between the glue binding and its in-game namesake. +""" +import argparse +import os +import sys + +# (registrar, table_va, rows, scope), in the order `0x46abb0` calls them. +REGISTRARS = [ + (0x51BB20, 0x8503F4, 6, "shared"), + (0x706350, 0x872E74, 4, "shared"), + (0x46DDF0, 0x8373B8, 60, "glue"), + (0x4736C0, 0x838578, 11, "glue"), + (0x471A20, 0x8380F8, 24, "glue"), + (0x46F480, 0x837EA0, 10, "glue"), + (0x4587F0, 0x835A50, 4, "shared"), +] +BASE_INIT = 0x7039E0 +BASE_TABLE = 0x811E28 +BASE_ROWS = 36 +# The libraries `0x7039e0` opens, by the name the compat chunk reaches them under. +LIBRARIES = {"table": 0x822D40, "math": 0x822C60, "string": 0x822D88, "bit": 0x822C18} +# The compat chunk's function assignments, in its order: (global, "library.field"), or None for a +# Lua closure that converts degrees around the `math` function of the same name. +COMPAT = [ + ("foreach", "table.foreach"), ("foreachi", "table.foreachi"), ("getn", "table.getn"), + ("tinsert", "table.insert"), ("tremove", "table.remove"), ("sort", "table.sort"), + ("abs", "math.abs"), ("acos", None), ("asin", None), ("atan", None), ("atan2", None), + ("ceil", "math.ceil"), ("cos", None), ("deg", "math.deg"), ("exp", "math.exp"), + ("floor", "math.floor"), ("frexp", "math.frexp"), ("ldexp", "math.ldexp"), ("log", "math.log"), + ("log10", "math.log10"), ("max", "math.max"), ("min", "math.min"), ("mod", "math.mod"), + ("rad", "math.rad"), ("random", "math.random"), ("randomseed", "math.randomseed"), + ("sin", None), ("sqrt", "math.sqrt"), ("tan", None), + ("strbyte", "string.byte"), ("strchar", "string.char"), ("strfind", "string.find"), + ("format", "string.format"), ("gsub", "string.gsub"), ("strlen", "string.len"), + ("strlower", "string.lower"), ("strrep", "string.rep"), ("strsub", "string.sub"), + ("strupper", "string.upper"), +] +# `luaopen_math` 0x7fb470 also sets the global `__pow` to 0x7fb050 (`0x7fb4af`-`0x7fb4d2`), which +# Lua 5.0 calls for `^`. +POW = ("__pow", 0x7FB050) + +REFERENCE = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "reference")) + + +def rows(path, header): + if not os.path.exists(path): + sys.exit(f"no table at {path}") + for line in open(path, encoding="utf-8"): + if line.startswith("#"): + continue + f = line.rstrip("\n").split("\t") + if f and f[0] == header: + continue + yield f + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--shapes", default=os.path.join(REFERENCE, "1.12-shapes.tsv")) + ap.add_argument("--globals", default=os.path.join(REFERENCE, "1.12-globals.tsv")) + ap.add_argument("--out", default=os.path.join(REFERENCE, "1.12-glue-surface.tsv")) + a = ap.parse_args() + + # name, fn, pair_va, table_va, table_kind, argc, argc_conf, arity, arity_conf, ... + shapes = [f for f in rows(a.shapes, "name") if len(f) >= 9] + by_table = {} + for f in shapes: + by_table.setdefault(int(f[3], 16), []).append(f) + + out = [] + + def take(table_va, n, registrar, scope): + got = by_table.get(table_va, []) + if len(got) != n: + sys.exit(f"table 0x{table_va:x}: the registrar loops {n} rows, the shapes table has {len(got)}") + for f in got: + out.append({"f": f, "scope": scope, "registrar": registrar}) + + take(BASE_TABLE, BASE_ROWS, BASE_INIT, "base") + for registrar, table_va, n, scope in REGISTRARS: + take(table_va, n, registrar, scope) + + # The base set's Lua side: the compat chunk's aliases and `__pow`, each a binding of a library + # the shapes table carries, or a Lua closure. + fields = {} + for lib, table_va in LIBRARIES.items(): + for f in by_table.get(table_va, []): + fields[f"{lib}.{f[0]}"] = f + aliases = [] + for name, target in COMPAT: + if target is None: + aliases.append((name, "lua", "-", "?")) + continue + f = fields.get(target) + if f is None: + sys.exit(f"compat alias {name} = {target}: no such library binding in {a.shapes}") + aliases.append((name, f[1], f[3], f[7])) + aliases.append((POW[0], f"0x{POW[1]:x}", "-", "1")) + + # A 1.12 engine function no registrar row names is one of the aliases, or the list is short. + registered = {f[0] for f in shapes if f[4] in ("global", "baselib")} + alias_names = {x[0] for x in aliases} + for f in rows(a.globals, "name"): + if len(f) >= 3 and f[1] == "function" and f[2] in ("engine", "lua"): + if f[0] not in registered and f[0] not in alias_names: + sys.exit(f"{f[0]} is a 1.12 engine function no registrar and no compat alias names") + + # In-game namesakes: rows of every other `global` table, which only the in-game state installs. + ours = {t for _, t, _, _ in REGISTRARS} + world = {} + for f in shapes: + if f[4] == "global" and int(f[3], 16) not in ours: + world.setdefault(f[0], []).append(f) + + lines = [] + differs = 0 + for r in out: + f = r["f"] + name, fn, table_va, argc, argc_conf, arity, arity_conf = f[0], f[1], f[3], f[5], f[6], f[7], f[8] + world_fn, note = "-", "" + if r["scope"] == "glue" and name in world: + w = world[name] + world_fn = "|".join(x[1] for x in w) + notes = [] + for x in w: + if argc_conf == "exact" and x[6] == "exact" and argc != x[5]: + notes.append(f"argc {argc} here, {x[5]} in-game ({x[1]})") + if arity_conf == "exact" and x[8] == "exact" and arity != x[7]: + notes.append(f"arity {arity} here, {x[7]} in-game ({x[1]})") + if notes: + note = "shape-differs: " + "; ".join(notes) + differs += 1 + lines.append((r["scope"], f"0x{r['registrar']:x}", name, fn, table_va, arity, world_fn, note)) + for name, fn, table_va, arity in aliases: + lines.append(("base", f"0x{BASE_INIT:x}", name, fn, table_va, arity, "-", "")) + + names = [l[2] for l in lines] + if len(names) != len(set(names)): + sys.exit("a name appears twice in the glue state; the table is keyed by name") + + count = {s: sum(1 for l in lines if l[0] == s) for s in ("base", "shared", "glue")} + path = os.path.normpath(a.out) + with open(path, "w", encoding="utf-8") as fh: + fh.write( + "# The glue screens' Lua surface in the 1.12.1 client (build 5875): every function in `_G`\n" + "# once the glue builder 0x46a7b0 has installed its registrars, before GlueXML loads.\n" + "# Generated by scripts/gen-reference-glue-surface.py from reference/1.12-shapes.tsv and\n" + "# reference/1.12-globals.tsv; the generator's header carries the registrars and the method.\n" + "#\n" + "# scope base = FrameScript's Lua init 0x7039e0, which both states run; shared = a\n" + "# registrar the in-game state calls too (0x51bb20, 0x706350, 0x4587f0); glue =\n" + "# a glue-only registrar table (0x46ddf0, 0x4736c0, 0x471a20, 0x46f480).\n" + "# fn, table_va, arity as 1.12-shapes.tsv has them. A base alias the compat chunk\n" + "# 0x8722e8 defines carries the library binding it names (`tinsert` is table\n" + "# 0x822d40's `insert`), or fn `lua` for a degree-converting closure; math's\n" + "# global `__pow` (0x7fb050) has no table.\n" + "# world_fn a glue row's in-game namesake, a different binding; `-` if none.\n" + "# note shape-differs = both shapes exact, and the glue binding takes or returns a\n" + "# different count than its in-game namesake.\n" + f"# {len(lines)} functions: {count['base']} base, {count['shared']} shared, {count['glue']} glue;" + f" {differs} glue rows differ in shape from their in-game namesake.\n" + "scope\tregistrar\tname\tfn\ttable_va\tarity\tworld_fn\tnote\n" + ) + for l in lines: + fh.write("\t".join(l) + "\n") + print(f"wrote {path}: {len(lines)} functions, {differs} shape differences") + + +if __name__ == "__main__": + main()