Files
benilla-octo/crates/benilla-app/src/ui_unit.rs
T
SamandClaude Fable 5 d99ad8bc7d the dressing room opens, and the pet gets its paper doll
- ctrl-click an item to try it on: the dressing room dresses your double, and shift-click posts the item link to chat
- the pet tab lands in the character window: paper doll stats, family and diet, the right-click menu, and dropping food on the pet feeds it
- water-only spells refuse on dry land: aquatic form gates to swimming, client-side like the real one
- the macro icon picker's white squares get their art back, and option rows explain themselves on hover

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

971 lines
46 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 app-side **unit snapshot + event feed** (decision 0068 §3): the bridge that turns live ECS
//! game state into the plain data the engine-free `Unit*` Lua bindings read, and into the WoW events
//! that drive a frame's `OnEvent`.
//!
//! The architecture is deliberate (decisions 0006/0061): the Lua game-state API must **not** reach
//! into the ECS. Instead this runs each frame, *before* the VM's tick/event dispatch
//! ([`crate::ui_script::UiInput`]), and pushes a [`UnitState`] snapshot for each unit token into the
//! VM via [`UiScript::set_unit`]. The `"player"` token reads our own avatar's [`ObjectStore`] (tagged
//! [`SelfPlayer`]); `"target"` reads the [`Selection`]'s entity. Both are found by their ECS entity,
//! not by re-deriving a guid — the ECS already owns the guid↔entity map.
//!
//! Names come from the [`crate::names::NameCache`] (the 1.12 wire has no descriptor names — the
//! query-cache seam): the feed resolves each token's guid, which asks the server once on a miss and
//! fills in a later frame; the transition fires `UNIT_NAME_UPDATE` so frames repaint.
//!
//! The event surface is the Era set, fired per field on transitions: `UNIT_HEALTH`/`UNIT_MAXHEALTH`/
//! `UNIT_LEVEL` (arg1 = token), `UNIT_POWER_UPDATE`/`UNIT_MAXPOWER` (token, power token e.g.
//! `"MANA"`), `UNIT_DISPLAYPOWER` (power *type* changed), `UNIT_NAME_UPDATE`, plus
//! `PLAYER_ENTERING_WORLD` once and `PLAYER_TARGET_CHANGED` on selection change. A token appearing
//! counts as a transition of every present field (frames also pull on target change, so either path
//! populates).
use std::collections::HashMap;
use bevy::prelude::*;
use benilla_ui::script::{power_token, ScriptValue, UiScript, UnitState};
use crate::names::NameCache;
use crate::net::{Guid, NetCommands, ObjectStore, Reputations, SelfPlayer};
use crate::target::{ring_reaction, Factions, Selection};
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
use crate::ui_script::UiInput;
/// The feed pass — runs **after [`crate::schedule::WorldStage::Net`]** (the feeds snapshot state
/// the net apply writes; unordered, `apply_net_updates` could land BETWEEN two feeds, and a
/// synchronous event fired by the later one then re-read the earlier one's pre-mutation push —
/// the spellbook's cooldown pie stayed cold until a manual reopen, reproduced live 2026-07-31)
/// and before [`UiInput`], so the snapshot + events it produces are in place when the VM ticks
/// and dispatches this frame. A named set so the demo override ([`crate::ui_script`]) can order
/// itself after it. Configured in [`UiUnitPlugin`] — the set's home.
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct UnitFeed;
/// One combat occurrence over a unit — the `UNIT_COMBAT` event feed (decision 0576: the portrait
/// hit indicator's wire; the shipped `CombatFeedback.lua` is the consumer). **§5-verified**
/// (wow-re `object-layer/scratch/unit-combat-event-law.md`): the one emitter `0x494600` fires
/// `(token, action, descriptor, amount, type)` once per live token the unit maps to, with **no
/// self-suppression and no cvar gate** — the worldtext Gate A's inverse. `type` (arg5) is the
/// damage school on the melee/spell-damage paths; the miss and heal wrappers hard-code 0. The
/// melee victim event is **deferred to the swing impact keyframe** (it rides inside `0x6243e0`,
/// reached only from the `0x624530` victim dispatcher — C2 CONFIRMED). ENERGIZE never fires in
/// 5875 (string absent binary-wide). Producers: melee at [`melee_unit_combat`], spells/heals at
/// packet receive (`net/apply/combat_log.rs`). Consumed by [`fire_unit_combat`], which resolves
/// the entity to its live unit tokens.
#[derive(Message, Clone, Copy)]
pub(crate) struct UnitCombatFeedback {
pub(crate) unit: Entity,
/// `arg2` — the action: `WOUND`/`MISS`/`DODGE`/`PARRY`/`BLOCK`/`EVADE`/`IMMUNE`/`DEFLECT`/
/// `RESIST`/`ABSORB`/`REFLECT`/`HEAL`/`ENERGIZE`.
pub(crate) action: &'static str,
/// `arg3` — the descriptor: `CRITICAL`/`CRUSHING`/`GLANCING`/`ABSORB`/`BLOCK`/`RESIST`, or `""`.
pub(crate) flags: &'static str,
/// `arg4` — the amount (damage/heal/energize; 0 for pure words).
pub(crate) amount: u32,
/// `arg5` — the school int (0 = physical; the Lua's `type > 0` draws the number spell-yellow).
pub(crate) school: u32,
}
/// One center-combat-text message — the `COMBAT_TEXT_UPDATE` event feed (decision 0578; the
/// Blizzard_CombatText transcription is the consumer). **§5-verified** (wow-re
/// `playername/scratch/combat-text-update-emission-law.md`): event id 0x21E, fired via the
/// formatted SignalEvent `0x703f50` from the UnitCombatLog_C.cpp emit helpers — every producer
/// fires **at packet parse** (the melee one too: `0x6255b0 → 0x629d30`, one call stack — NOT the
/// impact-keyframe deferral, which belongs to the worldtext/UNIT_COMBAT victim dispatch).
/// `message_type` is the addon's vocabulary (`DAMAGE`/`DAMAGE_CRIT`/`SPELL_DAMAGE`/`HEAL`/…);
/// `data`/`extra` mirror `arg2`/`arg3` (all strings on the real wire — the fmt is `"%s..%d.."`).
/// Producers gate on the SELF recipient — the ref's emit is co-gated with the chat combat-log
/// category scope (participants beyond self CAN fire it there); the exact participant rule is an
/// open residual (decision 0580), and self-only is the display-equivalent conservative cut.
#[derive(Message, Clone)]
pub(crate) struct CombatTextEvent {
pub(crate) message_type: &'static str,
pub(crate) data: Option<String>,
pub(crate) extra: Option<String>,
}
/// The feed's change-tracking memory: what we last told the VM, so we only fire events on transitions.
#[derive(Resource, Default)]
struct UnitFeedState {
/// Whether `PLAYER_ENTERING_WORLD` has been fired (once at startup).
entered_world: bool,
/// Per token, the last snapshot we pushed — the per-field event triggers diff against it.
last: HashMap<String, UnitState>,
/// The last selection guid, for the `PLAYER_TARGET_CHANGED` trigger.
target_guid: Option<u64>,
/// The last `(PLAYER_XP, PLAYER_NEXT_LEVEL_XP)` pair pushed, for the `PLAYER_XP_UPDATE` trigger —
/// the XP bar's feed is a player-global (like coinage), not a per-unit-token field.
last_xp: Option<(u32, u32)>,
/// The last `(count, banked-target guid)` pair pushed, for the `PLAYER_COMBO_POINTS` trigger —
/// player-globals (`PLAYER_FIELD_BYTES` byte 1 + `PLAYER_FIELD_COMBO_TARGET`), not per-unit-
/// token fields. Diffed as a pair because the server writes them as one (decision 0875).
last_combo: Option<(u8, u64)>,
/// The self unit's last in-combat flag, for the `PLAYER_REGEN_DISABLED`/`ENABLED` triggers
/// (`None` until first seen — first sight fires only when already IN combat, so logging in
/// at peace never announces "Leaving Combat").
in_combat: Option<bool>,
/// The self unit's last `PLAYER_FLAGS_PVP_DESIRED` bit, for the toggle announcement
/// (decision 0652). `None` until first seen: the reference reacts to a *changed*-bits mask, so
/// the descriptor that first carries the flag at login announces nothing.
pvp_desired: Option<bool>,
/// Whether we have already warned that our own faction template names no side (decision 0657).
/// Re-arms when a side resolves again, so a `.gm on` / `.gm off` cycle logs once each way.
warned_sideless: bool,
}
/// Adds the per-frame unit feed. The `Unit*` bindings themselves live in `benilla-ui`; this only
/// supplies their data (and the events) from ECS state.
pub(crate) struct UiUnitPlugin;
impl Plugin for UiUnitPlugin {
fn build(&self, app: &mut App) {
app.configure_sets(
Update,
UnitFeed.after(crate::schedule::WorldStage::Net), // the set's own doc: the why
)
.init_resource::<UnitFeedState>()
.add_message::<UnitCombatFeedback>()
.add_message::<CombatTextEvent>()
.add_systems(
Update,
(
feed_units,
melee_unit_combat,
fire_unit_combat,
fire_combat_text,
)
.chain()
.in_set(UnitFeed)
.before(UiInput),
)
.add_systems(Update, drain_pvp_toggles.after(UiInput));
}
}
/// Melee swing → the `UNIT_COMBAT` vocabulary (§5-verified shape, wow-re
/// `unit-combat-event-law.md`): the action comes from the melee wrapper's per-victim-state table
/// (`0x4946d0` → `actionTable@0x83de28`), the descriptor from HitInfo bits **keyed on the
/// amount's sign** — `amount > 0` picks among CRITICAL `0x80` / GLANCING `0x4000` / CRUSHING
/// `0x8000`, `amount ≤ 0` among ABSORB `0x20` / BLOCK `0x800` / RESIST `0x40`, else `""`.
fn melee_feedback(hit_info: u32, victim_state: u32, damage: u32) -> (&'static str, &'static str) {
match victim_state {
2 => ("DODGE", ""),
3 => ("PARRY", ""),
5 => ("BLOCK", ""),
6 => ("EVADE", ""),
7 => ("IMMUNE", ""),
8 => ("DEFLECT", ""),
// 0 UNAFFECTED / 1 NORMAL / 4 INTERRUPT: WOUND, descriptor by the amount-sign key.
_ => {
if damage > 0 {
if hit_info & 0x80 != 0 {
("WOUND", "CRITICAL")
} else if hit_info & 0x4000 != 0 {
("WOUND", "GLANCING")
} else if hit_info & 0x8000 != 0 {
("WOUND", "CRUSHING")
} else {
("WOUND", "")
}
} else if hit_info & 0x20 != 0 {
("WOUND", "ABSORB") // full absorb
} else if hit_info & 0x800 != 0 {
("WOUND", "BLOCK") // full block, when the bridge didn't rewrite the state
} else if hit_info & 0x40 != 0 {
("WOUND", "RESIST") // full resist
} else {
("MISS", "")
}
}
}
}
/// The melee `UNIT_COMBAT` producer: rides the swing's impact keyframe ([`SwingImpact`]) with the
/// rest of the victim feedback — including `text_only` flushes, which the client fires the text
/// channel for. Every victim qualifies (no Gate A, no source class on the portrait path); token
/// resolution happens in [`fire_unit_combat`], so a swing on an un-tokened bystander simply
/// fires nothing. The center combat text does NOT ride here — the client fires it synchronously
/// at packet parse (§5-corrected, decision 0580; the producer lives in `net/apply/combat.rs`).
fn melee_unit_combat(
mut impacts: MessageReader<crate::creature_anim::SwingImpact>,
mut out: MessageWriter<UnitCombatFeedback>,
) {
for crate::creature_anim::SwingImpact { swing: s, .. } in impacts.read() {
let Some(victim) = s.victim else { continue };
let (action, flags) = melee_feedback(s.hit_info, s.victim_state, s.damage);
out.write(UnitCombatFeedback {
unit: victim,
action,
flags,
amount: s.damage,
school: 0, // melee is physical (the wire's sub-damage school is not carried — always 0 here)
});
}
}
/// Drain [`CombatTextEvent`] into the VM: `COMBAT_TEXT_UPDATE(messageType, data, extra)` — the
/// arg shape the shipped Blizzard_CombatText `OnEvent` reads (`arg1..arg3`).
fn fire_combat_text(
script: Option<NonSendMut<UiScript>>,
mut events: MessageReader<CombatTextEvent>,
) {
let Some(mut script) = script else {
return;
};
for ev in events.read() {
let arg = |v: &Option<String>| v.clone().map_or(ScriptValue::Nil, ScriptValue::Str);
script.fire_event(
"COMBAT_TEXT_UPDATE",
vec![
ScriptValue::Str(ev.message_type.to_string()),
arg(&ev.data),
arg(&ev.extra),
],
);
}
}
/// Drain [`UnitCombatFeedback`] into the VM: fire `UNIT_COMBAT` once per live token the entity
/// maps to (`"player"`, `"target"` — the same tokens [`feed_units`] feeds). The frames filter by
/// `arg1` exactly like the real client's; in 1.12 only PlayerFrame/PetFrame register it, so a
/// `"target"` fire is API surface, not pixels.
fn fire_unit_combat(
script: Option<NonSendMut<UiScript>>,
mut events: MessageReader<UnitCombatFeedback>,
self_q: Query<(), With<SelfPlayer>>,
selection: Res<Selection>,
) {
let Some(mut script) = script else {
return;
};
for ev in events.read() {
let mut fire = |token: &str| {
script.fire_event(
"UNIT_COMBAT",
vec![
ScriptValue::Str(token.to_string()),
ScriptValue::Str(ev.action.to_string()),
ScriptValue::Str(ev.flags.to_string()),
ScriptValue::Int(i64::from(ev.amount)),
ScriptValue::Int(i64::from(ev.school)),
],
);
};
if self_q.contains(ev.unit) {
fire("player");
}
if selection.target == Some(ev.unit) {
fire("target");
}
}
}
/// The 1.12 race id → (localized display, `raceFile` token) — `UnitRace`'s two returns. The file
/// token is the client's internal name (undead = `"Scourge"`, the space dropped from `"NightElf"`),
/// the same vocabulary the 2D portrait stand-in files use (`portrait::temporary_portrait`). The
/// display column is also what `$R`/`$r` expand to ([`crate::npc_text`]) — one table, both readers.
pub(crate) fn race_names(race: u8) -> Option<(&'static str, &'static str)> {
Some(match race {
1 => ("Human", "Human"),
2 => ("Orc", "Orc"),
3 => ("Dwarf", "Dwarf"),
4 => ("Night Elf", "NightElf"),
5 => ("Undead", "Scourge"),
6 => ("Tauren", "Tauren"),
7 => ("Gnome", "Gnome"),
8 => ("Troll", "Troll"),
_ => return None,
})
}
/// The 1.12 class id → (localized display, `classFileName`) — `UnitClass`'s two returns. The file
/// name is uppercase (the ref's `strupper(classFileName)` tooltip lookups index GlobalStrings keys
/// like `WARRIOR_STRENGTH_TOOLTIP` directly with it).
pub(crate) fn class_names(class: u8) -> Option<(&'static str, &'static str)> {
Some(match class {
1 => ("Warrior", "WARRIOR"),
2 => ("Paladin", "PALADIN"),
3 => ("Hunter", "HUNTER"),
4 => ("Rogue", "ROGUE"),
5 => ("Priest", "PRIEST"),
7 => ("Shaman", "SHAMAN"),
8 => ("Mage", "MAGE"),
9 => ("Warlock", "WARLOCK"),
11 => ("Druid", "DRUID"),
_ => return None,
})
}
/// Resolve a UnitPopup unit token to the **player guid** it names — `"target"` through the
/// selection iff it really is a player (the target frame's PLAYER menu), a `"partyN"` token through
/// the roster (the party frame's PARTY menu). `"player"` (yourself) and any unresolved token answer
/// `None`.
///
/// Shared by every popup verb that acts on another player: trade's `InitiateTrade` (decision 0592
/// P1) and inspect's `NotifyInspect` (decision 0631) both need exactly this step, so it lives here
/// rather than once per window.
pub(crate) fn player_token_guid(
token: &str,
selection: &Selection,
group: &crate::ui_party::GroupState,
) -> Option<u64> {
match token {
"target" => selection
.guid
.filter(|g| benilla_protocol::guid::is_player(*g)),
"player" => None,
tok => tok
.strip_prefix("party")
.and_then(|n| n.parse::<usize>().ok())
.and_then(|n| n.checked_sub(1))
.and_then(|n| group.party_slots().nth(n))
.map(|m| m.guid),
}
}
/// Build a unit snapshot from a streamed object's descriptor (decision 0061's `ObjectFields`) plus
/// its cache-resolved name and its `UnitReaction` value (`1..8`, or `0` for tokens whose reaction we
/// don't resolve — everything but `"target"`; see [`feed_units`]).
pub(crate) fn snapshot(store: &ObjectStore, name: Option<String>, reaction: u8) -> UnitState {
let power_type = store.0.unit_power_type();
let race = store.0.unit_race().and_then(race_names);
let class = store.0.unit_class().and_then(class_names);
UnitState {
exists: true,
name,
// The UI-facing health/power getters, not the raw fields (decision 1022): a unit carrying
// `UNIT_DYNFLAG_DEAD` — a feigning hunter — answers 0 to `UnitHealth 0x5174d0` and
// `UnitMana 0x517670`, while the *max* getters stay ungated, so its bars read 0/max (empty)
// for itself and for everyone watching. The zeroes ride the ordinary per-field diff in
// [`fire_transitions`], which fires `UNIT_HEALTH` + the power event on the flag's edge —
// exactly the pair the reference's `UNIT_DYNAMIC_FLAGS` watcher fires there
// (`0x6004c5`/`0x6004f0`, event `0x10` and `0x11 + powerType`). The power pair also carries
// the raw→display divide the same two getters do (decision 1034) — rage rides the wire ×10
// and pet happiness ×1000, so these are the numbers the reference shows, not the wire's.
health: store.0.unit_shown_health().unwrap_or(0),
max_health: store.0.unit_max_health().unwrap_or(0),
level: store.0.unit_level().unwrap_or(0),
power_type,
power: store.0.unit_shown_power(power_type).unwrap_or(0),
max_power: store.0.unit_shown_max_power(power_type).unwrap_or(0),
// `UnitIsDead 0x517ac0` — health ≤ 0 **or** the dead-looking flag, so a feigning unit is
// dead to the API, to the target frame's DEAD text and to the greyed portrait alike.
dead: store.0.unit_reads_dead(),
// The released-ghost predicate (decision 0308 §1): PLAYER_FLAGS bit 0x10 — a ghost's
// health is 1, so `dead` above is false for it. Zero/absent on creatures.
ghost: store.0.player_is_ghost(),
reaction,
race: race.map(|(n, _)| n.to_string()),
race_file: race.map(|(_, f)| f.to_string()),
class: class.map(|(n, _)| n.to_string()),
class_file: class.map(|(_, f)| f.to_string()),
// The descriptor's gender byte (0 male, 1 female) on the API's `UnitSex` scale (2 male,
// 3 female; 0 = unknown → the binding's nil).
sex: match store.0.unit_gender() {
Some(0) => 2,
Some(1) => 3,
_ => 0,
},
// The tooltip flag lines (decision 0276's unit law): PvP + Skinnable straight off
// UNIT_FIELD_FLAGS (vmangos UnitDefines.h: 0x1000 / 0x04000000, VERIFIED).
pvp: store.0.unit_flags() & 0x1000 != 0,
skinnable: store.0.unit_flags() & 0x0400_0000 != 0,
// Free-for-all PvP (decision 0646 §1): `PLAYER_FLAGS` bit 7, the same field the ghost
// predicate above reads (vmangos `Player.h:322` `PLAYER_FLAGS_FFA_PVP`, cross-read against
// 0633's byte-level `[+0xe68]+8` bit-7). Zero on creatures, which have no PLAYER_FLAGS —
// and `UnitIsPVPFreeForAll` is false for them in the reference too.
is_pvp_ffa: store.0.player_flags() & 0x80 != 0,
// is_player + the creature-record extras (subtitle/type/rank/civilian) are the caller's
// guid-keyed enrichment — [`enrich_unit`].
..Default::default()
}
}
/// Fill a snapshot's guid-keyed tooltip fields (decision 0276's unit law): players flag
/// `is_player` (the "Race Class (Player)" level line); creatures pull subtitle/type/rank/
/// civilian/leader from the ask-once template record, and resolve the faction-name line.
/// The type word is `CreatureType.dbc`'s enUS display list (ids 1..9 — a fixed 1.12
/// vocabulary; 10 "Not specified" shows nothing).
pub(crate) fn enrich_unit(
state: &mut UnitState,
guid: u64,
names: &NameCache,
store: &ObjectStore,
factions: Option<&Factions>,
self_store: Option<&ObjectStore>,
) {
if benilla_protocol::guid::is_player(guid) {
state.is_player = true;
// No faction line for players — their PLAYER,* factions carry no reputation slot, so
// the builder's rep-index gate always drops the line (byte-identical to resolving it).
return;
}
let Some(entry) = benilla_protocol::guid::entry(guid) else {
return;
};
if let Some(rec) = names.creature_record(entry) {
state.subtitle = rec.subname.clone();
state.creature_type_name = creature_type_word(rec.creature_type).map(str::to_string);
// The client's one rank getter, both gates (`gated_rank`, decision 0782) — never `rec.rank`
// directly: an enslaved elite reads rank 0, so it loses its border dragon, its ELITE
// tooltip word and its world-boss skull together, exactly as in the reference.
state.rank = crate::names::gated_rank(Some(rec), Some(store));
state.civilian = rec.civilian;
state.racial_leader = rec.racial_leader;
// The faction-name line ("Stormwind", between level and PvP) — the unit builder's tail
// block, every gate transcribed: the record's HIDE_FACTION_TOOLTIP type flag (0x10, the
// `0x612610` gate), the template → Faction.dbc hop, the reputation-slot gate
// (rep_index ≥ 0), and the race/class slot walk with its hidden flag (0x4). The record
// gate also stands in for the bytes' "no creature info → pass": before the query
// answers we have no name line either, and the tooltip rebuilds when it lands.
if rec.type_flags & 0x10 == 0 {
state.faction_name = (|| {
let catalog = factions?.catalog();
let faction_id = catalog.template(store.0.unit_faction_template()?)?.faction;
let info = catalog.reputation_faction(faction_id)?;
let self_store = self_store?;
let race = self_store.0.unit_race().unwrap_or(0);
let class = self_store.0.unit_class().unwrap_or(0);
info.tooltip_shows_for(race, class)
.then(|| catalog.faction_name(faction_id).map(str::to_string))
.flatten()
})();
}
}
}
/// Drain the `TogglePVP` intents into `CMSG_TOGGLE_PVP` (decision 0646 §3) — `/pvp` is the only
/// caller the reference has (`SlashCmdList["PVP"]`), and the only one we have.
///
/// This rides the unit feed's plugin rather than a `ui_pvp` of its own: the family's whole client
/// state is one remembered bit ([`UnitFeedState::pvp_desired`]), which lives with the feed's other
/// self-flag edge (`in_combat`) rather than in a plugin of its own.
fn drain_pvp_toggles(script: Option<NonSendMut<UiScript>>, commands: Res<NetCommands>) {
let Some(mut script) = script else {
return;
};
for _ in 0..script.take_pvp_toggles() {
let _ = commands.0.send(crate::net::ClientCommand::TogglePvp);
}
}
/// `PLAYER_FLAGS_PVP_DESIRED` — the PvP *preference* bit (vmangos `PlayerDefines.h`), which is what
/// `CMSG_TOGGLE_PVP` flips. Not to be confused with `UNIT_FIELD_FLAGS`' `PVP` bit `0x1000`, the flag
/// the icon draws: the preference clears instantly, the flag lingers for the server's timer.
const PLAYER_FLAGS_PVP_DESIRED: u32 = 0x200;
/// The PvP-preference announcement rule (decision 0652): `(toast, verbose)` on a real change of the
/// bit, `None` otherwise.
///
/// `was: None` is first sight and stays silent — the reference's handler is driven by a
/// *changed-bits* mask (`new ^ old`), so the descriptor that first carries the flag at login says
/// nothing. The two texts are verbatim 1.12 `GlobalStrings.lua`: the `ERR_PVP_TOGGLE_*` toast
/// (l.1788-1789) and the `PVP_TOGGLE_*_VERBOSE` chat sentence (l.3221-3222). Both are
/// argument-free, so there is no format step.
fn pvp_announcement(was: Option<bool>, now: bool) -> Option<(&'static str, &'static str)> {
if was? == now {
return None;
}
Some(if now {
(
"PvP combat toggled on",
"You are now flagged for PvP combat and will remain so until toggled off.",
)
} else {
(
"PvP combat toggled off",
"You will be unflagged for PvP combat after five minutes of non-PvP action in friendly territory.",
)
})
}
/// A unit's PvP faction group — `UnitFactionGroup`'s pair, as the icon law reads it (decision
/// 0646 §1/§3): `UNIT_FIELD_FACTIONTEMPLATE` → `FactionTemplate.dbc`'s group mask → the
/// `FactionGroup.dbc` name of its **side** bit.
///
/// The `& 6` is the whole rule and it is load-bearing, not a tidy-up: every playable race's
/// template carries `Player|<side>` (mask 3 Alliance, 5 Horde) and so do the PvP-flagged city
/// guards, while `FactionGroup.dbc`'s Player and Monster rows have EMPTY localized names and no
/// `UI-PVP-Player`/`UI-PVP-Monster` texture ships. Masking to the two side bits before the lookup
/// is therefore the only reading the shipped art admits — a lowest-bit walk would answer "Player"
/// for every player in the game.
pub(crate) fn faction_group(store: &ObjectStore, factions: Option<&Factions>) -> Option<String> {
let catalog = factions?.catalog();
let template = catalog.template(store.0.unit_faction_template()?)?;
catalog
.faction_group_name(template.group_mask & 6)
.map(str::to_string)
}
/// `CreatureType.dbc` id → the enUS display word (the level line's class slot for creatures).
fn creature_type_word(t: u32) -> Option<&'static str> {
Some(match t {
1 => "Beast",
2 => "Dragonkin",
3 => "Demon",
4 => "Elemental",
5 => "Giant",
6 => "Undead",
7 => "Humanoid",
8 => "Critter",
9 => "Mechanical",
_ => return None, // 10 "Not specified" shows nothing
})
}
/// Diff a token's fresh snapshot against the last one pushed and fire the per-field Era events.
/// `prev = None` (the token just appeared) treats every present field as a transition.
pub(crate) fn fire_transitions(
script: &mut UiScript,
token: &str,
prev: Option<&UnitState>,
cur: &UnitState,
) {
let tok = || ScriptValue::Str(token.to_string());
let ptok = || ScriptValue::Str(power_token(cur.power_type).to_string());
let changed = |f: fn(&UnitState) -> u64| prev.is_none_or(|p| f(p) != f(cur));
if changed(|u| u64::from(u.health)) {
script.fire_event("UNIT_HEALTH", vec![tok()]);
}
if changed(|u| u64::from(u.max_health)) {
script.fire_event("UNIT_MAXHEALTH", vec![tok()]);
}
if changed(|u| u64::from(u.level)) {
script.fire_event("UNIT_LEVEL", vec![tok()]);
}
if changed(|u| u64::from(u.power_type)) {
script.fire_event("UNIT_DISPLAYPOWER", vec![tok()]);
}
if changed(|u| u64::from(u.power)) {
script.fire_event("UNIT_POWER_UPDATE", vec![tok(), ptok()]);
}
if changed(|u| u64::from(u.max_power)) {
script.fire_event("UNIT_MAXPOWER", vec![tok(), ptok()]);
}
if prev.is_none_or(|p| p.name != cur.name) {
script.fire_event("UNIT_NAME_UPDATE", vec![tok()]);
}
// UNIT_CLASSIFICATION_CHANGED (decision 0782) — the target frame's border-art repaint wire, and
// the only frame that registers it in the reference. Edge-fired on the gated rank, which IS the
// classification (`classification_word` is a pure table index), so this fires exactly when the
// border would change: the creature query landing on a freshly-seen elite, and a mob being
// enslaved or released. Without it the border would only be right on re-target, because the
// ref's own `TargetFrame_Update` is the sole other caller of CheckClassification.
if prev.is_none_or(|p| p.rank != cur.rank) {
script.fire_event("UNIT_CLASSIFICATION_CHANGED", vec![tok()]);
}
// UNIT_FACTION (decision 0646 §2) — the PvP-icon repaint wire, fired on the three fields the
// icon law reads. Exactly the three frames that draw the icon register it in the reference
// (player, target, party member) and nothing else does. Edge-fired, so the player frame's
// `igPVPUpdate` sounds once per flag change rather than once per repaint.
if prev.is_none_or(|p| {
(p.pvp, p.is_pvp_ffa, &p.faction_group) != (cur.pvp, cur.is_pvp_ffa, &cur.faction_group)
}) {
script.fire_event("UNIT_FACTION", vec![tok()]);
}
}
#[allow(clippy::too_many_arguments)]
fn feed_units(
script: Option<NonSendMut<UiScript>>,
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
selection: Res<Selection>,
stores: Query<&ObjectStore>,
mut feed: ResMut<UnitFeedState>,
mut names: ResMut<NameCache>,
commands: Res<NetCommands>,
factions: Option<Res<Factions>>,
reputations: Res<Reputations>,
group: Res<crate::ui_party::GroupState>,
mut chat: ResMut<ChatLog>,
) {
let Some(mut script) = script else {
return;
};
// "player" = our own avatar's descriptor; "target" = the selected entity's. Absent → None, which
// set_unit clears (UnitExists false), exactly as the real client reports a missing unit. Names
// resolve through the cache — a miss queries the server once and lands on a later frame.
let self_pair = self_q.iter().next();
let player = self_pair.map(|(store, guid)| {
let name = names.resolve(guid.0, &commands).map(str::to_string);
let mut s = snapshot(store, name, 0);
s.is_player = true;
// Identity + the raid-target board mark (decision 0434 §5's popup gating; §6's board).
s.guid = guid.0;
s.raid_target = group.raid_target_index(guid.0);
s.faction_group = faction_group(store, factions.as_deref());
s
});
// The GM-mode confound, made audible (decision 0657). A self player whose faction template
// names no side is almost always GM mode: vmangos swaps a GM to faction template 35, whose
// `FactionTemplate.dbc` group mask is 0, and every faction-derived surface then has no side to
// draw — the PvP flag icon most visibly, which simply hides. That is faithful (the only side
// art that ships is Alliance/Horde/FFA), and it is indistinguishable on screen from the icon
// being broken, which has now cost two separate sessions an investigation. So it is a BENCH
// diagnostic, not UI: nothing appears on screen, exactly as in the reference.
if let Some(p) = &player {
let sideless = p.faction_group.is_none();
if sideless && !feed.warned_sideless {
warn!(
"faction: our own template names no side (usually GM mode — vmangos forces \
template 35, group mask 0). Faction-derived UI cannot resolve a side while this \
holds: the PvP flag icon stays hidden however flagged you are. `.gm off` restores it."
);
}
feed.warned_sideless = sideless;
}
let target = selection.target.zip(selection.guid).and_then(|(e, guid)| {
let store = stores.get(e).ok()?;
let name = names.resolve(guid, &commands).map(str::to_string);
// The target's reaction toward us, on the `UnitReaction` 1..8 scale — the same decode the
// selection ring runs (reputation-first, else the faction-template comparator). `ring_reaction`
// returns the raw 0..7 rank (neutral its no-data fallback), which is `UnitReaction − 1`; +1
// lands it on the Lua scale the name-plate palette (`UnitReactionColor`) indexes.
let reaction = ring_reaction(
factions.as_deref(),
&reputations,
Some(store),
self_pair.map(|(s, _)| s),
) + 1;
let mut s = snapshot(store, name, reaction);
s.guid = guid;
s.raid_target = group.raid_target_index(guid);
s.faction_group = faction_group(store, factions.as_deref());
// The byte-confirmed CanAttack 0x606980 (decision 0172) — the same predicate TAB and the
// combat flash run; `UnitCanAttack("player","target")` gates the target frame's
// difficulty-colored level (ref TargetFrame_CheckLevel).
s.can_attack = crate::target::can_attack(
Some(store),
factions.as_deref(),
&reputations,
self_pair.map(|(s, _)| s),
);
enrich_unit(
&mut s,
guid,
&names,
store,
factions.as_deref(),
self_pair.map(|(s, _)| s),
);
Some(s)
});
script.set_unit("player", player.clone());
script.set_unit("target", target.clone());
// Initial pull: fire PLAYER_ENTERING_WORLD once so frames do their first paint on their own.
if !feed.entered_world {
script.fire_event("PLAYER_ENTERING_WORLD", vec![]);
feed.entered_world = true;
}
for (token, snap) in [("player", &player), ("target", &target)] {
match snap {
Some(cur) => {
let prev = feed.last.get(token);
if prev != Some(cur) {
fire_transitions(&mut script, token, prev, cur);
feed.last.insert(token.to_string(), cur.clone());
}
}
None => {
// Clearing a token isn't a UNIT_* event; the target frame reacts to
// PLAYER_TARGET_CHANGED below.
feed.last.remove(token);
}
}
}
// PLAYER_TARGET_CHANGED (no args, real WoW's shape) when the selection changes.
if selection.guid != feed.target_guid {
feed.target_guid = selection.guid;
script.fire_event("PLAYER_TARGET_CHANGED", vec![]);
}
// PLAYER_REGEN_DISABLED/ENABLED: the self in-combat flag transition (`UNIT_FIELD_FLAGS`
// bit `UNIT_FLAG_IN_COMBAT 0x00080000`, vmangos `UnitDefines.h:564`) — the center combat
// text's ENTERING/LEAVING_COMBAT feed (decision 0578; the trigger is PROVISIONAL pending
// the COMBAT_TEXT_UPDATE emission pin).
if let Some((store, _)) = self_pair {
let in_combat = store.0.unit_flags() & 0x0008_0000 != 0;
if feed.in_combat != Some(in_combat) {
let first_sight = feed.in_combat.is_none();
feed.in_combat = Some(in_combat);
if !first_sight || in_combat {
script.fire_event(
if in_combat {
"PLAYER_REGEN_DISABLED"
} else {
"PLAYER_REGEN_ENABLED"
},
vec![],
);
}
}
}
// The PvP toggle's own feedback (decision 0652). The reference's local-player PLAYER_FLAGS
// change handler reacts to the PVP_DESIRED bit — and to nothing else about PvP — with two
// lines: a yellow UI_INFO_MESSAGE toast, then the verbose sentence as a system chat line. It is
// keyed on the *preference*, not on the flag the icon draws, which is exactly why it matters:
// toggling OFF changes no visible flag for five minutes, so without these two lines the key
// reads as dead. The reference only announces its own player (the whole branch sits behind a
// guid == localPlayer gate) and only on a change, never on the descriptor that first carries it.
if let Some((store, _)) = self_pair {
let desired = store.0.player_flags() & PLAYER_FLAGS_PVP_DESIRED != 0;
if let Some((toast, verbose)) = pvp_announcement(feed.pvp_desired, desired) {
script.fire_event("UI_INFO_MESSAGE", vec![ScriptValue::Str(toast.to_string())]);
chat.push_event(ChatEvent::text_only(
ChatEventKind::System,
verbose.to_string(),
));
}
feed.pvp_desired = Some(desired);
}
// The XP bar's feed: push our own avatar's PLAYER_XP / PLAYER_NEXT_LEVEL_XP (both PRIVATE, only
// ever streamed for self) and fire PLAYER_XP_UPDATE when either changes — the coinage feed's
// shape. Absent fields read 0 (a fresh descriptor's zero default; the bar shows empty until XP
// streams in).
if let Some((store, _)) = self_q.iter().next() {
let xp = store.0.player_xp().unwrap_or(0);
let next = store.0.player_next_level_xp().unwrap_or(0);
if feed.last_xp != Some((xp, next)) {
feed.last_xp = Some((xp, next));
script.set_player_xp(xp, next);
script.fire_event("PLAYER_XP_UPDATE", vec![]);
}
}
// The combo-point feed: `PLAYER_FIELD_BYTES` byte 1 and the `PLAYER_FIELD_COMBO_TARGET` GUID
// it is banked against, both PRIVATE, pushed as the pair the server writes (0869, 0875).
//
// Both halves are PUSHED whenever either moves — `GetComboPoints` reads both, so a stale
// target would make it lie on the next call. Only the COUNT fires the event: the client's
// `PLAYER_COMBO_POINTS` (event 202, `0x5ddff0`) is registered at `0x5dd9d9` as a **one-byte
// field-change watch on `+0x1029`** and nothing else — the combo-target field has no watch of
// its own (0879). The watch carries no value test, so it fires on the drop back to zero too,
// which is the edge `ComboFrame` needs to hide itself when the server's 4 s
// `REACTIVE_OVERPOWER` timer clears the point.
//
// The reader's other input — which unit is selected — reaches it through
// `PLAYER_TARGET_CHANGED` above, the event the reference registers `ComboFrame` for precisely
// because `GetComboPoints` consults the current target.
if let Some((store, _)) = self_q.iter().next() {
let banked = (
store.0.player_combo_points().unwrap_or(0),
store.0.player_combo_target(),
);
if let Some(fire) = combo_edge(feed.last_combo, banked) {
feed.last_combo = Some(banked);
script.set_combo_points(banked.0, banked.1);
if fire {
script.fire_event("PLAYER_COMBO_POINTS", vec![]);
}
}
}
}
/// The combo feed's edge, given the last `(count, banked-target)` pair pushed and the current one:
/// `None` = nothing moved, `Some(fire)` = push the pair, and whether `PLAYER_COMBO_POINTS` fires.
///
/// The two halves diverge because the client's watches do. Event 202 is registered at `0x5dd9d9`
/// as a **one-byte field-change watch on `+0x1029`** — the count — and `PLAYER_FIELD_COMBO_TARGET`
/// carries no watch of its own (§5-VERIFIED, decision 0879). So a re-bank onto a different unit at
/// an unchanged count moves the *value* `GetComboPoints` reads without announcing itself, and the
/// UI hears about it through `PLAYER_TARGET_CHANGED` instead. The watch has no value test, so the
/// drop back to zero fires like any other change — the edge that takes the dots down.
fn combo_edge(last: Option<(u8, u64)>, now: (u8, u64)) -> Option<bool> {
(last != Some(now)).then(|| last.map(|(count, _)| count) != Some(now.0))
}
#[cfg(test)]
mod tests {
use super::*;
/// The combo feed pushes on either half moving but speaks only for the count — the client's
/// watch is on that byte alone (decision 0879).
#[test]
fn only_the_count_fires_the_combo_event() {
const A: u64 = 0xF130_0000_0000_0001;
const B: u64 = 0xF130_0000_0000_0002;
assert_eq!(combo_edge(Some((1, A)), (1, A)), None, "nothing moved");
assert_eq!(
combo_edge(Some((1, A)), (2, A)),
Some(true),
"a builder lands: push and speak"
);
assert_eq!(
combo_edge(Some((5, A)), (0, 0)),
Some(true),
"the clear speaks too — the falling edge is what hides the dots"
);
assert_eq!(
combo_edge(Some((1, A)), (1, B)),
Some(false),
"re-banked onto another unit at the same count: pushed, but silent like the client"
);
assert_eq!(
combo_edge(None, (0, 0)),
Some(true),
"first sight announces once, as the descriptor block's first write does"
);
}
/// **A feigning hunter's frame reads as a corpse's** (decision 1022) — the symptom the whole
/// change exists for: the wire says nothing but `UNIT_DYNFLAG_DEAD`, and the snapshot has to
/// turn that into empty bars against a real maximum plus `UnitIsDead`. Asserted through
/// `snapshot` rather than the field getters (which have their own byte-law test) because what
/// can regress here is the *wiring* — a future edit reaching for `unit_health` again.
#[test]
fn a_feigning_unit_snapshots_empty_bars_over_a_real_maximum() {
use benilla_protocol::messages::ObjectFields;
/// `UNIT_FIELD_HEALTH` / `MAXHEALTH` / `POWER1` / `MAXPOWER1` / `DYNAMIC_FLAGS`.
const HEALTH: u16 = 22;
const POWER1: u16 = 23;
const MAXHEALTH: u16 = 28;
const MAXPOWER1: u16 = 29;
const DYNFLAGS: u16 = 143;
let vitals = [
(HEALTH, 1200),
(MAXHEALTH, 1500),
(POWER1, 300),
(MAXPOWER1, 900),
];
let alive = snapshot(
&ObjectStore(ObjectFields::from_pairs(&vitals)),
Some("Hunter".into()),
0,
);
assert_eq!((alive.health, alive.max_health), (1200, 1500));
assert_eq!((alive.power, alive.max_power), (300, 900));
assert!(!alive.dead);
let feigning = snapshot(
&ObjectStore(ObjectFields::from_pairs(
&[vitals.as_slice(), &[(DYNFLAGS, 0x20)]].concat(),
)),
Some("Hunter".into()),
0,
);
assert_eq!(
(feigning.health, feigning.max_health),
(0, 1500),
"UnitHealth 0x5174d0 zeroes, UnitHealthMax 0x5175b0 does not — an EMPTY bar, not a gone one"
);
assert_eq!(
(feigning.power, feigning.max_power),
(0, 900),
"UnitMana 0x517670 zeroes, UnitManaMax 0x5177e0 does not"
);
assert!(feigning.dead, "UnitIsDead 0x517ac0's dynflag leg");
assert!(
!feigning.ghost,
"feign is not a ghost — PLAYER_FLAGS is clear"
);
// …and the flag moves the two fields [`fire_transitions`] diffs, so the edge announces
// itself as `UNIT_HEALTH` + the power event — the very pair the reference's own dynamic-
// flags watcher fires there (`0x6004c5`, `0x6004f0`). Nothing else about the unit moved,
// which is why routing the flag through the getters is enough: no extra watcher needed.
assert_ne!(alive.health, feigning.health);
assert_ne!(alive.power, feigning.power);
assert_eq!(
(alive.max_health, alive.max_power, alive.level),
(feigning.max_health, feigning.max_power, feigning.level),
);
}
/// The rank getter's two gates (`0x605620`, decision 0782) as they reach a snapshot. The pet
/// gate is the interesting half: a charmed or enslaved elite reports rank 0, so it loses its
/// dragon border AND its ELITE tooltip word AND (at rank 3) its world-boss skull together.
/// Asserted through `enrich_unit` rather than `gated_rank` directly, because the thing that
/// can regress is the *wiring* — a future edit reaching for `rec.rank` again.
#[test]
fn the_rank_gate_zeroes_a_pet_or_charm() {
use benilla_protocol::messages::ObjectFields;
const ENTRY: u32 = 12397; // Ol' Sooty, a rank-1 elite
const GUID: u64 = (0xF130u64 << 48) | ((ENTRY as u64) << 24) | 0x42;
/// `UNIT_FIELD_PETNUMBER` — absolute descriptor index (`OBJECT_END(6) + 0x85`).
const PETNUMBER: u16 = 139;
let mut names = NameCache::default();
names.insert_creature(
ENTRY,
Some(crate::names::CreatureRecord {
name: "Ol' Sooty".into(),
subname: None,
creature_type: 1,
pet_family: 4, // Bear — a real tameable family, so the record is a plausible one
rank: 1,
type_flags: 0,
civilian: false,
racial_leader: false,
}),
);
let rank_of = |fields: &[(u16, u32)]| {
let store = ObjectStore(ObjectFields::from_pairs(fields));
let mut s = UnitState::default();
enrich_unit(&mut s, GUID, &names, &store, None, None);
s.rank
};
assert_eq!(rank_of(&[]), 1, "a free elite keeps its template rank");
assert_eq!(
rank_of(&[(PETNUMBER, 0)]),
1,
"an explicit zero pet number is not a pet"
);
assert_eq!(
rank_of(&[(PETNUMBER, 7)]),
0,
"a non-zero pet number forces rank 0 — no dragon on an enslaved elite"
);
// The record gate, the getter's other half: no cached template at all → rank 0, and the
// border stays plain until the creature query answers.
let store = ObjectStore(ObjectFields::from_pairs(&[]));
let mut s = UnitState::default();
enrich_unit(&mut s, GUID ^ (1 << 24), &names, &store, None, None);
assert_eq!(s.rank, 0, "an un-queried creature has no classification");
}
/// The PvP-preference announcement law (decision 0652), as the reference's changed-bits handler
/// runs it: silent on first sight, one pair per real edge, and the OFF text is the one that
/// explains the five-minute wait — the whole reason the toggle doesn't read as dead.
#[test]
fn pvp_announcement_speaks_only_on_an_edge() {
assert_eq!(
pvp_announcement(None, false),
None,
"first sight, unflagged"
);
assert_eq!(pvp_announcement(None, true), None, "first sight, flagged");
assert_eq!(pvp_announcement(Some(true), true), None, "no change");
assert_eq!(pvp_announcement(Some(false), false), None, "no change");
let (toast, verbose) = pvp_announcement(Some(false), true).expect("turning it on speaks");
assert_eq!(toast, "PvP combat toggled on"); // GlobalStrings ERR_PVP_TOGGLE_ON
assert_eq!(
verbose,
"You are now flagged for PvP combat and will remain so until toggled off."
);
let (toast, verbose) = pvp_announcement(Some(true), false).expect("turning it off speaks");
assert_eq!(toast, "PvP combat toggled off"); // GlobalStrings ERR_PVP_TOGGLE_OFF
assert!(
verbose.contains("five minutes"),
"the OFF sentence is what tells the player the flag lingers: {verbose}"
);
}
}