Bring the nampower, UnitXP_SP3 and ClassicAPI ports up to the installed builds

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-11-06 09:28:56 -07:00
co-authored by Claude Opus 5
parent b39651d9b4
commit 4974326381
29 changed files with 2101 additions and 112 deletions
Generated
+1
View File
@@ -541,6 +541,7 @@ dependencies = [
"toml",
"wgpu",
"windows-sys 0.59.0",
"winit",
]
[[package]]
+4
View File
@@ -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"
+1
View File
@@ -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.
+71 -1
View File
@@ -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<i32> {
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<KeybindCommand> {
@@ -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 {
+23 -1
View File
@@ -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<Guid>` (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) {
+1
View File
@@ -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;
+9 -9
View File
@@ -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<Res<'w, super::Factions>>,
reputations: Res<'w, crate::net::Reputations>,
self_q: Query<'w, 's, &'static ObjectStore, With<SelfPlayer>>,
classes: Option<Res<'w, crate::chr_classes::ChrClassTable>>,
group: Res<'w, crate::ui_party::GroupState>,
stores: Query<'w, 's, &'static ObjectStore>,
positions: Query<'w, 's, &'static Transform, With<Guid>>,
pub(super) names: Res<'w, NameCache>,
pub(super) commands: Res<'w, crate::net::NetCommands>,
pub(super) factions: Option<Res<'w, super::Factions>>,
pub(super) reputations: Res<'w, crate::net::Reputations>,
pub(super) self_q: Query<'w, 's, &'static ObjectStore, With<SelfPlayer>>,
pub(super) classes: Option<Res<'w, crate::chr_classes::ChrClassTable>>,
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<Guid>>,
}
/// Keep every `TargetByName` query a script has asked for resolved **a frame ahead**, so the
+1 -1
View File
@@ -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),
)
+46
View File
@@ -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<NonSendMut<benilla_ui::script::UiScript>>,
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(
+59 -5
View File
@@ -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<Vec<DebuffSlot>>,
}
/// 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));
}
}
}
@@ -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,
}
@@ -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,
}
+33 -18
View File
@@ -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);
@@ -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<NonSendMut<UiScript>>,
// `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<NonSend<WinitWindows>>,
primary: Query<Entity, With<PrimaryWindow>>,
) {
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),
);
}
}
+19
View File
@@ -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::<Value>(n.as_str()),
None => Ok(Value::Nil),
}
})?,
)?;
m.set(
"SetHighlightFontObject",
lua.create_function(|lua, (this, font): (Table, Value)| {
@@ -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
@@ -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*
@@ -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.
@@ -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, <its own args>)`. 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::<i64>("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>, 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::<String>(
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::<bool>(r#"return StringContains("a+b", "a+b")"#)
.unwrap());
assert!(!s
.eval::<bool>(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::<String>(r#"return CreateSimpleTextureMarkup("Interface\\Icons\\X", 16, 20)"#)
.unwrap(),
"|TInterface\\Icons\\X:20:16:0:0|t"
);
assert_eq!(
s.eval::<String>(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::<i64>("return Sign(-3)").unwrap(), -1);
assert_eq!(s.eval::<i64>("return Sign(0)").unwrap(), 0);
assert_eq!(s.eval::<f64>("return ClampDegrees(-90)").unwrap(), 270.0);
assert_eq!(s.eval::<f64>("return Wrap(5, 4)").unwrap(), 1.0);
assert_eq!(
s.eval::<f64>("return RoundToNearestMultiple(85, 50)")
.unwrap(),
100.0
);
assert!(s
.eval::<bool>("return WithinRange(5, 5, 7) and not WithinRangeExclusive(5, 5, 7)")
.unwrap());
assert_eq!(
s.eval::<f64>("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::<String>("return SecondsToClock(125)").unwrap(),
"02:05"
);
assert_eq!(
s.eval::<String>("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::<bool>(
"local src = { inner = {} } \
return CopyTable(src).inner ~= src.inner \
and CopyTable(src, true).inner == src.inner"
)
.unwrap());
assert_eq!(
s.eval::<Option<bool>>("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::<String>(r#"return GetClassColoredTextForUnit("player", "Bob")"#)
.unwrap(),
"Bob",
"no unit snapshot: the text passes through unchanged, never raising"
);
assert_eq!(
s.eval::<String>(r#"return GetClassColorObj("WARRIOR"):WrapTextInColorCode("x")"#)
.unwrap(),
"|cffc79c6ex|r"
);
}
}
@@ -86,6 +86,49 @@ fn handle(lua: &Lua, id: u64) -> mlua::Result<Table> {
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<f64>,
name: &str,
) -> mlua::Result<u64> {
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<Function> = 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::<Model>().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::<Model>().expect("model app_data");
model.timers.iter().filter(|r| !r.cancelled).count() as i64
}
pub(super) fn install(lua: &Lua) -> mlua::Result<Table> {
let t = lua.create_table()?;
t.set(
+390 -60
View File
@@ -58,18 +58,165 @@ fn distance_between(lua: &Lua, args: &[Value]) -> Option<f64> {
Some(dist(a, b))
}
/// `UnitXP("target", <sub>)`: 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", <sub>)`, 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::<Model>().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<usize>, 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<usize> = (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<usize> = (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<f32> {
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::<Model>()
.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<Value> {
}
}
/// 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<Value> {
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::<Model>()
.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(<bool>)`: 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::<Model>()
.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::<Model>()
.expect("model app_data")
.attention_requests
.push(sound);
Value::Boolean(true)
}
// `version(<what>)`. 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::<i64>(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::<Option<i64>>(r#"return UnitXP("target", "notASubCommand")"#)
s.eval::<Option<bool>>(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::<bool>(r#"return UnitXP("target", "nearestEnemy")"#)
.unwrap(),
"a pick answers true"
);
assert_eq!(
s.eval::<String>(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::<String>(r#"return UnitName("target")"#).unwrap(),
"Bear"
);
// Rank 3 is `UnitClassification`'s "worldboss".
s.run(r#"UnitXP("target", "worldBoss")"#).unwrap();
assert_eq!(
s.eval::<String>(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::<String>("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::<Option<bool>>(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::<i64>(r#"return UnitXP("target")"#).unwrap(), 0);
+10 -5
View File
@@ -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.
+47
View File
@@ -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<UnitState>,
/// 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<UnitState>,
/// 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<u64>,
/// 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<bool>,
/// 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(),
@@ -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.
@@ -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",
+5 -1
View File
@@ -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);
+22
View File
@@ -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<UnitState>) {
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<bool> {
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)
+244
View File
@@ -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 `<GameTooltip parent=…>` 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.
+99
View File
@@ -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("<I", image, 0x3C)[0]
nsec = struct.unpack_from("<H", image, pe + 6)[0]
opt = struct.unpack_from("<H", image, pe + 20)[0]
base = pe + 24 + opt
rsrc = None
for i in range(nsec):
off = base + 40 * i
name = image[off : off + 8].rstrip(b"\0").decode("latin1")
vsize, va, rsize, raw = struct.unpack_from("<IIII", image, off + 8)
if name == ".rsrc":
rsrc = (va, raw, rsize)
if rsrc is None:
sys.exit(f"{dll.name}: no .rsrc section")
rva, raw, rsize = rsrc
blob = image[raw : raw + rsize]
entries: list[bytes] = []
def walk(off: int) -> None:
nnamed, nid = struct.unpack_from("<HH", blob, off + 12)
for i in range(nnamed + nid):
_, dataoff = struct.unpack_from("<II", blob, off + 16 + 8 * i)
if dataoff & 0x80000000:
walk(dataoff & 0x7FFFFFFF)
else:
drva, dsize = struct.unpack_from("<II", blob, dataoff)
start = raw + (drva - rva)
entries.append(image[start : start + dsize])
walk(0)
# The paths, in the order the pool holds them — which is the order the files inside one blob
# are concatenated. Grouped by top-level folder, because each blob is one addon and the pool
# also holds paths that belong to none of them (`Interface\\FrameXML\\Bindings.xml`, the
# SavedVariables templates). A blob is matched to the group whose file count it equals, which
# is unambiguous here and refuses rather than guesses when it is not.
groups: dict[str, list[str]] = {}
for m in re.finditer(rb"[A-Za-z!][\w!.-]*\\[^\x00\n]*?\.(?:lua|xml|toc|tga|blp)", image):
path = m.group(0).decode("latin1").replace("\\", "/")
if "%" in path or path.startswith(("Interface/", "WTF/")):
continue
groups.setdefault(path.split("/", 1)[0], []).append(path)
written = 0
for data in entries:
if data[:2] != b"\x1f\x8b":
continue
body = gzip.decompress(data)
nl = body.find(b"\n\n")
if nl < 0:
continue
try:
sizes = [int(x) for x in body[:nl].split(b"\n")]
except ValueError:
continue
files = body[nl + 2 :]
if sum(sizes) != len(files):
continue
hits = [k for k, v in groups.items() if len(v) == len(sizes)]
if len(hits) != 1:
print(f" skipped a {len(sizes)}-file blob: {len(hits)} folders match that count")
continue
paths, pos = groups.pop(hits[0]), 0
for path, size in zip(paths, sizes):
dst = out / path
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_bytes(files[pos : pos + size])
pos += size
written += 1
if written == 0:
sys.exit(f"{dll.name}: no packed addon found in .rsrc")
print(f"{dll.name}: wrote {written} files to {out}")
def main() -> 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")