Files
benilla-octo/crates/benilla-ui/src/script/unit/mod.rs
T
SamandClaude Fable 5 19461d1e0f fishing becomes whole: the bite, the line, the splash and the reel
- the whole loop plays: the line arcs rod tip to bobber, the bite splashes with its own sound, and clicking the bobber reels in
- the verdicts read like 1.12: yellow "your fish got away" lines, the reel-in sound, the fishing loot window
- "You feel rested." prints on the transition, rest states come from the client's own Exhaustion table, and level 60 trades the XP strip for the max-level rail
- the Skills pane lists what the real client lists: the hidden rows, the sort, gray numberless single-rank lines

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:03:16 +07:00

371 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 creature classification every rank reader in the client shares, via
/// the one getter `0x605620`: the level line's rank word `{"", Elite, Elite, Boss, ""}` (0276),
/// `UnitLevel`'s world-boss −1 ([`level_reads_unknown`]), and `UnitClassification`
/// ([`classification_word`], decision 0782). **Already gated** by the app when it fills this:
/// the getter answers `0` unless the unit has a cached creature template *and* a zero
/// `UNIT_FIELD_PETNUMBER`, so a player, an un-queried creature and an enslaved elite all read
/// `0` here — the gate belongs at the one write, not in each of the three readers.
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 PvP faction group — `"Alliance"` or `"Horde"`, else `None` (`UnitFactionGroup`;
/// decision 0646 §1). App-resolved from `UNIT_FIELD_FACTIONTEMPLATE` → `FactionTemplate.dbc`'s
/// group mask, with the Player bit skipped: every playable race's template carries
/// Player|<side> (mask 3 or 5), and so do the PvP-flagged city guards, so the side bit is the
/// only one that can name a shipped `UI-PVP-<group>` texture. `None` for Monster/neutral
/// templates is a state the icon callers explicitly handle (`if ( factionGroup and … )`).
///
/// The Era binding returns it twice — English token first, localized name second; enUS ships
/// the same word for both (FactionGroup.dbc's `InternalName` and `Name0`).
pub faction_group: Option<String>,
/// 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)
}
/// `UnitClassification`'s return — the classification-word table, **byte-verified** at `0x850424`
/// (decision 0782): the binding `0x516d90` is nothing but `TABLE[rank(unit)]`, indexed by the same
/// gated rank [`UnitState::rank`] carries. The table is exactly five entries long (the sixth dword
/// at `0x850438` is an unrelated `"UnitExists"` literal), and rank comes pre-clamped to 0..4 by
/// `CreatureInfo+0x20`, so an out-of-range value can only be our own bug — it reads `"normal"`,
/// which is also what the binding's unresolved-token path pushes (`0x516dc4` loads index 0).
///
/// Note `"rareelite"` is a real return here even though 1.12 has no rare-elite *art* and no
/// rare-elite tooltip *word* — the reference's `TargetFrame_CheckClassification` sends it to the
/// same Elite border as `"elite"`, and the tooltip's `{"", Elite, Elite, Boss, ""}` prints ELITE for
/// it. The distinction exists in the API and nowhere in the pixels.
pub fn classification_word(rank: u32) -> &'static str {
match rank {
1 => "elite",
2 => "rareelite",
3 => "worldboss",
4 => "rare",
_ => "normal",
}
}
/// 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;
}
/// Push the player's rest snapshot — the `PLAYER_BYTES_2` byte 3 rest state, the
/// `PLAYER_REST_STATE_EXPERIENCE` pool (raw wire value, base kill-XP units) and the
/// `PLAYER_FLAGS_RESTING` bit — taken together so the `GetRestState`/`GetXPExhaustion`/
/// `IsResting` trio can never read a half-updated rest picture. Player-level fields, same
/// shape as [`Self::set_money`]; the app calls this each frame any of the three moves
/// (decision 1082).
pub fn set_rest_state(&mut self, state: u8, pool: u32, resting: bool) {
let mut model = self.model_mut();
model.rest_state = state;
model.rest_pool = pool;
model.resting = resting;
}
/// Push Exhaustion.dbc — `(rest-state byte, localized name, factor)` rows for the
/// `GetRestState`/`GetXPExhaustion` bindings (they read the table exactly as `0x48d350` /
/// `0x48d3f0` read the client's own copy; decision 1087). Called once at startup off the
/// patch chain; an empty push is ignored so a failed DBC read keeps the shipped-table
/// fallback the model seeds.
pub fn set_exhaustion_rows(&mut self, rows: Vec<(u8, String, f64)>) {
if rows.is_empty() {
return;
}
let mut model = self.model_mut();
model.exhaustion = rows.into_iter().map(|(id, n, f)| (id, (n, f))).collect();
}
/// Push the player's banked combo points (`PLAYER_FIELD_BYTES` byte 1) and the unit they sit
/// on (`PLAYER_FIELD_COMBO_TARGET`) — the pair `Player::SetComboPoints` writes together, taken
/// together so they can never be read half-updated. Player-level PRIVATE fields, same shape as
/// [`Self::set_money`]; the app calls this each frame either moves, including the drop back to
/// zero (decisions 0869, 0875).
///
/// **Raw wire values** — `GetComboPoints`'s class and current-target gates live in the binding,
/// where the binary puts them.
pub fn set_combo_points(&mut self, points: u8, target: u64) {
let mut model = self.model_mut();
model.combo_points = points;
model.combo_target = target;
}
/// 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)
}
/// Drain the unit tokens `DropItemOnUnit` queued since the last call — the app runs every gate
/// and, on the pet leg, casts the learned Feed Pet spell at the held item (`0x48d960`).
pub fn take_drop_item_on_unit(&mut self) -> Vec<String> {
std::mem::take(&mut self.model_mut().drop_item_on_unit)
}
}
/// 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;