unitscan probes for a watched rare by targeting its name, reading the name back,
and calling TargetLastTarget to put the old target back — but only
`if prevTarget ~= foundTarget` (unitscan.lua:112-130). Two things had to be true
inside that one Lua frame and neither was, so the restore never fired and every
scan kept your target. TargetLastTarget itself was fine and was never even
reached.
**The read after the call.** `TargetByName 0x489d60` runs the resolver and
selects before it returns, so the next line sees the pick. Ours queued a request
the app drained later in the frame, and the token snapshots had been pushed
*before* Lua ran — so the probing frame saw no change at all, and by the frame
that did see the mob, `prevTarget` had already been re-read as the mob.
The app now keeps each query a script has asked for resolved a frame ahead
(`by_name::feed_named_targets`, one candidate walk for the whole asked set, and
nothing walked at all when no script uses the verb). With the unit already in
hand the binding answers `"target"` for the rest of the Lua frame and queues the
selection *by GUID*, so the optimistic answer and the committed selection cannot
name different units. The override is dropped at the top of the next tick. A
query asked for the very first time keeps the old one-frame-late path, and that
ask is what starts it being resolved.
**The order of two selection verbs in one frame.** `TargetLastTarget`'s memory
was sampled once a frame, so mid-frame it still named whatever preceded the
*previous* selection: the restore would have landed on the wrong unit even with a
synchronous read. The reference stamps inside the selection itself, so its memory
is always the thing being left behind; `selection_requests` now calls
`LastTarget::note` per request, and the per-frame sampler stays as the catch-all
for selection changes that are not requests (a stream-out, a death clear, a
click-off). The two compose: the test walks the whole probe-and-restore sequence
and checks both the mid-frame pick and the memory the frame ends with.
Fixes the `/target X` then `UnitName("target")` macro idiom generally, not just
unitscan — SuperCleveRoidMacros is built on it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
1241 lines
52 KiB
Rust
1241 lines
52 KiB
Rust
//! The `Unit*` global registrations, over the per-token [`UnitState`](super::UnitState) snapshots.
|
|
//! Every predicate answers the number 1 or nil through [`super::unit_predicate`] or
|
|
//! [`flag`](super::super::binding_abi::flag): no binding in the reference's unit table (`0x850438`)
|
|
//! calls `lua_pushboolean` (`0x6f39f0`), though `IsPetAttackActive` (`0x4be0e0`) outside it does.
|
|
|
|
use mlua::{Lua, Value};
|
|
|
|
use super::super::binding_abi::flag;
|
|
use super::super::Model;
|
|
use super::{
|
|
check_unit_token, classification_word, grey_band, guid_token, level_reads_unknown,
|
|
pick_unit_token, unit_predicate, unknownobject, with_unit, PlayerRecord, SelectionRequest,
|
|
};
|
|
|
|
/// The `"player"` fast path of `UnitName`, `UnitRace`, `UnitClass` and `UnitSex`, never
|
|
/// `UnitLevel`: the record's answer when the token equals `"player"` (`0x847894`) whole and
|
|
/// case-insensitively; any other token, `"playerfoo"` included, goes to the resolver.
|
|
fn player_record_arm<T>(
|
|
lua: &Lua,
|
|
token: &Option<String>,
|
|
f: impl FnOnce(&PlayerRecord) -> T,
|
|
) -> Option<T> {
|
|
if !token
|
|
.as_deref()
|
|
.is_some_and(|t| t.eq_ignore_ascii_case("player"))
|
|
{
|
|
return None;
|
|
}
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Some(f(&model.player_record))
|
|
}
|
|
|
|
/// The class ids `GetComboPoints` (`0x51a190`) accepts: it compares the class, `UNIT_FIELD_BYTES_0`
|
|
/// byte 1, with 4 and 0xb.
|
|
const CLASS_ROGUE: u32 = 4;
|
|
const CLASS_DRUID: u32 = 11;
|
|
|
|
/// `UNIT_FIELD_FLAGS` bit 25, `UNIT_FLAG_NOT_SELECTABLE` (vmangos `UnitDefines.h:570`):
|
|
/// `CanAssist`'s first refusal, and the same bit `benilla-app`'s `target/relations.rs` tests.
|
|
const UNIT_FLAG_NOT_SELECTABLE: u32 = 1 << 25;
|
|
|
|
/// Register the `Unit*` globals.
|
|
pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
|
let g = lua.globals();
|
|
|
|
// SuperWoW: a second return, the unit's GUID (`guid_token`), only where one resolves —
|
|
// addons feature-detect SuperWoW off this second value, then round-trip it through
|
|
// `UnitExists`, `SetMouseoverUnit` and the rest as an opaque unit id (see `Model::unit`'s
|
|
// SuperWoW arms). The false leg (and a resolved unit with no known guid) stays single-valued,
|
|
// exactly the stock shape: nothing here should need a second value to read as nil.
|
|
g.set(
|
|
"UnitExists",
|
|
lua.create_function(|lua, token: Option<String>| {
|
|
let (exists, guid) = with_unit(lua, &token, (false, 0), |u| (u.exists, u.guid))?;
|
|
let mut out = mlua::MultiValue::new();
|
|
out.push_back(flag(exists));
|
|
if exists && guid != 0 {
|
|
out.push_back(Value::String(lua.create_string(guid_token(guid))?));
|
|
}
|
|
Ok(out)
|
|
})?,
|
|
)?;
|
|
|
|
// `UnitIsVisible(unit)` (`0x516030`): whether the client holds the unit's object, nothing else.
|
|
// Not `UnitExists`: an out-of-range party member exists through its roster entry, unseen.
|
|
g.set(
|
|
"UnitIsVisible",
|
|
lua.create_function(|lua, token: Option<String>| {
|
|
unit_predicate(lua, &token, |u| u.has_object)
|
|
})?,
|
|
)?;
|
|
|
|
// `UnitIsTapped`/`UnitIsTappedByPlayer` (`0x519c90`/`0x519d00`): the object is present and its
|
|
// `UNIT_DYNAMIC_FLAGS` has the mask bit, nothing more. Unlike `UnitIsVisible` they gate with
|
|
// `lua_isstring`: another type raises `Usage:`, and a number raises `Unknown unit name`.
|
|
for (name, usage, by_player) in [
|
|
("UnitIsTapped", r#"Usage: UnitIsTapped("unit")"#, false),
|
|
(
|
|
"UnitIsTappedByPlayer",
|
|
r#"Usage: UnitIsTappedByPlayer("unit")"#,
|
|
true,
|
|
),
|
|
] {
|
|
g.set(
|
|
name,
|
|
lua.create_function(move |lua, token: Value| {
|
|
let token = Some(crate::script::binding_abi::string_arg(lua, token, usage)?);
|
|
let hit = with_unit(lua, &token, false, |u| {
|
|
if by_player {
|
|
u.tapped_by_player
|
|
} else {
|
|
u.tapped
|
|
}
|
|
})?;
|
|
Ok(flag(hit))
|
|
})?,
|
|
)?;
|
|
}
|
|
|
|
// `UnitIsPartyLeader(unit)` (`0x516210`): a held player's `PLAYER_FLAGS` bit 0x1, for a
|
|
// stranger leading their own party, or a GUID equal to the group leader's, for a member not
|
|
// held. No zero guard (unlike `IsPartyLeader`, `0x4e9130`): GUID 0 matches the zeroed leader,
|
|
// so `UnitIsPartyLeader(nil)` answers 1 while ungrouped. No `Usage:` gate; a bad token or a
|
|
// number raises `Unknown unit name`.
|
|
g.set(
|
|
"UnitIsPartyLeader",
|
|
lua.create_function(|lua, token: Option<String>| {
|
|
check_unit_token(&token)?;
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let by_flag = token
|
|
.as_ref()
|
|
.and_then(|t| model.unit(t))
|
|
.is_some_and(|u| u.group_leader);
|
|
// A token with no snapshot is GUID 0, the reference's `0:0`.
|
|
let guid = token
|
|
.as_ref()
|
|
.and_then(|t| model.unit(t))
|
|
.map_or(0, |u| u.guid);
|
|
Ok(flag(by_flag || guid == model.party.leader_guid))
|
|
})?,
|
|
)?;
|
|
|
|
g.set(
|
|
"UnitName",
|
|
lua.create_function(|lua, token: Value| {
|
|
let token = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
token,
|
|
r#"Usage: UnitName("unit")"#,
|
|
)?);
|
|
// Two values (`0x5170ae`, `0x517289`): the name, and the realm, nil for a same-realm
|
|
// player or a non-player (`0x609210`), so always nil in single-realm benilla. A second
|
|
// argument (stock `UnitPopup.lua:106` passes `true`) is not read. The name is nil only
|
|
// for an empty `"player"` buffer (`0x51708c`, `0x6f3895`) or GUID 0 (`0x5170c0`);
|
|
// otherwise a string, `UNKNOWNOBJECT` until the name cache answers (`0x517220`,
|
|
// `0x609324`), as for a new pet. A seated snapshot answers even where `exists` is
|
|
// false; the `"player"` arm reads the local name buffer (`0xc27d88`), never the unit.
|
|
if let Some(seeded) = player_record_arm(lua, &token, |r| r.name.clone()) {
|
|
let name = if seeded.is_empty() {
|
|
Value::Nil
|
|
} else {
|
|
Value::String(lua.create_string(&seeded)?)
|
|
};
|
|
return Ok((name, Value::Nil));
|
|
}
|
|
let name = with_unit(lua, &token, None, |u| Some(u.name.clone()))?;
|
|
let name = match name {
|
|
None => Value::Nil,
|
|
Some(Some(n)) => Value::String(lua.create_string(&n)?),
|
|
Some(None) => Value::String(unknownobject(lua)?),
|
|
};
|
|
Ok((name, Value::Nil))
|
|
})?,
|
|
)?;
|
|
|
|
g.set(
|
|
"UnitHealth",
|
|
lua.create_function(|lua, token: Value| {
|
|
let token = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
token,
|
|
r#"Usage: UnitHealth("unit")"#,
|
|
)?);
|
|
with_unit(lua, &token, 0i64, |u| i64::from(u.health))
|
|
})?,
|
|
)?;
|
|
|
|
g.set(
|
|
"UnitHealthMax",
|
|
lua.create_function(|lua, token: Value| {
|
|
let token = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
token,
|
|
r#"Usage: UnitHealthMax("unit")"#,
|
|
)?);
|
|
with_unit(lua, &token, 0i64, |u| i64::from(u.max_health))
|
|
})?,
|
|
)?;
|
|
|
|
// UnitLevel (`0x517fc0`): the raw level (a raw 0 stays 0), or -1 for a world boss or a hostile
|
|
// unit 10 or more levels above the player. The reference's attackable-decay override
|
|
// (`max(1, raw - round(min(b, 100) * 0.05))`, `b` an untraced byte not seen set) is not built.
|
|
g.set(
|
|
"UnitLevel",
|
|
lua.create_function(|lua, token: Value| {
|
|
let token = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
token,
|
|
r#"Usage: UnitLevel("unit")"#,
|
|
)?);
|
|
check_unit_token(&token)?;
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let Some(u) = token.as_ref().and_then(|t| model.unit(t)) else {
|
|
return Ok(0i64);
|
|
};
|
|
Ok(if u.level == 0 {
|
|
0
|
|
} else if level_reads_unknown(u, model.player_req.level) {
|
|
-1
|
|
} else {
|
|
i64::from(u.level)
|
|
})
|
|
})?,
|
|
)?;
|
|
|
|
// UnitIsCorpse (`0x5161c0`): the token names a corpse object, a released player's remains; a
|
|
// dead unit is not one. No feed sets `corpse_object`, so this answers nil.
|
|
g.set(
|
|
"UnitIsCorpse",
|
|
lua.create_function(|lua, token: Option<String>| {
|
|
unit_predicate(lua, &token, |u| u.corpse_object)
|
|
})?,
|
|
)?;
|
|
|
|
// UnitCanAttack (`0x516c50`) delegates to `CanAttack` (`0x606980`). The snapshot holds only
|
|
// whether the player can attack the unit, fed for `target`, `targettarget` and `npc`, and that
|
|
// answers both argument orders; the reverse direction stock FrameXML also asks
|
|
// (`TargetFrame.lua:147`) is not built.
|
|
g.set(
|
|
"UnitCanAttack",
|
|
lua.create_function(|lua, (a, b): (Value, Value)| {
|
|
// Both arguments are `lua_isstring`-gated, so either one nil raises.
|
|
let a = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
a,
|
|
r#"Usage: UnitCanAttack("unit", "otherUnit")"#,
|
|
)?);
|
|
let b = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
b,
|
|
r#"Usage: UnitCanAttack("unit", "otherUnit")"#,
|
|
)?);
|
|
let token = pick_unit_token(&a, &b);
|
|
unit_predicate(lua, &token, |u| u.can_attack)
|
|
})?,
|
|
)?;
|
|
|
|
// GetQuestGreenRange (`0x4e17d0`): the green-to-grey boundary `QuestLogFrame.lua:593` buckets
|
|
// by, from the table at `0x8076c0`. The reference answers 0 with no player object (`0x4e17f8`);
|
|
// here a level of 0 reads the table's first entry, 4.
|
|
g.set(
|
|
"GetQuestGreenRange",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(i64::from(grey_band(model.player_req.level)))
|
|
})?,
|
|
)?;
|
|
|
|
g.set(
|
|
"UnitIsDead",
|
|
lua.create_function(|lua, token: Value| {
|
|
let token = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
token,
|
|
r#"Usage: UnitIsDead("unit")"#,
|
|
)?);
|
|
unit_predicate(lua, &token, |u| u.dead)
|
|
})?,
|
|
)?;
|
|
|
|
// A released ghost has health 1, so `UnitIsDead` is false for it.
|
|
g.set(
|
|
"UnitIsGhost",
|
|
lua.create_function(|lua, token: Value| {
|
|
let token = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
token,
|
|
r#"Usage: UnitIsGhost("unit")"#,
|
|
)?);
|
|
unit_predicate(lua, &token, |u| u.ghost)
|
|
})?,
|
|
)?;
|
|
g.set(
|
|
"UnitIsDeadOrGhost",
|
|
lua.create_function(|lua, token: Value| {
|
|
let token = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
token,
|
|
r#"Usage: UnitIsDeadOrGhost("unit")"#,
|
|
)?);
|
|
unit_predicate(lua, &token, |u| u.dead || u.ghost)
|
|
})?,
|
|
)?;
|
|
|
|
// UnitReaction(unit, other) (`0x5167e0`): `0x6061e0(unit, other)` plus one (`0x51683e`), so
|
|
// 1-based, 5 for a unit toward itself, never 0; nil (`0x51685f`) only when a token names no
|
|
// unit. Our reaction 0 means "not fed" and also answers nil, where the reference answers a
|
|
// number. The snapshot holds the reaction toward the player, so `other` is checked, not read.
|
|
g.set(
|
|
"UnitReaction",
|
|
lua.create_function(|lua, (token, _other): (Value, Value)| {
|
|
// Both arguments are `lua_isstring`-gated, so either one nil raises.
|
|
let token = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
token,
|
|
r#"Usage: UnitReaction("unit", "otherUnit")"#,
|
|
)?);
|
|
let _other = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
_other,
|
|
r#"Usage: UnitReaction("unit", "otherUnit")"#,
|
|
)?);
|
|
let r = with_unit(lua, &token, 0u8, |u| u.reaction)?;
|
|
Ok(if r == 0 {
|
|
Value::Nil
|
|
} else {
|
|
Value::Integer(i64::from(r))
|
|
})
|
|
})?,
|
|
)?;
|
|
|
|
// UnitIsEnemy/UnitIsFriend (`0x516890`/`0x516930`) threshold the reaction `UnitReaction`
|
|
// reads: enemy at 1 or 2, friend at 5 and up, and an unfed 0 is neither. The snapshot holds the
|
|
// reaction toward the player, so the non-player argument names the unit.
|
|
g.set(
|
|
"UnitIsEnemy",
|
|
lua.create_function(|lua, (a, b): (Value, Value)| {
|
|
// Both arguments are `lua_isstring`-gated, so either one nil raises.
|
|
let a = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
a,
|
|
r#"Usage: UnitIsEnemy("unit", "otherUnit")"#,
|
|
)?);
|
|
let b = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
b,
|
|
r#"Usage: UnitIsEnemy("unit", "otherUnit")"#,
|
|
)?);
|
|
let token = pick_unit_token(&a, &b);
|
|
let r = with_unit(lua, &token, 0u8, |u| u.reaction)?;
|
|
Ok(flag((1..=2).contains(&r)))
|
|
})?,
|
|
)?;
|
|
g.set(
|
|
"UnitIsFriend",
|
|
lua.create_function(|lua, (a, b): (Value, Value)| {
|
|
// Both arguments are `lua_isstring`-gated, so either one nil raises.
|
|
let a = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
a,
|
|
r#"Usage: UnitIsFriend("unit", "otherUnit")"#,
|
|
)?);
|
|
let b = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
b,
|
|
r#"Usage: UnitIsFriend("unit", "otherUnit")"#,
|
|
)?);
|
|
let token = pick_unit_token(&a, &b);
|
|
let r = with_unit(lua, &token, 0u8, |u| u.reaction)?;
|
|
Ok(flag(r >= 5))
|
|
})?,
|
|
)?;
|
|
|
|
// UnitIsCivilian(unit): killing the unit would be dishonorable, the predicate `0x612550`. It
|
|
// shares `is_civilian_kill` with the tooltip's civilian line and `UnitPVPName`, so all agree.
|
|
g.set(
|
|
"UnitIsCivilian",
|
|
lua.create_function(|lua, unit: Value| {
|
|
let unit = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
unit,
|
|
r#"Usage: UnitIsCivilian("unit")"#,
|
|
)?);
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let player_level = model.player_req.level;
|
|
Ok(flag(
|
|
unit.and_then(|u| model.unit(&u))
|
|
.is_some_and(|u| super::is_civilian_kill(u, player_level)),
|
|
))
|
|
})?,
|
|
)?;
|
|
// UnitPlayerControlled(unit) (`0x516410`): `UNIT_FIELD_FLAGS` bit 0x8, vmangos
|
|
// `UNIT_FLAG_PLAYER_CONTROLLED`, so a player's pet or a charmed creature answers 1 where
|
|
// `UnitIsPlayer` answers nil. An unrecognised token answers nil here; the reference raises.
|
|
g.set(
|
|
"UnitPlayerControlled",
|
|
lua.create_function(|lua, unit: Option<String>| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(flag(
|
|
unit.and_then(|u| model.unit(&u))
|
|
.is_some_and(|u| u.player_controlled),
|
|
))
|
|
})?,
|
|
)?;
|
|
g.set(
|
|
"UnitIsPlayer",
|
|
lua.create_function(|lua, token: Option<String>| {
|
|
unit_predicate(lua, &token, |u| u.is_player)
|
|
})?,
|
|
)?;
|
|
// UnitIsPlusMob(unit) (`0x516d40`): `UNIT_FIELD_FLAGS` bit 0x40, never the creature rank
|
|
// (`0x605620`). The server sets it for a non-pet above normal rank (`Creature.cpp:637`), so a
|
|
// rare answers 1, and an uncached creature still answers from its flags.
|
|
g.set(
|
|
"UnitIsPlusMob",
|
|
lua.create_function(|lua, token: Option<String>| {
|
|
// No `lua_isstring` gate, so nil answers nil; an unrecognised token still raises in
|
|
// the resolver (`0x515940`).
|
|
unit_predicate(lua, &token, |u| u.flags & 0x40 != 0)
|
|
})?,
|
|
)?;
|
|
|
|
// UnitIsUnit(a, b): both units exist and are the same token or share a nonzero GUID.
|
|
g.set(
|
|
"UnitIsUnit",
|
|
lua.create_function(|lua, (a, b): (Value, Value)| {
|
|
// Both arguments are `lua_isstring`-gated, so either one nil raises.
|
|
let a = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
a,
|
|
r#"Usage: UnitIsUnit("unit", "otherUnit")"#,
|
|
)?);
|
|
let b = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
b,
|
|
r#"Usage: UnitIsUnit("unit", "otherUnit")"#,
|
|
)?);
|
|
// Both go through the resolver, so either one unrecognised raises.
|
|
check_unit_token(&a)?;
|
|
check_unit_token(&b)?;
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let (Some(a), Some(b)) = (a, b) else {
|
|
return Ok(Value::Nil);
|
|
};
|
|
let (Some(ua), Some(ub)) = (model.unit(&a), model.unit(&b)) else {
|
|
return Ok(Value::Nil);
|
|
};
|
|
if !ua.exists || !ub.exists {
|
|
return Ok(Value::Nil);
|
|
}
|
|
Ok(flag(a == b || (ua.guid != 0 && ua.guid == ub.guid)))
|
|
})?,
|
|
)?;
|
|
|
|
// UnitAffectingCombat(unit) (`0x517e10`): 1 in combat, else nil. No such unit takes the same
|
|
// arm as a unit out of combat (`0x517e48`, `0x517e5c`), so it cannot probe existence. A missing
|
|
// argument, or one neither string nor number, raises `Usage:` (`0x6f3510`, `0x6f4940`), unlike
|
|
// `UnitInRaid`; a number reaches the resolver, which raises `Unknown unit name` (`0x515c14`).
|
|
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(flag(hot))
|
|
})?,
|
|
)?;
|
|
|
|
// HasFullControl(): the flag `SMSG_CLIENT_CONTROL_UPDATE` writes for the local player
|
|
// (`0xb4b3e4`, read at `0x51a158`), which the stock unit menu greys Trade and Duel on
|
|
// (`UnitPopup.lua:474-477`, `:502-505`).
|
|
g.set(
|
|
"HasFullControl",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(flag(model.player_control))
|
|
})?,
|
|
)?;
|
|
|
|
// UnitPlayerOrPetInParty/InRaid(unit): the unit or its owner (`UnitState::owner`) is in the
|
|
// group. The reference's predicate behind `0x5162f0`/`0x5163b0` is untraced; the owner reading
|
|
// is benilla's.
|
|
for (name, raid) in [
|
|
("UnitPlayerOrPetInParty", false),
|
|
("UnitPlayerOrPetInRaid", true),
|
|
] {
|
|
g.set(
|
|
name,
|
|
lua.create_function(move |lua, token: Option<String>| {
|
|
check_unit_token(&token)?;
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let Some(u) = token.as_ref().and_then(|t| model.unit(t)) else {
|
|
return Ok(Value::Nil);
|
|
};
|
|
if !u.exists || u.guid == 0 {
|
|
return Ok(Value::Nil);
|
|
}
|
|
let me = model.unit("player").map(|p| p.guid).unwrap_or(0);
|
|
let in_group = |guid: u64| {
|
|
guid != 0
|
|
&& if raid {
|
|
model.party.raid.iter().any(|m| m.guid == guid)
|
|
} else {
|
|
guid == me || model.party.members.iter().any(|m| m.guid == guid)
|
|
}
|
|
};
|
|
let grouped = if raid {
|
|
!model.party.raid.is_empty()
|
|
} else {
|
|
!model.party.members.is_empty()
|
|
};
|
|
let hit = grouped && (in_group(u.guid) || in_group(u.owner));
|
|
Ok(flag(hit))
|
|
})?,
|
|
)?;
|
|
}
|
|
|
|
// UnitInRaid(unit) (`0x516350`): the constant 1 on a hit (`0x51637e`), never an index. The
|
|
// reference never raises `Usage:`: a missing or uncoercible argument is GUID 0 (`0x6f3690`,
|
|
// `0x515970`), a miss (`0x4baee0`). The roster holds the player, unlike `UnitInParty`'s.
|
|
g.set(
|
|
"UnitInRaid",
|
|
lua.create_function(|lua, token: Option<String>| {
|
|
check_unit_token(&token)?;
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let hit = token
|
|
.as_ref()
|
|
.and_then(|t| model.unit(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(flag(hit))
|
|
})?,
|
|
)?;
|
|
|
|
// UnitInParty(unit): the grouped player or a party member, by token or by roster GUID.
|
|
g.set(
|
|
"UnitInParty",
|
|
lua.create_function(|lua, token: Option<String>| {
|
|
check_unit_token(&token)?;
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let Some(t) = token else {
|
|
return Ok(Value::Nil);
|
|
};
|
|
let Some(u) = model.unit(&t) else {
|
|
return Ok(Value::Nil);
|
|
};
|
|
let grouped = !model.party.members.is_empty();
|
|
if !u.exists || !grouped {
|
|
return Ok(Value::Nil);
|
|
}
|
|
let hit = (t.starts_with("party") && !t.starts_with("partypet"))
|
|
|| t.eq_ignore_ascii_case("player")
|
|
|| (u.guid != 0
|
|
&& (model.unit("player").is_some_and(|p| p.guid == u.guid)
|
|
|| model
|
|
.party
|
|
.members
|
|
.iter()
|
|
.any(|m| m.guid != 0 && m.guid == u.guid)));
|
|
Ok(flag(hit))
|
|
})?,
|
|
)?;
|
|
|
|
// Deviation: UnitCanCooperate answers 1 for a friendly (5 and up) player, because the engine
|
|
// carries no faction templates; the reference resolves their cooperation masks. The verdict
|
|
// matches for the unit popup's invite and whisper gates.
|
|
g.set(
|
|
"UnitCanCooperate",
|
|
lua.create_function(|lua, (a, b): (Value, Value)| {
|
|
// Both arguments are `lua_isstring`-gated, so either one nil raises.
|
|
let a = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
a,
|
|
r#"Usage: UnitCanCooperate("unit", "otherUnit")"#,
|
|
)?);
|
|
let b = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
b,
|
|
r#"Usage: UnitCanCooperate("unit", "otherUnit")"#,
|
|
)?);
|
|
let token = pick_unit_token(&a, &b);
|
|
let ok = with_unit(lua, &token, false, |u| {
|
|
u.exists && u.is_player && u.reaction >= 5
|
|
})?;
|
|
Ok(flag(ok))
|
|
})?,
|
|
)?;
|
|
|
|
// UnitCanAssist(unit, other) (`0x516bb0`) delegates to `CanAssist` (`0x6066f0`), whose three
|
|
// terms are: `UNIT_FLAG_NOT_SELECTABLE` clear, an *internal* reaction of at least 4 — which is
|
|
// `UnitReaction`'s 1-based 5 (`0x51683e` adds the one), the same cut `UnitIsFriend` above
|
|
// takes — and, for a unit that is not player-controlled, `IsPvP` (`0x605ff0`) on it. The three
|
|
// are spelled the same way in `benilla-app`'s own port of the same engine function
|
|
// (`target/relations.rs`'s `can_assist`, which gates `UnitBuff`'s visibility), so this verdict
|
|
// and the engine's cannot disagree about the same unit.
|
|
//
|
|
// Two narrowings, each inherited from what the snapshot carries rather than chosen here:
|
|
//
|
|
// - `IsPvP` reads the *owner's* flag first (charmedBy, else createdBy, `0x5ee5a0`).
|
|
// [`UnitState::owner`] is a guid with no flags beside it, so a hostile minion that is not
|
|
// itself player-controlled reads its own bit. A player's pet carries
|
|
// `UNIT_FLAG_PLAYER_CONTROLLED` and takes the arm above it, which is the case that matters.
|
|
// - the reference's player-controlled arm reads an unnamed `[obj+0xe68]` record
|
|
// (`0x60673e`..`0x60679f`) that is not ported app-side either; a player-controlled unit
|
|
// passes, matching `relations.rs`'s own note.
|
|
//
|
|
// `CanAssist(P, P)` is true — `0x6061e0` answers 5 for a unit toward itself — but every feed
|
|
// site holds the reaction *toward* the player, so the player's own snapshot carries 0, "not
|
|
// fed". The guid compare restores that one answer instead of leaving it silently nil.
|
|
g.set(
|
|
"UnitCanAssist",
|
|
lua.create_function(|lua, (a, b): (Value, Value)| {
|
|
// Both arguments are `lua_isstring`-gated, so either one nil raises.
|
|
let a = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
a,
|
|
r#"Usage: UnitCanAssist("unit", "otherUnit")"#,
|
|
)?);
|
|
let b = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
b,
|
|
r#"Usage: UnitCanAssist("unit", "otherUnit")"#,
|
|
)?);
|
|
let token = pick_unit_token(&a, &b);
|
|
let self_guid = {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
model.unit("player").map_or(0, |p| p.guid)
|
|
};
|
|
let ok = with_unit(lua, &token, false, |u| {
|
|
u.exists
|
|
&& u.flags & UNIT_FLAG_NOT_SELECTABLE == 0
|
|
&& (u.reaction >= 5 || (u.guid != 0 && u.guid == self_guid))
|
|
&& (u.player_controlled || u.pvp)
|
|
})?;
|
|
Ok(flag(ok))
|
|
})?,
|
|
)?;
|
|
|
|
// GetRaidTargetIndex(unit): the mark, 1 to 8, or nil.
|
|
g.set(
|
|
"GetRaidTargetIndex",
|
|
lua.create_function(|lua, token: Value| {
|
|
// Gated (`0x4bb4be`, `0x4bb4cd`); the unquoted `unit` is the reference's spelling.
|
|
let token = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
token,
|
|
"Usage: GetRaidTargetIndex(unit)",
|
|
)?);
|
|
let idx = with_unit(lua, &token, 0u8, |u| u.raid_target)?;
|
|
Ok(if idx > 0 {
|
|
Value::Integer(i64::from(idx))
|
|
} else {
|
|
Value::Nil
|
|
})
|
|
})?,
|
|
)?;
|
|
|
|
// The party frame's status predicates. `UnitIsAFK` and `UnitIsDND` do not exist in the 1.12
|
|
// client, which has no unit AFK or DND predicate; benilla adds them beyond the 1.12 surface.
|
|
g.set(
|
|
"UnitIsConnected",
|
|
lua.create_function(|lua, token: Value| {
|
|
let token = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
token,
|
|
r#"Usage: UnitIsConnected("unit")"#,
|
|
)?);
|
|
unit_predicate(lua, &token, |u| u.is_connected)
|
|
})?,
|
|
)?;
|
|
g.set(
|
|
"UnitIsAFK",
|
|
lua.create_function(|lua, token: Option<String>| {
|
|
unit_predicate(lua, &token, |u| u.is_afk)
|
|
})?,
|
|
)?;
|
|
g.set(
|
|
"UnitIsDND",
|
|
lua.create_function(|lua, token: Option<String>| {
|
|
unit_predicate(lua, &token, |u| u.is_dnd)
|
|
})?,
|
|
)?;
|
|
g.set(
|
|
"UnitIsPVP",
|
|
lua.create_function(|lua, token: Value| {
|
|
let token = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
token,
|
|
r#"Usage: UnitIsPVP("unit")"#,
|
|
)?);
|
|
unit_predicate(lua, &token, |u| u.pvp)
|
|
})?,
|
|
)?;
|
|
g.set(
|
|
"UnitIsPVPFreeForAll",
|
|
lua.create_function(|lua, token: Value| {
|
|
let token = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
token,
|
|
r#"Usage: UnitIsPVPFreeForAll("unit")"#,
|
|
)?);
|
|
unit_predicate(lua, &token, |u| u.is_pvp_ffa)
|
|
})?,
|
|
)?;
|
|
// UnitFactionGroup(unit) → FactionGroup.dbc's `InternalName` (field 2), which stock FrameXML
|
|
// builds texture paths from (`PlayerFrame.lua:68`), and its localized `Name0` (field 3); nil,
|
|
// nil for a unit with no side or no snapshot.
|
|
g.set(
|
|
"UnitFactionGroup",
|
|
lua.create_function(|lua, token: Value| {
|
|
let token = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
token,
|
|
r#"Usage: UnitFactionGroup("unit")"#,
|
|
)?);
|
|
let pair = with_unit(lua, &token, (None, None), |u| {
|
|
(u.faction_group.clone(), u.faction_group_localized.clone())
|
|
})?;
|
|
match pair {
|
|
(Some(english), localized) => {
|
|
let a = Value::String(lua.create_string(&english)?);
|
|
let b = match localized {
|
|
Some(l) if !l.is_empty() => Value::String(lua.create_string(&l)?),
|
|
_ => a.clone(),
|
|
};
|
|
Ok((a, b))
|
|
}
|
|
(None, _) => Ok((Value::Nil, Value::Nil)),
|
|
}
|
|
})?,
|
|
)?;
|
|
|
|
// UnitRace(unit) → localized, raceFile; UnitClass(unit) → localized, classFileName; nil, nil
|
|
// for a unit without one.
|
|
g.set(
|
|
"UnitRace",
|
|
lua.create_function(|lua, token: Value| {
|
|
let token = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
token,
|
|
r#"Usage: UnitRace("unit")"#,
|
|
)?);
|
|
// The `"player"` arm reads the character-select record's race byte (`0x518269`,
|
|
// `0xc27e80`), never the unit resolver.
|
|
if let Some(pair) = player_record_arm(lua, &token, |r| r.race.clone()) {
|
|
return match pair {
|
|
Some((loc, file)) => Ok((
|
|
Value::String(lua.create_string(&loc)?),
|
|
Value::String(lua.create_string(&file)?),
|
|
)),
|
|
None => Ok((Value::Nil, Value::Nil)),
|
|
};
|
|
}
|
|
let pair = with_unit(lua, &token, None, |u| {
|
|
u.race.clone().zip(u.race_file.clone())
|
|
})?;
|
|
match pair {
|
|
Some((loc, file)) => Ok((
|
|
Value::String(lua.create_string(&loc)?),
|
|
Value::String(lua.create_string(&file)?),
|
|
)),
|
|
None => Ok((Value::Nil, Value::Nil)),
|
|
}
|
|
})?,
|
|
)?;
|
|
g.set(
|
|
"UnitClass",
|
|
lua.create_function(|lua, token: Value| {
|
|
let token = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
token,
|
|
r#"Usage: UnitClass("unit")"#,
|
|
)?);
|
|
// The `"player"` arm reads the record's class byte (`0x5183b9`, `0xc27e81`); the second
|
|
// return is the uppercase token.
|
|
if let Some(pair) = player_record_arm(lua, &token, |r| r.class.clone()) {
|
|
return match pair {
|
|
Some((loc, file)) => Ok((
|
|
Value::String(lua.create_string(&loc)?),
|
|
Value::String(lua.create_string(&file)?),
|
|
)),
|
|
None => Ok((Value::Nil, Value::Nil)),
|
|
};
|
|
}
|
|
let pair = with_unit(lua, &token, None, |u| {
|
|
u.class.clone().zip(u.class_file.clone())
|
|
})?;
|
|
match pair {
|
|
Some((loc, file)) => Ok((
|
|
Value::String(lua.create_string(&loc)?),
|
|
Value::String(lua.create_string(&file)?),
|
|
)),
|
|
None => Ok((Value::Nil, Value::Nil)),
|
|
}
|
|
})?,
|
|
)?;
|
|
// UnitHasRelicSlot(unit) (`0x519e50`): a player unit's class has a relic slot, `ChrClasses.dbc`
|
|
// field 16 (`ds:0xc0def4`), which the app resolves; no class id is compared in code. Field 16
|
|
// is in the `patch.MPQ` copy only, set for Paladin, Shaman and Druid. No `"player"` fast path,
|
|
// so any token answers, as stock `InspectPaperDollFrame.lua:123` needs.
|
|
g.set(
|
|
"UnitHasRelicSlot",
|
|
lua.create_function(|lua, token: Value| {
|
|
let token = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
token,
|
|
r#"Usage: UnitHasRelicSlot("unit")"#,
|
|
)?);
|
|
let has = with_unit(lua, &token, false, |u| u.has_relic_slot)?;
|
|
Ok(flag(has))
|
|
})?,
|
|
)?;
|
|
|
|
// UnitSex(unit): 2 male, 3 female, or 1 neuter, which no feed sends. A unit that does not
|
|
// resolve answers 2, never nil (`0x517f9f`).
|
|
g.set(
|
|
"UnitSex",
|
|
lua.create_function(|lua, token: Value| {
|
|
let token = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
token,
|
|
r#"Usage: UnitSex("unit")"#,
|
|
)?);
|
|
// The `"player"` arm reads the record's byte (`0x517ef9`, `0xc27e82`) into
|
|
// `{2, 3, 1, 6}` unchecked (`0x808be4`), so an unset record answers 2 there too.
|
|
let sex = match player_record_arm(lua, &token, |r| r.sex) {
|
|
Some(sex) => sex,
|
|
None => with_unit(lua, &token, 0u8, |u| u.sex)?,
|
|
};
|
|
Ok(Value::Integer(i64::from(if sex == 0 { 2 } else { sex })))
|
|
})?,
|
|
)?;
|
|
|
|
// UnitCreatureType(unit) (`0x51a280`): one string, nil for a token naming no unit (`0x51a2b8`).
|
|
// Its resolver `0x605570` tries the shapeshift form's type (`SpellShapeshiftForm.dbc` column
|
|
// 12, only above 0, `0x60559a`), the cached creature record, then the race's (`ChrRaces.dbc`
|
|
// column 9, "Humanoid" for every race). The form stage is not built (no form index here), so an
|
|
// animal-form druid or a Ghost Wolf shaman answers "Humanoid" where the reference says "Beast".
|
|
// A missing argument, or one neither string nor number, raises `Usage:` (`0x6f3510`,
|
|
// `0x6f4940`).
|
|
g.set(
|
|
"UnitCreatureType",
|
|
lua.create_function(|lua, token: Value| {
|
|
let token = match &token {
|
|
Value::String(s) => Some(s.to_str()?.to_string()),
|
|
// A number passes `lua_isstring`; here it answers nil, where the reference's
|
|
// resolver raises `Unknown unit name` for it.
|
|
Value::Number(_) | Value::Integer(_) => Some(String::new()),
|
|
_ => return Err(mlua::Error::runtime("Usage: UnitCreatureType(\"unit\")")),
|
|
};
|
|
let word = with_unit(lua, &token, None, |u| {
|
|
u.creature_type_name
|
|
.clone()
|
|
// The race stage, collapsed: every player race maps to type 7.
|
|
.or_else(|| u.is_player.then(|| "Humanoid".to_string()))
|
|
})?;
|
|
match word {
|
|
Some(w) => Ok(Value::String(lua.create_string(&w)?)),
|
|
None => Ok(Value::Nil),
|
|
}
|
|
})?,
|
|
)?;
|
|
|
|
// UnitClassification(unit) (`0x516d90`): the rank's word, and "normal" for a token that
|
|
// resolves to no unit, never nil.
|
|
g.set(
|
|
"UnitClassification",
|
|
lua.create_function(|lua, token: Option<String>| {
|
|
let rank = with_unit(lua, &token, 0u32, |u| u.rank)?;
|
|
Ok(classification_word(rank).to_string())
|
|
})?,
|
|
)?;
|
|
|
|
// UnitPowerType(unit) (`0x517940`): one number, the power index (0 mana, 1 rage, ...), with no
|
|
// token string. A unit that does not resolve answers 0, never nil: `UnitFrame.lua:122` indexes
|
|
// `ManaBarColor` with it.
|
|
g.set(
|
|
"UnitPowerType",
|
|
lua.create_function(|lua, token: Value| {
|
|
let token = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
token,
|
|
r#"Usage: UnitPowerType("unit")"#,
|
|
)?);
|
|
let ty = with_unit(lua, &token, 0u8, |u| u.power_type)?;
|
|
Ok(i64::from(ty))
|
|
})?,
|
|
)?;
|
|
|
|
// UnitMana/UnitManaMax(unit): the current and maximum of whatever power the unit uses; 1.12
|
|
// has no `UnitPower` and no power-type argument.
|
|
g.set(
|
|
"UnitMana",
|
|
lua.create_function(|lua, token: Value| {
|
|
let token = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
token,
|
|
r#"Usage: UnitMana("unit")"#,
|
|
)?);
|
|
with_unit(lua, &token, 0i64, |u| i64::from(u.power))
|
|
})?,
|
|
)?;
|
|
g.set(
|
|
"UnitManaMax",
|
|
lua.create_function(|lua, token: Value| {
|
|
let token = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
token,
|
|
r#"Usage: UnitManaMax("unit")"#,
|
|
)?);
|
|
with_unit(lua, &token, 0i64, |u| i64::from(u.max_power))
|
|
})?,
|
|
)?;
|
|
|
|
// UnitXPMax(unit): `PLAYER_XP` is a private field, so only `"player"` has a value and any
|
|
// other token answers 0. `UnitXP(unit)` itself is registered in `compat/unitxp.rs`: UnitXP_SP3
|
|
// (the mod) names its own command dispatcher the same as this stock getter, and on a real
|
|
// install shadows it exactly as a detour over the original falls through to it, which is also
|
|
// how the fallback there is built — so there is only one `"UnitXP"` registration, not two.
|
|
let is_player = |token: &Option<String>| token.as_deref() == Some("player");
|
|
g.set(
|
|
"UnitXPMax",
|
|
lua.create_function(move |lua, token: Value| {
|
|
let token = Some(crate::script::binding_abi::string_arg(
|
|
lua,
|
|
token,
|
|
r#"Usage: UnitXPMax("unit")"#,
|
|
)?);
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(if is_player(&token) {
|
|
i64::from(model.player_next_level_xp)
|
|
} else {
|
|
0
|
|
})
|
|
})?,
|
|
)?;
|
|
|
|
// UnitIsCharmed(unit) (`0x516cf0`): `UNIT_FIELD_CHARMEDBY` is nonzero, not a flags bit, so the
|
|
// charmed unit answers 1 and its charmer nil. 1.12 has no `UnitIsPossessed`.
|
|
g.set(
|
|
"UnitIsCharmed",
|
|
lua.create_function(|lua, token: Option<String>| {
|
|
unit_predicate(lua, &token, |u| u.charmed)
|
|
})?,
|
|
)?;
|
|
|
|
g.set(
|
|
"GetMoney",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(model.money as i64)
|
|
})?,
|
|
)?;
|
|
|
|
// GetComboPoints() (`0x51a190`): `PLAYER_FIELD_BYTES` byte 1, behind two gates stock
|
|
// `ComboFrame.lua` does not repeat: the class is rogue or druid (`0x51a1cc`), so a warrior's
|
|
// dodge point never shows, and `PLAYER_FIELD_COMBO_TARGET` equals the current target
|
|
// (`0x51a205`), so the points read per target. Both are plain equality, GUID 0 included.
|
|
g.set(
|
|
"GetComboPoints",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let class = model.player_req.class_id;
|
|
if class != CLASS_ROGUE && class != CLASS_DRUID {
|
|
return Ok(0_i64);
|
|
}
|
|
let target = model.unit("target").map_or(0, |u| u.guid);
|
|
if model.combo_target != target {
|
|
return Ok(0_i64);
|
|
}
|
|
Ok(i64::from(model.combo_points))
|
|
})?,
|
|
)?;
|
|
|
|
// GetRestState() (`0x48d350`) → the Exhaustion.dbc row's ID, name and factor, indexed directly
|
|
// by `PLAYER_BYTES_2` byte 3 (`0xc0dd78`); nil, nil, nil when there is no such row.
|
|
g.set(
|
|
"GetRestState",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(match model.exhaustion.get(&model.rest_state) {
|
|
Some((name, factor)) => (
|
|
Value::Number(f64::from(model.rest_state)),
|
|
Value::String(lua.create_string(name)?),
|
|
Value::Number(*factor),
|
|
),
|
|
None => (Value::Nil, Value::Nil, Value::Nil),
|
|
})
|
|
})?,
|
|
)?;
|
|
// GetXPExhaustion() (`0x48d3f0`): the rested pool times the factor of Exhaustion.dbc row 1
|
|
// whatever the state (2.0 shipped). Nil unless the rest byte is 1 (`0x48d43b`), whatever the
|
|
// pool holds, so a rested byte with an empty pool answers 0.
|
|
g.set(
|
|
"GetXPExhaustion",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(if model.rest_state != 1 {
|
|
Value::Nil
|
|
} else {
|
|
let factor = model.exhaustion.get(&1).map_or(2.0, |(_, f)| *f);
|
|
Value::Number(f64::from(model.rest_pool) * factor)
|
|
})
|
|
})?,
|
|
)?;
|
|
// IsResting() (`0x516ea0`): `PLAYER_FLAGS` bit 0x20, in a rest area.
|
|
g.set(
|
|
"IsResting",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(flag(model.resting))
|
|
})?,
|
|
)?;
|
|
// PartialPlayTime()/NoPlayTime(): the play-time limit flags, `PLAYER_FLAGS` bits 0x1000
|
|
// (`0x48eb70`) and 0x2000 (`0x48ebe0`).
|
|
g.set(
|
|
"PartialPlayTime",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(flag(model.partial_play_time))
|
|
})?,
|
|
)?;
|
|
g.set(
|
|
"NoPlayTime",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(flag(model.no_play_time))
|
|
})?,
|
|
)?;
|
|
// GetBillingTimeRested() (`0x48ec50`): always one number, unconverted (`0x48ec5e`); stock
|
|
// `PlayerFrame.lua:246` reads it as minutes. vmangos always sends 0 (`World.cpp:331`).
|
|
g.set(
|
|
"GetBillingTimeRested",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(Value::Number(f64::from(model.billing_time_rested)))
|
|
})?,
|
|
)?;
|
|
// GetTimeToWellRested(): always nil; the reference's binding (`0x48d4b0`) only pushes nil.
|
|
g.set(
|
|
"GetTimeToWellRested",
|
|
lua.create_function(|_, ()| Ok(Value::Nil))?,
|
|
)?;
|
|
|
|
// TargetUnit(unit): queue the token for the app to resolve and select; a token naming no unit
|
|
// is a no-op, as in the reference.
|
|
g.set(
|
|
"TargetUnit",
|
|
lua.create_function(|lua, token: Option<String>| {
|
|
if let Some(token) = token {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
model.selection_requests.push(SelectionRequest::Unit(token));
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// AttackTarget() (`0x489b50`): start auto-attack on the current selection.
|
|
//
|
|
// A stock 1.12 engine global (`reference/1.12-globals.tsv`), not a ClassicAPI backport — it
|
|
// was simply never built here. pfUI's nameplates call it on a click that targets a hostile
|
|
// (`modules/nameplates.lua:1930`, guarded by `UnitCanAttack` and "not already in combat"),
|
|
// so every such click raised: 48 in one logged session.
|
|
//
|
|
// Queued rather than acted on, and deliberately through the *binding's* own system: the
|
|
// reference reaches one body from both this global and the Attack action (`0x612df0`), and
|
|
// that body is `ui_action::drain::attack_target_binding` here — which already owns the
|
|
// attack-actor refusals, the engaged/not-engaged toggle and the "no target, acquire the
|
|
// nearest" arm. Re-implementing any of that beside it would be a second opinion about when
|
|
// a swing is legal.
|
|
g.set(
|
|
"AttackTarget",
|
|
lua.create_function(|lua, ()| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
model
|
|
.selection_requests
|
|
.push(SelectionRequest::AttackTarget);
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// AssistUnit(unit) (`0x489b80`): select the unit's `UNIT_FIELD_TARGET`. The shared tail
|
|
// (`0x489bb2`-`0x489c07`) returns silently on 0 and selects through `0x489a40`, which leaves
|
|
// the selection alone when nothing resolves; no `CanAssist` gate (`0x6066f0`), any unit, and a
|
|
// swing only with `assistAttack` set (default "0", `0x48fc50`). A token that resolves nothing
|
|
// is silent here, where the reference shows `0xb8` ERR_GENERIC_NO_TARGET (`0x489c0e`).
|
|
g.set(
|
|
"AssistUnit",
|
|
lua.create_function(|lua, token: Option<String>| {
|
|
if let Some(token) = token {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
model
|
|
.selection_requests
|
|
.push(SelectionRequest::Assist(token));
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// TargetLastEnemy(): re-select the last attackable target (`0x489b45` reads `0xb4e2e8`, which
|
|
// `SetSelection` stamps at `0x49377d`), a no-op once it has streamed out. The app remembers it.
|
|
g.set(
|
|
"TargetLastEnemy",
|
|
lua.create_function(|lua, ()| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
model.selection_requests.push(SelectionRequest::LastEnemy);
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// TargetLastTarget() (`0x489b00`): swap back to the selection held before this one. Its
|
|
// memory is every selection's predecessor, not just the attackable ones, and an empty one
|
|
// **deselects** (`0x493540(0, 0)`) where `TargetLastEnemy`'s shim tests nothing and returns —
|
|
// that difference is the whole verb: it toggles.
|
|
//
|
|
// unitscan is the consumer that found this missing. It probes for a watched rare by
|
|
// targeting the name and reading `UnitName("target")` back, then calls this to put the old
|
|
// target back (`unitscan.lua:84`); with the global absent the probe's target just stayed,
|
|
// which looks exactly like the addon stealing your target on every scan tick.
|
|
g.set(
|
|
"TargetLastTarget",
|
|
lua.create_function(|lua, ()| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
model.selection_requests.push(SelectionRequest::LastTarget);
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// TargetNearestFriend([reverse]) (`0x489aa0`): the Tab cycler `0x493f60` in mode 2 (enemy is
|
|
// 1), whose filter (`0x493eca`) wants `CanAssist` and health above 0. Argument 1 reverses
|
|
// (`0x6f1c10`, absent is 0); stock `Bindings.xml` says "1 (or "true")", so a number or a
|
|
// boolean reverses.
|
|
g.set(
|
|
"TargetNearestFriend",
|
|
lua.create_function(|lua, reverse: Option<Value>| {
|
|
let reverse = match reverse {
|
|
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_nearest_friend_requests.push(reverse);
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// TargetByName(name [, exactMatch]) (`0x489d60`, resolver `0x493aa0`, shared with `/target`): a
|
|
// case-insensitive whole-name match wins, else without `exactMatch` the longest common prefix,
|
|
// so "Rag" selects Ragnaros. Any unit (typemask 8), no dead, reaction, range or self gate. A
|
|
// missing name, or one neither string nor number, raises `Usage:` (`0x489d69`, `0x6f4940`); a
|
|
// number is taken as its string. Deviation: among whole-name matches ours picks the nearest,
|
|
// because the reference's first-walked pick is order-dependent and reads as a bug. A miss is
|
|
// silent here, where the reference shows `0x127` ERR_UNIT_NOT_FOUND, or `0xb8`
|
|
// ERR_GENERIC_NO_TARGET for an empty name.
|
|
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\")")?;
|
|
// Argument 2 comes from `0x6f1c10` with default 0: it never raises, and 0 is absent.
|
|
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");
|
|
// The reference resolves and selects before returning, so the caller's own
|
|
// `UnitName("target")` on the next line sees the pick. Ours is a queued request, so
|
|
// the app keeps each asked query's unit resolved a frame ahead and the hit is
|
|
// answered here: `"target"` is overridden for the rest of this Lua frame and the
|
|
// selection rides the shared queue, in call order with whatever else the frame does
|
|
// (unitscan's own `TargetLastTarget` right after, for one). A query asked for the
|
|
// first time has nothing resolved yet and takes the old, one-frame-late path.
|
|
let key = (name.to_ascii_lowercase(), exact);
|
|
if model.by_name_asked.len() < super::BY_NAME_ASKED_CAP
|
|
|| model.by_name_asked.contains(&key)
|
|
{
|
|
model.by_name_asked.insert(key.clone());
|
|
}
|
|
match model.named_targets.get(&key) {
|
|
Some(state) => {
|
|
let guid = state.guid;
|
|
model.target_override = Some(state.clone());
|
|
model
|
|
.selection_requests
|
|
.push(SelectionRequest::ByGuid(guid));
|
|
}
|
|
None => model.target_by_name_requests.push((name, exact)),
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// DropItemOnUnit(unit) (`0x48d960`): drop the cursor's item on a unit, feeding a pet or
|
|
// opening a trade with a player. Only the token is queued; the app runs every gate.
|
|
g.set(
|
|
"DropItemOnUnit",
|
|
lua.create_function(|lua, token: Option<String>| {
|
|
if let Some(token) = token {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
model.drop_item_on_unit.push(token);
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// SpellTargetUnit(unit) (`0x6e6d90`): an argument that is not a string or a number raises
|
|
// `Usage:`; while not targeting the call is a silent no-op, before the token is read; an
|
|
// unknown token then raises `Unknown unit name`. The token queues for the host, which ends
|
|
// targeting with "Out of range." when it names no unit and otherwise runs `BindTarget`
|
|
// (`0x6e5b10` → `0x6e5b40`).
|
|
g.set(
|
|
"SpellTargetUnit",
|
|
lua.create_function(|lua, token: Value| {
|
|
let token = crate::script::binding_abi::string_arg(
|
|
lua,
|
|
token,
|
|
r#"Usage: SpellTargetUnit("unit")"#,
|
|
)?;
|
|
if !lua
|
|
.app_data_ref::<Model>()
|
|
.expect("model app_data")
|
|
.spell_targeting
|
|
{
|
|
return Ok(());
|
|
}
|
|
check_unit_token(&Some(token.clone()))?;
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
model.spell_target_unit.push(token);
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// ClearTarget(): 1 when it cleared a target, nil when there was none, which `ToggleGameMenu`'s
|
|
// Escape chain needs to fall through to the menu. The app commits the deselect.
|
|
g.set(
|
|
"ClearTarget",
|
|
lua.create_function(|lua, ()| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
let had = model.unit("target").is_some_and(|u| u.exists);
|
|
if had {
|
|
model.target_clear = true;
|
|
}
|
|
Ok(flag(had))
|
|
})?,
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|