the addon arc: the AddOns folder opens
- benilla loads real 1.12 addons from Interface/AddOns — TOC, XML and Lua through one loader, with per-addon saved variables, Bindings.xml and the lifecycle events addons expect - both AddOns screens are in: the character-select list with per-addon enable state, and the ESC menu's in-game list - the API surface is measured, not guessed — a corpus harness runs hundreds of real 1.12 addons and ranks exactly which globals, templates and verbs they demand - the Lua dialect is the client's own, read off the binary — 5.0 semantics with the 1.12 additions, and UI source files are bytes, not UTF-8 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -238,9 +238,69 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// UnitAffectingCombat(unit) → the number 1 in combat, else nil (`0x517e10`).
|
||||
//
|
||||
// The unusual half is the miss shape: **false and "no such unit" are the SAME arm**
|
||||
// (`0x517e48 je 0x517e73` joins `0x517e5c je 0x517e73`), so an unresolvable token is
|
||||
// indistinguishable from a peaceful one. Reproduced deliberately — an addon cannot use this
|
||||
// binding to probe whether a unit exists, and ours must not let it either.
|
||||
//
|
||||
// A **bad or missing argument raises**, unlike its `UnitInRaid` neighbour: the guard here is
|
||||
// `0x6f3510` (number-or-string, and NULL for an absent argument) with the usage literal at
|
||||
// `0x851070` behind `0x6f4940`, which does not return. A *number* is accepted and stringified
|
||||
// — `UnitAffectingCombat(5)` resolves the token `"5"`, finds nothing, and answers nil.
|
||||
g.set(
|
||||
"UnitAffectingCombat",
|
||||
lua.create_function(|lua, token: Value| {
|
||||
let token = super::super::binding_abi::string_arg(
|
||||
lua,
|
||||
token,
|
||||
"Usage: UnitAffectingCombat(\"unit\")",
|
||||
)?;
|
||||
let hot = with_unit(lua, &Some(token), false, |u| u.exists && u.in_combat);
|
||||
Ok(if hot { Value::Integer(1) } else { Value::Nil })
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// UnitInRaid(unit) → **the constant number 1** on a hit, nil on a miss. NOT an index, and
|
||||
// never a raise (`0x516350`, wow-re `raid-roster-bindings.md` §1, §5-cross-checked).
|
||||
//
|
||||
// Three things about this binding are the opposite of the obvious guess, and all three are
|
||||
// read off its 83 bytes:
|
||||
//
|
||||
// * **The hit value is a hard-coded `1.0`** — `0x51637e push 0x3ff00000; push 0` into the
|
||||
// push-number helper. The membership helper underneath (`0x4baee0`) only ever returns
|
||||
// `mov eax,1` / `xor eax,eax`; it never exposes its loop counter, so there is no index to
|
||||
// be 0- or 1-based about. An addon doing `local i = UnitInRaid(u)` and then
|
||||
// `GetRaidRosterInfo(i)` reads member 1 on the real client too.
|
||||
// * **It never raises.** `0x6f3690` hands back NULL for a missing or uncoercible argument
|
||||
// instead of erroring, `0x515970` maps NULL/empty to GUID `0:0`, and `0x4baee0`
|
||||
// short-circuits `0:0` to false at its entry (`or eax,esi; je`). So a missing, wrong-typed
|
||||
// or unresolvable unit answers `nil`, not an error — hence `Option<String>` and no usage
|
||||
// string, unlike its `GetRaidRosterInfo` sibling.
|
||||
// * **The player is in the roster array**, so no `t == "player"` special case is needed here
|
||||
// — unlike `UnitInParty` below, whose roster excludes the recipient.
|
||||
g.set(
|
||||
"UnitInRaid",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let hit = token
|
||||
.as_ref()
|
||||
.and_then(|t| model.units.get(t))
|
||||
.filter(|u| u.exists && u.guid != 0)
|
||||
.is_some_and(|u| {
|
||||
model
|
||||
.party
|
||||
.raid
|
||||
.iter()
|
||||
.any(|m| m.guid != 0 && m.guid == u.guid)
|
||||
});
|
||||
Ok(if hit { Value::Integer(1) } else { Value::Nil })
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// UnitInParty(unit) → 1 when the unit is the player-in-a-group or one of the party1..4
|
||||
// members (a party token directly, or any token whose guid matches the roster's). Raid
|
||||
// membership waits for the raid arc (the party.rs module doc's stated v1 gap).
|
||||
// members (a party token directly, or any token whose guid matches the roster's).
|
||||
g.set(
|
||||
"UnitInParty",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
@@ -440,26 +500,27 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// UnitPower/UnitPowerMax(unit [, powerType]) — the snapshot carries the *active* power only
|
||||
// (the app feeds `POWER<active>`), so an explicit `powerType` argument serves the active type's
|
||||
// value and `0` for any other: stated v1 shape, not hidden (a druid's mana-in-bear-form needs
|
||||
// the full 5-slot feed later).
|
||||
// **`UnitMana`/`UnitManaMax(unit)`, not `UnitPower`** (1188 phase 5, decision 1190's list).
|
||||
// These carried Era's names — `UnitPower`/`UnitPowerMax` do not exist in 1.12 at all, which
|
||||
// `reference/1.12-globals.tsv` says outright — and an addon feature-detecting `if UnitPower`
|
||||
// would have taken a path this client cannot honour.
|
||||
//
|
||||
// The rename is exact, not approximate: 1.12's verbs take **one argument** and return the
|
||||
// current/max of whatever power the unit actually uses (`UnitFrame.lua`: `local currValue =
|
||||
// UnitMana(unit)`), which is precisely what the Era pair did when called without a type — and
|
||||
// every one of our own call sites called it that way. The type-filtering second argument goes
|
||||
// with the name: 1.12 has no such parameter, and asking "how much *mana* does a rage user
|
||||
// have" is spelled `UnitPowerType(unit)` first, which we already provide and which IS 1.12.
|
||||
g.set(
|
||||
"UnitPower",
|
||||
lua.create_function(|lua, (token, ty): (Option<String>, Option<i64>)| {
|
||||
Ok(with_unit(lua, &token, 0i64, |u| match ty {
|
||||
Some(t) if t != i64::from(u.power_type) => 0,
|
||||
_ => i64::from(u.power),
|
||||
}))
|
||||
"UnitMana",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
Ok(with_unit(lua, &token, 0i64, |u| i64::from(u.power)))
|
||||
})?,
|
||||
)?;
|
||||
g.set(
|
||||
"UnitPowerMax",
|
||||
lua.create_function(|lua, (token, ty): (Option<String>, Option<i64>)| {
|
||||
Ok(with_unit(lua, &token, 0i64, |u| match ty {
|
||||
Some(t) if t != i64::from(u.power_type) => 0,
|
||||
_ => i64::from(u.max_power),
|
||||
}))
|
||||
"UnitManaMax",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
Ok(with_unit(lua, &token, 0i64, |u| i64::from(u.max_power)))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -625,6 +686,53 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// TargetByName(name [, exactMatch]) — select by NAME through the shared resolver `0x493aa0`
|
||||
// (`0x489d60`; wow-re `object-layer/scratch/targeting-by-name.md`, a §5 trio + two pairs).
|
||||
// The app already owns that resolver for `/target` (decision 0886, `crate::target::by_name`);
|
||||
// this is the binding half, which 11 corpus addons call and the slash command bypassed.
|
||||
//
|
||||
// The four things this signature is not:
|
||||
//
|
||||
// * **not exact by default** — tier 1 is a whole-string case-insensitive compare, tier 2 is
|
||||
// the longest common PREFIX (anchored at 0, never a substring), and tier 2 is live
|
||||
// whenever Lua arg #2 is absent or 0. `TargetByName("Rag")` selects Ragnaros.
|
||||
// * **not nearest** — a tier-1 hit returns 0 from the walk callback and *terminates the
|
||||
// enumeration*, so among whole-string matches the winner is the first in enumeration
|
||||
// order, not the closest. Our resolver ranks exact-then-longest-prefix-then-nearest
|
||||
// instead; the deviation and its cost are argued at length in `by_name`'s module header,
|
||||
// which is the one place to change if that judgement is ever reversed.
|
||||
// * **not players-only** — typemask 8 is UNIT, i.e. creatures *and* players (contrast
|
||||
// `AssistByName`/`FollowByName`, which pass `0x10`). And with filter mode 0 there is no
|
||||
// dead, reaction, attackability, range, cone or scene-attach gate, and **no
|
||||
// self-exclusion**: `TargetByName(UnitName("player"))` self-targets.
|
||||
// * **not silent on a miss** — the reference emits game-message `0x127` (named, nothing
|
||||
// matched) or `0xb8` (null/empty name) and leaves the current target untouched. We keep
|
||||
// the target untouched and say nothing: the id→string table is runtime-populated BSS that
|
||||
// wow-re could not statically recover, and inventing a line is worse than omitting one.
|
||||
// Same known deviation the slash path already carries.
|
||||
//
|
||||
// A missing or wrong-typed first argument RAISES (`0x489d69 call 0x6f3510` → `0x489de1` →
|
||||
// `0x6f4940`, usage literal `0x842698`); a number is accepted and stringified.
|
||||
g.set(
|
||||
"TargetByName",
|
||||
lua.create_function(|lua, (name, exact): (Value, Option<Value>)| {
|
||||
let name =
|
||||
super::super::binding_abi::string_arg(lua, name, "Usage: TargetByName(\"name\")")?;
|
||||
// Arg #2 is fetched with `0x6f1c10(idx=2, default 0)` — it never raises, and a
|
||||
// numeric 0 is the same as absent. Matches `FollowByName`'s reading of its own
|
||||
// arg #2, which reaches the identical `ctx+0x0c` slot.
|
||||
let exact = match exact {
|
||||
None | Some(Value::Nil) | Some(Value::Boolean(false)) => false,
|
||||
Some(Value::Integer(n)) => n != 0,
|
||||
Some(Value::Number(n)) => n != 0.0,
|
||||
Some(_) => true,
|
||||
};
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.target_by_name_requests.push((name, exact));
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// DropItemOnUnit(unit) — drop the cursor's held item onto a unit (`0x48d960`). Two legs in the
|
||||
// reference: the PET leg feeds the pet, the PLAYER leg opens a trade. Queues the raw token and
|
||||
// nothing else — every gate reads state this VM does not hold, so the app owns all of them
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
//! interprets — the app decides what each maps to.
|
||||
//!
|
||||
//! v1 gaps, stated not hidden: the snapshot carries only the *active* power slot (see the
|
||||
//! `UnitPower` note below); `UnitIsDead` returns a Lua boolean rather than the client's `1`/nil
|
||||
//! `UnitMana` note below); `UnitIsDead` returns a Lua boolean rather than the client's `1`/nil
|
||||
//! (truthy either way — the shape a caller branches on is identical); `UnitIsConnected`'s backing
|
||||
//! field defaults `false`, and no app-side feed sets it `true` for live units yet (decision 0434
|
||||
//! §2's party-frame predicates land the binding first, the feed follows — see the field doc).
|
||||
@@ -43,9 +43,9 @@ pub struct UnitState {
|
||||
/// The active power type (`UnitPowerType`): `0` mana, `1` rage, `2` focus, `3` energy,
|
||||
/// `4` happiness — the descriptor's `UNIT_FIELD_BYTES_0` byte 3.
|
||||
pub power_type: u8,
|
||||
/// Current power of the active type (`UnitPower`).
|
||||
/// Current power of the active type (`UnitMana`).
|
||||
pub power: u32,
|
||||
/// Maximum power of the active type (`UnitPowerMax`).
|
||||
/// Maximum power of the active type (`UnitManaMax`).
|
||||
pub max_power: u32,
|
||||
/// Whether the unit is dead (`UnitIsDead`). NB a released ghost is NOT dead by this
|
||||
/// predicate — its wire health is 1 (decision 0308 §1); the trio is dead / [`Self::ghost`] /
|
||||
@@ -161,6 +161,18 @@ pub struct UnitState {
|
||||
/// mob/player is NOT a corpse). No feed sets it yet — corpse objects (a released player's
|
||||
/// remains) aren't selectable in benilla — a stated gap, not an oversight.
|
||||
pub corpse_object: bool,
|
||||
/// In combat (`UnitAffectingCombat`) — `UNIT_FIELD_FLAGS` (descriptor index 46) **bit 19**,
|
||||
/// mask `0x00080000` (`0x517e10`, wow-re `bag-language-combat-action-bindings.md` §3,
|
||||
/// §5-cross-checked: `mov ecx,[eax+0xa0]; shr ecx,0x13; test cl,1`).
|
||||
///
|
||||
/// **There is no player-specific combat latch**, stated as the explicit negative because it
|
||||
/// is precisely what a client is tempted to invent: a whole-image census of the
|
||||
/// `shr reg,0x13` + `test rl,1` idiom returns 7 hits, six of them this same field+bit — and
|
||||
/// two of those six are hardcoded *local-player* readers taking no token at all. `"player"`
|
||||
/// takes a fast path to the cached local-player GUID inside `0x515970` and then reads the
|
||||
/// identical bit. One wire flag answers this for every token, which is what this one field
|
||||
/// is; `benilla::ui_action::usable` already reads the same bit off the same descriptor.
|
||||
pub in_combat: bool,
|
||||
}
|
||||
|
||||
/// The grey-band table `0x80ae98` (a byte-identical twin at `0x81dda8` drives the nameplate's
|
||||
@@ -322,6 +334,12 @@ impl super::UiScript {
|
||||
std::mem::take(&mut self.model_mut().target_requests)
|
||||
}
|
||||
|
||||
/// Drain the `(name, exactMatch)` pairs `TargetByName` queued since the last call — the app
|
||||
/// runs the shared by-name resolver (decision 0886) and commits the selection.
|
||||
pub fn take_target_by_name_requests(&mut self) -> Vec<(String, bool)> {
|
||||
std::mem::take(&mut self.model_mut().target_by_name_requests)
|
||||
}
|
||||
|
||||
/// Drain the `ClearTarget()` trigger: `true` if it fired with a live target since the last
|
||||
/// call — the ESC chain's last leg; the app commits the deselect (SetSelection guid 0).
|
||||
pub fn take_target_clear(&mut self) -> bool {
|
||||
|
||||
@@ -54,7 +54,7 @@ fn unit_reaction_reports_the_scale_value_or_nil() {
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// The target plate's faction decision (TargetFrame_CheckFaction, as `BenillaUnitFrame_Update`
|
||||
/// The target plate's faction decision (TargetFrame_CheckFaction, as `UnitFrame_Update`
|
||||
/// composes it): a player-controlled target picks red (mutually hostile) or blue (else) and
|
||||
/// NEVER the reaction green; only an NPC reads the reaction swatch. This pins the predicate
|
||||
/// composition the plate branch depends on — a friendly *player* must resolve to blue, which is
|
||||
@@ -356,19 +356,20 @@ fn power_bindings_read_the_active_type() {
|
||||
.unwrap(),
|
||||
(1, "RAGE".into())
|
||||
);
|
||||
assert_eq!(s.eval::<i64>(r#"return UnitPower("player")"#).unwrap(), 35);
|
||||
assert_eq!(s.eval::<i64>(r#"return UnitMana("player")"#).unwrap(), 35);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return UnitPowerMax("player")"#).unwrap(),
|
||||
s.eval::<i64>(r#"return UnitManaMax("player")"#).unwrap(),
|
||||
100
|
||||
);
|
||||
// The active type asked for explicitly serves the value; another type serves 0 (v1 shape).
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return UnitPower("player", 1)"#).unwrap(),
|
||||
35
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return UnitPower("player", 0)"#).unwrap(),
|
||||
0
|
||||
// **The 1.12 verbs take one argument and report the ACTIVE power**, whatever it is — this
|
||||
// unit is a rage user and `UnitMana` reads 35 rage, which looks wrong and is exactly right
|
||||
// (`UnitFrame.lua`: `local currValue = UnitMana(unit)` for every power type). Asking about a
|
||||
// specific type is `UnitPowerType(unit)` first; there is no second argument to pass, and the
|
||||
// Era pair that had one is gone (decision 1190's beyond-1.12 list, 1188 phase 5).
|
||||
assert!(
|
||||
s.eval::<bool>("return UnitPower == nil and UnitPowerMax == nil")
|
||||
.unwrap(),
|
||||
"the Era spellings must not linger beside the 1.12 ones"
|
||||
);
|
||||
// An absent unit: type reads as mana, values 0.
|
||||
assert_eq!(
|
||||
@@ -611,3 +612,85 @@ fn unit_classification_reads_the_five_word_table() {
|
||||
"a missing token is normal, not nil"
|
||||
);
|
||||
}
|
||||
|
||||
// ── `UnitAffectingCombat` and `TargetByName` ────────────────────────────────────────────────────
|
||||
|
||||
/// `UnitAffectingCombat` → the number 1 / nil, and **one arm serves both "not in combat" and "no
|
||||
/// such unit"** (`0x517e48` and `0x517e5c` jump to the same `0x517e73`). That indistinguishability
|
||||
/// is the behaviour, not a shortcut: an addon must not be able to probe existence with this verb.
|
||||
#[test]
|
||||
fn unit_affecting_combat_is_one_or_nil_and_hides_the_missing_unit() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let mut hot = player();
|
||||
hot.in_combat = true;
|
||||
s.set_unit("player", Some(hot));
|
||||
s.set_unit("target", Some(player())); // exists, peaceful
|
||||
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return UnitAffectingCombat("player")"#)
|
||||
.unwrap(),
|
||||
1,
|
||||
"the number 1, never a boolean"
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitAffectingCombat("target") == nil"#)
|
||||
.unwrap());
|
||||
assert!(
|
||||
s.eval::<bool>(r#"return UnitAffectingCombat("party3") == nil"#)
|
||||
.unwrap(),
|
||||
"an unresolvable token is the SAME nil a peaceful unit gives"
|
||||
);
|
||||
// A number is accepted and stringified (`0x6f3510` is number-OR-string): it resolves the
|
||||
// token "5", finds nothing, and answers nil — it does not raise.
|
||||
assert!(s
|
||||
.eval::<bool>("return UnitAffectingCombat(5) == nil")
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// A missing or wrong-typed argument **raises** — `0x6f4940` is `luaL_error` and does not return,
|
||||
/// so the caller's statement is abandoned rather than continuing with a nil.
|
||||
#[test]
|
||||
fn unit_affecting_combat_raises_on_a_bad_argument() {
|
||||
let s = UiScript::new().unwrap();
|
||||
for call in ["UnitAffectingCombat()", "UnitAffectingCombat({})"] {
|
||||
let err = s
|
||||
.eval::<mlua::Value>(&format!("return {call}"))
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
format!("{err}").contains(r#"Usage: UnitAffectingCombat("unit")"#),
|
||||
"{call} must raise the usage line, got {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `TargetByName(name [, exactMatch])` queues the name **and the second argument** — the flag the
|
||||
/// slash command has no way to supply, which turns the resolver's longest-common-prefix tier off.
|
||||
#[test]
|
||||
fn target_by_name_queues_the_name_and_the_exact_flag() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
assert!(s.take_target_by_name_requests().is_empty());
|
||||
|
||||
s.run(r#"TargetByName("Rag")"#).unwrap();
|
||||
s.run(r#"TargetByName("Ragnaros", 1)"#).unwrap();
|
||||
s.run(r#"TargetByName("Ragnaros", 0)"#).unwrap();
|
||||
s.run(r#"TargetByName("Ragnaros", true)"#).unwrap();
|
||||
s.run("TargetByName(5)").unwrap(); // a number stringifies, as `0x6f3690` does
|
||||
assert_eq!(
|
||||
s.take_target_by_name_requests(),
|
||||
vec![
|
||||
("Rag".to_string(), false),
|
||||
("Ragnaros".to_string(), true),
|
||||
("Ragnaros".to_string(), false),
|
||||
("Ragnaros".to_string(), true),
|
||||
("5".to_string(), false),
|
||||
]
|
||||
);
|
||||
assert!(s.take_target_by_name_requests().is_empty());
|
||||
|
||||
// A missing/wrong-typed name raises (`0x489d69 call 0x6f3510` → `0x489de1`).
|
||||
let err = s.eval::<mlua::Value>("return TargetByName()").unwrap_err();
|
||||
assert!(
|
||||
format!("{err}").contains(r#"Usage: TargetByName("name")"#),
|
||||
"got {err}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user