From 48afcbce0a65c3bd2107bffc4666a38393ccd479 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:30:40 +0700 Subject: [PATCH] The host's clock, zone texts, game clock and error-handler slot leave _G, and the getters over them answer the reference's shapes (#580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host's state leaves `_G`: the eleven `__benilla_*` globals, which any addon could read and overwrite, move into the host's model (and, for the error handler, the Lua registry, as the reference keeps it), and the getters over them are Rust bindings answering the reference's shapes. - `GetTime` returns one number (`0x515ea0`), and `GetGameTime` returns hour then minute (`0x515ee0`). The tick advances the model clock, and every cooldown and buff reader reads it. - `GetZoneText`, `GetRealZoneText`, `GetSubZoneText` and `GetMinimapZoneText` always answer a string, `""` when unset (`0x48a0a0`-`0x48a100`). `GetZonePVPInfo` answers three values (`0x48d540`). - `seterrorhandler`/`geterrorhandler` work over a registry slot that starts empty, as the reference's does (`0x7039e0`, `0x702950`, `0x702900`). A non-function raises `Usage: seterrorhandler(errfunc)`. The engine dispatches caught errors through the slot, not through the global (`0x703b40`). The stock `BasicControls.xml` installs `_ERRORMESSAGE` before any addon, so play is unchanged. - The engine's surface allowlist now holds only `_G`. `scripts/api-coverage.sh` no longer excuses `__` names. Verified: - New tests show each of the eleven names absent from `_G` and that writing it changes nothing. On main, `__benilla_now = 0` rewound `GetTime` and every buff and cooldown. - The getter shapes and the error-handler slot are pinned. The surface gate fails when a name is re-added. - `scripts/gates.sh` and `scripts/smoke.sh` are green. - A live run read the clock, zone texts, PvP info and game time, then showed that writing the old names changes nothing. Built on macOS; no platform seam touched. Fixes #576 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/benilla-app/src/area.rs | 29 +- crates/benilla-app/src/capture/fixtures.rs | 4 +- crates/benilla-app/src/capture/scenarios.rs | 4 +- crates/benilla-app/src/minimap/mod.rs | 14 +- .../src/ui_script/minimap_tests.rs | 15 +- .../benilla-app/src/ui_script/shape_gate.rs | 6 +- .../src/ui_script/zone_text_tests.rs | 90 +++--- crates/benilla-ui/src/script/action.rs | 2 +- .../benilla-ui/src/script/aura/player_buff.rs | 2 +- crates/benilla-ui/src/script/clock.rs | 58 ++++ crates/benilla-ui/src/script/container.rs | 2 +- crates/benilla-ui/src/script/mod.rs | 38 +-- crates/benilla-ui/src/script/model.rs | 10 + crates/benilla-ui/src/script/party.rs | 50 ++-- crates/benilla-ui/src/script/pet.rs | 2 +- crates/benilla-ui/src/script/shapeshift.rs | 2 +- crates/benilla-ui/src/script/spellbook.rs | 2 +- crates/benilla-ui/src/script/stdlib.rs | 126 +++------ .../benilla-ui/src/script/tests/host_state.rs | 258 ++++++++++++++++++ crates/benilla-ui/src/script/tests/mod.rs | 7 +- .../src/script/tests/reference_surface.rs | 15 - crates/benilla-ui/src/script/tick.rs | 28 +- crates/benilla-ui/src/script/tooltip/mod.rs | 2 +- crates/benilla-ui/src/script/tooltip_spell.rs | 5 +- crates/benilla-ui/src/script/zone_text.rs | 82 ++++++ scripts/api-coverage.sh | 6 +- 26 files changed, 596 insertions(+), 263 deletions(-) create mode 100644 crates/benilla-ui/src/script/clock.rs create mode 100644 crates/benilla-ui/src/script/tests/host_state.rs create mode 100644 crates/benilla-ui/src/script/zone_text.rs diff --git a/crates/benilla-app/src/area.rs b/crates/benilla-app/src/area.rs index 2f49d4f6..f511943a 100644 --- a/crates/benilla-app/src/area.rs +++ b/crates/benilla-app/src/area.rs @@ -99,7 +99,7 @@ fn elect_event(cache: &ZoneCache, next: &ZoneSignal) -> Option<&'static str> { None } -/// Resolves the texts and PvP info, writes the host globals, then fires the elected zone event and, +/// Resolves the texts and PvP info, pushes the zone caches, then fires the elected zone event and, /// independently, `MINIMAP_ZONE_CHANGED`. fn feed_zone_events( script: Option>, @@ -213,21 +213,20 @@ fn feed_zone_events( }; Some((ty, f.catalog().faction_group_name(zone_mask).unwrap_or(""))) }); - let (pvp_type, pvp_faction) = pvp.unwrap_or(("", "")); + let pvp_type = pvp.map_or("", |(ty, _)| ty); - let globals = script.lua().globals(); - let pushed = globals - .set("__benilla_zone_name", signal.zone_text.clone()) - .and_then(|()| globals.set("__benilla_real_zone_name", real_zone_text)) - .and_then(|()| globals.set("__benilla_subzone_name", signal.subzone_text.clone())) - .and_then(|()| globals.set("__benilla_zone_text", minimap_text.clone())) - .and_then(|()| globals.set("__benilla_pvp_type", pvp_type)) - .and_then(|()| globals.set("__benilla_pvp_faction", pvp_faction)) - .and_then(|()| globals.set("__benilla_pvp_arena", is_arena)); - if let Err(e) = pushed { - warn!("area: zone host globals: {e}"); - return; - } + script.set_zone_texts(benilla_ui::script::ZoneTexts { + zone: signal.zone_text.clone(), + real_zone: real_zone_text, + subzone: signal.subzone_text.clone(), + minimap: minimap_text.clone(), + pvp_type: pvp.map(|(ty, _)| ty.to_string()), + pvp_faction: pvp + .map(|(_, faction)| faction) + .filter(|f| !f.is_empty()) + .map(str::to_string), + is_arena, + }); if let Some(event) = event { script.fire_event(event, vec![]); diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs index 88563974..1a668ca7 100644 --- a/crates/benilla-app/src/capture/fixtures.rs +++ b/crates/benilla-app/src/capture/fixtures.rs @@ -1360,9 +1360,7 @@ fn seed_cooldown_filmstrip( ) { // The clock first: `set_container` stores each triple against it, and // `GetContainerItemCooldown`'s expiry guard reads it. - if let Err(e) = script.run(&format!("__benilla_now = {COOLDOWN_NOW_S}")) { - warn!("capture: ui-cooldown failed to pin the session clock: {e}"); - } + script.set_now(COOLDOWN_NOW_S); const DISP_STONE: u32 = 6418; let texture = icons .and_then(|i| i.catalog.get(DISP_STONE)) diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index e8b2290b..08ad000b 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -41,8 +41,8 @@ pub(super) enum UiFixture { Bag, /// The cooldown sweep at sixteen phases in one still: each backpack slot's /// `GetContainerItemCooldown` sits at its own fraction of one long cooldown, in reading order - /// (slot 1 renders top-left, `ContainerFrame_GenerateFrame` numbering backwards). The VM clock - /// `__benilla_now` is parked at a large value, so the settle's seconds are ~5e-4 of a phase. + /// (slot 1 renders top-left, `ContainerFrame_GenerateFrame` numbering backwards). The VM's + /// `GetTime` clock is parked at a large value, so the settle's seconds are ~5e-4 of a phase. Cooldown, /// The cooldown filmstrip with the pet bar's autocast shine beside it: `UI-AutoCastButton.m2` /// is four additive emitters and no batch, sharing the tile atlas, so this asks whether its diff --git a/crates/benilla-app/src/minimap/mod.rs b/crates/benilla-app/src/minimap/mod.rs index c25f66c8..10f72ccf 100644 --- a/crates/benilla-app/src/minimap/mod.rs +++ b/crates/benilla-app/src/minimap/mod.rs @@ -842,14 +842,14 @@ fn feed_minimap_player_facing( script.set_minimap_player_facing(player.facing().rem_euclid(std::f32::consts::TAU)); } -/// Push the game clock into `GetGameTime()`'s backing globals when the minute ticks, the API's own -/// resolution. Before the first `SMSG_LOGIN_SETTIMESPEED` they hold 0:00. +/// Push the game clock `GetGameTime()` answers when the minute ticks, the API's own resolution. +/// Before the first `SMSG_LOGIN_SETTIMESPEED` it reads 0:00. fn feed_game_time( script: Option>, time: Res, mut last: Local>>, ) { - let Some(script) = script else { return }; + let Some(mut script) = script else { return }; let last = last.get(&script); let Some(gt) = time.0 else { return }; let minute = gt.minute_of_day(); @@ -857,13 +857,7 @@ fn feed_game_time( return; } *last = Some(minute); - let globals = script.lua().globals(); - let pushed = globals - .set("__benilla_game_hour", minute / 60) - .and_then(|()| globals.set("__benilla_game_minute", minute % 60)); - if let Err(e) = pushed { - warn!("minimap: game-time globals: {e}"); - } + script.set_game_time(minute / 60, minute % 60); } /// The app half of the `` widget. The zone label feed is `crate::area`'s: the reference diff --git a/crates/benilla-app/src/ui_script/minimap_tests.rs b/crates/benilla-app/src/ui_script/minimap_tests.rs index 95c60b32..47cdecb2 100644 --- a/crates/benilla-app/src/ui_script/minimap_tests.rs +++ b/crates/benilla-app/src/ui_script/minimap_tests.rs @@ -120,10 +120,7 @@ fn game_time_session(hour: u32, minute: u32) -> UiScript { s.run("function GetMinimapZoneText() return '' end") .unwrap(); s.run("function PlaySound() end").unwrap(); - s.run(&format!( - "__benilla_game_hour = {hour}; __benilla_game_minute = {minute}" - )) - .unwrap(); + s.set_game_time(hour, minute); // `GameTime.lua` reads `TwentyFourHourTime`, which `LocalizeFrames` sets; the reference calls // that on `VARIABLES_LOADED` (`UIParent.lua:231-232`), and this session has no UIParent. load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); @@ -162,8 +159,7 @@ fn game_time_frame_slides_the_sun_moon_window_on_the_game_clock() { let day = (0.0, 50.0 / 128.0, 0.0, 50.0 / 64.0); assert_eq!(tod_window(&s), day, "mid-morning shows the sun half"); - s.run("__benilla_game_hour = 21; __benilla_game_minute = 0") - .unwrap(); + s.set_game_time(21, 0); s.tick(0.016); assert_eq!( tod_window(&s), @@ -171,11 +167,10 @@ fn game_time_frame_slides_the_sun_moon_window_on_the_game_clock() { "9:00 PM sharp is the moon half" ); - s.run("__benilla_game_hour = 5; __benilla_game_minute = 29") - .unwrap(); + s.set_game_time(5, 29); s.tick(0.016); assert_eq!(tod_window(&s).0, 0.5, "5:29 AM is still the moon"); - s.run("__benilla_game_minute = 30").unwrap(); + s.set_game_time(5, 30); s.tick(0.016); assert_eq!(tod_window(&s), day, "5:30 AM sharp flips to the sun"); } @@ -209,7 +204,7 @@ fn hovering_the_indicator_shows_and_live_updates_the_game_time_tooltip() { assert_eq!(text(&s), "21:07"); // While owned, `GameTimeFrame_Update` refreshes the tooltip (`GameTime.lua:20-22`). - s.run("__benilla_game_minute = 8").unwrap(); + s.set_game_time(21, 8); s.tick(0.016); assert_eq!(text(&s), "21:08", "the owned tooltip follows the clock"); diff --git a/crates/benilla-app/src/ui_script/shape_gate.rs b/crates/benilla-app/src/ui_script/shape_gate.rs index c21f56ec..a8b4f1fc 100644 --- a/crates/benilla-app/src/ui_script/shape_gate.rs +++ b/crates/benilla-app/src/ui_script/shape_gate.rs @@ -218,8 +218,10 @@ const BASELIB_PROBES: &[(&str, &str)] = &[ ("rawequal", "1, 1"), ("rawget", "{}, 1"), ("rawset", "{}, 1, 1"), - // These write, but namespaced or self-restoring, so every later probe sees the same VM. - ("seterrorhandler", "geterrorhandler()"), + // These write, but namespaced or inert, so every later probe sees the same VM. The bare VM's + // handler slot is empty and takes a function alone (`0x702900`), so the probe hands it a no-op, + // which nothing here dispatches to. + ("seterrorhandler", "function() end"), ("setglobal", "\"BenillaShapeGateProbe\", 1"), ("setmetatable", "{}, nil"), ("tonumber", "\"1\""), diff --git a/crates/benilla-app/src/ui_script/zone_text_tests.rs b/crates/benilla-app/src/ui_script/zone_text_tests.rs index 09624607..84cec1fe 100644 --- a/crates/benilla-app/src/ui_script/zone_text_tests.rs +++ b/crates/benilla-app/src/ui_script/zone_text_tests.rs @@ -1,8 +1,8 @@ -//! Stock `ZoneText.xml`, driven by hand as `crate::area::feed_zone_events` drives it: host globals -//! written, the event fired, the clock ticked. A plain `ZONE_CHANGED` re-caches the zone name +//! Stock `ZoneText.xml`, driven by hand as `crate::area::feed_zone_events` drives it: zone caches +//! pushed, the event fired, the clock ticked. A plain `ZONE_CHANGED` re-caches the zone name //! silently (`ZoneText.xml:92`), so a later `ZONE_CHANGED_NEW_AREA` on that name never splashes. -use benilla_ui::script::UiScript; +use benilla_ui::script::{UiScript, ZoneTexts}; use super::test_ui::load_ui as load_xml; @@ -16,14 +16,23 @@ fn text_of(s: &UiScript, fontstring: &str) -> String { .unwrap() } -/// The host globals the app writes on an area change, in long brackets so an apostrophe survives. -fn set_area(s: &UiScript, zone: &str, sub: &str, pvp: &str, faction: &str) { - s.run(&format!( - "__benilla_zone_name = [[{zone}]]; __benilla_subzone_name = [[{sub}]]; \ - __benilla_pvp_type = [[{pvp}]]; __benilla_pvp_faction = [[{faction}]]; \ - __benilla_pvp_arena = false" - )) - .unwrap(); +/// The zone caches the app pushes on an area change: the minimap line is the subzone, else the +/// zone; an empty PvP word or faction is nil. +fn area(zone: &str, sub: &str, pvp: &str, faction: &str) -> ZoneTexts { + let some = |t: &str| (!t.is_empty()).then(|| t.to_string()); + ZoneTexts { + zone: zone.into(), + real_zone: zone.into(), + subzone: sub.into(), + minimap: if sub.is_empty() { zone } else { sub }.into(), + pvp_type: some(pvp), + pvp_faction: some(faction), + is_arena: false, + } +} + +fn set_area(s: &mut UiScript, zone: &str, sub: &str, pvp: &str, faction: &str) { + s.set_zone_texts(area(zone, sub, pvp, faction)); } fn harness() -> UiScript { @@ -56,7 +65,7 @@ fn new_area_splashes_zone_pvp_and_subzone_then_fades_out() { assert!(!visible(&s, "ZoneTextFrame")); assert!(!visible(&s, "SubZoneTextFrame")); - set_area(&s, "Westfall", "", "friendly", "Alliance"); + set_area(&mut s, "Westfall", "", "friendly", "Alliance"); s.fire_event("ZONE_CHANGED_NEW_AREA", vec![]); assert!(visible(&s, "ZoneTextFrame"), "zone splash shows"); assert_eq!(text_of(&s, "ZoneTextString"), "Westfall"); @@ -78,12 +87,12 @@ fn new_area_splashes_zone_pvp_and_subzone_then_fades_out() { fn subzone_hop_shows_only_the_small_line() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = harness(); - set_area(&s, "Elwynn Forest", "", "friendly", "Alliance"); + set_area(&mut s, "Elwynn Forest", "", "friendly", "Alliance"); s.fire_event("ZONE_CHANGED_NEW_AREA", vec![]); s.tick(4.0); // let the login splash finish assert!(!visible(&s, "ZoneTextFrame")); - set_area(&s, "Elwynn Forest", "Goldshire", "friendly", "Alliance"); + set_area(&mut s, "Elwynn Forest", "Goldshire", "friendly", "Alliance"); s.fire_event("ZONE_CHANGED", vec![]); assert!(visible(&s, "SubZoneTextFrame"), "subzone splash shows"); assert!( @@ -97,12 +106,18 @@ fn subzone_hop_shows_only_the_small_line() { fn plain_zone_changed_recaches_silently_so_new_area_wont_resplash() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = harness(); - set_area(&s, "Elwynn Forest", "", "friendly", "Alliance"); + set_area(&mut s, "Elwynn Forest", "", "friendly", "Alliance"); s.fire_event("ZONE_CHANGED_NEW_AREA", vec![]); s.tick(4.0); assert!(!visible(&s, "ZoneTextFrame")); - set_area(&s, "Westfall", "The Jansen Stead", "friendly", "Alliance"); + set_area( + &mut s, + "Westfall", + "The Jansen Stead", + "friendly", + "Alliance", + ); s.fire_event("ZONE_CHANGED", vec![]); assert!( !visible(&s, "ZoneTextFrame"), @@ -114,7 +129,7 @@ fn plain_zone_changed_recaches_silently_so_new_area_wont_resplash() { "NEW_AREA on the already-cached zone text must not re-splash" ); - set_area(&s, "Duskwood", "", "contested", ""); + set_area(&mut s, "Duskwood", "", "contested", ""); s.fire_event("ZONE_CHANGED_NEW_AREA", vec![]); assert!(visible(&s, "ZoneTextFrame")); assert_eq!(text_of(&s, "PVPInfoTextString"), "Contested Territory"); @@ -127,7 +142,7 @@ fn abbey_grounds_subzone_hop_shows_no_territory_line() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = harness(); set_area( - &s, + &mut s, "Elwynn Forest", "Northshire Valley", "friendly", @@ -137,7 +152,7 @@ fn abbey_grounds_subzone_hop_shows_no_territory_line() { s.tick(4.0); // both splashes fully faded set_area( - &s, + &mut s, "Elwynn Forest", "Northshire Abbey", "friendly", @@ -159,7 +174,7 @@ fn abbey_interior_shows_the_room_in_the_small_line_alone() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = harness(); set_area( - &s, + &mut s, "Elwynn Forest", "Northshire Abbey", "friendly", @@ -168,7 +183,7 @@ fn abbey_interior_shows_the_room_in_the_small_line_alone() { s.fire_event("ZONE_CHANGED_NEW_AREA", vec![]); s.tick(4.0); - set_area(&s, "Elwynn Forest", "Main Hall", "friendly", "Alliance"); + set_area(&mut s, "Elwynn Forest", "Main Hall", "friendly", "Alliance"); s.fire_event("ZONE_CHANGED_INDOORS", vec![]); assert!( !visible(&s, "ZoneTextFrame"), @@ -187,11 +202,11 @@ fn abbey_interior_shows_the_room_in_the_small_line_alone() { fn inn_entry_splashes_the_inn_name_with_territory_line() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = harness(); - set_area(&s, "Elwynn Forest", "Goldshire", "friendly", "Alliance"); + set_area(&mut s, "Elwynn Forest", "Goldshire", "friendly", "Alliance"); s.fire_event("ZONE_CHANGED_NEW_AREA", vec![]); s.tick(4.0); - set_area(&s, "Lion's Pride Inn", "", "friendly", "Alliance"); + set_area(&mut s, "Lion's Pride Inn", "", "friendly", "Alliance"); s.fire_event("ZONE_CHANGED_INDOORS", vec![]); assert!(visible(&s, "ZoneTextFrame"), "the inn name splashes big"); assert_eq!(text_of(&s, "ZoneTextString"), "Lion's Pride Inn"); @@ -207,19 +222,19 @@ fn indoor_exit_returns_the_subzone_line_alone() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = harness(); set_area( - &s, + &mut s, "Elwynn Forest", "Northshire Abbey", "friendly", "Alliance", ); s.fire_event("ZONE_CHANGED_NEW_AREA", vec![]); - s.run("__benilla_subzone_name = 'Main Hall'").unwrap(); + set_area(&mut s, "Elwynn Forest", "Main Hall", "friendly", "Alliance"); s.fire_event("ZONE_CHANGED_INDOORS", vec![]); s.tick(4.0); // everything faded set_area( - &s, + &mut s, "Elwynn Forest", "Northshire Abbey", "friendly", @@ -238,11 +253,17 @@ fn indoor_exit_returns_the_subzone_line_alone() { fn room_to_room_hop_splashes_the_room_name_alone() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = harness(); - set_area(&s, "Elwynn Forest", "Main Hall", "friendly", "Alliance"); + set_area(&mut s, "Elwynn Forest", "Main Hall", "friendly", "Alliance"); s.fire_event("ZONE_CHANGED_INDOORS", vec![]); s.tick(4.0); - set_area(&s, "Elwynn Forest", "Library Wing", "friendly", "Alliance"); + set_area( + &mut s, + "Elwynn Forest", + "Library Wing", + "friendly", + "Alliance", + ); s.fire_event("ZONE_CHANGED_INDOORS", vec![]); assert!( !visible(&s, "ZoneTextFrame"), @@ -257,15 +278,14 @@ fn room_to_room_hop_splashes_the_room_name_alone() { fn arena_pit_shows_the_ffa_line() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = harness(); - set_area(&s, "Stranglethorn Vale", "", "contested", ""); + set_area(&mut s, "Stranglethorn Vale", "", "contested", ""); s.fire_event("ZONE_CHANGED_NEW_AREA", vec![]); s.tick(4.0); - s.run( - "__benilla_subzone_name = 'Gurubashi Arena'; __benilla_zone_text = 'Gurubashi Arena'; \ - __benilla_pvp_arena = true", - ) - .unwrap(); + s.set_zone_texts(ZoneTexts { + is_arena: true, + ..area("Stranglethorn Vale", "Gurubashi Arena", "contested", "") + }); s.fire_event("ZONE_CHANGED", vec![]); assert!(visible(&s, "SubZoneTextFrame")); assert_eq!(text_of(&s, "SubZoneTextString"), "Gurubashi Arena"); @@ -278,7 +298,7 @@ fn subzone_seat_hangs_under_the_territory_line_on_new_area() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = harness(); set_area( - &s, + &mut s, "Stormwind City", "Valley of Heroes", "friendly", diff --git a/crates/benilla-ui/src/script/action.rs b/crates/benilla-ui/src/script/action.rs index 26277f3a..6e94354b 100644 --- a/crates/benilla-ui/src/script/action.rs +++ b/crates/benilla-ui/src/script/action.rs @@ -332,7 +332,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { g.set( "GetActionCooldown", lua.create_function(|lua, action: u32| { - let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0); + let now = crate::script::clock::now(lua); let model = lua.app_data_ref::().expect("model app_data"); Ok( match model.action_states.get(&action).and_then(|s| s.cooldown) { diff --git a/crates/benilla-ui/src/script/aura/player_buff.rs b/crates/benilla-ui/src/script/aura/player_buff.rs index 921de582..93581a16 100644 --- a/crates/benilla-ui/src/script/aura/player_buff.rs +++ b/crates/benilla-ui/src/script/aura/player_buff.rs @@ -164,7 +164,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "GetPlayerBuffTimeLeft", lua.create_function(|lua, index: Value| { let pos = buff_index_arg(lua, index, "Usage: GetPlayerBuffTimeLeft(buffIndex)")?; - let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0); + let now = crate::script::clock::now(lua); // `max(0, …)` covers both zero cases: an expired aura, and a permanent one, expiry 0. Ok(player_buff_record(lua, pos).map_or(0.0, |a| (a.expiration_time - now).max(0.0))) })?, diff --git a/crates/benilla-ui/src/script/clock.rs b/crates/benilla-ui/src/script/clock.rs new file mode 100644 index 00000000..20e3176f --- /dev/null +++ b/crates/benilla-ui/src/script/clock.rs @@ -0,0 +1,58 @@ +//! The two clocks Lua reads: `GetTime()`, the session seconds [`UiScript::tick`] advances, and +//! `GetGameTime()`, the game clock the host pushes. Both live in the [`Model`], out of an addon's +//! reach, as the reference's live in the client. +//! +//! [`UiScript::tick`]: super::UiScript::tick + +use mlua::Lua; + +use super::Model; + +impl super::UiScript { + /// The current `GetTime()` value in seconds; the app stamps absolute expiries with it, such as + /// an aura's `expirationTime`. + pub fn now(&self) -> f64 { + self.model_ref().now + } + + /// Start this VM's `GetTime()` clock at `secs`, so a rebuilt VM (a relog, a `ReloadUI`) keeps + /// the process's clock. The reference's `GetTime` (`0x515ea0`, through `0x42c010` and + /// `0x42b790`) is `GetTickCount` scaled by 0.001, an OS clock that never restarts, which stock + /// `Cooldown.lua` relies on (`start > 0`). Set once, at construction; after that only + /// [`Self::tick`] moves it. + pub fn set_now(&mut self, secs: f64) { + self.model_mut().now = secs; + } + + /// Push the game clock `GetGameTime()` answers, `(hour, minute)`; 0:00 until the first push. + pub fn set_game_time(&mut self, hour: u32, minute: u32) { + self.model_mut().game_time = (hour, minute); + } +} + +/// `GetTime()`'s clock, for a caller holding no model borrow; one that holds it reads `model.now`. +pub(crate) fn now(lua: &Lua) -> f64 { + lua.app_data_ref::().expect("model app_data").now +} + +/// Register `GetTime` and `GetGameTime`. +pub(super) fn install(lua: &Lua) -> mlua::Result<()> { + let g = lua.globals(); + // GetTime() (`0x515ea0`): one number, `GetTickCount` times the f64 0.001 at `0x801608`. Ours is + // the session clock the tick advances, so `GetTime()` deltas and the `elapsed` handed to + // OnUpdate agree; reference FrameXML (CastingBarFrame and kin) anchors cast windows on it. + g.set("GetTime", lua.create_function(|lua, ()| Ok(now(lua)))?)?; + // GetGameTime() (`0x515ee0`): two numbers, the hour `[0xce853c]` then the minute `[0xce8538]`, + // the clock `SMSG_LOGIN_SETTIMESPEED` seeds; minute resolution, no seconds. + g.set( + "GetGameTime", + lua.create_function(|lua, ()| { + let (hour, minute) = lua + .app_data_ref::() + .expect("model app_data") + .game_time; + Ok((f64::from(hour), f64::from(minute))) + })?, + )?; + Ok(()) +} diff --git a/crates/benilla-ui/src/script/container.rs b/crates/benilla-ui/src/script/container.rs index 7985031e..944e07ba 100644 --- a/crates/benilla-ui/src/script/container.rs +++ b/crates/benilla-ui/src/script/container.rs @@ -506,7 +506,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { lua.globals().set( "GetContainerItemCooldown", lua.create_function(|lua, (bag, slot): (i64, u32)| { - let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0); + let now = crate::script::clock::now(lua); let model = lua.app_data_ref::().expect("model app_data"); Ok(match model.container_cooldowns.get(&(bag, slot)) { Some(&(start, duration, enabled)) if start + duration > now || !enabled => { diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index adcdeefc..180a00cb 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -52,6 +52,7 @@ pub(crate) mod addon; mod addon_enable; pub mod addon_gate; mod client; +mod clock; mod cvars; mod dressup; mod duel; @@ -140,6 +141,7 @@ mod who_sort; mod worldmap; mod worldstate; mod worn_display; +mod zone_text; pub use action::{ActionSlot, ActionState, ActionUse}; pub use addon::AddOnInfo; @@ -270,6 +272,7 @@ pub use worldmap::{ }; pub use worldstate::WorldStateUiView; pub use worn_display::WornDisplay; +pub use zone_text::ZoneTexts; use mlua::Lua; @@ -292,9 +295,9 @@ const REG_TEXTURE_METHODS: &str = "__benilla_texture_methods"; const REG_TEXTURE_META: &str = "__benilla_texture_meta"; const REG_FONTSTRING_METHODS: &str = "__benilla_fontstring_methods"; const REG_FONTSTRING_META: &str = "__benilla_fontstring_meta"; -/// The stdlib's default error handler, kept by identity for -/// [`UiScript::dispatch_script_errors_to_handler`] to skip; stored at [`stdlib::install`]. -const REG_DEFAULT_ERRORHANDLER: &str = "__benilla_default_errorhandler"; +/// The error handler `seterrorhandler` holds, the reference's registry reference `[0x8722cc]`; +/// unset until one is given, as the reference's starts at -1. +const REG_ERRORHANDLER: &str = "__benilla_errorhandler"; /// Names on both region leaves, each leaf registering its own copy (Texture's `SetAlpha` /// `0x79b580`, FontString's `0x79cb70`), so they are not on the Region map and must not be hoisted @@ -1178,31 +1181,20 @@ impl UiScript { .push(msg.to_string()); } - /// Hand each queued script error to the Lua error handler, as the reference invokes the one - /// `seterrorhandler`/`geterrorhandler` (`0x702900`/`0x702950`) hold on a caught error; - /// FrameXML installs `_ERRORMESSAGE`, the ScriptErrors dialog (`BasicControls.xml:16`). The - /// stdlib default is skipped, as it already reported into [`UiScript::errors`]; a handler that - /// raises is recorded on the host channel only and stops the batch, so the path cannot recurse. + /// Hand each queued script error to the Lua error handler, as the reference's catch closure + /// (`0x703b40`) calls the one `seterrorhandler` (`0x702900`) holds, read from its slot and not + /// through the `geterrorhandler` global; FrameXML installs `_ERRORMESSAGE`, the ScriptErrors + /// dialog (`BasicControls.xml:16`). With none installed the error stays on the host channel, + /// [`UiScript::errors`], alone; a handler that raises is recorded there only and stops the + /// batch, so the path cannot recurse. pub fn dispatch_script_errors_to_handler(&mut self) { let pending = std::mem::take(&mut self.model_mut().pending_error_dispatch); if pending.is_empty() { return; } - let handler: Option = self - .lua - .globals() - .get::("geterrorhandler") - .ok() - .and_then(|g| g.call::(()).ok()); - let Some(handler) = handler else { return }; - if let Ok(default) = self - .lua - .named_registry_value::(REG_DEFAULT_ERRORHANDLER) - { - if handler == default { - return; - } - } + let Some(handler) = stdlib::error_handler(&self.lua) else { + return; + }; for msg in pending { if let Err(e) = handler.call::<()>(msg) { self.model_mut() diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 97b1e963..156d096d 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -260,6 +260,13 @@ pub(crate) struct Model { pub(crate) saved_instances: Vec, /// `SetRaidRosterSelection`'s raid row index, client-side only. pub(crate) raid_selection: i64, + /// `GetTime()`'s session seconds, which only the host moves ([`super::UiScript::tick`], + /// [`super::UiScript::set_now`]); off `_G`, as the reference's clock is the OS tick count. + pub(crate) now: f64, + /// `GetGameTime()`'s `(hour, minute)`, pushed by the host as the game clock ticks. + pub(crate) game_time: (u32, u32), + /// The zone caches the four zone-text getters and `GetZonePVPInfo` read. + pub(crate) zone: super::ZoneTexts, /// The current map's `Map.dbc` `InstanceType`, all `IsInInstance()` reads; `None` for no row. pub(crate) instance_type: Option, /// `CanShowResetInstances()`, the reference's four-term predicate (`0x495c90`), app-computed. @@ -1066,6 +1073,9 @@ impl Model { ready_check: party::ReadyCheckState::default(), saved_instances: Vec::new(), raid_selection: 0, + now: 0.0, + game_time: (0, 0), + zone: Default::default(), instance_type: None, can_reset_instances: false, reset_instance_asks: 0, diff --git a/crates/benilla-ui/src/script/party.rs b/crates/benilla-ui/src/script/party.rs index e346eb23..808bbc57 100644 --- a/crates/benilla-ui/src/script/party.rs +++ b/crates/benilla-ui/src/script/party.rs @@ -715,7 +715,7 @@ pub(crate) struct ReadyCheckState { /// `GetTime()`'s clock, the session seconds `UiScript::tick` advances. fn clock(lua: &Lua) -> f64 { - lua.globals().get("__benilla_now").unwrap_or(0.0) + crate::script::clock::now(lua) } /// The timeout worker `0x4bb310`. @@ -765,7 +765,7 @@ fn ready_check_force_close(lua: &Lua, model: &mut Model) { if model.ready_check.deadline.is_none() { return; } - let now = clock(lua); + let now = model.now; model.ready_check.deadline = Some(now - 1.0); ready_check_tick(lua, model, now); } @@ -1460,21 +1460,24 @@ mod tests { let mut s = UiScript::new().unwrap(); let party = raid_we_lead(&s); s.set_party(party); - s.run("__benilla_now = 100 DoReadyCheck()").unwrap(); + s.set_now(100.0); + s.run("DoReadyCheck()").unwrap(); assert_eq!(s.take_party_requests(), vec![PartyRequest::ReadyCheckStart]); - s.run("__benilla_now = 129.9 CheckReadyCheckTime()") - .unwrap(); + s.set_now(129.9); + s.run("CheckReadyCheckTime()").unwrap(); assert!( s.take_ready_check_lines().is_empty(), "not before the deadline" ); - s.run("__benilla_now = 130 CheckReadyCheckTime()").unwrap(); + s.set_now(130.0); + s.run("CheckReadyCheckTime()").unwrap(); assert_eq!( s.take_ready_check_lines(), vec!["The following players are AFK: Alice, Bob".to_string()] ); - s.run("__benilla_now = 200 CheckReadyCheckTime()").unwrap(); + s.set_now(200.0); + s.run("CheckReadyCheckTime()").unwrap(); assert!( s.take_ready_check_lines().is_empty(), "disarmed by the summary" @@ -1487,7 +1490,8 @@ mod tests { let mut s = UiScript::new().unwrap(); let party = raid_we_lead(&s); s.set_party(party); - s.run("__benilla_now = 100 DoReadyCheck()").unwrap(); + s.set_now(100.0); + s.run("DoReadyCheck()").unwrap(); s.ready_check_answered(0xA11CE, true); assert!( s.take_ready_check_lines().is_empty(), @@ -1499,7 +1503,8 @@ mod tests { vec!["No players are AFK".to_string()], "nobody left pending ⇒ the summary now, not at 130" ); - s.run("__benilla_now = 130 CheckReadyCheckTime()").unwrap(); + s.set_now(130.0); + s.run("CheckReadyCheckTime()").unwrap(); assert!(s.take_ready_check_lines().is_empty()); } @@ -1511,7 +1516,8 @@ mod tests { s.run(r#"RAID_MEMBER_NOT_READY = "%s is not ready""#) .unwrap(); s.set_party(party); - s.run("__benilla_now = 100 DoReadyCheck()").unwrap(); + s.set_now(100.0); + s.run("DoReadyCheck()").unwrap(); s.ready_check_answered(0xDEAD, false); assert!( s.take_ready_check_lines().is_empty(), @@ -1522,7 +1528,8 @@ mod tests { s.take_ready_check_lines(), vec!["Alice is not ready".to_string()] ); - s.run("__benilla_now = 130 CheckReadyCheckTime()").unwrap(); + s.set_now(130.0); + s.run("CheckReadyCheckTime()").unwrap(); assert_eq!( s.take_ready_check_lines(), vec!["The following players are AFK: Bob".to_string()], @@ -1541,7 +1548,8 @@ mod tests { .iter_mut() .for_each(|m| m.online = m.guid != 0xB0B); // Bob is offline s.set_party(party); - s.run("__benilla_now = 100 DoReadyCheck()").unwrap(); + s.set_now(100.0); + s.run("DoReadyCheck()").unwrap(); s.ready_check_answered(0xA11CE, true); assert_eq!( s.take_ready_check_lines(), @@ -1557,9 +1565,10 @@ mod tests { s.run(r#"READY_CHECK_NO_AFK = "No players are AFK""#) .unwrap(); s.set_party(two_member_party()); // Alice leads; we are 0x5E1F - s.run("__benilla_now = 100").unwrap(); + s.set_now(100.0); s.ready_check_request(false); - s.run("__benilla_now = 130 CheckReadyCheckTime()").unwrap(); + s.set_now(130.0); + s.run("CheckReadyCheckTime()").unwrap(); assert!(s.take_ready_check_lines().is_empty()); assert_eq!(s.model_mut().ready_check.deadline, Some(130.0)); } @@ -1569,7 +1578,8 @@ mod tests { let mut s = UiScript::new().unwrap(); let party = raid_we_lead(&s); s.set_party(party.clone()); - s.run("__benilla_now = 100 DoReadyCheck()").unwrap(); + s.set_now(100.0); + s.run("DoReadyCheck()").unwrap(); s.ready_check_answered(0xA11CE, true); let mut without_bob = party; without_bob.raid.retain(|m| m.guid != 0xB0B); @@ -1588,7 +1598,8 @@ mod tests { let mut party = raid_we_lead(&s); party.raid.clear(); s.set_party(party); - s.run("__benilla_now = 100 DoReadyCheck()").unwrap(); + s.set_now(100.0); + s.run("DoReadyCheck()").unwrap(); s.ready_check_request(true); assert_eq!( s.take_ready_check_lines(), @@ -1602,9 +1613,10 @@ mod tests { let mut s = UiScript::new().unwrap(); let party = raid_we_lead(&s); s.set_party(party); - s.run("RAID_MEMBERS_AFK = nil __benilla_now = 100 DoReadyCheck()") - .unwrap(); - s.run("__benilla_now = 130 CheckReadyCheckTime()").unwrap(); + s.set_now(100.0); + s.run("RAID_MEMBERS_AFK = nil DoReadyCheck()").unwrap(); + s.set_now(130.0); + s.run("CheckReadyCheckTime()").unwrap(); assert_eq!(s.take_ready_check_lines(), vec![String::new()]); } } diff --git a/crates/benilla-ui/src/script/pet.rs b/crates/benilla-ui/src/script/pet.rs index f78b3b31..69732839 100644 --- a/crates/benilla-ui/src/script/pet.rs +++ b/crates/benilla-ui/src/script/pet.rs @@ -280,7 +280,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { g.set( "GetPetActionCooldown", lua.create_function(|lua, i: u32| { - let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0); + let now = crate::script::clock::now(lua); let model = lua.app_data_ref::().expect("model app_data"); Ok(match slot_at(&model, i).and_then(|s| s.cooldown) { Some((start, duration, enabled)) if start + duration > now || !enabled => { diff --git a/crates/benilla-ui/src/script/shapeshift.rs b/crates/benilla-ui/src/script/shapeshift.rs index c958f7ac..926fdd7e 100644 --- a/crates/benilla-ui/src/script/shapeshift.rs +++ b/crates/benilla-ui/src/script/shapeshift.rs @@ -104,7 +104,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { g.set( "GetShapeshiftFormCooldown", lua.create_function(|lua, i: u32| { - let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0); + let now = crate::script::clock::now(lua); let model = lua.app_data_ref::().expect("model app_data"); Ok(match form_at(&model, i).and_then(|f| f.cooldown) { Some((start, duration, enabled)) if start + duration > now || !enabled => { diff --git a/crates/benilla-ui/src/script/spellbook.rs b/crates/benilla-ui/src/script/spellbook.rs index ba395ffc..7c706c9f 100644 --- a/crates/benilla-ui/src/script/spellbook.rs +++ b/crates/benilla-ui/src/script/spellbook.rs @@ -445,7 +445,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "GetSpellCooldown", lua.create_function(|lua, (id, book_type): (Value, Value)| { let (id, book_type) = spell_slot_args(id, book_type, "GetSpellCooldown")?; - let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0); + let now = crate::script::clock::now(lua); let model = lua.app_data_ref::().expect("model app_data"); let cooldown = book_slot(&model, id, &book_type).and_then(|s| s.cooldown); Ok(match cooldown { diff --git a/crates/benilla-ui/src/script/stdlib.rs b/crates/benilla-ui/src/script/stdlib.rs index a2c967fc..f6241407 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, the clock and zone getters), `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. +//! 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. use mlua::{Lua, Value, Variadic}; @@ -91,32 +91,45 @@ pub(super) fn sandbox(lua: &Lua) -> mlua::Result<()> { /// Install the stdlib layer: the bare globals, `time`/`date` and the positional `format`. pub(super) fn install(lua: &Lua) -> mlua::Result<()> { - // The default `geterrorhandler()` reports into the host's script-error channel; the - // `__benilla_` prefix marks host plumbing, not a 1.12 global. - lua.globals().set( - "__benilla_script_error", - lua.create_function(|lua, msg: mlua::Value| { - let text = match &msg { - mlua::Value::String(s) => s.to_string_lossy(), - other => format!("{other:?}"), - }; - lua.app_data_mut::() - .expect("model app_data") - .errors - .push(text); - Ok(()) - })?, - )?; - + 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)?; lua.load(WOW_STDLIB) .set_name("=[benilla wow stdlib]") .set_mode(mlua::ChunkMode::Text) .exec()?; - // Kept by identity: dispatch skips the default handler, whose channel already has the - // message, and fires only one that FrameXML or an addon set. - let default: mlua::Function = lua.load("return geterrorhandler()").eval()?; - lua.set_named_registry_value(super::REG_DEFAULT_ERRORHANDLER, default)?; + 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) + .ok() + .flatten() +} + +/// `seterrorhandler(f)` / `geterrorhandler()`, engine globals in 1.12 (`0x702900`, `0x702950`) +/// over one registry reference, `[0x8722cc]`. It starts at -1 (`0x7039e0`), so `geterrorhandler()` +/// answers nil until something is set; in the running client FrameXML sets `_ERRORMESSAGE` first +/// (`BasicControls.xml:16`), so every addon finds that. `seterrorhandler` takes a function alone +/// and raises its `Usage:` on anything else, nil included, so the slot can never be cleared. +fn install_error_handler(lua: &Lua) -> mlua::Result<()> { + let g = lua.globals(); + g.set( + "seterrorhandler", + lua.create_function(|lua, f: Value| match f { + Value::Function(f) => lua.set_named_registry_value(super::REG_ERRORHANDLER, f), + _ => Err(mlua::Error::RuntimeError( + "Usage: seterrorhandler(errfunc)".into(), + )), + })?, + )?; + g.set( + "geterrorhandler", + lua.create_function(|lua, ()| Ok(error_handler(lua)))?, + )?; Ok(()) } @@ -205,8 +218,10 @@ ldexp = math.ldexp -- `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. +-- 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. do + local GetTime = GetTime local function noop() end debuginfo = noop debugload = noop @@ -220,21 +235,6 @@ do function debugprofilestop() return (GetTime() - profileStart) * 1000 end end --- ── the error handler ────────────────────────────────────────────────────────────────────────── --- `seterrorhandler(f)` / `geterrorhandler()` are ENGINE globals in 1.12 (the captured `_G` says --- so), and `_ERRORMESSAGE` — the default handler they start out holding — is FrameXML's. That --- split is why the pair lives here and the default is a plain function rather than a Rust binding: --- our own transcribed UI can replace it exactly as the reference's `UIErrorsFrame` does. --- --- The idiom this exists for is `geterrorhandler()(msg)` — an addon's pcall wrapper reporting a --- caught error the way the client would. Without the pair that line is `attempt to call a nil --- value` INSIDE an error path, which turns a recoverable addon fault into a dead addon. -do - local handler = function(msg) __benilla_script_error(msg) end - function seterrorhandler(f) handler = f end - function geterrorhandler() return handler 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 @@ -242,50 +242,6 @@ 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 --- ── GetTime: the FrameXML session clock (seconds, monotonic, arbitrary epoch — like the real --- client's uptime-based GetTime). `__benilla_now` is advanced by `UiScript::tick` in the same --- call that fires OnUpdate, so `GetTime()` deltas and accumulated `elapsed` agree. Reference --- FrameXML (CastingBarFrame & co.) anchors cast windows on it. -__benilla_now = 0.0 -function GetTime() return __benilla_now end - --- ── The zone-text family. --- The app pushes the host globals on an area change (the same shape as --- GetTime) and fires MINIMAP_ZONE_CHANGED / the ZONE_CHANGED family; these getters just read the --- cached slots, like the real bindings (0x48a0a0/c0/e0/100 read BSS caches). GetZoneText = the --- zone name, replaced by the WMO interior's name indoors; GetRealZoneText = the WMO-immune zone --- name; GetSubZoneText = the leaf subzone, "" when the leaf IS the zone (and indoors); --- GetMinimapZoneText = subzone-else-zone. GetZonePVPInfo returns (pvpType or nil, factionName or --- nil, isArena) — pvpType is friendly/hostile/contested, never "arena". -__benilla_zone_text = "" -function GetMinimapZoneText() return __benilla_zone_text end -__benilla_zone_name = "" -__benilla_real_zone_name = "" -__benilla_subzone_name = "" -__benilla_pvp_type = "" -__benilla_pvp_faction = "" -__benilla_pvp_arena = false -function GetZoneText() return __benilla_zone_name end -function GetRealZoneText() return __benilla_real_zone_name end -function GetSubZoneText() return __benilla_subzone_name end -function GetZonePVPInfo() - local t, f = __benilla_pvp_type, __benilla_pvp_faction - if t == "" then t = nil end - if f == "" then f = nil end - -- isArena is 1/nil, never a Lua boolean: the reference's third slot is `(nil) | (number)` - -- like every other 1.12 predicate. - return t, f, __benilla_pvp_arena and 1 or nil -end - --- ── GetGameTime: the server's in-game clock (hour, minute) — the reference reads the --- SMSG_LOGIN_SETTIMESPEED-seeded clock the client advances by its timescale. The app pushes the --- host globals when the game minute ticks (`crate::minimap::feed_game_time` — same shape as the --- zone-text family); minute resolution is the API's own (the binding returns no seconds). --- 0:00 until the first time packet lands. -__benilla_game_hour = 0 -__benilla_game_minute = 0 -function GetGameTime() return __benilla_game_hour, __benilla_game_minute 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/host_state.rs b/crates/benilla-ui/src/script/tests/host_state.rs new file mode 100644 index 00000000..34ee8aa2 --- /dev/null +++ b/crates/benilla-ui/src/script/tests/host_state.rs @@ -0,0 +1,258 @@ +//! The host's state lives off `_G`: 1.12.1's global table has no name for the clock, the zone +//! caches, the game clock or the error-handler slot (`reference/1.12-globals.tsv`), and the +//! getters over them read the client's own state, which Lua cannot reach. So a global an addon +//! assigns under one of benilla's old host names is a global no getter reads. + +use super::common::script; +use crate::script::{ActionState, AuraState, UiScript}; + +/// Each old host name, the Lua that writes it, and a probe of every answer it once fed. The probe +/// is read before and after the write; the two must agree, and the name must be absent from `_G`. +const HOST_NAMES: &[(&str, &str, &str)] = &[ + ( + "__benilla_now", + "__benilla_now = 0", + "return GetTime() .. ' ' .. GetPlayerBuffTimeLeft(0)", + ), + ( + "__benilla_zone_name", + "__benilla_zone_name = 'x'", + "return GetZoneText()", + ), + ( + "__benilla_real_zone_name", + "__benilla_real_zone_name = 'x'", + "return GetRealZoneText()", + ), + ( + "__benilla_subzone_name", + "__benilla_subzone_name = 'x'", + "return GetSubZoneText()", + ), + ( + "__benilla_zone_text", + "__benilla_zone_text = 'x'", + "return GetMinimapZoneText()", + ), + ( + "__benilla_pvp_type", + "__benilla_pvp_type = 'hostile'", + "return tostring((GetZonePVPInfo()))", + ), + ( + "__benilla_pvp_faction", + "__benilla_pvp_faction = 'Horde'", + "local _, f = GetZonePVPInfo() return tostring(f)", + ), + ( + "__benilla_pvp_arena", + "__benilla_pvp_arena = true", + "local _, _, a = GetZonePVPInfo() return tostring(a)", + ), + ( + "__benilla_game_hour", + "__benilla_game_hour = 13", + "local h, m = GetGameTime() return h .. ':' .. m", + ), + ( + "__benilla_game_minute", + "__benilla_game_minute = 59", + "local h, m = GetGameTime() return h .. ':' .. m", + ), + ( + "__benilla_script_error", + "__benilla_script_error = function() ErrorSinkHit = 1 end", + "local h = geterrorhandler() if h then pcall(h, 'probe') end \ + return tostring(ErrorSinkHit)", + ), +]; + +/// A VM a few seconds into its session with one buff ticking, so the clock's probe has a value +/// worth rewinding. +fn session() -> UiScript { + let mut s = script(); + s.set_now(500.0); + s.tick(0.5); + s.set_player_auras(vec![AuraState { + spell_id: 1243, + count: 1, + duration: 1800.0, + expiration_time: 1700.0, + helpful: true, + cancelable: true, + ..Default::default() + }]); + s +} + +/// No old host name is a global, and assigning one changes no answer it once fed. +#[test] +fn a_host_name_is_absent_from_g_and_writing_it_changes_nothing() { + let mut failures = Vec::new(); + for &(name, write, probe) in HOST_NAMES { + let s = session(); + if !s + .eval::(&format!("return rawget(getfenv(0), '{name}') == nil")) + .unwrap() + { + failures.push(format!("{name} is a global")); + } + let before = s.eval::(probe).unwrap(); + s.run(write).unwrap(); + let after = s.eval::(probe).unwrap(); + if before != after { + failures.push(format!( + "`{write}` moved `{probe}` from {before:?} to {after:?}" + )); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +/// The clock an addon cannot rewind: after `__benilla_now = 0`, the next tick advances `GetTime` +/// from where it was, and every countdown on it (a buff's time left, an action's cooldown) reads +/// on as before. +#[test] +fn rewinding_the_old_clock_name_leaves_the_session_clock_running() { + let mut s = session(); + s.set_action_state( + 1, + Some(ActionState { + cooldown: Some((490_000, 30_000, true)), + ..Default::default() + }), + ); + let read = |s: &UiScript| { + s.eval::<(f64, f64, f64)>( + "local start, duration = GetActionCooldown(1) \ + return GetTime(), GetPlayerBuffTimeLeft(0), start + duration", + ) + .unwrap() + }; + let (t0, left0, cd0) = read(&s); + assert_eq!((t0, left0, cd0), (500.5, 1199.5, 520.0)); + s.run("__benilla_now = 0").unwrap(); + s.tick(0.25); + let (t1, left1, cd1) = read(&s); + assert_eq!(t1, 500.75, "the tick advances from the session clock"); + assert_eq!(left1, 1199.25); + assert_eq!(cd1, 520.0, "the running cooldown still reads as running"); + assert_eq!(s.now(), 500.75, "and the host reads the same clock"); +} + +/// The getters answer what the host pushed, in the reference's shapes: the four zone texts one +/// string each, never nil (`0x48a0a0`..`0x48a100`); `GetZonePVPInfo` three values, isArena 1 or +/// nil (`0x48d540`); `GetGameTime` two numbers (`0x515ee0`); `GetTime` one (`0x515ea0`). +#[test] +fn the_getters_answer_the_pushed_state_in_their_1_12_shapes() { + let mut s = script(); + for call in [ + "GetZoneText()", + "GetRealZoneText()", + "GetSubZoneText()", + "GetMinimapZoneText()", + "GetTime()", + ] { + assert_eq!(s.arity(call).unwrap(), 1, "{call}"); + } + assert_eq!(s.arity("GetZonePVPInfo()").unwrap(), 3); + assert_eq!(s.arity("GetGameTime()").unwrap(), 2); + assert_eq!( + s.eval::( + "local t, f, a = GetZonePVPInfo() \ + return GetZoneText() .. '|' .. GetSubZoneText() .. '|' .. GetMinimapZoneText() .. '|' \ + .. tostring(t) .. '|' .. tostring(f) .. '|' .. tostring(a)" + ) + .unwrap(), + "|||nil|nil|nil", + "before the first push: empty strings, never nil, and no PvP info" + ); + assert_eq!( + s.eval::<(f64, f64)>("return GetGameTime()").unwrap(), + (0.0, 0.0) + ); + + s.set_zone_texts(crate::script::ZoneTexts { + zone: "Stormwind City".into(), + real_zone: "Elwynn Forest".into(), + subzone: "".into(), + minimap: "Stormwind City".into(), + pvp_type: Some("friendly".into()), + pvp_faction: Some("Alliance".into()), + is_arena: true, + }); + s.set_game_time(21, 7); + assert_eq!( + s.eval::<(String, String, String, String)>( + "return GetZoneText(), GetRealZoneText(), GetSubZoneText(), GetMinimapZoneText()" + ) + .unwrap(), + ( + "Stormwind City".into(), + "Elwynn Forest".into(), + String::new(), + "Stormwind City".into() + ) + ); + assert_eq!( + s.eval::<(String, String, f64)>("return GetZonePVPInfo()") + .unwrap(), + ("friendly".into(), "Alliance".into(), 1.0) + ); + assert_eq!( + s.eval::<(f64, f64)>("return GetGameTime()").unwrap(), + (21.0, 7.0) + ); +} + +/// The reference's handler slot starts empty (`[0x8722cc]` = -1 at `0x7039e0`), so +/// `geterrorhandler()` (`0x702950`) answers one nil until FrameXML sets `_ERRORMESSAGE`. +/// `seterrorhandler` (`0x702900`) takes a function alone, raising `Usage: seterrorhandler(errfunc)` +/// (`0x872a30`) on anything else and keeping the slot, and returns nothing. +#[test] +fn the_error_handler_slot_starts_empty_and_takes_only_a_function() { + let s = script(); + assert_eq!(s.arity("geterrorhandler()").unwrap(), 1); + assert!(s.eval::("return geterrorhandler() == nil").unwrap()); + + s.run("Handler = function() end").unwrap(); + assert_eq!(s.arity("seterrorhandler(Handler)").unwrap(), 0); + assert!(s + .eval::("return geterrorhandler() == Handler") + .unwrap()); + for bad in ["nil", "", "'Handler'", "{}"] { + let e = s + .run(&format!("seterrorhandler({bad})")) + .expect_err(bad) + .to_string(); + assert!(e.contains("Usage: seterrorhandler(errfunc)"), "{bad}: {e}"); + assert!( + s.eval::("return geterrorhandler() == Handler") + .unwrap(), + "seterrorhandler({bad}) keeps the slot" + ); + } +} + +/// The engine calls the handler in its slot on a caught error (`0x703b40`), not whatever the +/// `geterrorhandler` global answers, so an addon replacing that global redirects nothing. +#[test] +fn the_engine_reads_the_handler_slot_not_the_geterrorhandler_global() { + let mut s = script(); + s.run( + "Heard, Decoy = {}, {} \ + seterrorhandler(function(m) table.insert(Heard, m) end) \ + geterrorhandler = function() return function(m) table.insert(Decoy, m) end end \ + local f = CreateFrame('Frame') \ + f:RegisterEvent('ERROR_PROBE') \ + f:SetScript('OnEvent', function() error('slot probe') end)", + ) + .unwrap(); + s.fire_event("ERROR_PROBE", vec![]); + s.dispatch_script_errors_to_handler(); + assert_eq!( + s.eval::<(i64, i64)>("return table.getn(Heard), table.getn(Decoy)") + .unwrap(), + (1, 0) + ); +} diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs index c4710726..7f97fb8d 100644 --- a/crates/benilla-ui/src/script/tests/mod.rs +++ b/crates/benilla-ui/src/script/tests/mod.rs @@ -19,6 +19,7 @@ mod frame_api; mod generic_for; mod gm_ticket; mod handler_prof; +mod host_state; mod input; mod keyboard; mod layout_gate; @@ -156,7 +157,7 @@ fn request_time_played_queues_an_ask_and_the_answer_arrives_as_an_event() { /// `seterrorhandler`'s contract: an engine-caught script error goes to the chosen handler. #[test] -fn a_chosen_error_handler_hears_engine_caught_errors_and_the_default_does_not_duplicate() { +fn a_chosen_error_handler_hears_engine_caught_errors_and_none_leaves_the_host_channel_alone() { let mut s = crate::script::UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); s.run( @@ -166,14 +167,14 @@ fn a_chosen_error_handler_hears_engine_caught_errors_and_the_default_does_not_du ) .unwrap(); - // With the stdlib default handler, recognised by identity, the dispatch adds nothing. + // With no handler set, as before FrameXML sets one, the dispatch adds nothing. s.fire_event("ERROR_PROBE", vec![]); s.dispatch_script_errors_to_handler(); let errors = s.take_errors(); assert_eq!( errors.iter().filter(|e| e.contains("boom")).count(), 1, - "one error, once — the default handler must not double-report: {errors:?}" + "one error, once: {errors:?}" ); // A chosen handler gets the message, and the host channel still records it. diff --git a/crates/benilla-ui/src/script/tests/reference_surface.rs b/crates/benilla-ui/src/script/tests/reference_surface.rs index 99377e16..7b7213a1 100644 --- a/crates/benilla-ui/src/script/tests/reference_surface.rs +++ b/crates/benilla-ui/src/script/tests/reference_surface.rs @@ -14,21 +14,6 @@ const BEYOND_1_12: &[&str] = &[ // addon reaches the globals with `getfenv(0)`); ours does, as our `getglobal`/`setglobal` are // written over it. "_G", - // The host's pushed state, which the Lua getters over it read (`script::stdlib`): the tick's - // clock for `GetTime`, the zone texts and PvP info for the zone family, the game clock for - // `GetGameTime`, and the default error handler's sink. Each is a slot the app writes, not an - // API; they await a home off `_G`. - "__benilla_game_hour", - "__benilla_game_minute", - "__benilla_now", - "__benilla_pvp_arena", - "__benilla_pvp_faction", - "__benilla_pvp_type", - "__benilla_real_zone_name", - "__benilla_script_error", - "__benilla_subzone_name", - "__benilla_zone_name", - "__benilla_zone_text", ]; /// `reference/1.12-globals.tsv`'s names. diff --git a/crates/benilla-ui/src/script/tick.rs b/crates/benilla-ui/src/script/tick.rs index cffc511a..03688f79 100644 --- a/crates/benilla-ui/src/script/tick.rs +++ b/crates/benilla-ui/src/script/tick.rs @@ -1,5 +1,5 @@ //! The host runtime loop: event fan-out ([`UiScript::fire_event`]), the per-frame advance -//! ([`UiScript::tick`]) and the `GetTime()` clock ([`UiScript::now`]). +//! ([`UiScript::tick`]), which advances the `GetTime()` clock ([`UiScript::now`]). use mlua::Lua; @@ -70,35 +70,11 @@ pub(crate) fn fire_event_into(lua: &Lua, event: &str, args: Vec) { } impl super::UiScript { - /// The current `GetTime()` value in seconds; the app stamps absolute expiries with it, such as - /// an aura's `expirationTime`. - pub fn now(&self) -> f64 { - self.lua.globals().get("__benilla_now").unwrap_or(0.0) - } - - /// Start this VM's `GetTime()` clock at `secs`, so a rebuilt VM (a relog, a `ReloadUI`) keeps - /// the process's clock. The reference's `GetTime` (`0x515ea0`, through `0x42c010` and - /// `0x42b790`) is `GetTickCount` scaled by 0.001, an OS clock that never restarts, which stock - /// `Cooldown.lua` relies on (`start > 0`). Set once, at construction; after that only - /// [`Self::tick`] moves it. - pub fn set_now(&mut self, secs: f64) { - if let Err(e) = self.lua.globals().set("__benilla_now", secs) { - self.push_error(e); - } - } - /// Advance a frame: the `GetTime()` clock, the edit boxes and queued events, then /// `OnUpdate(self, elapsed)` on every visible frame that has one (`0x704f10`), then the /// engine's own fades, model panes and hover. pub fn tick(&mut self, elapsed: f32) { - let clock = { - let g = self.lua.globals(); - let now: f64 = g.get("__benilla_now").unwrap_or(0.0); - g.set("__benilla_now", now + f64::from(elapsed)) - }; - if let Err(e) = clock { - self.push_error(e); - } + self.model_mut().now += f64::from(elapsed); // The focused edit box's caret blink (`0x77a790`, on the client's frame tick). editbox::tick_blink(&self.lua, elapsed); // Then `0x77a790`'s drain of the `OnTextChanged`s an edit only marked (`0x77a7a1`), before diff --git a/crates/benilla-ui/src/script/tooltip/mod.rs b/crates/benilla-ui/src/script/tooltip/mod.rs index 17a5bec9..5cdcad93 100644 --- a/crates/benilla-ui/src/script/tooltip/mod.rs +++ b/crates/benilla-ui/src/script/tooltip/mod.rs @@ -352,7 +352,7 @@ fn full_alpha(model: &mut Model, h: FrameHandle) { /// The engine's `GetTime` clock, which [`super::UiScript::tick`] advances. fn now(lua: &Lua) -> f64 { - lua.globals().get("__benilla_now").unwrap_or(0.0) + crate::script::clock::now(lua) } /// Shows or hides through the arena and fires the visibility events. diff --git a/crates/benilla-ui/src/script/tooltip_spell.rs b/crates/benilla-ui/src/script/tooltip_spell.rs index 3e3dee52..f1884e83 100644 --- a/crates/benilla-ui/src/script/tooltip_spell.rs +++ b/crates/benilla-ui/src/script/tooltip_spell.rs @@ -540,10 +540,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { m.set( "SetPlayerBuff", lua.create_function(|lua, (this, index): (Table, i64)| { - let now = { - let g = lua.globals(); - g.get::("__benilla_now").unwrap_or(0.0) - }; + let now = crate::script::clock::now(lua); let (spell_id, name, remaining_ms) = { let model = lua.app_data_mut::().expect("model app_data"); let hit = usize::try_from(index) diff --git a/crates/benilla-ui/src/script/zone_text.rs b/crates/benilla-ui/src/script/zone_text.rs new file mode 100644 index 00000000..11692423 --- /dev/null +++ b/crates/benilla-ui/src/script/zone_text.rs @@ -0,0 +1,82 @@ +//! The zone-text family, `GetZoneText`, `GetRealZoneText`, `GetSubZoneText`, +//! `GetMinimapZoneText` and `GetZonePVPInfo`: readers of the zone caches the host pushes on an +//! area change, before it fires `MINIMAP_ZONE_CHANGED` or the `ZONE_CHANGED` family. The caches +//! live in the [`Model`], out of an addon's reach, as the reference's live in the client's BSS. + +use mlua::{Lua, MultiValue, Value}; + +use super::Model; + +/// The zone caches, as the host resolves them from the area tables. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ZoneTexts { + /// `GetZoneText`: the zone's name, replaced by the WMO interior's name indoors. + pub zone: String, + /// `GetRealZoneText`: the zone's name before any WMO override. + pub real_zone: String, + /// `GetSubZoneText`: the leaf area's name, `""` when the leaf is the zone, and indoors. + pub subzone: String, + /// `GetMinimapZoneText`: the subzone, else the zone. + pub minimap: String, + /// `GetZonePVPInfo`'s type, `"friendly"`, `"hostile"` or `"contested"`; `None` when the + /// player, the zone or its faction template is missing. + pub pvp_type: Option, + /// `GetZonePVPInfo`'s faction, `FactionGroup.dbc`'s name for the zone's mask bit; `None` for + /// none. + pub pvp_faction: Option, + /// `GetZonePVPInfo`'s isArena: the leaf area's flag `0x80`, the free-for-all pit. + pub is_arena: bool, +} + +impl super::UiScript { + /// Push the zone caches; the caller fires the zone events after. + pub fn set_zone_texts(&mut self, texts: ZoneTexts) { + self.model_mut().zone = texts; + } +} + +/// Register the five zone getters. +pub(super) fn install(lua: &Lua) -> mlua::Result<()> { + let g = lua.globals(); + // The four text getters (`0x48a0a0`, `0x48a0c0`, `0x48a0e0`, `0x48a100`) each push one string, + // their cache slot or the shared empty string `0x882748` when the slot is unset, never nil. + let text = |pick: fn(&ZoneTexts) -> &str| { + move |lua: &Lua, ()| { + let model = lua.app_data_ref::().expect("model app_data"); + lua.create_string(pick(&model.zone)) + } + }; + g.set("GetZoneText", lua.create_function(text(|z| &z.zone))?)?; + g.set( + "GetRealZoneText", + lua.create_function(text(|z| &z.real_zone))?, + )?; + g.set("GetSubZoneText", lua.create_function(text(|z| &z.subzone))?)?; + g.set( + "GetMinimapZoneText", + lua.create_function(text(|z| &z.minimap))?, + )?; + // GetZonePVPInfo() (`0x48d540`): always three values, `pvpType, factionName, isArena`; the + // type is never `"arena"`, and isArena is 1 or nil, never a boolean. + g.set( + "GetZonePVPInfo", + lua.create_function(|lua, ()| { + let model = lua.app_data_ref::().expect("model app_data"); + let z = &model.zone; + let string = |s: &Option| match s { + Some(s) => lua.create_string(s).map(Value::String), + None => Ok(Value::Nil), + }; + Ok(MultiValue::from_vec(vec![ + string(&z.pvp_type)?, + string(&z.pvp_faction)?, + if z.is_arena { + Value::Number(1.0) + } else { + Value::Nil + }, + ])) + })?, + )?; + Ok(()) +} diff --git a/scripts/api-coverage.sh b/scripts/api-coverage.sh index 2af061ff..1cc397a8 100755 --- a/scripts/api-coverage.sh +++ b/scripts/api-coverage.sh @@ -50,10 +50,9 @@ awk -F'\t' -v ref="$REF" -v show_missing="$show_missing" -v show_beyond="$show_b if (o == "engine" || o == "lua") have++ else if (o == "framexml") transcribable[$1] = 1 else { - # Categorize the superset. Only the last bucket is an API-target question; the first two are - # benilla being benilla, and the third is our Lua runtime being 5.1 where 1.12 is 5.0. + # Categorize the superset. Only the last bucket is an API-target question; the first is + # benilla being benilla, and the second is our Lua runtime being 5.1 where 1.12 is 5.0. if ($1 ~ /^Benilla/) bridge[$1] = 1 - else if ($1 ~ /^__/) internal[$1] = 1 else if ($1 ~ /^(_G|_VERSION|coroutine|print|select)$/) lua51[$1] = 1 else api[$1] = 1 } @@ -78,7 +77,6 @@ awk -F'\t' -v ref="$REF" -v show_missing="$show_missing" -v show_beyond="$show_b printf "\nbeyond 1.12, by kind — every one of these is a deliberate exception or a bug:\n" printf " %3d benilla host bridge Benilla*, called only by our own FrameXML\n", length(bridge) - printf " %3d VM internals __benilla_*, pushed by the tick\n", length(internal) printf " %3d Lua 5.1 past 1.12'\''s 5.0 ", length(lua51); dump(lua51, "") printf " %3d WoW API past 1.12 the phase-5 list\n", length(api) printf " %3d ours in Rust that 1.12 defines in FrameXML\n", length(transcribable)