From 49743263817063ad1c750e1f301a53cb2cfec66b Mon Sep 17 00:00:00 2001 From: grom Date: Fri, 6 Nov 2026 09:28:56 -0700 Subject: [PATCH] Bring the nampower, UnitXP_SP3 and ClassicAPI ports up to the installed builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three DLLs beside the client were updated; re-measured each against the thing with evidence behind it rather than against a wiki. nampower's own string pool is exact — `^NP_[A-Za-z0-9]+$` is the CVar list and `Usage:` the arity of every function — and both came back identical to this tree: 49/49 CVars, 63/63 functions. The gap was events. Four built: BUFF_ADDED_OTHER / BUFF_REMOVED_OTHER ui_aura.rs, AuraEdge::OtherBuff KEY_DOWN / KEY_UP bindings.rs, key_event_code The buff pair was argued against here before, on the cost of diffing the helpful half of every streamed unit for events nothing registered. Answered rather than overruled: both are gated on `has_listener`, which is the test upstream now recommends over its own deprecated NP_Enable*Events CVars. The key pair uses the client's own code table, which `chord::key_code` already held with a comment saying it was the fact these events were blocked on. UnitXP_SP3 is packed, so its contract comes from the wiki plus the call sites in the installed addons — and those call sites exposed a live bug. Every `UnitXP("target", …)` queued a SelectionRequest, but the mod selects INSIDE the call and SuperCleveRoidMacros steps the cycle and reads `"target"` back on the next line, fifty times in one Lua frame. Queued, every pass read the same unit and the scan reported one enemy however many were there — truthy return, no error. The pick now resolves in-call from a pool the app publishes (`feed_unitxp_scan`), exactly the bargain TargetByName already strikes. All nine target modes are built where four were; `behindThreshold` is real (the reach feed publishes the angle now, not a verdict); `timer` rides the one timer queue; `notify` reaches winit's user-attention request; `version`, `performanceProfile` and a non-raising `debug` answer. ClassicAPI carries its whole Lua addon gzipped in `.rsrc` — 173 files, a length header then the bodies, keyed by the paths in its string pool — so the spec for the installed build is on disk. `scripts/dll-surface.py --unpack` writes it out. Read against a LIVE VM (not `UiScript::new()`, which is engine-only), the gap is 898 globals, and almost all of it is either a locale string or a subsystem this client models differently. What is between those is now in `compat/classic_api/shared_xml.rs`: the pure Blizzard_SharedXML helpers, in 1.12's grammar, from the shipped addon's own source. `tostringall` stays absent on `tests/stdlib.rs`'s own rule. The native half is mostly widget methods; intersected with what the corpus actually calls it is four, of which three are built (`SetResizeBounds` over 1.12's own resize pair, `GetNormalFontObject` — which needed 1.12's missing `GetTextFontObject` implemented first — and a bare `IsEquippedItem`) and `SetMotionScriptsWhileDisabled` deliberately is not, because both its call sites guard on it and the behaviour behind it does not exist here. Also measured and recorded: every addon-load warning in grom's own logs is a third-party optional dependency he has not installed or an addon's own packaging bug, not a defect on this side. docs/ADDON_COMPAT.md has the table. Ambiguity ceiling 4,956 -> 4,966, measured: nine for `feed_unitxp_scan` against transform writers on non-networked lanes, one for the attention drain against a system that also reads WinitWindows and neither writes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- Cargo.lock | 1 + Cargo.toml | 4 + crates/benilla-app/Cargo.toml | 1 + crates/benilla-app/src/bindings.rs | 72 +- crates/benilla-app/src/game_plugins.rs | 24 +- crates/benilla-app/src/lib.rs | 1 + crates/benilla-app/src/target/by_name.rs | 18 +- crates/benilla-app/src/target/mod.rs | 2 +- crates/benilla-app/src/target/scan.rs | 46 ++ crates/benilla-app/src/ui_aura.rs | 64 +- .../src/ui_script/honor_frame_tests.rs | 4 +- .../src/ui_script/inspect_tests.rs | 4 +- crates/benilla-app/src/ui_unit.rs | 51 +- crates/benilla-app/src/window_attention.rs | 57 ++ crates/benilla-ui/src/script/button.rs | 19 + .../src/script/compat/classic_api/frame.rs | 36 +- .../src/script/compat/classic_api/item.rs | 15 + .../src/script/compat/classic_api/mod.rs | 7 +- .../script/compat/classic_api/shared_xml.rs | 727 ++++++++++++++++++ .../src/script/compat/classic_api/timer.rs | 43 ++ crates/benilla-ui/src/script/compat/unitxp.rs | 450 +++++++++-- crates/benilla-ui/src/script/inspect.rs | 15 +- crates/benilla-ui/src/script/model.rs | 47 ++ .../src/script/tests/reference_surface.rs | 116 +++ .../src/script/tests/widget_surface.rs | 18 +- crates/benilla-ui/src/script/tick.rs | 6 +- crates/benilla-ui/src/script/unit/mod.rs | 22 + docs/ADDON_COMPAT.md | 244 ++++++ scripts/dll-surface.py | 99 +++ 29 files changed, 2101 insertions(+), 112 deletions(-) create mode 100644 crates/benilla-app/src/window_attention.rs create mode 100644 crates/benilla-ui/src/script/compat/classic_api/shared_xml.rs diff --git a/Cargo.lock b/Cargo.lock index 58a145c3..19f4dea5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -541,6 +541,7 @@ dependencies = [ "toml", "wgpu", "windows-sys 0.59.0", + "winit", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 038a0b84..569192df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,10 @@ bevy = { version = "0.18.1", default-features = false, features = [ ] } # For the `WOW_GPU_MS` meter's query set, which bevy 0.18 does not wrap; must match bevy's wgpu. wgpu = "27" +# For `UnitXP("notify", …)`'s window-manager attention request, which `bevy_winit` does not +# re-export (`window_attention.rs`); must match bevy_winit's winit, or the `WinitWindows` handle +# would belong to a different crate version. +winit = { version = "0.30", default-features = false } clap = { version = "4.6.1", features = ["derive"] } sha1 = "0.10.6" diff --git a/crates/benilla-app/Cargo.toml b/crates/benilla-app/Cargo.toml index f562f3dc..453f350f 100644 --- a/crates/benilla-app/Cargo.toml +++ b/crates/benilla-app/Cargo.toml @@ -26,6 +26,7 @@ benilla-ui = { path = "../benilla-ui" } ahash = "0.8.12" bevy.workspace = true wgpu.workspace = true +winit.workspace = true bevy_egui = { version = "0.39", optional = true } bytemuck = { version = "1", features = ["derive"] } # `f16` decode for the `WOW_BOOTH_DUMP` dump of the `Rgba16Float` booth targets. diff --git a/crates/benilla-app/src/bindings.rs b/crates/benilla-app/src/bindings.rs index fc5c3d02..6bd1e9dd 100644 --- a/crates/benilla-app/src/bindings.rs +++ b/crates/benilla-app/src/bindings.rs @@ -28,7 +28,7 @@ use bevy::input::ButtonState; use bevy::prelude::*; use benilla_ui::script::keybind::{AddonBindingBody, KeybindCommand, KeybindRequest}; -use benilla_ui::script::UiScript; +use benilla_ui::script::{ScriptValue, UiScript}; use crate::char_select::InWorldGated; use crate::ui_script::{ @@ -176,6 +176,39 @@ impl Plugin for BindingsPlugin { } } +/// nampower's `KEY_DOWN`/`KEY_UP` `arg1`: the client's own key code, or the mod's own 0/1/2 for +/// the three modifier keys, which have no binding code of their own. +/// +/// The codes come from [`chord::key_code`], transcribed from the reference's binding parser +/// (`0x4886f0`) — ASCII in bank 0, numpad in `0x100`, specials in `0x200`, function keys in +/// `0x300`. Every value a macro conditional is realistically written against agrees with the +/// consumer's own table: SuperCleveRoidMacros publishes `a=65 … z=90`, `0=48 … 9=57`, +/// `space=32`, `escape=512`, `enter=513`, `f1=768 … f12=779` +/// (`SuperCleveRoidMacros/Utility.lua:675-692`). +/// +/// **One bank disagrees and is written down rather than papered over.** That table also carries +/// `tilde=256` and `numpad0=257 … numpad9=266`, where the parser puts `NUMPAD0` at `0x100` (=256) +/// and has no `TILDE` name at all — the client's key-name table (`0x846c20`) does not contain one. +/// So either nampower numbers its own enum differently in that bank, or the consumer's table was +/// written from a guess. The traced client table is what ships here because it is the one with +/// evidence behind it; `docs/ADDON_COMPAT.md` records the open question, and a `[keydown:numpad3]` +/// conditional is the thing to re-measure if it ever matters. +/// +/// A key the parser has no code for (CapsLock, the mouse wheel names, a key winit reports and +/// 1.12 never had) fires nothing: a fabricated number would be worse than silence. +fn key_event_code(key: bevy::input::keyboard::KeyCode) -> Option { + use bevy::input::keyboard::KeyCode; + // Upstream numbers the modifier keys themselves 0/1/2, outside every bank — the consumer + // skips exactly those three by number (`CleveRoids.Frame:KEY_DOWN`). + match key { + KeyCode::ShiftLeft | KeyCode::ShiftRight => return Some(0), + KeyCode::ControlLeft | KeyCode::ControlRight => return Some(1), + KeyCode::AltLeft | KeyCode::AltRight => return Some(2), + _ => {} + } + chord::key_code(chord::key_token(chord::normalize_key(key))?) +} + /// The registry as the engine table's registration payload, for the boot seed and the capture /// fixtures. pub(crate) fn registry_commands() -> Vec { @@ -492,6 +525,43 @@ fn latch_and_dispatch( // ── Keyboard ── press edges latch or fire, gated on ownership and the capture arm; release // edges unlatch and fire the `runOnUp` up-half. let typing = capture.typing; + // nampower's `KEY_DOWN`/`KEY_UP`, fired before every ownership gate below because the mod's + // producer is a raw keyboard hook: it reports a key a focused edit box is swallowing, a key a + // keyboard frame ate, and the modifiers themselves, none of which reach a binding + // ([`fire_key_events`], whose doc carries the code table and its one open question). + // + // Gated on a listener, which is also what upstream now recommends over its deprecated + // per-family CVars: with nothing registered this is one hash lookup per frame that has key + // messages at all. + let key_events = script + .as_ref() + .is_some_and(|s| s.has_listener("KEY_DOWN") || s.has_listener("KEY_UP")); + if key_events { + let mut pending: Vec<(bool, i32, bool)> = Vec::new(); + for ev in keyboard.read() { + let code = key_event_code(ev.key_code); + let repeat = ev.state == ButtonState::Pressed + && state.down.contains(&chord::normalize_key(ev.key_code)); + if let Some(code) = code { + pending.push((ev.state == ButtonState::Pressed, code, repeat)); + } + } + if let Some(script) = script.as_mut() { + let meta = i64::from(shift) | (i64::from(ctrl) << 1) | (i64::from(alt) << 2); + let now = script.now(); + for (down, code, repeat) in pending { + script.fire_event( + if down { "KEY_DOWN" } else { "KEY_UP" }, + vec![ + ScriptValue::Int(i64::from(code)), + ScriptValue::Int(meta), + ScriptValue::Int(i64::from(repeat)), + ScriptValue::Number(now), + ], + ); + } + } + } for ev in keyboard.read() { let key = chord::normalize_key(ev.key_code); match ev.state { diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index 7e8fe01b..f6a0c6d1 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -115,6 +115,8 @@ impl PluginGroup for GamePlugins { .add(benilla_world::liquid::WaterDepthPlugin) .add(BowstringPlugin) .add(crate::weapon_trail::WeaponTrailPlugin) + // UnitXP_SP3's `UnitXP("notify", …)`, one drain over the primary window. + .add(crate::window_attention::WindowAttentionPlugin) .add(FishingLinePlugin) .add(QuestMarkersPlugin) .add(crate::pipe_warm::plugin) @@ -916,7 +918,27 @@ pub(crate) mod schedule_tests { /// process: this one is `run_if(capture_ui_active)`, and a capture has no roster identity, so /// `load_camera_pose` returns at its first line. Were they ever both live, the order would /// decide only which frame the synthetic snapshot first carries the restored position. - const UPDATE_ACTIONABLE_CEILING: usize = 4_956; + /// + /// 4,956 → 4,966 (addon compat, `docs/ADDON_COMPAT.md`, UnitXP_SP3's enemy scan and + /// `UnitXP("notify", …)`): **exactly ten**, read off the dump, and the two systems are + /// different cases. + /// + /// Nine are `target::scan::feed_unitxp_scan`, which publishes the TAB scan's enemy pool into + /// the VM so `UnitXP("target", …)` can pick inside the call. It takes the existing + /// `TargetScan`, whose `units` query reads `Transform` **without** `With` (it must: + /// the camera's own transform is one of its inputs), so it pairs with the nine transform + /// writers on non-networked lanes — the booth billboards, the portrait syncs, the quest-marker + /// seat scale, the world-camera gate and the four transport riders. None of them moves a + /// streamed unit, so the pool they could perturb is one the scan never builds from; and a + /// unit's position read a frame early or late is the same one-frame-stale tolerance the TAB + /// scan itself has always had. Its sibling `scan::target_nearest_enemy` pays 37 for the same + /// `TargetScan` shape (the row above), so this is a cheaper instance of an accepted cost. + /// + /// The tenth is `window_attention::drain_attention_requests` against + /// `terrain_stream::collider::finish_colliders`, over the non-Send `WinitWindows` both reach. + /// Neither writes it — one asks the window manager for attention, the other reads window + /// state — so the order decides nothing at all. + const UPDATE_ACTIONABLE_CEILING: usize = 4_966; const UPDATE_ACTIONABLE_SLACK: usize = 40; fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) { diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index 7bec825a..5b3e3954 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -217,6 +217,7 @@ pub mod ui_world_map; mod video; mod vplates; mod weapon_trail; +mod window_attention; mod world_backdrop; mod world_shadow; mod world_state; diff --git a/crates/benilla-app/src/target/by_name.rs b/crates/benilla-app/src/target/by_name.rs index 2f689e14..fd255db1 100644 --- a/crates/benilla-app/src/target/by_name.rs +++ b/crates/benilla-app/src/target/by_name.rs @@ -388,15 +388,15 @@ fn scan_units<'a>( /// disagree. #[derive(SystemParam)] pub(crate) struct NamedSnapshot<'w, 's> { - names: Res<'w, NameCache>, - commands: Res<'w, crate::net::NetCommands>, - factions: Option>, - reputations: Res<'w, crate::net::Reputations>, - self_q: Query<'w, 's, &'static ObjectStore, With>, - classes: Option>, - group: Res<'w, crate::ui_party::GroupState>, - stores: Query<'w, 's, &'static ObjectStore>, - positions: Query<'w, 's, &'static Transform, With>, + pub(super) names: Res<'w, NameCache>, + pub(super) commands: Res<'w, crate::net::NetCommands>, + pub(super) factions: Option>, + pub(super) reputations: Res<'w, crate::net::Reputations>, + pub(super) self_q: Query<'w, 's, &'static ObjectStore, With>, + pub(super) classes: Option>, + pub(super) group: Res<'w, crate::ui_party::GroupState>, + pub(super) stores: Query<'w, 's, &'static ObjectStore>, + pub(super) positions: Query<'w, 's, &'static Transform, With>, } /// Keep every `TargetByName` query a script has asked for resolved **a frame ahead**, so the diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs index a7af93ee..cf0b98ee 100644 --- a/crates/benilla-app/src/target/mod.rs +++ b/crates/benilla-app/src/target/mod.rs @@ -309,7 +309,7 @@ impl Plugin for TargetPlugin { // `every_vm_holder_in_update_declares_its_side_of_the_tick`). .add_systems( Update, - by_name::feed_named_targets + (by_name::feed_named_targets, scan::feed_unitxp_scan) .in_set(crate::ui_unit::UnitFeed) .before(crate::ui_unit::UnitFeedDispatch), ) diff --git a/crates/benilla-app/src/target/scan.rs b/crates/benilla-app/src/target/scan.rs index cbb4e07d..38610c86 100644 --- a/crates/benilla-app/src/target/scan.rs +++ b/crates/benilla-app/src/target/scan.rs @@ -757,6 +757,52 @@ pub(crate) struct AttackTargetRequest; #[derive(Message)] pub(crate) struct TargetNearestRequest; +/// Publish UnitXP_SP3's enemy candidate pool into the VM, best first, so `UnitXP("target", …)` +/// can pick **inside the call** (`compat/unitxp.rs`, whose `target_command` carries the why). +/// +/// The same bargain [`super::by_name::feed_named_targets`] strikes, in the same set and for the +/// same reason: the readers of these commands step a cycle and read `"target"` back on the next +/// line, inside one Lua frame, and a queued-only selection answers a frame late. +/// +/// Gated on a script having asked at least once, so a client whose addons never call it walks +/// nothing. The snapshot builder is `ui_unit`'s own, so a pooled unit and a token naming the same +/// unit cannot disagree. +pub(super) fn feed_unitxp_scan( + script: Option>, + scan: TargetScan, + ctx: super::by_name::NamedSnapshot, +) { + let Some(mut script) = script else { + return; + }; + if !script.unitxp_scan_asked() { + return; + } + let snap = crate::ui_unit::SnapshotCtx { + names: &ctx.names, + commands: &ctx.commands, + factions: ctx.factions.as_deref(), + reputations: &ctx.reputations, + self_store: ctx.self_q.single().ok(), + classes: ctx.classes.as_deref().map(|t| &t.0), + group: &ctx.group, + }; + let pool = scan + .build(ScanSide::Enemy) + .into_iter() + .filter_map(|c| { + let store = ctx.stores.get(c.entity).ok()?; + let position = ctx + .positions + .get(c.entity) + .ok() + .map(|tf| (tf.translation.x, tf.translation.y, tf.translation.z)); + Some(snap.full(c.guid, store, position)) + }) + .collect(); + script.set_unitxp_scan(pool); +} + /// Commit the scan's best enemy as the selection, attacking nothing. Nothing eligible is a silent /// no-op, as the mod's own "returns TRUE if successful" implies: no error line, just no change. pub(super) fn target_nearest_enemy( diff --git a/crates/benilla-app/src/ui_aura.rs b/crates/benilla-app/src/ui_aura.rs index 57c8763c..3289173d 100644 --- a/crates/benilla-app/src/ui_aura.rs +++ b/crates/benilla-app/src/ui_aura.rs @@ -287,6 +287,18 @@ struct AuraFeedMemory { /// Whether [`Self::own_buffs`] has been seeded. A first sight fires nothing, the same rule /// the other units' map states by having no entry yet. own_buffs_seeded: bool, + /// Every **other** unit's helpful half, the delta `BUFF_ADDED_OTHER`/`BUFF_REMOVED_OTHER` is + /// taken against — [`Self::debuffs`]'s mirror, and kept apart from it for the same reason + /// [`Self::own_buffs`] is: the halves fire different events and so must be diffed apart. + /// + /// **Only filled while something is listening.** Upstream's own CVar switches for these + /// families are now documented as deprecated in favour of registering the event, which is + /// exactly the test [`benilla_ui::script::UiScript::has_listener`] makes — so an install with + /// no addon reading this pair walks nothing and holds nothing, and the map is dropped the + /// moment the last listener unregisters. That is what makes the helpful half of every streamed + /// unit affordable at all, and it is why this pair could be built where the earlier note here + /// said it should not be. + other_buffs: bevy::ecs::entity::EntityHashMap>, } /// The per-VM `UNIT_AURA` edge keys. @@ -415,6 +427,7 @@ fn fire_debuff_edges( AuraEdge::OwnBuff => script.fire_event("BUFF_REMOVED_SELF", a), AuraEdge::OwnDebuff => script.fire_event("DEBUFF_REMOVED_SELF", a), AuraEdge::OtherDebuff => script.fire_event("DEBUFF_REMOVED_OTHER", a), + AuraEdge::OtherBuff => script.fire_event("BUFF_REMOVED_OTHER", a), } } } @@ -425,6 +438,7 @@ fn fire_debuff_edges( AuraEdge::OwnBuff => script.fire_event("BUFF_ADDED_SELF", a), AuraEdge::OwnDebuff => script.fire_event("DEBUFF_ADDED_SELF", a), AuraEdge::OtherDebuff => script.fire_event("DEBUFF_ADDED_OTHER", a), + AuraEdge::OtherBuff => script.fire_event("BUFF_ADDED_OTHER", a), } } } @@ -433,11 +447,12 @@ fn fire_debuff_edges( /// Which of nampower's aura-edge families one diff reports. /// /// Upstream `EVENTS.md` has **eight** — `BUFF_`/`DEBUFF_` by half, `_SELF`/`_OTHER` by whether the -/// unit is the player. Three are produced here, and the missing pair is named rather than left to -/// be noticed: `BUFF_ADDED_OTHER`/`BUFF_REMOVED_OTHER` would mean diffing the helpful half of -/// **every streamed unit** on every aura edge, and no addon in this corpus registers either. A -/// variant for them would be a dead arm firing events the gate then has to allow, which is worse -/// than their absence. Add them the day something reads them. +/// unit is the player — and all eight are produced here. +/// +/// [`Self::OtherBuff`] is the expensive one, and it earns its keep by not running: diffing the +/// helpful half of every streamed unit is only done while a frame is registered for one of its two +/// events ([`benilla_ui::script::UiScript::has_listener`]), which is the same test upstream now +/// recommends over its own deprecated `NP_Enable*Events` CVars. #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum AuraEdge { /// The player's helpful half: `BUFF_ADDED_SELF` / `BUFF_REMOVED_SELF`. @@ -446,6 +461,8 @@ enum AuraEdge { OwnDebuff, /// Any other unit's harmful half: `DEBUFF_ADDED_OTHER` / `DEBUFF_REMOVED_OTHER`. OtherDebuff, + /// Any other unit's helpful half: `BUFF_ADDED_OTHER` / `BUFF_REMOVED_OTHER`. + OtherBuff, } /// One unit's occupied debuff slots into `out`, reusing its allocation: the negative half of @@ -1074,8 +1091,18 @@ fn feed_auras( debuff_scratch, own_buffs, own_buffs_seeded, + other_buffs, .. } = &mut *mem; + // The helpful half of every OTHER unit is only diffed while something reads its events. The + // map is dropped the moment the last listener goes, so an addon that unregisters stops costing + // anything and a later re-register starts from a clean seed rather than from a stale delta — + // a first sight fires nothing, which is the rule every other seed here follows. + let other_buffs_wanted = + script.has_listener("BUFF_ADDED_OTHER") || script.has_listener("BUFF_REMOVED_OTHER"); + if !other_buffs_wanted && !other_buffs.is_empty() { + other_buffs.clear(); + } // A unit streamed in mid-debuff raises no field edge — its aura ids arrive with the object — // so first sight **seeds** the memory and fires nothing. Those debuffs were not newly added, // nothing here watched them land, and the descriptor keeps no caster for them, so reporting @@ -1146,6 +1173,30 @@ fn feed_auras( *own_buffs_seeded = true; } } + // Any other unit's HELPFUL half, gated on a listener above. Before the harmful half for + // the same reason the player's is: one edge that moves both reports the buff first, which + // is the order a single ascending walk of the block would give. + if !own && other_buffs_wanted { + collect_aura_slots(store, debuff_scratch, false); + match other_buffs.get_mut(e) { + Some(prev) => { + if prev != debuff_scratch { + fire_debuff_edges( + &mut script, + guid.0, + catalog, + prev, + debuff_scratch, + AuraEdge::OtherBuff, + ); + prev.clone_from(debuff_scratch); + } + } + None => { + other_buffs.insert(*e, debuff_scratch.clone()); + } + } + } collect_debuff_slots(store, debuff_scratch); match debuffs.get_mut(e) { Some(prev) => { @@ -1177,6 +1228,9 @@ fn feed_auras( // an idle frame never walks the map. if !due.is_empty() || !arrived.is_empty() { debuffs.retain(|e, _| watched.contains(*e)); + if other_buffs_wanted { + other_buffs.retain(|e, _| watched.contains(*e)); + } } } diff --git a/crates/benilla-app/src/ui_script/honor_frame_tests.rs b/crates/benilla-app/src/ui_script/honor_frame_tests.rs index 222f89cf..63a57151 100644 --- a/crates/benilla-app/src/ui_script/honor_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/honor_frame_tests.rs @@ -345,8 +345,8 @@ use std::collections::HashMap; fn reach(d2: f64) -> benilla_ui::script::UnitReach { benilla_ui::script::UnitReach { in_sight: true, - player_at_its_back: false, - at_player_back: false, + player_behind_angle: 0.0, + unit_behind_angle: 0.0, dist_sq: d2, inspectable: true, } diff --git a/crates/benilla-app/src/ui_script/inspect_tests.rs b/crates/benilla-app/src/ui_script/inspect_tests.rs index cd3d628f..48fea2be 100644 --- a/crates/benilla-app/src/ui_script/inspect_tests.rs +++ b/crates/benilla-app/src/ui_script/inspect_tests.rs @@ -14,8 +14,8 @@ use benilla_ui::script::{ fn reach(dist_sq: f64) -> UnitReach { UnitReach { in_sight: true, - player_at_its_back: false, - at_player_back: false, + player_behind_angle: 0.0, + unit_behind_angle: 0.0, dist_sq, inspectable: true, } diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 9a5f1a07..6cd39895 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -866,8 +866,8 @@ fn feed_unit_reach( tf.translation, ) }), - player_at_its_back: in_rear_arc(tf, self_tf.translation), - at_player_back: in_rear_arc(self_tf, tf.translation), + player_behind_angle: rear_angle(tf, self_tf.translation), + unit_behind_angle: rear_angle(self_tf, tf.translation), }, ); } @@ -875,25 +875,33 @@ fn feed_unit_reach( script.set_unit_reach(reach); } -/// Whether `other` stands in `subject`'s **rear arc**: UnitXP_SP3's `behind`. +/// The angle in radians between `subject`'s facing and the direction to `other`: UnitXP_SP3's +/// `behind` geometry, 0 dead ahead and π dead astern. /// -/// The rear **hemisphere**, which is what this realm's own server means by behind: vmangos tests -/// it as `!target->HasInArc(M_PI, attacker)` — a π arc centred on the facing, so anything outside -/// it is behind — and that is the rule the server already enforces for the abilities that care -/// (Backstab's "You must be behind your target"). The mod publishes no arc of its own, so matching -/// the authority that will accept or refuse the cast is the one choice that cannot disagree with -/// the game. +/// The **angle**, not a verdict, because `UnitXP("behindThreshold", "set", v)` moves the line at +/// runtime (`compat/unitxp.rs`). The default threshold on both sides is π/2, which makes behind +/// the rear **hemisphere** — what this realm's own server means by it: vmangos tests +/// `!target->HasInArc(M_PI, attacker)`, a π arc centred on the facing, and that is the rule it +/// already enforces for the abilities that care (Backstab's "You must be behind your target"). The +/// mod publishes no arc of its own, so matching the authority that will accept or refuse the cast +/// is the one choice that cannot disagree with the game. /// -/// Flat: the dot is taken in the horizontal plane, so standing on a ledge above a mob is not -/// "behind" it. A unit exactly beside the subject (dot 0) is **not** behind, as the strict -/// inequality in the server's own test is not. -fn in_rear_arc(subject: &Transform, other: Vec3) -> bool { - let facing = subject.forward().as_vec3(); - let to_other = other - subject.translation; +/// Flat: taken in the horizontal plane, so standing on a ledge above a mob is not "behind" it. A +/// zero-length separation (the two at one point) answers 0, which no threshold calls behind — and +/// a unit exactly beside the subject answers exactly π/2, which the strict `>` in the reader +/// refuses, as the strict inequality in the server's own test does. +fn rear_angle(subject: &Transform, other: Vec3) -> f32 { let flat = |v: Vec3| Vec3::new(v.x, 0.0, v.z); - let (facing, to_other) = (flat(facing), flat(to_other)); - // A zero-length separation (the subject and the other at one point) is not behind. - to_other.length_squared() > 0.0 && facing.dot(to_other) < 0.0 + let facing = flat(subject.forward().as_vec3()); + let to_other = flat(other - subject.translation); + if to_other.length_squared() == 0.0 || facing.length_squared() == 0.0 { + return 0.0; + } + facing + .normalize() + .dot(to_other.normalize()) + .clamp(-1.0, 1.0) + .acos() } /// [`feed_units`]' stores and change tracking, grouped to stay under Bevy's 16-parameter limit. @@ -3470,8 +3478,15 @@ mod tests { /// The rear **hemisphere**, which is what this realm's server means by behind /// (`!HasInArc(M_PI, ...)`), taken flat and strictly: beside is not behind, and above is not /// behind either. + /// + /// The feed publishes the angle and the reader compares it against UnitXP_SP3's threshold + /// (`compat/unitxp.rs`), so this asserts through that same comparison at the mod's default, + /// π/2 — which is the hemisphere. #[test] fn the_rear_arc_is_the_servers_own_half_circle() { + let in_rear_arc = |subject: &Transform, other: Vec3| { + rear_angle(subject, other) > std::f32::consts::FRAC_PI_2 + }; // A unit at the origin facing north. Bevy's identity rotation looks down −Z, and WoW's // north is −Z here too, so "in front" is −Z and "behind" is +Z. let facing_north = Transform::from_xyz(0.0, 0.0, 0.0); diff --git a/crates/benilla-app/src/window_attention.rs b/crates/benilla-app/src/window_attention.rs new file mode 100644 index 00000000..8c6efea3 --- /dev/null +++ b/crates/benilla-app/src/window_attention.rs @@ -0,0 +1,57 @@ +//! UnitXP_SP3's `UnitXP("notify", …)`: ask the window manager for the player's attention. +//! +//! The mod's two spellings are `"taskbarIcon"` (flash the taskbar entry) and `"systemSound"` (a +//! Windows `MessageBeep`), and both are documented to do nothing while the game has focus. Both +//! reach **one** request here, winit's `request_user_attention`, because that is the one attention +//! channel a window manager offers portably: it is literally the taskbar flash on Windows, the +//! dock bounce on macOS and the urgency hint on X11/Wayland. There is no portable system beep, and +//! a client that played a sound through its own mixer instead would be claiming to have played the +//! desktop's alert when it had not (`docs/ADDON_COMPAT.md`). + +use bevy::prelude::*; +use bevy::window::PrimaryWindow; +use bevy::winit::WinitWindows; + +use benilla_ui::script::UiScript; + +/// Drain the VM's `UnitXP("notify", …)` asks onto the primary window. +/// +/// `Informational` rather than `Critical`: the mod's own notify is "something happened while you +/// were alt-tabbed", not a demand, and `Critical` keeps bouncing until the window is focused on +/// macOS. A request made while the window already has focus is dropped by winit itself, which is +/// the mod's own "only takes effect when the game is in the background". +pub(crate) fn drain_attention_requests( + script: Option>, + // `Option`: a headless run (`$WOW_CAPTURE`, the test harness) has no winit at all, and a + // required `NonSend` there is a hard panic rather than a missing alert. + windows: Option>, + primary: Query>, +) { + let Some(mut script) = script else { + return; + }; + let asks = script.take_attention_requests(); + if asks.is_empty() { + return; + } + let (Some(windows), Ok(entity)) = (windows, primary.single()) else { + return; + }; + let Some(window) = windows.get_window(entity) else { + return; + }; + window.request_user_attention(Some(winit::window::UserAttentionType::Informational)); +} + +/// The one system this module adds, after the tick: the requests are Lua's own, queued by handlers +/// the tick dispatched, and an alert must not wait a frame. +pub(crate) struct WindowAttentionPlugin; + +impl Plugin for WindowAttentionPlugin { + fn build(&self, app: &mut App) { + app.add_systems( + Update, + drain_attention_requests.after(crate::ui_script::UiInput), + ); + } +} diff --git a/crates/benilla-ui/src/script/button.rs b/crates/benilla-ui/src/script/button.rs index b99da42b..693b6bca 100644 --- a/crates/benilla-ui/src/script/button.rs +++ b/crates/benilla-ui/src/script/button.rs @@ -628,6 +628,25 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { Ok(()) })?, )?; + // `GetTextFontObject()` (`0x7803b0`): the font object the Normal state's label wears, as the + // Font **object**, not its name — the reference answers the object and a caller hands it + // straight to another widget's `SetFontObject`. Stored by name here, so this is a lookup in + // `_G`, where a font object created by `CreateFont` or declared in XML lives. + // + // Nil when nothing was set, which is also the answer for a button whose label font came from + // its template rather than from this setter: the reference distinguishes those two and this + // does not, and the one consumer in the corpus (Ace3's button widget, which reads it back + // after setting it) only ever sees a value it put there itself. + m.set( + "GetTextFontObject", + lua.create_function(|lua, this: Table| { + let name = with_button(lua, &this, |bs| bs.normal_font.clone())?; + match name { + Some(n) => lua.globals().get::(n.as_str()), + None => Ok(Value::Nil), + } + })?, + )?; m.set( "SetHighlightFontObject", lua.create_function(|lua, (this, font): (Table, Value)| { diff --git a/crates/benilla-ui/src/script/compat/classic_api/frame.rs b/crates/benilla-ui/src/script/compat/classic_api/frame.rs index a5316e12..ea47e0e6 100644 --- a/crates/benilla-ui/src/script/compat/classic_api/frame.rs +++ b/crates/benilla-ui/src/script/compat/classic_api/frame.rs @@ -38,7 +38,7 @@ //! `SetColorTexture`, `SetDesaturation`/`GetDesaturation`, `SetRotation`, and the EditBox //! cursor/highlight/history getters: each plausible, none traced this pass. -use mlua::{Lua, MultiValue, Table, Value}; +use mlua::{Lua, MultiValue, ObjectLike, Table, Value}; use crate::script::object::frame_handle_of; use crate::script::{ @@ -530,6 +530,40 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { b.set("SetNormalFontObject", existing)?; } + // `GetNormalFontObject`, the getter the rename above left without a partner. 1.12's own + // spelling is `GetTextFontObject` (`0x7803b0`), so this is the same aliasing trick and the + // same reason: one function value, two names, no drift. Ace3's button widget reads it back + // after setting it (`AceGUIWidget-Button.lua`), which is a nil-method raise in the middle of + // building a frame. + { + let b: Table = lua.named_registry_value(crate::script::button::REG_BUTTON_METHODS)?; + let existing: Value = b.get("GetTextFontObject")?; + b.set("GetNormalFontObject", existing)?; + } + + // `SetResizeBounds(minWidth, minHeight [, maxWidth, maxHeight])`: 10.0's single verb for the + // pair 1.12 already has. Written over `SetMinResize`/`SetMaxResize` rather than reaching into + // the arena, so the clamp a resize actually obeys is the one this client has always obeyed, + // and the usage errors stay theirs. + // + // Omitting the maximum means **no** maximum, which is upstream's own reading — so it clears + // the pair rather than leaving a stale one behind. AceGUI's resizable containers are the + // consumers, and both call it behind `if frame.SetResizeBounds then` (the 10.0 feature test), + // so this is the branch they prefer when it is there. + m.set( + "SetResizeBounds", + lua.create_function( + |_, (this, min_w, min_h, max_w, max_h): (Table, Value, Value, Value, Value)| { + this.call_method::<()>("SetMinResize", (min_w, min_h))?; + let (max_w, max_h) = match (&max_w, &max_h) { + (Value::Nil, _) | (_, Value::Nil) => (Value::Integer(0), Value::Integer(0)), + _ => (max_w, max_h), + }; + this.call_method::<()>("SetMaxResize", (max_w, max_h)) + }, + )?, + )?; + // `RegisterUnitEvent(event, unit1 [, unit2])`: the stock registration, narrowed to the units // whose fire the frame wants. Everything about the dispatch stays the reference's — the frame // goes into the same `event_to_frames` list in the same registration order — and the filter is diff --git a/crates/benilla-ui/src/script/compat/classic_api/item.rs b/crates/benilla-ui/src/script/compat/classic_api/item.rs index e72bfbc1..ec6048b2 100644 --- a/crates/benilla-ui/src/script/compat/classic_api/item.rs +++ b/crates/benilla-ui/src/script/compat/classic_api/item.rs @@ -840,6 +840,21 @@ pub(super) fn install_globals(lua: &Lua) -> mlua::Result<()> { g.set("INVSLOT_FIRST_EQUIPPED", INVSLOT_FIRST_EQUIPPED)?; g.set("INVSLOT_LAST_EQUIPPED", INVSLOT_LAST_EQUIPPED)?; + // `IsEquippedItem(itemID|itemName|itemLink)` as a **bare** global, which is how 2.0 spells + // the question `C_Item.IsEquippedItem` answers above. Aliased to that same function value + // rather than reimplemented, so the two names cannot come to disagree about one bag. + // + // 1.12 has no spelling for it at all — the nearest is walking `GetInventoryItemLink` over + // nineteen slots — and addons call the bare name unguarded. + // + // Read back off the global `C_Item` the namespace install already published, so this runs + // after it (`classic_api/mod.rs`'s ordered call list) and there is exactly one body. + { + let c_item: Table = g.get("C_Item")?; + let f: Value = c_item.get("IsEquippedItem")?; + g.set("IsEquippedItem", f)?; + } + // `GetInventoryItemDurability(slot)` → `(current, maximum)`. 1.12 has the whole // `GetInventoryItem*` family but not this one (`reference/1.12-globals.tsv` lists Broken, // Cooldown, Count, Link, Quality and Texture, and stops) — it answers the *predicate* diff --git a/crates/benilla-ui/src/script/compat/classic_api/mod.rs b/crates/benilla-ui/src/script/compat/classic_api/mod.rs index 04c5c1a0..1ee02dc4 100644 --- a/crates/benilla-ui/src/script/compat/classic_api/mod.rs +++ b/crates/benilla-ui/src/script/compat/classic_api/mod.rs @@ -80,6 +80,7 @@ mod player_util; mod pools; mod quest_log; mod secure; +mod shared_xml; mod spell; mod spellbook; mod state; @@ -88,7 +89,7 @@ mod string_methods; mod string_trim; mod table_util; mod taxi_map; -mod timer; +pub(in crate::script) mod timer; mod tooltip; mod totem; mod unit; @@ -214,6 +215,10 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { // After `string_methods`: `string.trim` is reached as `("x"):trim()` through that metatable. string_trim::install(lua)?; table_util::install(lua)?; + // After `math_ext`, `color_mixin` and `lua_ext`: the chunk calls `Lerp`, `Saturate`, `Round`, + // `CreateColor` and `CreateAndInitFromMixin` at *run* time, but `MathUtil` and + // `TextureKitConstants` are seeded with `or`, so an earlier definition keeps its own table. + shared_xml::install(lua)?; // After `item::install`: the mixin's `IsValid` calls `C_Item.DoesItemExist`. Only at *call* // time, as `color_mixin.rs` explains for `CreateColor`, so the order is for the reader rather // than for correctness. diff --git a/crates/benilla-ui/src/script/compat/classic_api/shared_xml.rs b/crates/benilla-ui/src/script/compat/classic_api/shared_xml.rs new file mode 100644 index 00000000..312b7fe3 --- /dev/null +++ b/crates/benilla-ui/src/script/compat/classic_api/shared_xml.rs @@ -0,0 +1,727 @@ +//! ClassicAPI's backport of Blizzard's `Blizzard_SharedXML` utility layer: the pure-Lua helpers +//! that carry no engine state and are simply *absent* from 1.12 — maths, time, tables, colours, +//! texture markup, strings, varargs and the print/error plumbing. +//! +//! **Why this exists as one chunk.** `ClassicAPI.dll` ships a whole `!!!ClassicAPI` addon in its +//! resource section (a gzip per folder, each a length header plus the concatenated files) and +//! extracts it into `Interface/AddOns` at load. benilla loads no DLL, so none of that addon is +//! there — but this client already *claims to be* ClassicAPI (`CLASSIC_API_VERSION`, +//! `INTERFACE_VERSION`, and pfUI gates its whole load on the first). A client that says it is +//! ClassicAPI and then answers nil for `CopyTable` is worse than one that says neither: the +//! feature test passes and the call raises. +//! +//! So the names below are the ones where that gap is **cheap and unambiguous** to close: every +//! one is a pure function of its arguments with exactly one possible reading, none of them needs +//! a host feed, and each is transcribed from the shipped addon's own source rather than from +//! memory. What is deliberately NOT here is the half that is a *subsystem* — the scroll boxes, +//! the menu system, the pools, the layout frames, the secure state drivers, the interface-options +//! panel. Those are hundreds of mixins over widget behaviour benilla models differently, and a +//! half-built one is worse than an absent one. `docs/ADDON_COMPAT.md` carries the list. +//! +//! **The grammar matters.** This VM keeps 1.12's Lua 5.0 grammar (`script/lua50.rs`): no `...` as +//! a value, no `select`, no `string.match`/`gmatch`. ClassicAPI's own shipped source is already +//! written that way — it is a vanilla addon — so these bodies are its, not a re-derivation, and +//! the implicit `arg` table is the vararg channel throughout. +//! +//! **`tostringall` is deliberately absent**, though ClassicAPI provides it, for the same reason +//! `lua_ext.rs` leaves `strrev` out and `tests/stdlib.rs` pins it nil: it is a 2.0 name an addon +//! can feature-detect the client era with, and un-nilling one of those is a 1.12-fidelity call for +//! the whole corpus rather than a ClassicAPI one. + +use mlua::Lua; + +/// The chunk, in the grammar this VM parses. Loaded once at install. +const SHARED_XML: &str = r#" +-- ── MathUtil ─────────────────────────────────────────────────────────────────────────────────── +-- `Round`, `Clamp`, `Saturate`, `Lerp`, `PercentageBetween`, `ClampedPercentageBetween`, `Square` +-- and `Negate` already live in `compat/classic_api/math_ext.rs`; this is the rest of the file. +MathUtil = MathUtil or { Epsilon = .000001 } +MathUtil.ApproxZero = MathUtil.Epsilon +MathUtil.ApproxOne = 1.0 - MathUtil.Epsilon + +function CreateCounter(initialCount, step) + local count = initialCount or 0 + step = step or 1 + return function() + count = count + step + return count + end +end + +function ClampMod(value, mod) + local m = value - math.floor(value / mod) * mod + return m - math.floor(m / mod) * mod +end + +function ClampDegrees(value) + return ClampMod(value, 360) +end + +function Wrap(value, max) + return (value - 1) - math.floor((value - 1) / max) * max + 1 +end + +function NegateIf(value, condition) + if condition then return -value end + return value +end + +-- 60 fps is upstream's own reference rate: `amount` is "fraction per frame at 60", so the factor +-- is scaled by how long this frame actually took. +local TARGET_FRAME_PER_SEC = 60.0 +function DeltaLerp(startValue, endValue, amount, timeSec) + return Lerp(startValue, endValue, Saturate(amount * timeSec * TARGET_FRAME_PER_SEC)) +end + +-- The host stamps `__benilla_tick` with the frame's own elapsed seconds at the top of every tick +-- (`script/tick.rs`); 1.12 publishes no such number at all, and `FrameDeltaLerp` is unusable +-- without one. +function GetTickTime() + return __benilla_tick or 0 +end + +function FrameDeltaLerp(startValue, endValue, amount) + return DeltaLerp(startValue, endValue, amount, GetTickTime()) +end + +function RandomFloatInRange(minValue, maxValue) + return Lerp(minValue, maxValue, math.random()) +end + +function RoundToSignificantDigits(value, numDigits) + local multiplier = 10 ^ numDigits + return Round(value * multiplier) / multiplier +end + +function RoundToNearestMultiple(value, multiplier) + return Round(value / multiplier) * multiplier +end + +function Sign(value) + if value > 0 then return 1 end + if value == 0 then return 0 end + return -1 +end + +function WithinRange(value, min, max) + return value >= min and value <= max +end + +function WithinRangeExclusive(value, min, max) + return value > min and value < max +end + +function ApproximatelyEqual(v1, v2, epsilon) + return math.abs(v1 - v2) < (epsilon or MathUtil.Epsilon) +end + +function CalculateDistanceSq(x1, y1, x2, y2) + local dx = x2 - x1 + local dy = y2 - y1 + return (dx * dx) + (dy * dy) +end + +function CalculateDistance(x1, y1, x2, y2) + return math.sqrt(CalculateDistanceSq(x1, y1, x2, y2)) +end + +function CalculateAngleBetween(x1, y1, x2, y2) + return math.atan2(y2 - y1, x2 - x1) +end + +AccumulatorMixin = {} +function AccumulatorMixin:Init(initialCount) self.count = initialCount or 0 end +function AccumulatorMixin:Add(count) self.count = self.count + count return self.count end +function AccumulatorMixin:Subtract(count) self.count = self.count - count return self.count end +function AccumulatorMixin:Count() return self.count end +function CreateAccumulator(initialCount) + return CreateAndInitFromMixin(AccumulatorMixin, initialCount) +end + +-- ── TimeUtil ─────────────────────────────────────────────────────────────────────────────────── +SECONDS_PER_MIN = 60 +SECONDS_PER_HOUR = 60 * SECONDS_PER_MIN +SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR +SECONDS_PER_MONTH = 30 * SECONDS_PER_DAY +SECONDS_PER_YEAR = 12 * SECONDS_PER_MONTH +-- The two clock formats `SecondsToClock` prints through. 1.12 ships neither; these are the +-- shipped addon's own enUS values, and a localization that defines them first keeps its own. +HOURS_MINUTES_SECONDS = HOURS_MINUTES_SECONDS or "%.2d:%.2d:%.2d" +MINUTES_SECONDS = MINUTES_SECONDS or "%.2d:%.2d" + +function SecondsToMinutes(seconds) return seconds / SECONDS_PER_MIN end +function MinutesToSeconds(minutes) return minutes * SECONDS_PER_MIN end + +function ConvertSecondsToUnits(timestamp) + timestamp = math.max(timestamp, 0) + local days = math.floor(timestamp / SECONDS_PER_DAY) + timestamp = timestamp - (days * SECONDS_PER_DAY) + local hours = math.floor(timestamp / SECONDS_PER_HOUR) + timestamp = timestamp - (hours * SECONDS_PER_HOUR) + local minutes = math.floor(timestamp / SECONDS_PER_MIN) + timestamp = timestamp - (minutes * SECONDS_PER_MIN) + local seconds = math.floor(timestamp) + return { + days = days, + hours = hours, + minutes = minutes, + seconds = seconds, + milliseconds = timestamp - seconds, + } +end + +function SecondsToClock(seconds, displayZeroHours) + local units = ConvertSecondsToUnits(seconds) + if units.hours > 0 or displayZeroHours then + return string.format(HOURS_MINUTES_SECONDS, units.hours, units.minutes, units.seconds) + end + return string.format(MINUTES_SECONDS, units.minutes, units.seconds) +end + +-- ── TableUtil ────────────────────────────────────────────────────────────────────────────────── +function CopyTable(settings, shallow) + local copy = {} + for k, v in pairs(settings) do + if type(v) == "table" and not shallow then + copy[k] = CopyTable(v) + else + copy[k] = v + end + end + return copy +end + +function CopyTableSafe(settings, shallow) + if not settings then return nil end + return CopyTable(settings, shallow) +end + +function MergeTable(destination, source) + for k, v in pairs(source) do destination[k] = v end +end + +function tIndexOf(tbl, item) + for i, v in ipairs(tbl) do + if item == v then return i end + end +end + +-- A varargs list with nils in it loses its length through a plain `{...}`; `SafePack` records it +-- and `SafeUnpack` reads it back. `arg.n` is 5.0's own count, which is why this pair works here +-- at all without `select`. +function SafePack(...) + local tbl = {} + local n = arg.n or 0 + for i = 1, n do tbl[i] = arg[i] end + tbl.n = n + return tbl +end + +function SafeUnpack(tbl, startIndex) + return unpack(tbl, startIndex or 1, tbl.n) +end + +function SafeLength(tbl) + if not tbl then return 0 end + local operatorCount = table.getn(tbl) + local safePackCount = tbl.n + if safePackCount and safePackCount > operatorCount then return safePackCount end + return operatorCount +end + +-- ── ColorUtil / Color ────────────────────────────────────────────────────────────────────────── +COLOR_FORMAT_RGBA = "RRGGBBAA" +COLOR_FORMAT_RGB = "RRGGBB" +COLOR_FORMAT_ARGB = "AARRGGBB" + +function ExtractColorValueFromHex(str, index) + return tonumber(string.sub(str, index, index + 1), 16) / 255 +end + +function CreateColorFromHexString(hexColor) + if string.len(hexColor) == string.len(COLOR_FORMAT_ARGB) then + local a = ExtractColorValueFromHex(hexColor, 1) + local r = ExtractColorValueFromHex(hexColor, 3) + local g = ExtractColorValueFromHex(hexColor, 5) + local b = ExtractColorValueFromHex(hexColor, 7) + return CreateColor(r, g, b, a) + end + error("CreateColorFromHexString input must be hexadecimal digits in this format: " .. + COLOR_FORMAT_ARGB .. ".", 2) +end + +function CreateColorFromRGBAHexString(hexColor) + if string.len(hexColor) == string.len(COLOR_FORMAT_RGBA) then + local r = ExtractColorValueFromHex(hexColor, 1) + local g = ExtractColorValueFromHex(hexColor, 3) + local b = ExtractColorValueFromHex(hexColor, 5) + local a = ExtractColorValueFromHex(hexColor, 7) + return CreateColor(r, g, b, a) + end + error("CreateColorFromRGBAHexString input must be hexadecimal digits in this format: " .. + COLOR_FORMAT_RGBA .. ".", 2) +end + +function CreateColorFromRGBHexString(hexColor) + if string.len(hexColor) == string.len(COLOR_FORMAT_RGB) then + local r = ExtractColorValueFromHex(hexColor, 1) + local g = ExtractColorValueFromHex(hexColor, 3) + local b = ExtractColorValueFromHex(hexColor, 5) + return CreateColor(r, g, b, 1) + end + error("CreateColorFromRGBHexString input must be hexadecimal digits in this format: " .. + COLOR_FORMAT_RGB .. ".", 2) +end + +function CreateColorFromBestRGBHexString(hexColor) + if string.len(hexColor) == string.len(COLOR_FORMAT_RGBA) then + return CreateColorFromRGBAHexString(hexColor) + elseif string.len(hexColor) == string.len(COLOR_FORMAT_RGB) then + return CreateColorFromRGBHexString(hexColor) + end + error("CreateColorFromBestRGBHexString input must be hexadecimal digits in either of these " .. + "formats: " .. COLOR_FORMAT_RGBA .. " / " .. COLOR_FORMAT_RGB .. ".", 2) +end + +function CreateColorFromBytes(r, g, b, a) + return CreateColor(r / 255, g / 255, b / 255, a / 255) +end + +function AreColorsEqual(left, right) + if left and right then return left:IsEqualTo(right) end + return left == right +end + +function IsRGBAEqualToColor(r, g, b, a, color) + return (color.r == r) and (color.g == g) and (color.b == b) and (color.a == a) +end + +-- `RAID_CLASS_COLORS` is FrameXML's, not the engine's, so it is read through a guard: upstream +-- runs after the interface has loaded and can assume it, while this chunk installs with the VM. +-- The documented white fallback covers both an unknown token and a table that is not there yet. +-- +-- Upstream also does something at load that is easy to miss and that its own readers depend on: +-- it mixes `ColorMixin` into every entry of that table and stamps each one's `colorStr` +-- (`ClassicAPI/Util/ColorUtil.lua`'s opening `do` block). Without it `GetClassColor`'s fourth +-- return — the `"ffc79c6e"` a caller concatenates into a colour code — is nil, and +-- `GetClassColorObj(...):WrapTextInColorCode(text)` has no method to call. Done lazily here +-- rather than at install for two reasons: the table does not exist yet when this chunk loads, and +-- an addon that assigns its own plain `RAID_CLASS_COLORS` at file scope would strip a one-time +-- pass anyway — which is exactly why upstream re-runs its own after every addon loads. +local function EnsureClassColors() + local colors = RAID_CLASS_COLORS + if not colors or not ColorMixin then return end + for _, c in pairs(colors) do + if type(c) == "table" then + if not c.GenerateHexColor then + Mixin(c, ColorMixin) + c.a = c.a or 1 + end + if not c.colorStr then c.colorStr = c:GenerateHexColor() end + end + end +end + +function GetClassColor(classFilename) + EnsureClassColors() + local color = RAID_CLASS_COLORS and RAID_CLASS_COLORS[classFilename] + if color then + return color.r, color.g, color.b, color.colorStr + end + return 1, 1, 1, "ffffffff" +end + +function GetClassColorObj(classFilename) + EnsureClassColors() + return RAID_CLASS_COLORS and RAID_CLASS_COLORS[classFilename] +end + +function GetClassColoredTextForUnit(unit, text) + local _, classFilename = UnitClass(unit) + local color = GetClassColorObj(classFilename) + if color then return color:WrapTextInColorCode(text) end + return text +end + +function RGBToColorCode(r, g, b) + return string.format("|cff%02x%02x%02x", r * 255, g * 255, b * 255) +end + +function RGBTableToColorCode(rgbTable) + return RGBToColorCode(rgbTable.r, rgbTable.g, rgbTable.b) +end + +function WrapTextInColorCode(text, colorHexString) + return C_ColorUtil.WrapTextInColorCode(text, colorHexString) +end + +function WrapTextInColor(text, color) + return C_ColorUtil.WrapTextInColor(text, color) +end + +-- ── TextureUtil ──────────────────────────────────────────────────────────────────────────────── +TextureUtil = TextureUtil or {} +TextureKitConstants = TextureKitConstants or { + SetVisibility = true, + DoNotSetVisibility = false, + UseAtlasSize = true, + IgnoreAtlasSize = false, + AddressModeClamp = 1, + AddressModeWrap = 2, + AddressModeAllowAssetToDetermine = 3, +} + +function GetTexCoordsByGrid(xOffset, yOffset, textureWidth, textureHeight, gridWidth, gridHeight) + local widthPerGrid = gridWidth / textureWidth + local heightPerGrid = gridHeight / textureHeight + return (xOffset - 1) * widthPerGrid, xOffset * widthPerGrid, + (yOffset - 1) * heightPerGrid, yOffset * heightPerGrid +end + +-- `left`/`right`/`top`/`bottom` are normalized [0..1] within the file; the markup's texel fields +-- are those multiplied back up by the file size. +function CreateTextureMarkup(file, fileWidth, fileHeight, width, height, left, right, top, bottom, + xOffset, yOffset) + return string.format("|T%s:%d:%d:%d:%d:%d:%d:%d:%d:%d:%d|t", + file, height, width, xOffset or 0, yOffset or 0, fileWidth, fileHeight, + left * fileWidth, right * fileWidth, top * fileHeight, bottom * fileHeight) +end + +function CreateSimpleTextureMarkup(file, width, height, xOffset, yOffset) + return string.format("|T%s:%d:%d:%d:%d|t", + file, height or width, width, xOffset or 0, yOffset or 0) +end + +function CreateAtlasMarkup(atlasName, width, height, offsetX, offsetY) + return string.format("|A:%s:%d:%d:%d:%d|a", + atlasName, height or 0, width or 0, offsetX or 0, offsetY or 0) +end + +-- ── StringUtil / TypeUtil ────────────────────────────────────────────────────────────────────── +StringUtil = StringUtil or {} + +function ConfirmationStringMatches(userInput, expectedText) + return strupper(userInput) == strupper(expectedText) +end + +function ConfirmationEditBoxMatches(editBox, expectedText) + return ConfirmationStringMatches(editBox:GetText(), expectedText) +end + +function UserInputNonEmpty(userInput) + return userInput and strtrim(userInput) ~= "" +end + +function UserEditBoxNonEmpty(editBox) + return UserInputNonEmpty(editBox:GetText()) +end + +function StringSplitIntoTable(sep, str) + return { strsplit(sep, str) } +end + +-- Plain, not a pattern: upstream passes `plain = true`, so a substring with magic characters in +-- it is matched literally. +function StringContains(str, substring) + return string.find(str, substring, 1, true) ~= nil +end + +function ValueToBoolean(valueToCheck, defaultValue, defaultReturn) + if type(valueToCheck) == "nil" then + return false + elseif type(valueToCheck) == "boolean" then + return valueToCheck + elseif type(valueToCheck) == "number" then + return valueToCheck ~= 0 + elseif type(valueToCheck) == "string" then + return StringToBoolean(valueToCheck, defaultReturn) + end + return defaultReturn +end + +function StringToBoolean(stringToCheck, defaultReturn) + stringToCheck = string.lower(stringToCheck) + local firstChar = string.sub(stringToCheck, 1, 1) + if firstChar == "0" or firstChar == "n" or firstChar == "f" + or stringToCheck == "off" or stringToCheck == "disabled" then + return false + elseif firstChar == "1" or firstChar == "2" or firstChar == "3" or firstChar == "4" + or firstChar == "5" or firstChar == "6" or firstChar == "7" or firstChar == "8" + or firstChar == "9" or firstChar == "y" or firstChar == "t" + or stringToCheck == "on" or stringToCheck == "enabled" then + return true + end + return defaultReturn +end + +-- ── EnvironmentUtil ──────────────────────────────────────────────────────────────────────────── +-- `nop` is the no-op every modern default handler is written as; `InGlue` answers whether this is +-- the login/character screen, which for a loaded interface is always false here. +function nop() end +function InGlue() + return C_Glue and C_Glue.IsOnGlueScreen and C_Glue.IsOnGlueScreen() +end + +-- ── ErrorUtil ────────────────────────────────────────────────────────────────────────────────── +-- 1.12 has no callstack-height control, so the two depth setters upstream brackets its handler +-- with are no-ops here; the handler call itself is the part with behaviour. +function GetCallstackHeight() return 0 end +function SetErrorCallstackHeight(height) end + +function CallErrorHandler(...) + local handler = geterrorhandler() + if not handler then return end + return handler(unpack(arg, 1, arg.n or 0)) +end + +function assertsafe(cond, msgStringOrFunction, ...) + if not cond then + local n = arg.n or 0 + local message = msgStringOrFunction or "non-fatal assertion failed" + if type(msgStringOrFunction) == "string" and n > 0 then + message = string.format(msgStringOrFunction, unpack(arg, 1, n)) + elseif type(msgStringOrFunction) == "function" then + message = msgStringOrFunction(unpack(arg, 1, n)) + end + local handler = geterrorhandler() + if handler then handler(message) end + end + -- Parity with `assert`, which answers its own input. + return cond +end + +-- ── FunctionUtil ─────────────────────────────────────────────────────────────────────────────── +-- `GenerateClosure(fn, a, b)` → a function that calls `fn(a, b, )`. The two lists +-- are spliced by explicit count so a nil in either survives. +function GenerateClosure(fn, ...) + local bound, bn = {}, arg.n or 0 + for i = 1, bn do bound[i] = arg[i] end + return function(...) + local all, cn = {}, arg.n or 0 + for i = 1, bn do all[i] = bound[i] end + for i = 1, cn do all[bn + i] = arg[i] end + return fn(unpack(all, 1, bn + cn)) + end +end + +-- The same, except the returned function drops whatever it is called with. +function GenerateFlatClosure(fn, ...) + local bound, bn = {}, arg.n or 0 + for i = 1, bn do bound[i] = arg[i] end + return function() + return fn(unpack(bound, 1, bn)) + end +end + +function GetValueOrCallFunction(tbl, key, ...) + if not tbl then return end + local value = tbl[key] + if type(value) == "function" then + return value(unpack(arg, 1, arg.n or 0)) + end + return value +end + +function ExecuteFrameScript(frame, scriptName, ...) + local script = frame:GetScript(scriptName) + if script then + local args, n = arg, arg.n or 0 + local function invoke() + return script(frame, unpack(args, 1, n)) + end + xpcall(invoke, CallErrorHandler) + end +end + +function CallMethodOnNearestAncestor(self, methodName, ...) + local ancestor = self:GetParent() + while ancestor and not ancestor[methodName] do + ancestor = ancestor:GetParent() + end + if ancestor then + return true, ancestor[methodName](ancestor, unpack(arg, 1, arg.n or 0)) + end + return false +end +"#; + +pub(super) fn install(lua: &Lua) -> mlua::Result<()> { + lua.load(SHARED_XML) + .set_name("benilla:ClassicAPI_SharedXML") + .exec() +} + +#[cfg(test)] +mod tests { + use crate::script::UiScript; + + /// The vararg pair, which is the only part of this chunk with a trap in it: a plain `{...}` + /// loses a nil in the middle, and `SafePack`/`SafeUnpack` exist precisely to keep it. + #[test] + fn safe_pack_keeps_a_nil_in_the_middle_of_a_varargs_list() { + let s = UiScript::new().unwrap(); + let n: i64 = s.eval("local t = SafePack(1, nil, 3) return t.n").unwrap(); + assert_eq!(n, 3, "the count survives the hole"); + assert_eq!( + s.eval::("local t = SafePack(1, nil, 3) return SafeLength(t)") + .unwrap(), + 3 + ); + // Round-tripped: three values back out, the middle one still nil. + let (a, b, c): (i64, Option, i64) = s + .eval("local t = SafePack(1, nil, 3) return SafeUnpack(t)") + .unwrap(); + assert_eq!((a, b, c), (1, None, 3)); + } + + /// `GenerateClosure` splices its bound arguments ahead of the call's own, by count, so a nil + /// on either side keeps its place. + #[test] + fn generate_closure_splices_bound_args_before_the_call_args() { + let s = UiScript::new().unwrap(); + let joined: String = s + .eval( + r#" + local f = GenerateClosure(function(a, b, c, d) + return tostring(a) .. "," .. tostring(b) .. "," .. tostring(c) .. "," .. tostring(d) + end, "bound", nil) + return f("call", 7) + "#, + ) + .unwrap(); + assert_eq!(joined, "bound,nil,call,7"); + // The flat one drops whatever it is called with. + assert_eq!( + s.eval::( + r#" + local f = GenerateFlatClosure(function(a, b) return tostring(a) .. "/" .. tostring(b) end, "x") + return f("ignored") + "# + ) + .unwrap(), + "x/nil" + ); + } + + /// `StringContains` is a **plain** find, not a pattern one: upstream passes `plain = true`, + /// so a substring full of magic characters matches itself. + #[test] + fn string_contains_is_plain_not_a_pattern() { + let s = UiScript::new().unwrap(); + assert!(s + .eval::(r#"return StringContains("a+b", "a+b")"#) + .unwrap()); + assert!(!s + .eval::(r#"return StringContains("aab", "a+b")"#) + .unwrap()); + } + + /// The markup builders and the grid helper, whose argument *order* is the thing to pin: + /// the markup puts height before width. + #[test] + fn texture_markup_and_grid_coords() { + let s = UiScript::new().unwrap(); + assert_eq!( + s.eval::(r#"return CreateSimpleTextureMarkup("Interface\\Icons\\X", 16, 20)"#) + .unwrap(), + "|TInterface\\Icons\\X:20:16:0:0|t" + ); + assert_eq!( + s.eval::(r#"return CreateAtlasMarkup("some-atlas", 8, 9)"#) + .unwrap(), + "|A:some-atlas:9:8:0:0|a" + ); + // Cell (2,1) of a 4x4 grid over a 256-square sheet. + let (l, r, t, b): (f64, f64, f64, f64) = s + .eval("return GetTexCoordsByGrid(2, 1, 256, 256, 64, 64)") + .unwrap(); + assert_eq!((l, r, t, b), (0.25, 0.5, 0.0, 0.25)); + } + + /// Maths and time, each one line and each with one reading — the few where a sign or a + /// boundary could silently go the other way. + #[test] + fn the_math_and_time_helpers_answer_their_documented_values() { + let s = UiScript::new().unwrap(); + assert_eq!(s.eval::("return Sign(-3)").unwrap(), -1); + assert_eq!(s.eval::("return Sign(0)").unwrap(), 0); + assert_eq!(s.eval::("return ClampDegrees(-90)").unwrap(), 270.0); + assert_eq!(s.eval::("return Wrap(5, 4)").unwrap(), 1.0); + assert_eq!( + s.eval::("return RoundToNearestMultiple(85, 50)") + .unwrap(), + 100.0 + ); + assert!(s + .eval::("return WithinRange(5, 5, 7) and not WithinRangeExclusive(5, 5, 7)") + .unwrap()); + assert_eq!( + s.eval::("return CalculateDistance(0, 0, 3, 4)") + .unwrap(), + 5.0 + ); + // Under an hour prints mm:ss; over it prints hh:mm:ss. + assert_eq!( + s.eval::("return SecondsToClock(125)").unwrap(), + "02:05" + ); + assert_eq!( + s.eval::("return SecondsToClock(3725)").unwrap(), + "01:02:05" + ); + } + + /// A deep copy is deep by default and shallow on request; the shallow one shares its nested + /// tables, which is the whole difference. + #[test] + fn copy_table_is_deep_unless_asked_otherwise() { + let s = UiScript::new().unwrap(); + assert!(s + .eval::( + "local src = { inner = {} } \ + return CopyTable(src).inner ~= src.inner \ + and CopyTable(src, true).inner == src.inner" + ) + .unwrap()); + assert_eq!( + s.eval::>("return CopyTableSafe(nil)").unwrap(), + None + ); + } + + /// `GetClassColor` answers the raid table's own numbers and falls back to white with a + /// four-byte colour string, which is what its callers concatenate. + #[test] + fn get_class_color_falls_back_to_white() { + let s = UiScript::new().unwrap(); + let (r, g, b, str_): (f64, f64, f64, String) = + s.eval(r#"return GetClassColor("NOT_A_CLASS")"#).unwrap(); + assert_eq!((r, g, b, str_.as_str()), (1.0, 1.0, 1.0, "ffffffff")); + // A known token reads the raid table, including its `colorStr`, which callers concatenate. + // A PLAIN raid-colour table — no mixin, no `colorStr`, which is what FrameXML leaves — + // is seeded on first read, as upstream seeds it at load. Both of those are things its + // readers call straight away: the fourth return is concatenated into a colour code, and + // `GetClassColorObj(...):WrapTextInColorCode(…)` needs the method. + let known: (f64, f64, f64, String) = s + .eval( + r#" + RAID_CLASS_COLORS = { WARRIOR = { r = .78, g = .61, b = .43 } } + return GetClassColor("WARRIOR") + "#, + ) + .unwrap(); + assert_eq!(known.3, "ffc79c6e", "colorStr stamped from the numbers"); + assert!((known.0 - 0.78).abs() < 1e-6); + assert_eq!( + s.eval::(r#"return GetClassColoredTextForUnit("player", "Bob")"#) + .unwrap(), + "Bob", + "no unit snapshot: the text passes through unchanged, never raising" + ); + assert_eq!( + s.eval::(r#"return GetClassColorObj("WARRIOR"):WrapTextInColorCode("x")"#) + .unwrap(), + "|cffc79c6ex|r" + ); + } +} diff --git a/crates/benilla-ui/src/script/compat/classic_api/timer.rs b/crates/benilla-ui/src/script/compat/classic_api/timer.rs index e9ae693e..19b1ea61 100644 --- a/crates/benilla-ui/src/script/compat/classic_api/timer.rs +++ b/crates/benilla-ui/src/script/compat/classic_api/timer.rs @@ -86,6 +86,49 @@ fn handle(lua: &Lua, id: u64) -> mlua::Result { Ok(t) } +/// Arm a row whose callback is a **global looked up by name at fire time**, passed the row's own +/// id — UnitXP_SP3's `UnitXP("timer", "arm", …)` contract (`compat/unitxp.rs`). On this queue +/// rather than a second one, for the reason `RunNextFrame` is. +pub(in crate::script) fn arm_named( + lua: &Lua, + seconds: f64, + interval: Option, + name: &str, +) -> mlua::Result { + let name = name.to_string(); + // The id is only known after `arm`, so the closure reads it out of a cell the arm fills. + let id_cell = std::rc::Rc::new(std::cell::Cell::new(0u64)); + let seen = id_cell.clone(); + let callback = lua.create_function(move |lua, ()| { + let f: Option = lua.globals().get(name.as_str())?; + match f { + Some(f) => f.call::<()>(seen.get()), + // A name that no longer names a function is a silent no-op, as the mod's is: the + // timer keeps running, since the addon may define it later. + None => Ok(()), + } + })?; + let id = arm(lua, seconds, callback, interval, None)?; + id_cell.set(id); + Ok(id) +} + +/// Flag a row cancelled by id, both where the drain can see it and where it cannot — the same +/// two places [`handle`]'s `Cancel` writes, and for the same reason. +pub(in crate::script) fn cancel(lua: &Lua, id: u64) { + let mut model = lua.app_data_mut::().expect("model app_data"); + match model.timers.iter_mut().find(|r| r.id == id) { + Some(row) => row.cancelled = true, + None => model.timers_cancelled.push(id), + } +} + +/// How many rows are armed and not cancelled: `UnitXP("timer", "size")`. +pub(in crate::script) fn live_count(lua: &Lua) -> i64 { + let model = lua.app_data_ref::().expect("model app_data"); + model.timers.iter().filter(|r| !r.cancelled).count() as i64 +} + pub(super) fn install(lua: &Lua) -> mlua::Result
{ let t = lua.create_table()?; t.set( diff --git a/crates/benilla-ui/src/script/compat/unitxp.rs b/crates/benilla-ui/src/script/compat/unitxp.rs index 04b31122..05e1b6a9 100644 --- a/crates/benilla-ui/src/script/compat/unitxp.rs +++ b/crates/benilla-ui/src/script/compat/unitxp.rs @@ -58,18 +58,165 @@ fn distance_between(lua: &Lua, args: &[Value]) -> Option { Some(dist(a, b)) } -/// `UnitXP("target", )`: queue a selection ask for the app's own `SelectionRequest` drain -/// (`crates/benilla-app/src/target/click.rs`), the same queue `TargetUnit`/`TargetLastEnemy` use, -/// so a UnitXP target and a stock one commit through one path in call order. Answers `1` for a -/// sub-command that queued and nil for one not built, which is what the README's "returns TRUE if -/// successful" can honestly mean from here: whether the scan found anything is only known a frame -/// later, app-side. +/// `UnitXP("target", )`, every mode the mod documents, **resolved inside the call**. /// -/// Not built (`docs/ADDON_COMPAT.md`): `nextEnemyConsideringDistance`/`previousEnemyConsideringDistance` -/// and `nextEnemyInCycle`/`previousEnemyInCycle` (the 41-yd distance-banded cycles — the existing -/// tab-target history is keyed to its own two sides, and threading a third cycle through it is its -/// own piece of work), `worldBoss` (no classification data reaches here) and `rangeCone` (it tunes -/// the scan's own cone constant, which is not a Lua-settable knob today). +/// The mod's selection is synchronous, and its readers depend on that: SuperCleveRoidMacros steps +/// a cycle and reads `UnitExists("target")` plus the new target's GUID back on the next line, in a +/// 50-iteration loop that walks the whole cycle inside one Lua frame +/// (`Core.lua:2884-2900`, `Conditionals.lua:9670-9685`). A queued-only selection answers a frame +/// late, so that loop reads the same unit on every pass and breaks out after one — the scan looks +/// like it found a single enemy however many are standing there. +/// +/// So the pick is made here, from the pool the app publishes each frame +/// ([`Model::unitxp_scan`]), `"target"` is overridden for the rest of the Lua frame and the +/// selection rides [`SelectionRequest::ByGuid`] — exactly the bargain `TargetByName` already +/// strikes, and the GUID answered and the GUID committed are the same by construction. +/// +/// The pool is the TAB scan's own enemy candidates, best first (on-screen tier, then +/// off-centre + distance with the fighting-me bonus), so "nearest" means here what it means for +/// `TargetNearestEnemy`. It is published only once a script has asked, so the **first ever** call +/// in a session finds it empty and answers nil; every later one is served. That is the same +/// first-ask miss `by_name_asked` takes, and these commands are called in loops. +/// +/// Answers `true` on a pick and nil otherwise, which is what the README's "returns TRUE if +/// successful" means and what its readers test (`if found and UnitExists("target")`). +fn target_command(lua: &Lua, sub: Option<&str>, arg: Option<&Value>) -> Value { + let mut model = lua.app_data_mut::().expect("model app_data"); + // Latched here rather than at install: the app pays for the walk only on a client whose + // addons actually use the scan. + model.unitxp_scan_asked = true; + let Some(sub) = sub else { return Value::Nil }; + + // The two tuning sub-commands. `farRange` is the distance-banded cycle's outer band and is + // honoured below; `rangeCone` narrows the candidate cone, which lives app-side in the scan's + // own frustum pull and is not a Lua-settable knob here — stored and reported, not wired + // (`docs/ADDON_COMPAT.md`). `disableInCombatFilter` drops a rule benilla's scan never had. + match sub { + "farRange" => { + if let Some(v) = arg.and_then(num) { + // The mod's own clamp: 26..=60, default 41. + model.unitxp_far_range = v.clamp(26.0, 60.0); + } + return Value::Boolean(true); + } + "rangeCone" => { + if let Some(v) = arg.and_then(num) { + model.unitxp_range_cone = v.max(2.0); + } + return Value::Boolean(true); + } + "disableInCombatFilter" => return Value::Boolean(true), + _ => {} + } + + // The mark cycle keeps the app's own queue: its board is `GroupState::raid_targets`, which + // covers every streamed marked unit rather than only the attackable ones within the scan's + // 41 yd, and nothing in the corpus reads `"target"` back from it in the same frame. + if let Some(forward) = match sub { + "nextMarkedEnemyInCycle" => Some(true), + "previousMarkedEnemyInCycle" => Some(false), + _ => None, + } { + model + .selection_requests + .push(crate::script::SelectionRequest::MarkCycle { forward }); + return Value::Boolean(true); + } + + let pool = &model.unitxp_scan; + if pool.is_empty() { + return Value::Nil; + } + let far = model.unitxp_far_range; + // Distance from the player, for the banded cycle. The pool carries positions; the player's + // own comes from the token map, as `distanceBetween` reads it. + let me = model.unit("player").and_then(|u| u.position); + let distance = |u: &crate::script::UnitState| match (me, u.position) { + (Some(a), Some(b)) => dist(a, b), + _ => f64::MAX, + }; + + // Each arm answers an *order* over the pool plus whether it is a cycle; a non-cycle mode + // simply takes its first entry. + let (order, cycles): (Vec, bool) = match sub { + // The best candidate, which is `TargetNearestEnemy`'s own pick. + "nearestEnemy" => ((0..pool.len()).collect(), false), + // Most current health, ties broken by the pool's own order. + "mostHP" => { + let mut idx: Vec = (0..pool.len()).collect(); + idx.sort_by(|&a, &b| pool[b].health.cmp(&pool[a].health).then(a.cmp(&b))); + (idx, false) + } + // Rank 3 is `UnitClassification`'s `"worldboss"`. A cycle, as the mod documents. + "worldBoss" => ( + (0..pool.len()).filter(|&i| pool[i].rank == 3).collect(), + true, + ), + "nextEnemyInCycle" | "previousEnemyInCycle" => ((0..pool.len()).collect(), true), + // "prioritizing the 0-8, 8-25 and 25-farRange buckets": band first, then the pool's own + // order inside the band. + "nextEnemyConsideringDistance" | "previousEnemyConsideringDistance" => { + let band = |u: &crate::script::UnitState| { + let d = distance(u); + if d <= 8.0 { + 0 + } else if d <= 25.0 { + 1 + } else if d <= f64::from(far) { + 2 + } else { + 3 + } + }; + let mut idx: Vec = (0..pool.len()).filter(|&i| band(&pool[i]) < 3).collect(); + idx.sort_by_key(|&i| (band(&pool[i]), i)); + (idx, true) + } + _ => return Value::Nil, + }; + if order.is_empty() { + return Value::Nil; + } + let back = sub.starts_with("previous"); + let picked = if cycles { + // Step from the cursor the last step left, not from the selection: inside a same-frame + // loop the selection is still a frame behind. A cursor whose unit has left the order + // restarts the cycle, as the mod's does. + let at = model + .unitxp_cycle + .and_then(|g| order.iter().position(|&i| pool[i].guid == g)); + let n = order.len(); + order[match (at, back) { + (Some(i), false) => (i + 1) % n, + (Some(i), true) => (i + n - 1) % n, + (None, false) => 0, + (None, true) => n - 1, + }] + } else { + order[0] + }; + + let state = pool[picked].clone(); + let guid = state.guid; + model.unitxp_cycle = Some(guid); + model.target_override = Some(state); + model + .selection_requests + .push(crate::script::SelectionRequest::ByGuid(guid)); + Value::Boolean(true) +} + +/// A number from a Lua value the mod would accept: its own settings come through as numbers, a +/// hand-typed macro as a string. +fn num(v: &Value) -> Option { + match v { + Value::Integer(i) => Some(*i as f32), + Value::Number(n) => Some(*n as f32), + Value::String(s) => s.to_str().ok()?.trim().parse().ok(), + _ => None, + } +} + /// A player-anchored two-unit predicate: `read` is handed the other unit's [`UnitReach`] record /// and whether `"player"` was the **first** of the two arguments, which is what tells the two /// `behind` directions apart. @@ -101,21 +248,6 @@ fn player_anchored( } } -fn target_command(lua: &Lua, sub: Option<&str>) -> Value { - use crate::script::SelectionRequest as R; - let request = match sub { - Some("nearestEnemy") => R::NearestEnemy, - Some("nextMarkedEnemyInCycle") => R::MarkCycle { forward: true }, - Some("previousMarkedEnemyInCycle") => R::MarkCycle { forward: false }, - _ => return Value::Nil, - }; - lua.app_data_mut::() - .expect("model app_data") - .selection_requests - .push(request); - Value::Integer(1) -} - /// The stock `UnitXP(unit)` (`script/unit/bindings.rs`'s former registration, folded in here): a /// single-argument call the mod's command dispatcher doesn't recognise falls through to, exactly /// as a detour over the original address would on a real install. `PLAYER_XP` is a private field, @@ -154,6 +286,51 @@ fn vanilla1121(lua: &Lua, command: &str, args: &[Value]) -> Option { } } +/// The COFF `TimeDateStamp` of the UnitXP_SP3 build this port is measured against +/// (`UnitXP_SP3.dll`, 2026-08-15), answered by `UnitXP("version", "coffTimeDateStamp")`. Like +/// `GetNampowerVersion`'s number it is a claim about the *surface*, not about benilla: bump it +/// when the commands here are brought up to a newer build. +const UNITXP_BUILD_STAMP: i64 = 1_786_777_259; + +/// `UnitXP("version", "additionalInformation")`. Free-form upstream; nothing parses it. +const UNITXP_BUILD_INFO: &str = "benilla native UnitXP_SP3 compatibility (surface of 2026-08-15)"; + +/// `UnitXP("timer", …)`: the mod's own callback scheduler, on the one timer queue this VM has +/// (`compat/classic_api/timer.rs`, drained by `UiScript::tick`). One queue, one drain, one place +/// for a scheduling bug to live. +/// +/// `arm(delayMs, repeatMs, "callbackName")` answers the timer id, and the callback is looked up +/// **by name at fire time** and passed that id, which is the mod's contract — a name re-pointed +/// between the arm and the fire calls the new function, as it would there. `repeatMs` 0 is a +/// one-shot. `disarm(id)` stops one, `size()` counts the live ones. +fn timer_command(lua: &Lua, args: &[Value]) -> mlua::Result { + match args.first().and_then(as_str).as_deref() { + Some("arm") => { + let delay = args.get(1).and_then(num).unwrap_or(0.0).max(0.0); + let repeat = args.get(2).and_then(num).unwrap_or(0.0).max(0.0); + let Some(name) = args.get(3).and_then(as_str) else { + return Ok(Value::Nil); + }; + let id = super::classic_api::timer::arm_named( + lua, + f64::from(delay) / 1000.0, + (repeat > 0.0).then(|| f64::from(repeat) / 1000.0), + &name, + )?; + Ok(Value::Integer(id as i64)) + } + Some("disarm") => { + let Some(id) = args.get(1).and_then(num) else { + return Ok(Value::Nil); + }; + super::classic_api::timer::cancel(lua, id as u64); + Ok(Value::Boolean(true)) + } + Some("size") => Ok(Value::Integer(super::classic_api::timer::live_count(lua))), + _ => Ok(Value::Nil), + } +} + pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { lua.globals().set( "UnitXP", @@ -218,14 +395,23 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { // readers document: `[behind]` is "Player is behind target" and passes // `("behind", "player", unit)` (`Conditionals.lua:7381`). "behind" => { + let threshold = lua + .app_data_ref::() + .expect("model app_data") + .unitxp_behind_threshold; player_anchored(lua, args.get(1..3).unwrap_or(&[]), |r, player_first| { - if player_first { + // The feed publishes the *angle* between the subject's facing and the + // direction to the other unit; behind is an angle wider than the + // threshold, so a larger threshold is a smaller back arc, as the mod + // documents. π/2, the default, is the rear hemisphere. + let angle = if player_first { // ("behind", "player", unit): the player is at the unit's back. - r.player_at_its_back + r.player_behind_angle } else { // ("behind", unit, "player"): the unit is at the player's back. - r.at_player_back - } + r.unit_behind_angle + }; + angle > threshold }) } // `"target"` is ambiguous: stock 1.12's `UnitXP("target")` is the target unit's @@ -233,7 +419,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { // argument, so the stock one-argument getter keeps answering — the same way the // mod's own detour falls through to the original. "target" if args.len() >= 2 => { - target_command(lua, args.get(1).and_then(as_str).as_deref()) + target_command(lua, args.get(1).and_then(as_str).as_deref(), args.get(2)) } // `modernNameplateDistance()`: the mod's nameplate occlusion. Its name says // distance, but its own checkbox is labelled "Proper Nameplates Occlusion" @@ -270,6 +456,60 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { ); Value::Boolean(true) } + // `behindThreshold("set", v)`: the back-arc half-angle, the mod's own 0..=π + // clamp with its π/2 default. π/2 *is* the rear hemisphere, which is what this + // realm's server already enforces for the abilities that care — so the unset + // value is the behaviour [`behind`] always had, and a set one is honoured + // because the feed now publishes the angle rather than a precomputed verdict + // (`UnitReach::player_behind_angle`). + "behindThreshold" => { + if args.get(1).and_then(as_str).as_deref() == Some("set") { + if let Some(v) = args.get(2).and_then(num) { + lua.app_data_mut::() + .expect("model app_data") + .unitxp_behind_threshold = v.clamp(0.0, std::f32::consts::PI); + } + } + Value::Boolean(true) + } + "timer" => timer_command(lua, &args[1..])?, + // `notify("taskbarIcon"|"systemSound")`: ask the host window for attention. Both + // spellings reach the one request the platform layer has — a window-manager + // attention hint — because there is no portable "system sound" and a client that + // beeps through its own mixer would be lying about which sound it played + // (`docs/ADDON_COMPAT.md`). Queued for the app, like every other out-of-VM ask. + "notify" => { + let sound = args.get(1).and_then(as_str).as_deref() == Some("systemSound"); + lua.app_data_mut::() + .expect("model app_data") + .attention_requests + .push(sound); + Value::Boolean(true) + } + // `version()`. Both answers are a claim about which **surface** is present, + // not a version of benilla — the same reasoning `GetNampowerVersion` carries. The + // stamp is the COFF `TimeDateStamp` of the UnitXP_SP3 build this port was measured + // against, so an addon that compares it against a known release sees the release + // whose commands these are. + "version" => match args.get(1).and_then(as_str).as_deref() { + Some("coffTimeDateStamp") => Value::Integer(UNITXP_BUILD_STAMP), + Some("additionalInformation") => { + Value::String(lua.create_string(UNITXP_BUILD_INFO)?) + } + _ => Value::Nil, + }, + // `performanceProfile("get")`: the mod answers a free-form string describing what + // it is costing. benilla has real numbers for this — the UI tick's own split — but + // nothing parses this string, so it stays a plain, honest sentence. + "performanceProfile" => Value::String(lua.create_string( + "benilla native UnitXP_SP3 compatibility: no detours, no injected profiler", + )?), + // `debug("breakpoint")`: the mod's step-by-step Lua debugger, which this client + // does not have. **Answering nil rather than raising** is the whole contract here: + // the one call site in this corpus is a commented-out line kept for hand-editing + // (`pfUI-addonskinner/skins/Clique.lua:49`), and a raise from an uncommented one + // would take the rest of that skin with it. + "debug" => Value::Nil, _ => vanilla1121(lua, &command, &args[1..]) .unwrap_or_else(|| Value::Integer(stock_unit_xp(lua, &command))), }) @@ -326,8 +566,8 @@ mod tests { dist_sq: 25.0, inspectable: false, in_sight: true, - player_at_its_back: true, - at_player_back: false, + player_behind_angle: std::f32::consts::PI, + unit_behind_angle: 0.0, }, ), ( @@ -336,8 +576,8 @@ mod tests { dist_sq: 400.0, inspectable: false, in_sight: false, - player_at_its_back: false, - at_player_back: true, + player_behind_angle: 0.0, + unit_behind_angle: std::f32::consts::PI, }, ), ])); @@ -445,8 +685,8 @@ mod tests { "target".to_string(), UnitReach { in_sight: true, - player_at_its_back: false, - at_player_back: false, + player_behind_angle: 0.0, + unit_behind_angle: 0.0, dist_sq: 25.0, inspectable: false, }, @@ -552,41 +792,131 @@ mod tests { ); } - /// The `target` sub-commands queue onto the same selection queue the stock verbs use, and the - /// one-argument `UnitXP("target")` still answers the stock XP getter rather than being eaten - /// as a command — the ambiguity this dispatcher has to resolve on arity. + /// The `target` sub-commands pick **inside the call** from the pool the app publishes, and + /// the one-argument `UnitXP("target")` still answers the stock XP getter rather than being + /// eaten as a command — the ambiguity this dispatcher has to resolve on arity. + /// + /// The same-frame read is the whole point: SuperCleveRoidMacros steps a cycle and reads + /// `UnitExists("target")` and the new GUID back on the next line, fifty times in one Lua + /// frame. A queued-only selection answers a frame late, so that loop would read one unit + /// however many are standing there. #[test] - fn target_subcommands_queue_a_selection_without_shadowing_the_stock_getter() { - use crate::script::SelectionRequest as R; + fn target_subcommands_pick_from_the_pool_inside_the_call() { + use crate::script::{SelectionRequest as R, UnitState}; let mut s = UiScript::new().unwrap(); + // The first ever ask finds no pool — it is what latches the app's walk — and says so. assert_eq!( - s.eval::(r#"return UnitXP("target", "nearestEnemy")"#) - .unwrap(), - 1, - "a queued sub-command answers 1" - ); - s.run(r#"UnitXP("target", "nextMarkedEnemyInCycle")"#) - .unwrap(); - s.run(r#"UnitXP("target", "previousMarkedEnemyInCycle")"#) - .unwrap(); - assert_eq!( - s.eval::>(r#"return UnitXP("target", "notASubCommand")"#) + s.eval::>(r#"return UnitXP("target", "nearestEnemy")"#) .unwrap(), None, - "an unbuilt sub-command is nil, not a queued no-op" + "nothing published yet" ); + assert!(s.unitxp_scan_asked(), "the ask latched the app's feed"); + assert!(s.take_selection_requests().is_empty()); + let mob = |guid: u64, name: &str, health: u32, rank: u32, z: f32| UnitState { + exists: true, + guid, + name: Some(name.into()), + health, + rank, + position: Some((0.0, 0.0, z)), + ..Default::default() + }; + s.set_unit( + "player", + Some(UnitState { + exists: true, + position: Some((0.0, 0.0, 0.0)), + ..Default::default() + }), + ); + // Best first, as the app publishes it: the boar is the scan's own pick. + s.set_unitxp_scan(vec![ + mob(0x11, "Boar", 50, 0, 3.0), + mob(0x22, "Bear", 400, 0, 30.0), + mob(0x33, "Hakkar", 200, 3, 12.0), + ]); + + assert!( + s.eval::(r#"return UnitXP("target", "nearestEnemy")"#) + .unwrap(), + "a pick answers true" + ); + assert_eq!( + s.eval::(r#"return UnitName("target")"#).unwrap(), + "Boar", + "`\"target\"` answers the picked unit in the SAME Lua frame" + ); + assert_eq!(s.take_selection_requests(), vec![R::ByGuid(0x11)]); + + // Most current health, whatever its place in the scan's own order. + s.run(r#"UnitXP("target", "mostHP")"#).unwrap(); + assert_eq!( + s.eval::(r#"return UnitName("target")"#).unwrap(), + "Bear" + ); + // Rank 3 is `UnitClassification`'s "worldboss". + s.run(r#"UnitXP("target", "worldBoss")"#).unwrap(); + assert_eq!( + s.eval::(r#"return UnitName("target")"#).unwrap(), + "Hakkar" + ); assert_eq!( s.take_selection_requests(), - vec![ - R::NearestEnemy, - R::MarkCycle { forward: true }, - R::MarkCycle { forward: false }, - ], - "queued in call order, and the unbuilt one queued nothing" + vec![R::ByGuid(0x22), R::ByGuid(0x33)] ); + // The cycle steps from where the last step left it, not from the selection — which inside + // a loop like this is still a frame behind. + let step = |s: &mut UiScript, sub: &str| { + s.run(&format!(r#"UnitXP("target", "{sub}")"#)).unwrap(); + s.eval::("return UnitName(\"target\")").unwrap() + }; + // Entering the cycle from the world-boss cursor: the next in pool order wraps to the boar. + assert_eq!(step(&mut s, "nextEnemyInCycle"), "Boar"); + assert_eq!(step(&mut s, "nextEnemyInCycle"), "Bear"); + assert_eq!(step(&mut s, "nextEnemyInCycle"), "Hakkar"); + assert_eq!(step(&mut s, "nextEnemyInCycle"), "Boar", "wraps"); + assert_eq!(step(&mut s, "previousEnemyInCycle"), "Hakkar"); + + // The banded cycle prefers 0-8 yd, then 8-25, then 25-farRange: boar (3), Hakkar (12), + // bear (30) — a different order from the scan's own. + s.run(r#"UnitXP("target", "nearestEnemy")"#).unwrap(); // reset the cursor to the boar + assert_eq!(step(&mut s, "nextEnemyConsideringDistance"), "Hakkar"); + assert_eq!(step(&mut s, "nextEnemyConsideringDistance"), "Bear"); + // ...and `farRange` can push the far one out of the cycle entirely. + s.run(r#"UnitXP("target", "farRange", 26)"#).unwrap(); + s.run(r#"UnitXP("target", "nearestEnemy")"#).unwrap(); + assert_eq!(step(&mut s, "nextEnemyConsideringDistance"), "Hakkar"); + assert_eq!( + step(&mut s, "nextEnemyConsideringDistance"), + "Boar", + "the 30 yd bear is past the 26 yd farRange, so the cycle is two long" + ); + + // The mark cycle keeps the app queue: its board is the raid-target board, not this pool. + s.take_selection_requests(); + s.run(r#"UnitXP("target", "nextMarkedEnemyInCycle")"#) + .unwrap(); + assert_eq!( + s.take_selection_requests(), + vec![R::MarkCycle { forward: true }] + ); + assert_eq!( + s.eval::>(r#"return UnitXP("target", "notASubCommand")"#) + .unwrap(), + None, + "an unbuilt sub-command is nil, not a silent no-op that claims success" + ); + assert!(s.take_selection_requests().is_empty()); + } + + /// The stock one-argument getter still answers through the same dispatcher. + #[test] + fn the_one_argument_getter_is_not_eaten_as_a_command() { + let mut s = UiScript::new().unwrap(); // The stock one-argument getter: `UnitXP("target")` is the target's XP, 0 for a // non-`"player"` token, NOT the `target` command with a missing sub-command. assert_eq!(s.eval::(r#"return UnitXP("target")"#).unwrap(), 0); diff --git a/crates/benilla-ui/src/script/inspect.rs b/crates/benilla-ui/src/script/inspect.rs index 97e4ed7f..b5234c7e 100644 --- a/crates/benilla-ui/src/script/inspect.rs +++ b/crates/benilla-ui/src/script/inspect.rs @@ -48,11 +48,16 @@ pub struct UnitReach { /// unit. The same body-layer ray the nameplate occlusion casts (`vplates::camera_can_see`), /// from the player's chest instead of the camera's eye. pub in_sight: bool, - /// `UnitXP("behind", "player", unit)`: the **player** stands in this unit's rear arc — the - /// backstab condition, and the one `[behind]` asks for. - pub player_at_its_back: bool, - /// `UnitXP("behind", unit, "player")`: this unit stands in the **player's** rear arc. - pub at_player_back: bool, + /// `UnitXP("behind", "player", unit)`: the angle in radians between **this unit's** facing + /// and the direction to the player — the backstab geometry, and the one `[behind]` asks for. + /// + /// An angle rather than a verdict, because `UnitXP("behindThreshold", "set", v)` moves the + /// line and the mod's own default (π/2, the rear hemisphere) is only the default. A unit with + /// no separation from the player answers 0, which no threshold calls behind. + pub player_behind_angle: f32, + /// `UnitXP("behind", unit, "player")`: the same angle the other way round — between the + /// **player's** facing and the direction to this unit. + pub unit_behind_angle: f32, } /// What the app has resolved for the unit currently being inspected. diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 3dd7f5ee..f9980ea9 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -1587,6 +1587,46 @@ pub(crate) struct Model { /// re-read `prevTarget` as the mob — so the restore never fires and the scan keeps your /// target. That is the "unitscan steals my target" report, and it is this field's whole job. pub(crate) target_override: Option, + /// Whether a script has ever asked for UnitXP_SP3's enemy scan, so the app only pays for + /// the walk on a client whose addons use it ([`Self::unitxp_scan`]). Latching rather than + /// per-frame: the asking call needs the pool *in the same frame*, so the first ask is always + /// a miss and every later one a hit — exactly `by_name_asked`'s bargain. + pub(crate) unitxp_scan_asked: bool, + /// UnitXP_SP3's enemy candidate pool, best first, pushed once per frame by the app + /// (`benilla-app`'s `target::scan::feed_unitxp_scan`) and **not** written here. + /// + /// `UnitXP("target", …)` has to select **inside the call**: its readers target, then read + /// `UnitExists("target")` and the new target's GUID on the next line, in a loop that walks the + /// whole cycle in one Lua frame (`SuperCleveRoidMacros/Core.lua:2884-2900`, + /// `Conditionals.lua:9670-9685`). A queued request answers a frame late, so such a loop reads + /// the same unit every pass and gives up after one. With the pool in hand the pick is made + /// here, `"target"` is overridden for the rest of the frame ([`Self::target_override`]) and + /// the selection rides `SelectionRequest::ByGuid` — the same bargain `TargetByName` already + /// strikes, and for the same reason. + pub(crate) unitxp_scan: Vec, + /// The cycle cursor for `nextEnemyInCycle` and friends: the guid the last step landed on. + /// + /// Not the selection, because the selection is one frame behind inside such a loop; and not + /// cleared per tick, because the cycle is meant to persist across presses. A guid that has + /// left the pool restarts the cycle, as the mod's does. + pub(crate) unitxp_cycle: Option, + /// UnitXP_SP3's `UnitXP("target", "farRange", n)`: the outer band of the distance-banded + /// cycle, the mod's own 26..=60 clamp with its 41 default — which is also benilla's own + /// `TAB_RANGE`, so the unset value changes nothing. + pub(crate) unitxp_far_range: f32, + /// `UnitXP("target", "rangeCone", n)`: stored and reported, **not wired** + /// (`docs/ADDON_COMPAT.md`). It narrows the mod's candidate cone, which here is the scan's own + /// frustum pull, app-side and not a per-call knob. + pub(crate) unitxp_range_cone: f32, + /// `UnitXP("behindThreshold", "set", v)`: the back-arc half-angle in radians, the mod's own + /// 0..=π clamp. π/2 is its default and ours, which is the rear **hemisphere** — the rule this + /// realm's server already enforces for Backstab and friends, so the unset value cannot + /// disagree with the game. + pub(crate) unitxp_behind_threshold: f32, + /// `UnitXP("notify", …)`: ask the host window for the window manager's attention. `true` is + /// the `systemSound` spelling, `false` `taskbarIcon`; drained by the app, which owns the + /// window (`benilla-app`'s `window_attention`). + pub(crate) attention_requests: Vec, /// ClassicAPI's `FocusUnit(unit)`/`ClearFocus()` (`compat/classic_api/focus.rs`): the focus /// unit's GUID, the persistent secondary selection 1.12 has no verb and no token for. Stored /// as a GUID for the same reason [`Self::mouseover_override`] is — the `"focus"` token must @@ -2345,6 +2385,13 @@ impl Model { by_name_asked: std::collections::BTreeSet::new(), named_targets: HashMap::new(), target_override: None, + unitxp_scan_asked: false, + unitxp_scan: Vec::new(), + unitxp_cycle: None, + unitxp_far_range: 41.0, + unitxp_range_cone: 2.5, + unitxp_behind_threshold: std::f32::consts::FRAC_PI_2, + attention_requests: Vec::new(), focus_guid: None, clickthrough_corpses: false, race_info: std::collections::BTreeMap::new(), diff --git a/crates/benilla-ui/src/script/tests/reference_surface.rs b/crates/benilla-ui/src/script/tests/reference_surface.rs index 687d5e1b..41a6ff6c 100644 --- a/crates/benilla-ui/src/script/tests/reference_surface.rs +++ b/crates/benilla-ui/src/script/tests/reference_surface.rs @@ -438,6 +438,122 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> { // ClassicAPI's tInvert (script/compat/classic_api/lua_ext.rs): value->key table flip, a // Blizzard utility 1.12 has no counterpart for. "tInvert", + // ClassicAPI's bare `IsEquippedItem` (script/compat/classic_api/item.rs), 2.0's spelling + // of the question `C_Item.IsEquippedItem` answers — the same function value, so the two + // names cannot come to disagree about one bag. 1.12 has no spelling for it at all; the + // nearest is walking `GetInventoryItemLink` over nineteen slots, and addons call the bare + // name unguarded. + "IsEquippedItem", + // ── ClassicAPI's Blizzard_SharedXML utility layer ── + // (script/compat/classic_api/shared_xml.rs). `ClassicAPI.dll` ships these as a real addon + // in its resource section and extracts it at load; benilla loads no DLL, so without this + // chunk a client that *says* it is ClassicAPI — and pfUI gates its whole load on + // `CLASSIC_API_VERSION` — answers nil for `CopyTable`. The feature test passes and the + // call raises, which is strictly worse than claiming neither. + // + // Every name here is a pure function of its arguments with one possible reading, taken + // from the shipped addon's own source. The half that is a *subsystem* — scroll boxes, the + // menu system, pools, layout frames, secure state drivers, the interface-options panel — + // is deliberately absent, because a half-built subsystem is worse than none + // (`docs/ADDON_COMPAT.md` carries that list). + // MathUtil, the rest of the file math_ext.rs started: + "MathUtil", + "CreateCounter", + "ClampMod", + "ClampDegrees", + "Wrap", + "NegateIf", + "DeltaLerp", + "FrameDeltaLerp", + "RandomFloatInRange", + "RoundToSignificantDigits", + "RoundToNearestMultiple", + "Sign", + "WithinRange", + "WithinRangeExclusive", + "ApproximatelyEqual", + "CalculateDistanceSq", + "CalculateDistance", + "CalculateAngleBetween", + "AccumulatorMixin", + "CreateAccumulator", + // `GetTickTime`: the frame's own elapsed seconds, stamped by the tick. 1.12 publishes no + // frame delta at all, and `FrameDeltaLerp` is a different curve without one. + "GetTickTime", + // TimeUtil, and the two clock formats it prints through: + "SECONDS_PER_MIN", + "SECONDS_PER_HOUR", + "SECONDS_PER_DAY", + "SECONDS_PER_MONTH", + "SECONDS_PER_YEAR", + "HOURS_MINUTES_SECONDS", + "MINUTES_SECONDS", + "SecondsToMinutes", + "MinutesToSeconds", + "ConvertSecondsToUnits", + "SecondsToClock", + // TableUtil. `SafePack`/`SafeUnpack` are the pair that keeps nils inside a varargs list, + // which a plain `{...}` loses — the reason they exist at all. + "CopyTable", + "CopyTableSafe", + "MergeTable", + "tIndexOf", + "SafePack", + "SafeUnpack", + "SafeLength", + // ColorUtil / Color, over the `ColorMixin` and `C_ColorUtil` this tree already has: + "COLOR_FORMAT_RGBA", + "COLOR_FORMAT_RGB", + "COLOR_FORMAT_ARGB", + "ExtractColorValueFromHex", + "CreateColorFromHexString", + "CreateColorFromRGBAHexString", + "CreateColorFromRGBHexString", + "CreateColorFromBestRGBHexString", + "CreateColorFromBytes", + "AreColorsEqual", + "IsRGBAEqualToColor", + "GetClassColor", + "GetClassColorObj", + "GetClassColoredTextForUnit", + "RGBToColorCode", + "RGBTableToColorCode", + "WrapTextInColorCode", + "WrapTextInColor", + // TextureUtil: the inline-texture markup builders. 1.12 understands `|T…|t` in a string + // and simply has no helper that writes one. + "TextureUtil", + "TextureKitConstants", + "GetTexCoordsByGrid", + "CreateTextureMarkup", + "CreateSimpleTextureMarkup", + "CreateAtlasMarkup", + // StringUtil / TypeUtil: + "StringUtil", + "ConfirmationStringMatches", + "ConfirmationEditBoxMatches", + "UserInputNonEmpty", + "UserEditBoxNonEmpty", + "StringSplitIntoTable", + "StringContains", + "ValueToBoolean", + "StringToBoolean", + // EnvironmentUtil: `nop` is the no-op every modern default handler is written as. + "nop", + "InGlue", + // ErrorUtil. The two callstack-height setters are no-ops here — 1.12 has no such control — + // and are present because upstream's own `CallErrorHandler` brackets its handler with them, + // so an addon that copies that shape finds them. + "GetCallstackHeight", + "SetErrorCallstackHeight", + "CallErrorHandler", + "assertsafe", + // FunctionUtil: the closure builders modern handler code is written with. + "GenerateClosure", + "GenerateFlatClosure", + "GetValueOrCallFunction", + "ExecuteFrameScript", + "CallMethodOnNearestAncestor", // ClassicAPI's token -> display-name class tables (script/class_info.rs), published from // the same `ChrClasses` columns `C_CreatureInfo.GetClassInfo` answers from. The pair this // tree's `tInvert` exists to flip: pfUI inverts the male one to key its class colours. diff --git a/crates/benilla-ui/src/script/tests/widget_surface.rs b/crates/benilla-ui/src/script/tests/widget_surface.rs index 232d3e72..fd3f891e 100644 --- a/crates/benilla-ui/src/script/tests/widget_surface.rs +++ b/crates/benilla-ui/src/script/tests/widget_surface.rs @@ -98,11 +98,6 @@ const NOT_YET_ANSWERED: &[(&str, &str, &str)] = &[ "Button CheckButton LootButton", "the same family", ), - ( - "GetTextFontObject", - "Button CheckButton LootButton", - "the same family", - ), ( "GetPushedTextOffset", "Button CheckButton LootButton", @@ -279,6 +274,13 @@ const ALLOWED_BEYOND: &[(&str, &str, &str)] = &[ "Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame", "ClassicAPI: chains a handler after whatever GetScript answers, through the stock SetScript", ), + ( + "SetResizeBounds", + "Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame", + "ClassicAPI: 10.0's single verb for 1.12's SetMinResize/SetMaxResize pair, written over \ + them so the clamp a resize obeys is unchanged. AceGUI's resizable containers call it \ + behind `if frame.SetResizeBounds then`, so it is the branch they prefer when present", + ), ( "IsEventRegistered", "Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame", @@ -420,6 +422,12 @@ const ALLOWED_BEYOND: &[(&str, &str, &str)] = &[ the two spellings cannot drift. Ace3 calls it unguarded on every button it builds \ (AceConfigDialog-3.0.lua:586)", ), + ( + "GetNormalFontObject", + "Button CheckButton LootButton", + "ClassicAPI: the getter its setter above was renamed without — 1.12 spells it \ + GetTextFontObject (0x7803b0), and this hands out the same function value", + ), ( "IsMouseOver", "Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame FontString Texture", diff --git a/crates/benilla-ui/src/script/tick.rs b/crates/benilla-ui/src/script/tick.rs index 89b02564..f38663d7 100644 --- a/crates/benilla-ui/src/script/tick.rs +++ b/crates/benilla-ui/src/script/tick.rs @@ -185,7 +185,11 @@ impl super::UiScript { 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)) + // `GetTickTime()`'s source (`compat/classic_api/shared_xml.rs`): how long the frame + // this tick belongs to took. ClassicAPI's `FrameDeltaLerp` is the consumer, and a + // frame-rate-independent lerp with a frozen delta is simply a different curve. + g.set("__benilla_tick", f64::from(elapsed)) + .and_then(|()| g.set("__benilla_now", now + f64::from(elapsed))) }; if let Err(e) = clock { self.push_error(e); diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index caac1227..f82c9cdf 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -594,6 +594,28 @@ impl super::UiScript { self.model_mut().named_targets = resolved; } + /// Whether any script has asked for UnitXP_SP3's enemy scan yet, so the app can skip the + /// candidate walk entirely on a client with no addon that uses it. + pub fn unitxp_scan_asked(&self) -> bool { + self.model_ref().unitxp_scan_asked + } + + /// Publish UnitXP_SP3's enemy candidate pool, best first, replacing the last push. + /// + /// This is what makes `UnitXP("target", …)` synchronous, as the mod's is: its readers loop + /// "step the cycle, read `"target"` back" within one Lua frame, which a queued-only selection + /// cannot serve. See [`crate::script::Model::unitxp_scan`]. + pub fn set_unitxp_scan(&mut self, pool: Vec) { + self.model_mut().unitxp_scan = pool; + } + + /// Drain `UnitXP("notify", …)`: each ask is `true` for the `systemSound` spelling and `false` + /// for `taskbarIcon`. Both reach one window-manager attention request + /// (`benilla-app`'s `window_attention`), which is why the app ignores the flag. + pub fn take_attention_requests(&mut self) -> Vec { + std::mem::take(&mut self.model_mut().attention_requests) + } + /// Drain `ClearTarget()`: true if it fired with a live target; the app deselects (guid 0). pub fn take_target_clear(&mut self) -> bool { std::mem::take(&mut self.model_mut().target_clear) diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index 4b8a4c6c..c96994ed 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -4746,3 +4746,247 @@ reagent buttons — which `ATSW_Show` builds behind an `InitializedOnShow` flag for directly: `pcall(ATSW_CreateListButtons)` from `WOW_CAPTURE_SETUP`. The skin's own `hooksecurefunc` on that function then applies the rest, which is the path the live client takes too. + +## Re-measuring the three updated DLLs: nampower, UnitXP_SP3, ClassicAPI + +grom, 2026-11-06: *"just updated classicapi.dll, nampower, and unitxp, so make it so our client has +all the up to date functions and cvars."* The three builds beside `WoW.exe` carry COFF +`TimeDateStamp`s of 2026-08-22, 2026-08-15 and 2026-11-05. Re-measured rather than assumed, each +against the thing with evidence behind it. + +### How each surface was read + +**nampower** publishes its own names in its string pool, and the `Usage:` line of every function +that can be called wrongly: + +```sh +strings -n 3 nampower.dll | grep -oE '^NP_[A-Za-z0-9]+$' | sort -u # 49 CVars +strings -n 6 nampower.dll | grep -i 'usage:' | sort -u # the arities +``` + +Both sets came back **identical to what this tree already has**: 49 of 49 `NP_*` CVars registered +in `cvars.rs`, and all 63 functions upstream's `SCRIPTS.md` lists already installed. Two names in +the usage strings look new and are not — `GetItemCooldown` and `GetSpellCooldown` appear only +*inside* the `Usage:` text of `GetItemIdCooldown`/`GetSpellIdCooldown`, never as standalone +registration strings, so they are a stale spelling in an error message rather than a function. + +**UnitXP_SP3** is packed: `strings` on it returns three-character noise, and `dll-surface.py` +finds zero pointed-at names, which is the signature of a compressed image rather than of a DLL +with no API. Its surface therefore comes from the upstream wiki (konaka/allfoxwy) plus the one +oracle that cannot be stale — the call sites in the installed addons: + +``` +21 distanceBetween 8 inSight 5 behind 3 target nearestEnemy 2 target mostHP + 2 target nextEnemyConsideringDistance 1 target worldBoss 1 target nextEnemyInCycle + 1 target previousEnemyInCycle 1 target previousEnemyConsideringDistance + 1 target nextMarkedEnemyInCycle 1 target previousMarkedEnemyInCycle 1 debug breakpoint +``` + +**ClassicAPI** turned out to carry its whole Lua half *inside the DLL*. `.rsrc` holds five gzip +blobs, each a plain container: decimal byte lengths one per line, a blank line, then the files +concatenated in the order their paths appear in the string pool (`!!!ClassicAPI\…`). 173 files for +the main addon, 21 for `ClassicAPI_DebugTools`, 3 for `ClassicAPI_EventTrace`. That is the +authoritative spec for *this exact build* — no guessing from a wiki — and it is what the ClassicAPI +work below is transcribed from. The recipe is worth keeping: it is the same shape as +`benilla-octo-exe-patches-are-readable`, and the extraction is forty lines of Python. + +### nampower: the four events that were missing + +Functions and CVars were already complete, so the gap was events. Of upstream `EVENTS.md`'s list, +this tree fired all but five; `SPELL_DELAYED_OTHER` upstream documents as **never firing** (the +server sends the pushback packet only to the affected player), so it needs no producer. The other +four are now built: + +| event | where | note | +|---|---|---| +| `BUFF_ADDED_OTHER`, `BUFF_REMOVED_OTHER` | `ui_aura.rs`, `AuraEdge::OtherBuff` | the helpful half of every *other* unit | +| `KEY_DOWN`, `KEY_UP` | `bindings.rs`, `key_event_code` | every key, before every ownership gate | + +The buff pair was previously argued *against* in this tree, on cost: diffing the helpful half of +every streamed unit on every aura edge, for events nothing registered. That argument is answered +rather than overruled — both are gated on `UiScript::has_listener`, the same lookup +`GetFramesRegisteredForEvent` reads, which is also what upstream now recommends in place of its own +`NP_Enable*Events` CVars ("registering the event is preferred"). With nothing registered the per-unit +map is never filled and is dropped the moment the last listener goes; a later re-register seeds +from first sight and fires nothing, like every other seed in that file. + +`KEY_DOWN`/`KEY_UP` fire **before** the typing gate, the eaten-key gate and the binding resolve, +because the mod's producer is a raw keyboard hook: it reports a key a focused edit box is +swallowing and the modifier keys themselves, neither of which reaches a binding. The code table was +already in this tree — `bindings/chord.rs`'s `key_code`, transcribed from the reference's binding +parser at `0x4886f0`, landed earlier with its own test and a comment saying it was *"the fact +`KEY_DOWN`/`KEY_UP` were blocked on"*. The consumer is `SuperCleveRoidMacros`' `[keydown:X]` +conditional, whose own table agrees with it everywhere it matters: `a=65 … z=90`, `0=48 … 9=57`, +`space=32`, `escape=512`, `enter=513`, `f1=768 … f12=779`, and the modifier keys as 0/1/2. + +**One open question, written down rather than papered over.** That same consumer table has +`tilde=256` and `numpad0=257 … numpad9=266`, where the parser puts `NUMPAD0` at `0x100` (=256) and +has no `TILDE` at all — the client's key-name table at `0x846c20` contains `SPACE`, the five numpad +operators, the specials and `CAPSLOCK`/`NUMPADEQUALS`/the wheel names, and no tilde. So either +nampower numbers that one bank itself, or the addon's table was a guess. The traced client table is +what ships, because it is the one with evidence; `[keydown:numpad3]` is the thing to re-measure if +it ever matters. + +### UnitXP_SP3: the scan had to become synchronous, and that was a live bug + +Every `UnitXP("target", …)` mode this client built queued a `SelectionRequest` for the app to +drain later in the frame. That is wrong for this verb, and the consumer shows exactly how: + +```lua +-- SuperCleveRoidMacros/Core.lua:2884-2900, and Conditionals.lua:9670-9685 +for i = 1, 50 do + found = UnitXP("target", scanMode) + if not found then break end + local currentGuid = CleveRoids.GetGUID("target") -- read back on the next line + if firstGuid == nil then firstGuid = currentGuid + elseif currentGuid == firstGuid then break end -- "we have cycled" → stop + ... +end +``` + +The mod selects **inside the call**, so that loop walks the whole cycle in one Lua frame. A queued +selection answers a frame late, so every pass read the *same* unit, `currentGuid == firstGuid` hit +on pass two and the scan reported one enemy however many were standing there — silently, with no +error and a truthy return. `nearestEnemy` had shipped that way. + +The fix is the bargain `TargetByName` already strikes (`Model::named_targets`): the app publishes +the pool, the VM picks from it, `"target"` is overridden for the rest of the Lua frame and the +selection rides `SelectionRequest::ByGuid`, so the GUID answered and the GUID committed are the +same by construction. `target::scan::feed_unitxp_scan` publishes the TAB scan's own enemy +candidates — best first, on-screen tier then off-centre + distance with the fighting-me bonus, so +"nearest" means here what it means for `TargetNearestEnemy` — as full `UnitState` snapshots built +by `ui_unit`'s own builder, so a pooled unit and a token naming the same unit cannot disagree. It +is gated on `Model::unitxp_scan_asked`, latched by the first call, so a client with no addon that +uses it walks nothing. + +Every mode is now built, where four of nine were before: + +| sub-command | rule | +|---|---| +| `nearestEnemy` | the pool's first entry, which is `TargetNearestEnemy`'s own pick | +| `mostHP` | the highest current health, ties by the pool's order | +| `worldBoss` | a cycle over the rank-3 entries (`UnitClassification`'s `"worldboss"`) | +| `nextEnemyInCycle` / `previous…` | a cycle in pool order | +| `nextEnemyConsideringDistance` / `previous…` | banded 0–8, 8–25, 25–`farRange`, then pool order inside the band | +| `nextMarkedEnemyInCycle` / `previous…` | **kept on the app queue**: its board is `GroupState::raid_targets`, which covers every streamed marked unit rather than only the attackable ones inside the scan's 41 yd, and nothing reads `"target"` back from it in the same frame | +| `rangeCone` | stored, **not wired** — it narrows the mod's candidate cone, which here is the scan's own frustum pull, app-side and not a per-call knob | +| `farRange` | stored and honoured by the banded cycle; the mod's own 26..=60 clamp, default 41, which is also this client's `TAB_RANGE` | +| `disableInCombatFilter` | accepted; benilla's scan never had the rule it removes | + +The cycle steps from a cursor the VM keeps (`Model::unitxp_cycle`), **not** from the selection, +because inside such a loop the selection is still a frame behind; a cursor whose unit has left the +pool restarts the cycle, as the mod's does. + +The remaining commands landed with it: + +- **`behindThreshold("set", v)`** is real rather than stored, because the feed changed shape to + make it so: `UnitReach` published two *verdicts* (`player_at_its_back`, `at_player_back`) and now + publishes the two **angles**, with the comparison moved into the reader. π/2 — the mod's default + — is the rear hemisphere, which is what vmangos enforces for the abilities that care + (`!HasInArc(M_PI, attacker)`), so the unset value is the behaviour `[behind]` always had. +- **`timer` arm/disarm/size** rides the one timer queue this VM has (`classic_api/timer.rs`, + drained by `UiScript::tick`) rather than growing a second: one queue, one drain, one place for a + scheduling bug. The callback is looked up **by name at fire time** and passed the timer id, which + is the mod's contract. +- **`notify`** reaches winit's `request_user_attention` on the primary window + (`window_attention.rs`). Both of the mod's spellings reach that one request: it *is* the taskbar + flash on Windows, the dock bounce on macOS and the urgency hint on X11/Wayland, and there is no + portable system beep — a client that played a sound through its own mixer would be claiming to + have played the desktop's alert when it had not. +- **`version`** answers the measured COFF stamp of the build this port tracks (`1786777259`, + 2026-08-15) and a descriptive string; **`performanceProfile("get")`** a plain sentence, which is + all anything does with it; **`debug("breakpoint")`** nil, and the *not raising* is the contract — + the one call site in this corpus is a commented-out line kept for hand-editing + (`pfUI-addonskinner/skins/Clique.lua:49`) and an uncommented one would take the rest of that skin + with it. + +### ClassicAPI: the `Blizzard_SharedXML` utility layer + +With the DLL's own addon extracted, the gap is measurable rather than guessable. Of the 965 global +names that addon defines, a live VM with the whole corpus loaded already answered all but 898 — and +those 898 are overwhelmingly **one of two things**: a localization string (seven locale files, ~325 +names each), or a *subsystem* — scroll boxes, the menu system, pools, layout frames, secure state +drivers, data providers, the interface-options panel. Those are hundreds of mixins over widget +behaviour this client models differently, and a half-built one is worse than an absent one. + +What is left between those two is the part this round closes: the pure helpers that carry no engine +state and are simply absent from 1.12. `compat/classic_api/shared_xml.rs` installs them as one +chunk — **in 1.12's grammar**, which costs nothing here because ClassicAPI's own shipped source is +already written that way (it is a vanilla addon: `arg` for varargs, no `select`, no +`string.match`). The bodies are its, not a re-derivation. + +| group | names | +|---|---| +| MathUtil (the rest of the file `math_ext.rs` started) | `MathUtil`, `CreateCounter`, `ClampMod`, `ClampDegrees`, `Wrap`, `NegateIf`, `DeltaLerp`, `FrameDeltaLerp`, `RandomFloatInRange`, `RoundToSignificantDigits`, `RoundToNearestMultiple`, `Sign`, `WithinRange`, `WithinRangeExclusive`, `ApproximatelyEqual`, `CalculateDistanceSq`, `CalculateDistance`, `CalculateAngleBetween`, `AccumulatorMixin`, `CreateAccumulator`, `GetTickTime` | +| TimeUtil | `SECONDS_PER_*`, `HOURS_MINUTES_SECONDS`, `MINUTES_SECONDS`, `SecondsToMinutes`, `MinutesToSeconds`, `ConvertSecondsToUnits`, `SecondsToClock` | +| TableUtil | `CopyTable`, `CopyTableSafe`, `MergeTable`, `tIndexOf`, `SafePack`, `SafeUnpack`, `SafeLength` | +| ColorUtil / Color | `COLOR_FORMAT_*`, `ExtractColorValueFromHex`, the four `CreateColorFrom*HexString`, `CreateColorFromBytes`, `AreColorsEqual`, `IsRGBAEqualToColor`, `GetClassColor`, `GetClassColorObj`, `GetClassColoredTextForUnit`, `RGBToColorCode`, `RGBTableToColorCode`, `WrapTextInColorCode`, `WrapTextInColor` | +| TextureUtil | `TextureUtil`, `TextureKitConstants`, `GetTexCoordsByGrid`, `CreateTextureMarkup`, `CreateSimpleTextureMarkup`, `CreateAtlasMarkup` | +| StringUtil / TypeUtil | `StringUtil`, `ConfirmationStringMatches`, `ConfirmationEditBoxMatches`, `UserInputNonEmpty`, `UserEditBoxNonEmpty`, `StringSplitIntoTable`, `StringContains`, `ValueToBoolean`, `StringToBoolean` | +| EnvironmentUtil / ErrorUtil / FunctionUtil | `nop`, `InGlue`, `GetCallstackHeight`, `SetErrorCallstackHeight`, `CallErrorHandler`, `assertsafe`, `GenerateClosure`, `GenerateFlatClosure`, `GetValueOrCallFunction`, `ExecuteFrameScript`, `CallMethodOnNearestAncestor` | + +Three details that a reimplementation from the name alone would get wrong: + +- **`SafePack`/`SafeUnpack` exist because a plain `{...}` loses a nil.** The pair records `arg.n` + and reads it back, which is why they work in this VM at all without `select`. +- **`StringContains` is a plain find, not a pattern one.** Upstream passes `plain = true`, so + `StringContains("a+b", "a+b")` is true and `StringContains("aab", "a+b")` is false. +- **`GetClassColor` needs `RAID_CLASS_COLORS` to have been mixed in.** Upstream's `ColorUtil.lua` + opens with a `do` block that gives every entry `ColorMixin` and stamps its `colorStr`; without it + the fourth return — the `"ffc79c6e"` a caller concatenates — is nil and + `GetClassColorObj(...):WrapTextInColorCode(text)` has no method to call. Done lazily in the two + getters here, because that table does not exist when this chunk installs and because an addon + that assigns its own plain copy at file scope would strip a one-time pass anyway — which is why + upstream re-runs its own after every addon loads. + +**`tostringall` is deliberately absent**, though ClassicAPI provides it, for the reason +`tests/stdlib.rs` pins it nil alongside `strrev`: it is a 2.0 name an addon can feature-detect the +client era with, and un-nilling one of those is a 1.12-fidelity call for the whole corpus rather +than a ClassicAPI one. + +`GetTickTime()` is new host state, not a helper: `__benilla_tick`, stamped by the tick with the +frame's own elapsed seconds. 1.12 publishes no frame delta at all, and `FrameDeltaLerp` is a +different curve without one. + +### The addon-load warnings in grom's own log, classified + +Twenty-three distinct lines across three sessions, and **none of them is a defect on this side**. +Recorded because "check the logs" should not have to be re-done: + +| line | what it is | +|---|---| +| `BigWigs/Raids\Other\Nerubian.lua: not found` | the folder has `NerubianOverseer.lua`; BigWigs' own TOC names a file it does not ship | +| `QuickHeal/libs\AceDB-2.0\…`, `…ItemBonusLib-1.0\Locales\*` | those directories are empty in the package | +| `Gromissingcrafts/…\LibStub.lua` | the lib's XML sources a `LibStub.lua` it does not ship | +| `unknown template 'QuickHealMeleeDPSListEntryTemplate'` | QuickHeal inherits a template it never defines — ten times | +| `Couldn't find frame parent: QuickHealMTListEntryTemplate` | a `` naming a **virtual** template, which is not a frame | +| `unknown template 'TitanPanelIconTemplate'`, `'TitanPanelComboTemplate'`, `'IB_BaseFrame'`, `'OutfitDisplayFrame'`, `TitanPanelWIMButtonIcon`, `InfoBarFrame` | optional dependencies (TitanPanel, InfoBar, OutfitDisplayFrame) the player does not have installed; each addon's TOC lists them under `## OptionalDeps` | +| `BetterCharacterPanel: Couldn't find frame parent: InspectPaperDollFrame` (×45) | `Blizzard_InspectUI` is LoadOnDemand on a real 1.12 client too, so that frame does not exist when addons load. The reference behaves the same; `addons.rs` already collapses the run by shape for exactly this case | + +The one that *is* ours — `Turtle_ShopUI`'s `SetOwner: unknown anchor 'BOTTOMRIGHT'` — is a separate +question (the reference's `SetOwner` takes `ANCHOR_*` names and the addon passes a bare point) and +is left where it is: it logs, it does not raise, and nothing about the shop window misbehaves. + +### ClassicAPI's native half is mostly widget *methods*, and what of it the corpus calls + +The DLL's registration table — `(char* name, void* fn)` pairs scanned out of `.rdata`/`.data`, +`scripts/dll-surface.py`'s own recipe — holds **308** names, and almost none of them is a global: +they are the methods of the Animation system, Cooldown, MaskTexture, SecureTypes and ModelScene, +plus a long tail of modern frame/region accessors. Probed against this client's own objects, the +gap is 21 on Frame, 4 on Button, 5 on Texture, 10 on FontString, 8 on StatusBar, 6 on EditBox and +3 on GameTooltip. + +Intersected with what grom's hundred addons actually call, that list collapses to four, and the +counts are the whole argument: + +| name | sites | verdict | +|---|---|---| +| `SetResizeBounds` | 10 | **built** — over 1.12's own `SetMinResize`/`SetMaxResize`, so the clamp a resize obeys is unchanged. Every site is behind `if frame.SetResizeBounds then` (AceGUI's 10.0 feature test), so this is the branch they prefer when it is there | +| `GetNormalFontObject` | 4 | **built** — and the real gap behind it was that 1.12's own `GetTextFontObject` (`0x7803b0`) was missing too, so that is what was implemented and the modern name aliases it | +| `IsEquippedItem` | 2 | **built** as a bare global, aliased to the `C_Item` member that already existed | +| `SetMotionScriptsWhileDisabled` | 2 | **deliberately not built** — both sites are behind `if frame.SetMotionScriptsWhileDisabled then`, so defining it without the hover behaviour behind it would send SoftSpoken down a path this client cannot honour. That is the guard doing its job | +| `SetAtlas` / `GetAtlas` | 13 / 1 | **not built** — an atlas is a name for a *cell inside a sheet*, and the name→(file, texcoords) table is ClassicAPI's own data. Without it `SetAtlas` can only be a silent no-op that leaves a texture blank, which is worse than the nil method the callers already handle | + +`GetHighlightColor` looked like the biggest gap at 39 sites and is not a widget method at all: +every one of them is `DBB2:GetHighlightColor()`, DifficultBulletinBoard's own method on its own +table. Counting call sites without checking the receiver is how that kind of row gets written. diff --git a/scripts/dll-surface.py b/scripts/dll-surface.py index 984cc946..a56a9b14 100755 --- a/scripts/dll-surface.py +++ b/scripts/dll-surface.py @@ -4,6 +4,7 @@ scripts/dll-surface.py # the report scripts/dll-surface.py --names # + every name per DLL scripts/dll-surface.py --wanted # + only the names grom's addon corpus calls + scripts/dll-surface.py --unpack DLL DIR # write out an addon the DLL carries in .rsrc benilla loads no DLLs: it reimplements what they add (`docs/ADDON_COMPAT.md`). The problem that creates is knowing *what* they add, and the answer is not their export tables — every one of these @@ -24,6 +25,14 @@ the addon corpus calling it, or the DLL's own docs. Reading the DLL's code for one name is `benilla-octo-exe-patches-are-readable`'s recipe, which is how the `.octoql` and `.tdata` patches in `WoW.exe` were read. + +**`--unpack` is the better question for ClassicAPI**, and the answer the registration scan cannot +give: that DLL carries its whole `!!!ClassicAPI` addon — 173 Lua/XML files — gzipped in `.rsrc`, +and extracts it into `Interface/AddOns` at load. The decompressed blob is a plain container (decimal +byte lengths, one per line, a blank line, then the files concatenated) and the paths are the +`!!!ClassicAPI\…` strings in the pool, in the same order. So the authoritative spec for the build +sitting beside the client is on disk already; see `docs/ADDON_COMPAT.md`. Nothing else in this +bundle ships resources this way, so `--unpack` on another DLL simply finds none. """ import argparse @@ -131,12 +140,102 @@ def addon_calls(root: Path) -> set[str]: return called +def unpack(dll: Path, out: Path) -> None: + """Write out the addon a DLL carries in `.rsrc`, one file per path in its string pool.""" + import gzip + import os + + image = dll.read_bytes() + pe = struct.unpack_from(" None: + nnamed, nid = struct.unpack_from(" None: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--names", action="store_true", help="list every candidate per DLL") ap.add_argument("--wanted", action="store_true", help="list only what the addon corpus calls") + ap.add_argument( + "--unpack", + nargs=2, + metavar=("DLL", "DIR"), + help="write out the addon a DLL carries in .rsrc (ClassicAPI.dll) and exit", + ) args = ap.parse_args() + if args.unpack: + dll = Path(args.unpack[0]) + unpack(dll if dll.exists() else CLIENT / args.unpack[0], Path(args.unpack[1])) + return + vanilla = census(REPO / "reference" / "1.12-globals.tsv") have = ours() called = addon_calls(REPO / "benilla-config" / "AddOns")