benilla: initial public release
A from-scratch World of Warcraft 1.12.1 client in Rust + Bevy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,499 @@
|
||||
//! The `Unit*` global registrations (see the parent module's doc for the seam and the return
|
||||
//! shapes): every binding reads the per-token [`UnitState`](super::UnitState) snapshot store
|
||||
//! through the parent's `with_unit`/`pick_unit_token` helpers.
|
||||
|
||||
use mlua::{Lua, Value};
|
||||
|
||||
use super::super::Model;
|
||||
use super::{grey_band, level_reads_unknown, pick_unit_token, power_token, with_unit};
|
||||
|
||||
/// Register the `Unit*` globals reading the per-token snapshot store (the same style/place the
|
||||
/// object model and stdlib register their globals — bare globals on `_G`, matching the live API
|
||||
/// surface).
|
||||
pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
|
||||
g.set(
|
||||
"UnitExists",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
Ok(with_unit(lua, &token, false, |u| u.exists))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
g.set(
|
||||
"UnitName",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
let name = with_unit(lua, &token, None, |u| u.name.clone());
|
||||
match name {
|
||||
Some(n) => Ok(Value::String(lua.create_string(&n)?)),
|
||||
None => Ok(Value::Nil),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
g.set(
|
||||
"UnitHealth",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
Ok(with_unit(lua, &token, 0i64, |u| i64::from(u.health)))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
g.set(
|
||||
"UnitHealthMax",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
Ok(with_unit(lua, &token, 0i64, |u| i64::from(u.max_health)))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// UnitLevel (`0x517fc0`, §5-VERIFIED 2026-07-17): the raw UNIT_FIELD_LEVEL — a raw ≤ 0
|
||||
// VERBATIM (never −1) — or **−1** iff world-boss rank 3 (unconditional) / hostile
|
||||
// (reaction ≤ 1 internal) AND ≥ 10 levels above the player (inclusive). The FrameXML
|
||||
// target frame branches its skull on `<= 0` (`TargetFrame_CheckLevel`), so a level-0
|
||||
// (unstreamed) unit skulls through the verbatim 0, exactly as the reference does. Not
|
||||
// carried: the dormant attackable-decay override (`max(1, raw − round(min(b,100)·0.05))`,
|
||||
// `b` INFERRED and 0 in normal play — it can never drive the value ≤ 0 anyway).
|
||||
g.set(
|
||||
"UnitLevel",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(u) = token.as_ref().and_then(|t| model.units.get(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`, §5-VERIFIED 2026-07-17) → 1/nil: a pure OBJECT-TYPE check —
|
||||
// the token resolves to a live TYPEID_CORPSE world object (a released player's remains).
|
||||
// NO health test: a dead mob or dead player is NOT a corpse (the ref target frame shows a
|
||||
// dead mob's level number, not the skull). Reads [`UnitState::corpse_object`], which no
|
||||
// feed sets yet — corpse objects aren't selectable in benilla — so this returns nil today,
|
||||
// faithfully.
|
||||
g.set(
|
||||
"UnitIsCorpse",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
Ok(if with_unit(lua, &token, false, |u| u.corpse_object) {
|
||||
Value::Integer(1)
|
||||
} else {
|
||||
Value::Nil
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// UnitCanAttack (`0x516c50`, §5-VERIFIED 2026-07-17) → 1/nil: pure delegation to the
|
||||
// `CanAttack 0x606980` predicate (decision 0172), read from the non-player token's app-fed
|
||||
// snapshot ([`UnitState::can_attack`]). Directional in the live API; our snapshot carries
|
||||
// the player→unit direction, the only order the shipped FrameXML calls
|
||||
// (`UnitCanAttack("player", "target")`).
|
||||
g.set(
|
||||
"UnitCanAttack",
|
||||
lua.create_function(|lua, (a, b): (Option<String>, Option<String>)| {
|
||||
let token = pick_unit_token(&a, &b);
|
||||
Ok(if with_unit(lua, &token, false, |u| u.can_attack) {
|
||||
Value::Integer(1)
|
||||
} else {
|
||||
Value::Nil
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetQuestGreenRange (`0x4e17d0`, §5-VERIFIED 2026-07-17) — the green→grey boundary the
|
||||
// FrameXML `GetDifficultyColor` buckets by (ref QuestLogFrame.lua l.593):
|
||||
// `GRAYBAND[min(playerLevel/5, 19)]` off the binary's `0x8076c0` table, byte-identical to
|
||||
// the `0x81dda8`/`0x80ae98` twins [`grey_band`] transcribes. No args; 0 with no player.
|
||||
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: Option<String>| {
|
||||
Ok(with_unit(lua, &token, false, |u| u.dead))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// The other two of the client's death trio (decision 0308 §1): a released ghost has health 1,
|
||||
// so IsDead is false for it and the popup flow branches on all three.
|
||||
g.set(
|
||||
"UnitIsGhost",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
Ok(with_unit(lua, &token, false, |u| u.ghost))
|
||||
})?,
|
||||
)?;
|
||||
g.set(
|
||||
"UnitIsDeadOrGhost",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
Ok(with_unit(lua, &token, false, |u| u.dead || u.ghost))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// UnitReaction(unit, other) → 1..8 (hated..exalted) or nil. The live API is directional (unit's
|
||||
// reaction toward `other`); our feed only resolves it for the "target" token toward the player,
|
||||
// which is the sole caller (`TargetFrame_CheckFaction`), so the `other` arg is accepted and
|
||||
// unused. `0` (unknown / not yet streamed) reports as nil, the API's "can't tell" — the target
|
||||
// frame paints its name plate blue then, exactly like the reference.
|
||||
g.set(
|
||||
"UnitReaction",
|
||||
lua.create_function(|lua, (token, _other): (Option<String>, Option<String>)| {
|
||||
let r = with_unit(lua, &token, 0u8, |u| u.reaction);
|
||||
Ok(if r == 0 {
|
||||
Value::Nil
|
||||
} else {
|
||||
Value::Integer(i64::from(r))
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// UnitIsEnemy / UnitIsFriend — the reaction-thresholded pair (the ref target-select sound
|
||||
// branch, TargetFrame_OnShow). v1 derives both from the same snapshot as `UnitReaction`:
|
||||
// enemy = reaction ≤ 2 (hated/hostile), friend = reaction ≥ 5 (friendly+); the live API's
|
||||
// extra PvP inputs (duels, flagged players, sanctuaries) are deferred with the rest of the
|
||||
// PvP wire. The pair is directional in the live API but our snapshot only carries the
|
||||
// target↔player reaction, so the binding reads whichever arg isn't "player" (the ref calls
|
||||
// both orders: UnitIsEnemy("target","player"), UnitIsFriend("player","target")). Unknown
|
||||
// reaction (0) → nil for both, the API's "can't tell". `1`/nil returns, era-style.
|
||||
g.set(
|
||||
"UnitIsEnemy",
|
||||
lua.create_function(|lua, (a, b): (Option<String>, Option<String>)| {
|
||||
let token = pick_unit_token(&a, &b);
|
||||
let r = with_unit(lua, &token, 0u8, |u| u.reaction);
|
||||
Ok(if (1..=2).contains(&r) {
|
||||
Value::Integer(1)
|
||||
} else {
|
||||
Value::Nil
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
g.set(
|
||||
"UnitIsFriend",
|
||||
lua.create_function(|lua, (a, b): (Option<String>, Option<String>)| {
|
||||
let token = pick_unit_token(&a, &b);
|
||||
let r = with_unit(lua, &token, 0u8, |u| u.reaction);
|
||||
Ok(if r >= 5 {
|
||||
Value::Integer(1)
|
||||
} else {
|
||||
Value::Nil
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// UnitIsPlayer(unit) → 1 if the unit is a player character, else nil. Reads the snapshot's
|
||||
// guid-family flag (the same one the unit tooltip's "(Player)" line keys on). The target frame's
|
||||
// faction tint branches on it (`TargetFrame_CheckFaction`): a player-controlled unit takes the
|
||||
// red/blue player legs, an NPC the reaction swatch. (The live gate is `UnitPlayerControlled`,
|
||||
// which also covers pets/charmed creatures; we resolve only the player half of it — the extra
|
||||
// reach needs a player-controlled flag we don't carry, and a player's own alt is the case here.)
|
||||
g.set(
|
||||
"UnitIsPlayer",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
Ok(if with_unit(lua, &token, false, |u| u.is_player) {
|
||||
Value::Integer(1)
|
||||
} else {
|
||||
Value::Nil
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// The identity predicates (decision 0434 §5 — the unit popup's menu pick + gating). Same-token
|
||||
// is trivially the same unit; otherwise both snapshots must carry a real (nonzero) guid.
|
||||
g.set(
|
||||
"UnitIsUnit",
|
||||
lua.create_function(|lua, (a, b): (Option<String>, Option<String>)| {
|
||||
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.units.get(&a), model.units.get(&b)) else {
|
||||
return Ok(Value::Nil);
|
||||
};
|
||||
if !ua.exists || !ub.exists {
|
||||
return Ok(Value::Nil);
|
||||
}
|
||||
if a == b || (ua.guid != 0 && ua.guid == ub.guid) {
|
||||
Ok(Value::Integer(1))
|
||||
} else {
|
||||
Ok(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).
|
||||
g.set(
|
||||
"UnitInParty",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(t) = token else {
|
||||
return Ok(Value::Nil);
|
||||
};
|
||||
let Some(u) = model.units.get(&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 == "player"
|
||||
|| (u.guid != 0
|
||||
&& (model.units.get("player").is_some_and(|p| p.guid == u.guid)
|
||||
|| model
|
||||
.party
|
||||
.members
|
||||
.iter()
|
||||
.any(|m| m.guid != 0 && m.guid == u.guid)));
|
||||
Ok(if hit { Value::Integer(1) } else { Value::Nil })
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// UnitCanCooperate(a, b) → 1 for a friendly PLAYER unit. DEVIATION, stated: the client
|
||||
// resolves faction-template cooperation masks; this reads the snapshot's is_player +
|
||||
// UnitIsFriend's reaction>=5 — the same verdict for every case the popup gates on (invite/
|
||||
// whisper a same-faction player), without the faction machinery the engine doesn't carry.
|
||||
g.set(
|
||||
"UnitCanCooperate",
|
||||
lua.create_function(|lua, (a, b): (Option<String>, Option<String>)| {
|
||||
let token = pick_unit_token(&a, &b);
|
||||
let ok = with_unit(lua, &token, false, |u| {
|
||||
u.exists && u.is_player && u.reaction >= 5
|
||||
});
|
||||
Ok(if ok { Value::Integer(1) } else { Value::Nil })
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetRaidTargetIndex(unit) → the mark slot 1..8, or nil unmarked (decision 0434 §6's board,
|
||||
// fed per token).
|
||||
g.set(
|
||||
"GetRaidTargetIndex",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
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 status predicates (decision 0434 §2/§3): connection, AFK/DND, and the two PvP
|
||||
// flags. All 1/nil, the live API's own shape for these — unlike UnitIsDead/UnitIsGhost above (a
|
||||
// stated v1 shortcut, module doc), these are new and follow the era shape from the start.
|
||||
g.set(
|
||||
"UnitIsConnected",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
Ok(if with_unit(lua, &token, false, |u| u.is_connected) {
|
||||
Value::Integer(1)
|
||||
} else {
|
||||
Value::Nil
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
g.set(
|
||||
"UnitIsAFK",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
Ok(if with_unit(lua, &token, false, |u| u.is_afk) {
|
||||
Value::Integer(1)
|
||||
} else {
|
||||
Value::Nil
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
g.set(
|
||||
"UnitIsDND",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
Ok(if with_unit(lua, &token, false, |u| u.is_dnd) {
|
||||
Value::Integer(1)
|
||||
} else {
|
||||
Value::Nil
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
// UnitIsPVP reads the same `pvp` field the unit tooltip's "PvP" line already does (one flag,
|
||||
// two callers) — see the field doc.
|
||||
g.set(
|
||||
"UnitIsPVP",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
Ok(if with_unit(lua, &token, false, |u| u.pvp) {
|
||||
Value::Integer(1)
|
||||
} else {
|
||||
Value::Nil
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
g.set(
|
||||
"UnitIsPVPFreeForAll",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
Ok(if with_unit(lua, &token, false, |u| u.is_pvp_ffa) {
|
||||
Value::Integer(1)
|
||||
} else {
|
||||
Value::Nil
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// UnitRace(unit) → (localized, raceFile) or nil, nil; UnitClass(unit) → (localized,
|
||||
// classFileName) — the paper doll's "Level %d %s %s" line + the CLASSFILENAME-keyed
|
||||
// stat-tooltip lookups (decision 0208 §3). Unknown (feed not landed / a raceless creature)
|
||||
// reports nil, nil — the live API's shape for an absent unit.
|
||||
g.set(
|
||||
"UnitRace",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
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: Option<String>| {
|
||||
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)),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
// UnitSex(unit) → 2 male, 3 female (1 = neuter — no 1.12 unit feed produces it); nil when the
|
||||
// unit is absent or the sex hasn't streamed (`0`), the API's "can't tell".
|
||||
g.set(
|
||||
"UnitSex",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
let sex = with_unit(lua, &token, 0u8, |u| u.sex);
|
||||
Ok(if sex == 0 {
|
||||
Value::Nil
|
||||
} else {
|
||||
Value::Integer(i64::from(sex))
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// UnitPowerType(unit) → (index, token): (0, "MANA"), (1, "RAGE"), … (the live API also returns
|
||||
// alt-power color components; addons that read those handle nil).
|
||||
g.set(
|
||||
"UnitPowerType",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
let ty = with_unit(lua, &token, 0u8, |u| u.power_type);
|
||||
Ok((i64::from(ty), power_token(ty).to_string()))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// 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).
|
||||
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),
|
||||
}))
|
||||
})?,
|
||||
)?;
|
||||
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),
|
||||
}))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// UnitXP/UnitXPMax(unit) → the player's XP within the level / the level's requirement. Player-
|
||||
// level values (PLAYER_XP is PRIVATE, only our own avatar's), but the live API is unit-tokened:
|
||||
// it returns the values only for the "player" token and 0 for any other unit — faithfully, no
|
||||
// creature/other player exposes XP. `0` until the app's feed lands (SetMinMaxValues clamps).
|
||||
let is_player = |token: &Option<String>| token.as_deref() == Some("player");
|
||||
g.set(
|
||||
"UnitXP",
|
||||
lua.create_function(move |lua, token: Option<String>| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(if is_player(&token) {
|
||||
i64::from(model.player_xp)
|
||||
} else {
|
||||
0
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
g.set(
|
||||
"UnitXPMax",
|
||||
lua.create_function(move |lua, token: Option<String>| {
|
||||
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
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetMoney() → the player's purse in copper (a player-level global, not a unit token). The coin
|
||||
// display + the merchant window's money line read it; `0` until the app's coinage feed lands.
|
||||
g.set(
|
||||
"GetMoney",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(model.money as i64)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// TargetUnit(unit) — select a unit by token (the reference's `TargetUnit` Lua shim → SetSelection;
|
||||
// the caller here is `PlayerFrame_OnClick`'s left-click branch → `TargetUnit("player")`, and the
|
||||
// TARGETSELF binding). Queues the raw token; the app resolves it to a streamed entity and commits
|
||||
// the selection. A nil/absent unit is ignored, as is any token the app can't resolve — the real
|
||||
// client no-ops `TargetUnit` on a unit that doesn't exist.
|
||||
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.target_requests.push(token);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// ClearTarget() — drop the current selection (the reference API returns 1 when it cleared,
|
||||
// nil when there was nothing to clear; `ToggleGameMenu`'s ESC chain depends on the nil to
|
||||
// fall through to opening the menu). Reads the same per-token store `UnitExists("target")`
|
||||
// answers from; the app commits the actual deselect (SetSelection guid 0 + the engaged
|
||||
// attack-stop) from the drained trigger.
|
||||
g.set(
|
||||
"ClearTarget",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
if model.units.get("target").is_some_and(|u| u.exists) {
|
||||
model.target_clear = true;
|
||||
Ok(Value::Integer(1))
|
||||
} else {
|
||||
Ok(Value::Nil)
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! The game-state `Unit*` bindings (decision 0068 §3) — the first slice of the addon-facing API that
|
||||
//! reads *live game state*, and the seam that keeps this crate engine-free while doing so.
|
||||
//!
|
||||
//! The engine must not touch the ECS/net (decisions 0006/0061: the codec is stateless, the ECS owns
|
||||
//! state). So instead of reaching outward, the app pushes a per-frame **unit snapshot** into the VM
|
||||
//! via [`UiScript::set_unit`], and the `Unit*` globals here read that plain data. A frame's
|
||||
//! `UnitHealth("player")` therefore resolves against a [`UnitState`] the Bevy side deposited that
|
||||
//! frame — the same shape a real addon sees, with none of the coupling.
|
||||
//!
|
||||
//! Return shapes follow the live API (warcraft.wiki.gg): `UnitName` returns the name *or nil*;
|
||||
//! `UnitHealth`/`UnitHealthMax`/`UnitLevel` return numbers (`UnitLevel` returns **−1** for a
|
||||
//! unit whose level "can't be told" — [`level_reads_unknown`], the target frame's skull gate);
|
||||
//! a unit that doesn't exist reports `UnitExists` false and the numbers `0` (nil for the name),
|
||||
//! exactly as the client does for an absent unit token. Tokens (`"player"`, `"target"`, …) are opaque strings the host never
|
||||
//! 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
|
||||
//! (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).
|
||||
|
||||
use mlua::Lua;
|
||||
|
||||
use super::Model;
|
||||
|
||||
/// One unit-token's game-state snapshot, pushed by the app each frame and read by the `Unit*`
|
||||
/// bindings. Plain data (no mlua handles, no ECS types) — the engine-free seam decision 0068 §3
|
||||
/// draws between the app's net/ECS feed and the Lua API.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct UnitState {
|
||||
/// Whether the unit exists (`UnitExists`). A token with no snapshot is treated as `false` too.
|
||||
pub exists: bool,
|
||||
/// The unit's name (`UnitName` → the string, or nil). `None` while the app's name-query cache
|
||||
/// hasn't answered (or the server doesn't know the unit).
|
||||
pub name: Option<String>,
|
||||
/// Current health (`UnitHealth`).
|
||||
pub health: u32,
|
||||
/// Maximum health (`UnitHealthMax`).
|
||||
pub max_health: u32,
|
||||
/// Level (`UnitLevel`).
|
||||
pub level: u32,
|
||||
/// 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`).
|
||||
pub power: u32,
|
||||
/// Maximum power of the active type (`UnitPowerMax`).
|
||||
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`] /
|
||||
/// dead-or-ghost, the real client's three tests.
|
||||
pub dead: bool,
|
||||
/// Whether the unit is a released ghost (`UnitIsGhost` — `PLAYER_FLAGS` bit 0x10, decision
|
||||
/// 0308 §1). Only meaningful for player tokens; creatures stay `false`.
|
||||
pub ghost: bool,
|
||||
/// The unit's reaction toward the player on the `UnitReaction` scale (`1` hated … `4` neutral …
|
||||
/// `8` exalted; `0` = unknown → `UnitReaction` returns nil). The app resolves it from the
|
||||
/// faction-template / reputation decode (the same one the selection ring uses) for the `"target"`
|
||||
/// token; other tokens leave it `0`. Read by `UnitReaction`, which the target frame tints its
|
||||
/// name plate by (`TargetFrame_CheckFaction`).
|
||||
pub reaction: u8,
|
||||
/// The unit's localized race display name (`UnitRace`'s first return, e.g. "Night Elf").
|
||||
/// `None` = unknown (the race byte hasn't streamed, or a raceless creature) → `UnitRace`
|
||||
/// returns nil, nil. The app resolves it from `UNIT_FIELD_BYTES_0` byte 0 (decision 0208 §3).
|
||||
pub race: Option<String>,
|
||||
/// The race's file/token name (`UnitRace`'s second return, e.g. "NightElf" — 1.12's 8 races),
|
||||
/// the shape texture-path splices key on.
|
||||
pub race_file: Option<String>,
|
||||
/// The unit's localized class display name (`UnitClass`'s first return, e.g. "Warrior").
|
||||
pub class: Option<String>,
|
||||
/// The class's file name (`UnitClass`'s second return): the **uppercase** token ("WARRIOR")
|
||||
/// the ref's stat-tooltip lookups key on (`getglobal(strupper(class).."_STAT_TOOLTIP")` —
|
||||
/// the ref uppercases it again defensively; we store it already uppercase).
|
||||
pub class_file: Option<String>,
|
||||
/// The unit's sex on the `UnitSex` scale: `2` male, `3` female (`1` = neuter/unknown). `0`
|
||||
/// (the unfilled default) reports as nil — the API's "can't tell", like [`Self::reaction`].
|
||||
/// The app maps `UNIT_FIELD_BYTES_0` byte 2 (0 male / 1 female) onto this shape.
|
||||
pub sex: u8,
|
||||
/// This token is a PLAYER character (guid family) — the unit tooltip's level line renders
|
||||
/// "Race Class (Player)" instead of the creature type (decision 0276's verified law).
|
||||
pub is_player: bool,
|
||||
/// The NPC subtitle line ("Stable Master") — the creature template's subname; the unit
|
||||
/// tooltip's second line. `None` for players/untitled creatures.
|
||||
pub subtitle: Option<String>,
|
||||
/// The creature TYPE display word ("Beast", "Humanoid", …) — app-resolved from
|
||||
/// `CreatureType.dbc`; the level line's class slot for hostile/neutral creatures.
|
||||
pub creature_type_name: Option<String>,
|
||||
/// Elite rank 0..4 — the level line's rank word `{"", Elite, Elite, Boss, ""}` (0276).
|
||||
pub rank: u32,
|
||||
/// The creature civilian flag — feeds the tooltip's green CIVILIAN line (shown only for a
|
||||
/// PvP-flagged HOSTILE unit that is also GREY/trivial to the player — the dishonorable-kill
|
||||
/// warning, the client's `0x612550` gate; a friendly civilian never shows it).
|
||||
pub civilian: bool,
|
||||
/// The creature racial-leader flag — the tooltip's white LEADER line (`0x6125c0`, gated on
|
||||
/// the same PvP bit as CIVILIAN).
|
||||
pub racial_leader: bool,
|
||||
/// PvP-flagged (`UNIT_FIELD_FLAGS` PvP bit) — the tooltip's "PvP" line, and also `UnitIsPVP`
|
||||
/// (decision 0434 §2: the party-frame PVP icon reads the same flag the tooltip line does, so
|
||||
/// there's one field, not two).
|
||||
pub pvp: bool,
|
||||
/// Skinnable (`UNIT_FIELD_FLAGS` skinnable bit) — the tooltip's RED "Skinnable" line.
|
||||
pub skinnable: bool,
|
||||
/// The unit's faction display name ("Stormwind") — the tooltip's white line between the
|
||||
/// level line and "PvP". App-resolved (Faction.dbc through the unit's faction template,
|
||||
/// every gate applied: reputation faction, race/class slot, hidden flag, the creature
|
||||
/// HIDE_FACTION_TOOLTIP type flag); `None` = no line.
|
||||
pub faction_name: Option<String>,
|
||||
/// Connection to the server is live (`UnitIsConnected`) — an offline party member's portrait
|
||||
/// desaturates (decision 0434 §2/§3: the `GROUP_LIST` status byte's `0x01` online bit).
|
||||
/// **v1 default gap, stated not hidden:** the zero value is `false`, so a snapshot built via
|
||||
/// `..Default::default()` reports a unit disconnected unless the app sets this explicitly; the
|
||||
/// per-frame feed must set it `true` for every *live* unit token it pushes (mirroring
|
||||
/// [`Self::exists`]) — a pending app-side change, not yet wired as of this field's addition. A
|
||||
/// token absent from the store (`UnitExists` false) still reports nil regardless, via the same
|
||||
/// "no snapshot" path every other predicate here uses.
|
||||
pub is_connected: bool,
|
||||
/// Away-from-keyboard (`UnitIsAFK` — decision 0434 §2's status-byte `0x40` bit). The party
|
||||
/// frame's name line and chat both key off it. App-resolved; `false` until the feed lands.
|
||||
pub is_afk: bool,
|
||||
/// Do-not-disturb (`UnitIsDND` — decision 0434 §2's status-byte `0x80` bit), the AFK flag's
|
||||
/// sibling.
|
||||
pub is_dnd: bool,
|
||||
/// Free-for-all PvP-flagged (`UnitIsPVPFreeForAll` — decision 0434 §2's status-byte `0x10`
|
||||
/// bit) — distinct from [`Self::pvp`]'s ordinary faction PvP flag; a unit can carry either, both,
|
||||
/// or neither independently (an FFA zone flags this without the ordinary flag ever setting).
|
||||
pub is_pvp_ffa: bool,
|
||||
/// The unit's GUID (`OBJECT_FIELD_GUID`) — the identity the cross-token predicates compare
|
||||
/// (`UnitIsUnit`, `UnitInParty`; decision 0434 §5's popup gating). `0` = the app's feed didn't
|
||||
/// resolve one; two zero guids never compare equal.
|
||||
pub guid: u64,
|
||||
/// The raid-target mark on this unit (`GetRaidTargetIndex`): `0` none, `1..=8` the slot.
|
||||
/// App-fed from the `GroupState` raid-target board (decision 0434 §6's verified 8-slot table
|
||||
/// law) — the phase-5 submenu reads it; the phase-6 world renders draw from the same board.
|
||||
pub raid_target: u8,
|
||||
/// The player can attack this unit (`UnitCanAttack("player", unit)`) — app-fed from the
|
||||
/// byte-confirmed `CanAttack 0x606980` predicate (decision 0172; the flag disqualifiers +
|
||||
/// reaction ≤ neutral legs TAB and the combat flash share; the Lua binding `0x516c50` is
|
||||
/// pure delegation to it, §5-VERIFIED). Like [`Self::reaction`], the feed resolves it for
|
||||
/// the `"target"` token; other tokens leave the default `false` (→ nil), which is right for
|
||||
/// every current caller (`TargetFrame_CheckLevel`'s difficulty-color gate).
|
||||
pub can_attack: bool,
|
||||
/// This token resolves to a **TYPEID_CORPSE world object** — `UnitIsCorpse`'s whole
|
||||
/// predicate (`0x5161c0`, §5-VERIFIED: a pure object-type check, no health test; a dead
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// The grey-band table `0x80ae98` (a byte-identical twin at `0x81dda8` drives the nameplate's
|
||||
/// grey level colour), indexed by `playerLevel / 5` and clamped to 12 past the table. ONE home
|
||||
/// for its three consumers: [`unit_is_grey`] (tooltip CIVILIAN gate + the plate's skull/grey
|
||||
/// legs), the `GetQuestGreenRange` binding (the FrameXML `GetDifficultyColor` green→grey
|
||||
/// boundary), and — through both — every level con the UI shows.
|
||||
const GREY_BAND: [u32; 20] = [
|
||||
4, 4, 5, 5, 6, 6, 7, 7, 8, 9, 10, 11, 12, 12, 12, 12, 12, 12, 12, 12,
|
||||
];
|
||||
|
||||
/// The band value for a player level — `GREY_BAND[min(playerLevel/5, 19)]` (the
|
||||
/// `GetQuestGreenRange` return, §5-VERIFIED 2026-07-17: the binding `0x4e17d0` reads the
|
||||
/// byte-identical `0x8076c0` twin with exactly this index clamp).
|
||||
pub fn grey_band(player_level: u32) -> u32 {
|
||||
GREY_BAND
|
||||
.get((player_level / 5) as usize)
|
||||
.copied()
|
||||
.unwrap_or(12)
|
||||
}
|
||||
|
||||
/// The trivial/GREY check (`0x5f0700`, §5-VERIFIED 2026-07-17 — was INFERRED): a unit enough
|
||||
/// levels *below* the player that it cons grey — `playerLevel − unitLevel >
|
||||
/// GREY_BAND[playerLevel/5]`, **strict >** (diff == band is still green). The binary's full
|
||||
/// predicate also requires not-player-controlled; every consumer here applies this to creatures.
|
||||
pub fn unit_is_grey(player_level: u32, unit_level: u32) -> bool {
|
||||
player_level > unit_level && player_level - unit_level > grey_band(player_level)
|
||||
}
|
||||
|
||||
/// The "level reads ??" gate — the engine tooltip's byte-pinned leg set (`0x529fe0` §2-LEVEL):
|
||||
/// never players; level 0 (unstreamed), world-boss rank 3, or a much-higher hostile (reaction
|
||||
/// ≤ 2 on the 1..8 API scale, unit ≥ player + 10 — **inclusive**, byte-confirmed). The Lua
|
||||
/// `UnitLevel`'s −1 law was §5-VERIFIED against this (wow-re `ui/scratch/
|
||||
/// level-assess-bindings.md`, 2026-07-17): its two −1 legs are exactly the boss + hostile-≥10
|
||||
/// legs here (no trivial-gray term exists in `0x517fc0`, and none is possible at ≥ +10), with
|
||||
/// ONE divergence the binding handles itself — a raw level ≤ 0 returns VERBATIM, not −1
|
||||
/// (same skull outcome in FrameXML; the tooltip's "??" keeps the level-0 leg per its own law).
|
||||
pub fn level_reads_unknown(u: &UnitState, player_level: u32) -> bool {
|
||||
let much_higher_hostile =
|
||||
u.reaction != 0 && u.reaction <= 2 && u.level >= player_level.saturating_add(10);
|
||||
!u.is_player && (u.level == 0 || u.rank == 3 || much_higher_hostile)
|
||||
}
|
||||
|
||||
/// The Era power-token string for a power-type index (`UnitPowerType`'s second return,
|
||||
/// `UNIT_POWER_UPDATE`'s arg2). Unknown indices report as mana, the descriptor default.
|
||||
pub fn power_token(ty: u8) -> &'static str {
|
||||
match ty {
|
||||
1 => "RAGE",
|
||||
2 => "FOCUS",
|
||||
3 => "ENERGY",
|
||||
4 => "HAPPINESS",
|
||||
_ => "MANA",
|
||||
}
|
||||
}
|
||||
|
||||
impl super::UiScript {
|
||||
/// Push (or clear) a unit token's snapshot. `Some(state)` stores it under `token`; `None` removes
|
||||
/// it (so `UnitExists(token)` reports false and the numeric getters return `0`). The app's
|
||||
/// per-frame feed (decision 0068 §3) calls this for `"player"`/`"target"`/… before the VM's
|
||||
/// event dispatch, so a frame's `OnEvent` sees the current values.
|
||||
pub fn set_unit(&mut self, token: &str, state: Option<UnitState>) {
|
||||
{
|
||||
let mut model = self.model_mut();
|
||||
match state {
|
||||
Some(s) => {
|
||||
model.units.insert(token.to_string(), s);
|
||||
}
|
||||
None => {
|
||||
model.units.remove(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A push for a tooltip's LIVE unit token re-drives its health bar (the byte law's
|
||||
// HEALTH watcher — decision 0276; no line rebuild).
|
||||
super::tooltip_unit::on_unit_push(&self.lua, token);
|
||||
}
|
||||
|
||||
/// Push the player's purse in copper (`PLAYER_FIELD_COINAGE`), read by the `GetMoney` global. A
|
||||
/// player-level snapshot (like the unit tokens) — the app calls this each frame the coinage
|
||||
/// field changes (decision 0081 phase 4).
|
||||
pub fn set_money(&mut self, copper: u64) {
|
||||
self.model_mut().money = copper;
|
||||
}
|
||||
|
||||
/// Push the player's experience within the level (`PLAYER_XP`) and the level's requirement
|
||||
/// (`PLAYER_NEXT_LEVEL_XP`), read by the `UnitXP`/`UnitXPMax` globals. A player-level pair (like
|
||||
/// [`Self::set_money`]) — the app calls this each frame the XP fields change (the MainMenuBar XP
|
||||
/// bar feed). Both PRIVATE descriptor fields, so this only ever carries our own avatar's values.
|
||||
pub fn set_player_xp(&mut self, xp: u32, next_level_xp: u32) {
|
||||
let mut model = self.model_mut();
|
||||
model.player_xp = xp;
|
||||
model.player_next_level_xp = next_level_xp;
|
||||
}
|
||||
|
||||
/// Drain the unit tokens `TargetUnit` queued since the last call — the app resolves each to a
|
||||
/// streamed entity and commits the selection (the reference's `TargetUnit` → SetSelection path).
|
||||
pub fn take_target_requests(&mut self) -> Vec<String> {
|
||||
std::mem::take(&mut self.model_mut().target_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 {
|
||||
std::mem::take(&mut self.model_mut().target_clear)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the interesting token from a directional two-unit call (`UnitIsEnemy(a, b)`): whichever
|
||||
/// arg isn't `"player"` — our snapshot stores the relationship on the non-player unit (target),
|
||||
/// and the ref calls both argument orders.
|
||||
fn pick_unit_token(a: &Option<String>, b: &Option<String>) -> Option<String> {
|
||||
match (a, b) {
|
||||
(Some(x), _) if x != "player" => Some(x.clone()),
|
||||
(_, Some(y)) => Some(y.clone()),
|
||||
(x, _) => x.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a unit token's snapshot under a short model borrow, mapping it through `f`; `default` when the
|
||||
/// token is absent (the "unit doesn't exist" path).
|
||||
fn with_unit<T>(
|
||||
lua: &Lua,
|
||||
token: &Option<String>,
|
||||
default: T,
|
||||
f: impl FnOnce(&UnitState) -> T,
|
||||
) -> T {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
match token.as_ref().and_then(|t| model.units.get(t)) {
|
||||
Some(u) => f(u),
|
||||
None => default,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `Unit*`/`GetQuestGreenRange` Lua binding registrations — split from this module's
|
||||
/// state+laws half purely for size (same seam as the other script modules' install fns).
|
||||
mod bindings;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(super) use bindings::install;
|
||||
@@ -0,0 +1,520 @@
|
||||
//! The `Unit*` binding tests (the parent module is the unit under test).
|
||||
|
||||
use crate::script::{UiScript, UnitState};
|
||||
|
||||
fn player() -> UnitState {
|
||||
UnitState {
|
||||
exists: true,
|
||||
name: Some("Benilla".into()),
|
||||
health: 72,
|
||||
max_health: 100,
|
||||
level: 12,
|
||||
power_type: 1, // rage
|
||||
power: 35,
|
||||
max_power: 100,
|
||||
dead: false,
|
||||
reaction: 0,
|
||||
race: Some("Night Elf".into()),
|
||||
race_file: Some("NightElf".into()),
|
||||
class: Some("Warrior".into()),
|
||||
class_file: Some("WARRIOR".into()),
|
||||
sex: 3, // female
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_reaction_reports_the_scale_value_or_nil() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
// Neutral target (UnitReaction 4) → the integer; the name-plate palette indexes it.
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
reaction: 4,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return UnitReaction("target", "player")"#)
|
||||
.unwrap(),
|
||||
4
|
||||
);
|
||||
// reaction 0 (unresolved) reports as nil — the API's "can't tell".
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
reaction: 0,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitReaction("player", "target") == nil"#)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// The target plate's faction decision (TargetFrame_CheckFaction, as `BenillaUnitFrame_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
|
||||
/// exactly the green-vs-blue bug the branch fixes.
|
||||
#[test]
|
||||
fn the_name_plate_faction_branch_picks_player_red_blue_over_the_reaction_swatch() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
// The plate's decision, verbatim from the XML Update (a chunk whose if-branches each return).
|
||||
let plate = r#"
|
||||
local u = "target"
|
||||
if UnitIsPlayer(u) then
|
||||
if UnitIsEnemy(u, "player") then return "red" else return "blue" end
|
||||
else
|
||||
return UnitReaction(u, "player") and "reaction" or "reaction-blue"
|
||||
end
|
||||
"#;
|
||||
let decide = |s: &mut UiScript| s.eval::<String>(plate).unwrap();
|
||||
|
||||
// A friendly PLAYER (reaction 5) → blue, not the reaction green.
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
is_player: true,
|
||||
reaction: 5,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert_eq!(decide(&mut s), "blue");
|
||||
// A hostile PLAYER (reaction 2) → red.
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
is_player: true,
|
||||
reaction: 2,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert_eq!(decide(&mut s), "red");
|
||||
// A friendly NPC (same reaction 5, not a player) → the reaction swatch (green), unchanged.
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
is_player: false,
|
||||
reaction: 5,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert_eq!(decide(&mut s), "reaction");
|
||||
// UnitIsPlayer itself: 1 for a player, nil otherwise.
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitIsPlayer("target") == nil"#)
|
||||
.unwrap());
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
is_player: true,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return UnitIsPlayer("target")"#).unwrap(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_level_reads_minus_one_when_the_level_cant_be_told() {
|
||||
use crate::script::PlayerReqState;
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_player_req_state(PlayerReqState {
|
||||
level: 3,
|
||||
..Default::default()
|
||||
});
|
||||
// A hostile 10+ levels up (reaction 2, level 13 vs player 3) → −1: the target frame's
|
||||
// skull branch (TargetFrame_CheckLevel's `level <= 0`).
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
level: 13,
|
||||
reaction: 2,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert_eq!(s.eval::<i64>(r#"return UnitLevel("target")"#).unwrap(), -1);
|
||||
// One level short of the band (12 vs 3) → the raw number.
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
level: 12,
|
||||
reaction: 2,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert_eq!(s.eval::<i64>(r#"return UnitLevel("target")"#).unwrap(), 12);
|
||||
// The same 10-up gap on a NEUTRAL (reaction 4) tells its level — the gate is hostile-only.
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
level: 13,
|
||||
reaction: 4,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert_eq!(s.eval::<i64>(r#"return UnitLevel("target")"#).unwrap(), 13);
|
||||
// A world boss (creature rank 3) hides its level regardless of the gap.
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
level: 5,
|
||||
reaction: 4,
|
||||
rank: 3,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert_eq!(s.eval::<i64>(r#"return UnitLevel("target")"#).unwrap(), -1);
|
||||
// A PLAYER never reads −1 (rank/reaction notwithstanding).
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
level: 60,
|
||||
reaction: 2,
|
||||
is_player: true,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert_eq!(s.eval::<i64>(r#"return UnitLevel("target")"#).unwrap(), 60);
|
||||
// A raw level 0 (unstreamed) returns VERBATIM — never −1 (§5: `0x517fc0` pushes the
|
||||
// raw field; only the boss/hostile legs substitute). Same skull outcome via `<= 0`.
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
level: 0,
|
||||
reaction: 2,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert_eq!(s.eval::<i64>(r#"return UnitLevel("target")"#).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpse_can_attack_and_green_range_bindings() {
|
||||
use crate::script::PlayerReqState;
|
||||
let mut s = UiScript::new().unwrap();
|
||||
// UnitIsCorpse: a pure TYPEID_CORPSE object check (§5) — a DEAD unit is NOT a corpse…
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
dead: true,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitIsCorpse("target") == nil"#)
|
||||
.unwrap());
|
||||
// …only a resolved corpse world object is.
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
corpse_object: true,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return UnitIsCorpse("target")"#).unwrap(),
|
||||
1
|
||||
);
|
||||
// UnitCanAttack reads the app-fed 0x606980 verdict off the non-player token, both
|
||||
// argument orders; false → nil.
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitCanAttack("player", "target") == nil"#)
|
||||
.unwrap());
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
can_attack: true,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return UnitCanAttack("player", "target")"#)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return UnitCanAttack("target", "player")"#)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitIsCorpse("target") == nil"#)
|
||||
.unwrap());
|
||||
// GetQuestGreenRange: the grey band for the player's level — 4 at level 3, 9 at 46,
|
||||
// 12 clamped past the table (the byte-verified GREY_BAND).
|
||||
for (pl, want) in [(3u32, 4i64), (46, 9), (120, 12)] {
|
||||
s.set_player_req_state(PlayerReqState {
|
||||
level: pl,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetQuestGreenRange()").unwrap(),
|
||||
want,
|
||||
"green range at level {pl}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_unit_queues_the_token_for_the_app_to_resolve() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
// Nothing queued until a call lands.
|
||||
assert!(s.take_target_requests().is_empty());
|
||||
// Each call queues its raw token, in order; a nil token is ignored.
|
||||
s.eval::<()>(r#"TargetUnit("player")"#).unwrap();
|
||||
s.eval::<()>(r#"TargetUnit("target")"#).unwrap();
|
||||
s.eval::<()>(r#"TargetUnit(nil)"#).unwrap();
|
||||
assert_eq!(s.take_target_requests(), vec!["player", "target"]);
|
||||
// The drain is a take — a second read is empty.
|
||||
assert!(s.take_target_requests().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_unit_is_read_by_the_bindings() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_unit("player", Some(player()));
|
||||
|
||||
assert!(s.eval::<bool>(r#"return UnitExists("player")"#).unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<String>(r#"return UnitName("player")"#).unwrap(),
|
||||
"Benilla"
|
||||
);
|
||||
assert_eq!(s.eval::<i64>(r#"return UnitHealth("player")"#).unwrap(), 72);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return UnitHealthMax("player")"#).unwrap(),
|
||||
100
|
||||
);
|
||||
assert_eq!(s.eval::<i64>(r#"return UnitLevel("player")"#).unwrap(), 12);
|
||||
assert!(!s.eval::<bool>(r#"return UnitIsDead("player")"#).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_token_reports_not_existing_with_zero_numbers() {
|
||||
let s = UiScript::new().unwrap();
|
||||
assert!(!s.eval::<bool>(r#"return UnitExists("target")"#).unwrap());
|
||||
assert_eq!(s.eval::<i64>(r#"return UnitHealth("target")"#).unwrap(), 0);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return UnitHealthMax("target")"#).unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(s.eval::<i64>(r#"return UnitLevel("target")"#).unwrap(), 0);
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitName("target") == nil"#)
|
||||
.unwrap());
|
||||
assert!(!s.eval::<bool>(r#"return UnitIsDead("target")"#).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dead_unit_reports_dead_and_zero_health() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
name: None,
|
||||
health: 0,
|
||||
max_health: 3200,
|
||||
level: 30,
|
||||
dead: true,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert!(s.eval::<bool>(r#"return UnitExists("target")"#).unwrap());
|
||||
assert!(s.eval::<bool>(r#"return UnitIsDead("target")"#).unwrap());
|
||||
assert_eq!(s.eval::<i64>(r#"return UnitHealth("target")"#).unwrap(), 0);
|
||||
// Name unknown (no name-query yet) → nil, the absent-name shape.
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitName("target") == nil"#)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_bindings_read_the_active_type() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_unit("player", Some(player())); // rage 35/100
|
||||
assert_eq!(
|
||||
s.eval::<(i64, String)>(r#"return UnitPowerType("player")"#)
|
||||
.unwrap(),
|
||||
(1, "RAGE".into())
|
||||
);
|
||||
assert_eq!(s.eval::<i64>(r#"return UnitPower("player")"#).unwrap(), 35);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return UnitPowerMax("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
|
||||
);
|
||||
// An absent unit: type reads as mana, values 0.
|
||||
assert_eq!(
|
||||
s.eval::<(i64, String)>(r#"return UnitPowerType("target")"#)
|
||||
.unwrap(),
|
||||
(0, "MANA".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_money_reads_the_pushed_purse() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
// No feed yet: 0 copper.
|
||||
assert_eq!(s.eval::<i64>("return GetMoney()").unwrap(), 0);
|
||||
s.set_money(123_456);
|
||||
assert_eq!(s.eval::<i64>("return GetMoney()").unwrap(), 123_456);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_xp_reads_the_player_globals_only_for_the_player_token() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
// No feed yet: 0/0.
|
||||
assert_eq!(s.eval::<i64>(r#"return UnitXP("player")"#).unwrap(), 0);
|
||||
assert_eq!(s.eval::<i64>(r#"return UnitXPMax("player")"#).unwrap(), 0);
|
||||
s.set_player_xp(4200, 6000);
|
||||
assert_eq!(s.eval::<i64>(r#"return UnitXP("player")"#).unwrap(), 4200);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return UnitXPMax("player")"#).unwrap(),
|
||||
6000
|
||||
);
|
||||
// Any non-player token reports 0 (no creature/other unit exposes XP — the live shape).
|
||||
assert_eq!(s.eval::<i64>(r#"return UnitXP("target")"#).unwrap(), 0);
|
||||
assert_eq!(s.eval::<i64>(r#"return UnitXPMax("target")"#).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_unit_none_removes_the_token() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_unit("player", Some(player()));
|
||||
assert!(s.eval::<bool>(r#"return UnitExists("player")"#).unwrap());
|
||||
s.set_unit("player", None);
|
||||
assert!(!s.eval::<bool>(r#"return UnitExists("player")"#).unwrap());
|
||||
assert_eq!(s.eval::<i64>(r#"return UnitHealth("player")"#).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn party_frame_predicates_report_1_or_nil() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_unit(
|
||||
"party1",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
is_connected: true,
|
||||
is_afk: true,
|
||||
is_dnd: false,
|
||||
pvp: true,
|
||||
is_pvp_ffa: false,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return UnitIsConnected("party1")"#)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(s.eval::<i64>(r#"return UnitIsAFK("party1")"#).unwrap(), 1);
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitIsDND("party1") == nil"#)
|
||||
.unwrap());
|
||||
assert_eq!(s.eval::<i64>(r#"return UnitIsPVP("party1")"#).unwrap(), 1);
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitIsPVPFreeForAll("party1") == nil"#)
|
||||
.unwrap());
|
||||
|
||||
// Flip every flag: the false/true cases aren't just "always true".
|
||||
s.set_unit(
|
||||
"party1",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
is_connected: false,
|
||||
is_afk: false,
|
||||
is_dnd: true,
|
||||
pvp: false,
|
||||
is_pvp_ffa: true,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitIsConnected("party1") == nil"#)
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitIsAFK("party1") == nil"#)
|
||||
.unwrap());
|
||||
assert_eq!(s.eval::<i64>(r#"return UnitIsDND("party1")"#).unwrap(), 1);
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitIsPVP("party1") == nil"#)
|
||||
.unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return UnitIsPVPFreeForAll("party1")"#)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
|
||||
// An absent token reports nil regardless of any flag's zero value.
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitIsConnected("party2") == nil"#)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_race_class_sex_report_the_snapshot_or_the_absent_shape() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_unit("player", Some(player()));
|
||||
assert_eq!(
|
||||
s.eval::<(String, String)>(r#"return UnitRace("player")"#)
|
||||
.unwrap(),
|
||||
("Night Elf".into(), "NightElf".into())
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(String, String)>(r#"return UnitClass("player")"#)
|
||||
.unwrap(),
|
||||
("Warrior".into(), "WARRIOR".into())
|
||||
);
|
||||
assert_eq!(s.eval::<i64>(r#"return UnitSex("player")"#).unwrap(), 3);
|
||||
// An absent token: nil, nil / nil — the live API's absent-unit shape.
|
||||
assert!(s
|
||||
.eval::<bool>(r#"local a, b = UnitRace("target") return a == nil and b == nil"#)
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>(r#"local a, b = UnitClass("target") return a == nil and b == nil"#)
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitSex("target") == nil"#)
|
||||
.unwrap());
|
||||
// A snapshot whose race/class haven't resolved yet (feed pending): same nils.
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>(r#"local a, b = UnitRace("target") return a == nil and b == nil"#)
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitSex("target") == nil"#)
|
||||
.unwrap());
|
||||
}
|
||||
Reference in New Issue
Block a user