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)