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:
Generated
+1
@@ -541,6 +541,7 @@ dependencies = [
|
||||
"toml",
|
||||
"wgpu",
|
||||
"windows-sys 0.59.0",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user