Files
benilla-octo/crates/benilla-app/src/ui_unit.rs
T
SamandClaude Opus 5.5 293b8e57fb creatures keep their first corner, and characters never share files
- a creature walking a path no longer cuts its first corner: the path's first waypoint was dropped at decode (2342)
- two characters whose names differ only in accented or non-Latin letters no longer share macros, bindings and SavedVariables; a file that failed to load is not overwritten at logout (2342)
- custom chat channels survive a logout, a collapsed quest header keeps its quests watched, and a trackpad swipe scrolls as far as it adds up to (2342)
- a hearth off a zeppelin no longer lands back on its deck, and an opened lockbox with loot left in it unlocks (2342)
- every snapshot runs on Vulkan, in a container, before it ships (2344)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 12:19:22 +07:00

3552 lines
180 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. `"targettarget"` is the one
//! token here that IS reached by a guid: the target's own `UNIT_FIELD_TARGET`, looked up in the same
//! index (decision 1576).
//!
//! 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 fired per field on transitions: `UNIT_HEALTH`/`UNIT_MAXHEALTH`/
//! `UNIT_LEVEL` (arg1 = token), the **1.12** per-resource power pair `UNIT_MANA`/`UNIT_RAGE`/
//! `UNIT_FOCUS`/`UNIT_ENERGY`/`UNIT_HAPPINESS` and their five `UNIT_MAX*` twins (arg1 = token; the
//! resource is in the NAME, not an argument — decision 1819, which retired the Era
//! `UNIT_POWER_UPDATE`/`UNIT_MAXPOWER` pair that no 1.12 frame listens for),
//! `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_formats::ChrClasses;
use benilla_protocol::messages::ObjectType;
use benilla_ui::script::{power_token, ScriptValue, UiScript, UnitState, WornDisplay};
use crate::names::NameCache;
use crate::net::{
FieldChanged, FieldEdges, Guid, NetCommands, ObjectStore, Reputations, SelfPlayer,
};
use crate::target::{ring_reaction, Factions, Selection};
use crate::ui_script::{gate, UiInput};
/// The unit-feed pass — the GATED sub-phase of [`crate::ui_script::UiFeed`], which carries the
/// order: after [`benilla_world::schedule::WorldStage::Net`] and before [`UiInput`], so the
/// snapshot + events it produces are in place when the VM ticks and dispatches this frame. That
/// order was found here first (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 decision 2304 made it every
/// feed's. What stays this set's own is the gate: every member either fires a login one-shot or
/// latches a per-VM memo, so none may run before the in-game interface exists (1348). 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, plus one server-side log-once.
///
/// **`PLAYER_LEAVING_WORLD` on a cross-map worldport** (decision 2235, corrected by 2238).
///
/// The reference fires event `0x111` at `0x490b48`, inside `0x490a80`. wow-re's census of both
/// signal helpers puts the id at exactly one site image-wide — 336/336 ids resolved through
/// `0x703e50`, 149/149 through `0x703f50`, and the encoding `b9 11 01 00 00` occurs once in the
/// binary — so that is the whole of the FIRE, and it is what this doc used to conflate with the
/// whole of the event.
///
/// **One fire site, three callers, and this system is one of them** (2238; wow-re `5ad31a12`).
/// `0x490a80` is reached from the local player object's own destructor (`0x401bc0` → `0x467700`
/// → `0x467800` → the per-object `[vtbl+0]` → `0x5dd500` → `0x5dd600` → `0x5dd72c` → `0x5dd543`)
/// — **this system's occasion** — and also from `0x490c20` inside the shutdown tail `0x490bd0`,
/// which is where an in-world `/reload`, a logout, a quit and a disconnect reach it, and from
/// `0x5e9b5a`, vtable slot 1, on a DESTROY / OUT_OF_RANGE of the local player object.
/// 2235 read `5ce96437`'s "three gates" as three gates on the event and its subject line as a
/// census of the callers; neither is what they were. Two of those gates (`0x5dd71c`/`0x5dd721`/
/// `0x5dd725` and `0x5dd539`/`0x5dd53e`/`0x5dd541`) sit in the destructor chain and gate only the
/// caller below; `0x490a80`'s own only gate is the latch at `[0xb4b424]`.
///
/// **The tail's occasions are already ours**, and were years before this system existed:
/// [`crate::ui_script::shutdown_ui_state`] fires `PLAYER_LEAVING_WORLD` then `PLAYER_LOGOUT` as
/// the head of the same `0x490bd0` tail, from `end_ui_session` (logout, disconnect, and
/// `run_pending_reload`'s `/reload`) and from `shutdown_on_exit` (quit). So the two producers are
/// complements, not duplicates — a fact worth writing down precisely because nothing in either
/// file said so, and a reader of this doc alone would take the tail's fire for a bug.
///
/// **Cross-map only *for this occasion*, and that falls out of the gate rather than being a rule
/// on top of it.** The first gate (`0x5dd728`) admits only the local player's destructor, and a
/// same-map teleport never destroys the object — including the >30 yd variant that forces a
/// blocking terrain reload, which reloads tiles rather than `CGObject`s. So
/// [`crate::net::WorldportMessage`] is the right edge and `needs_ack` is the right discriminator:
/// the one worldport that does NOT need an ack is the initial-login map, where nothing is being
/// left.
///
/// **Nothing shipped listens to it.** Zero of the 232 extracted reference interface files
/// register `PLAYER_LEAVING_WORLD` (controls, same sweep: `PLAYER_ENTERING_WORLD` 22 files,
/// `VARIABLES_LOADED` 7, `PLAYER_LOGIN` 1). This is an addon-facing event, which is exactly why
/// it went missing here for so long — no stock window breaks without it, and only the corpus
/// notices. It is also why this stays a bare fire and promises nothing more: in the reference the
/// handler runs *during* teardown, after the three manager unlinks and ~35 of `0x490a80`'s 37
/// teardown calls, so a real addon's handler already sees a substantially dismantled UI.
///
/// **What "dismantled" costs there is the opposite of what 2235 guessed** (2238; wow-re
/// `20210d32`). The GUID hash's link is `obj+0x1c` and the destructor splices the object out
/// (`0x467887 call 0x468680`) *before* `call [vtbl+0]`, so on THIS occasion — and only this one —
/// a hash lookup misses. But `UnitName("player")` never asks the hash: `0x517020` short-circuits
/// at `0x51707d` and answers from the cached character record `[0xc27d88]`, which has one writer
/// (`CGlueMgr::EnterWorld`) and no clearer; `UnitRace`, `UnitClass` and `0x517ee0` take the same
/// fast path. The binding that *does* go nil is `UnitExists("player")`, whose `0x515970` resolves
/// through `0x468460` and takes `0x5159c9 je 0x515a39` → `0:0` on the miss. We reproduce none of
/// that ordering and are not trying to: ours fires with the descriptor still present, so every
/// unit binding answers. That is a divergence in our favour, recorded rather than closed —
/// closing it would mean deliberately breaking `UnitExists` to match a teardown artifact no
/// stock file observes.
fn fire_leaving_world_on_worldport(
script: Option<NonSendMut<UiScript>>,
mut armed: ResMut<crate::ui_script::LeavingWorldArmed>,
mut ports: MessageReader<crate::net::WorldportMessage>,
) {
// `needs_ack` false is the initial-login map (`player::wire_in`'s own split): an entry, not a
// departure. Read the whole iterator either way so the cursor never carries one over.
let leaving = ports.read().filter(|w| w.needs_ack).count() > 0;
if !leaving {
return;
}
// **The world latch, spent here** (2239): this producer and the shutdown tail are the
// reference's `0x5dd543` and `0x490c20`, two callers of one fire site, and `[0xb4b424]` is
// what keeps them from both claiming one departure. Spent even if the VM turns out to be
// absent below — the reference clears it at `0x490a8d`, ahead of the fire and of every
// teardown call after it, so a departure nobody could be told about is still a departure.
if !armed.spend() {
return;
}
let Some(mut script) = script else {
return;
};
script.fire_event("PLAYER_LEAVING_WORLD", Vec::new());
}
/// The VM half lives behind a [`crate::ui_script::VmMemo`], **inside the resource** — the same
/// law 1290 wrote for `Local` memos, reached the way a `ResMut` system has to reach it: a memory
/// about what THIS VM was told is unreadable against the next VM, so a `/reload` (1291) — which
/// replaces the VM without despawning the world — re-fires `PLAYER_ENTERING_WORLD` and re-runs
/// every transition diff exactly as a fresh login does. Before this, every one of these fields
/// survived the reload and the new VM never heard the events (the logout path re-armed off the
/// self descriptor despawning, which a reload never does).
#[derive(Resource, Default)]
struct UnitFeedState {
/// What we last told the VM — dies with the VM it was told to.
vm: crate::ui_script::VmMemo<UnitFeedMemo>,
/// 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.
/// **Server memory, not VM memory** — deliberately outside the memo: a `/reload` must not
/// re-log the GM-mode warning.
warned_sideless: bool,
}
/// The per-VM half of [`UnitFeedState`] — the event-trigger diffs.
#[derive(Default)]
struct UnitFeedMemo {
/// The gate's counter memories (1439) — the two lazy caches this feed resolves through
/// (their per-frame `&mut` misses poison `is_changed`, the counters carry the landings).
names_generation: gate::Watch,
guild_generation: gate::Watch,
/// Whether `PLAYER_ENTERING_WORLD` has been fired (once per world entry, once per VM).
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 `(restState, restPool, PLAYER_FLAGS)` triple pushed, for the `UPDATE_EXHAUSTION`,
/// `PLAYER_UPDATE_RESTING` and `PLAYTIME_CHANGED` triggers — player-globals like the XP pair
/// (decisions 1082/1087/2078). Pushed as one snapshot so
/// `GetRestState`/`GetXPExhaustion`/`IsResting` never read it half-updated.
///
/// The whole flags dword is kept because the two flag events read **different bits of it**
/// (`0x20` and `0x1000|0x2000`), not because either fires on the word as a whole — 2078
/// corrected that, and the feed XORs against this to find which arm moved.
last_rest: Option<(u8, u32, u32)>,
/// Our avatar's last-seen `UNIT_FIELD_LEVEL`, for the `PLAYER_LEVEL_UP` trigger (decision
/// 1094). `None` until first seen — the first sighting is the login descriptor, not a ding.
last_level: Option<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>,
/// The self unit's last `(HIDE_HELM, HIDE_CLOAK)` pair, for the worn-display push (decision
/// 1472). Pushed on the **edge** and never per frame: the Options row's setter flips the VM's
/// belief optimistically and the server's answer is a round trip away, so a per-frame push
/// would snap the value back to the stale descriptor in between.
worn_hidden: Option<(bool, bool)>,
/// The self player's last-pushed `PLAYER_FIELD_BYTES` byte 2 — the four extra bars' visibility
/// (wow-re `action-bar-toggles.md`). A player-global like the combo pair, pushed on the edge.
/// `None` until first seen; there is no event to fire on it, because the real client registers
/// no field-change callback anywhere near this offset.
action_bar_toggles: Option<u8>,
}
/// 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
// A sub-phase of the feed phase, which carries the order (the set's own doc)…
.in_set(crate::ui_script::UiFeed)
// …and never before the in-game UI exists (1348). The whole SET, not just
// `feed_units`: every feed in it either fires a login one-shot or latches a
// per-VM memo, and both are lost forever against the boot VM. The window and
// the reference's own ordering: `ui_script::ingame_ui_up`.
.run_if(crate::ui_script::ingame_ui_up),
)
.init_resource::<UnitFeedState>()
// [`feed_units`] shows catalog messages (the rest-state pair, the PvP toggle) through
// `ui_action::show_messages`, whose sink is the chat log and the message-sound queue.
// The queue belongs to the sound stack, which a UI-only harness does not stand up, and a
// missing `ResMut` is a system-validation panic — so this plugin declares it. `init` is a
// no-op when the sound plugin has already put it there.
.init_resource::<crate::sound::MessageSounds>()
.add_message::<UnitCombatFeedback>()
.add_message::<CombatTextEvent>()
// …and the worldport edge [`fire_leaving_world_on_worldport`] reads, for exactly the
// reason above: the message belongs to `crate::net`, which a UI-only harness does not
// stand up, and an unregistered `MessageReader` is a system-validation panic rather than
// an empty read. `add_message` is idempotent, so the net plugin declaring it too costs
// nothing. (1348's own `the_login_one_shots_wait_for_the_in_game_ui` is the harness that
// found this — it builds this plugin alone.)
.add_message::<crate::net::WorldportMessage>()
.add_message::<crate::net::FieldChanged>()
// The world latch (2239): this plugin owns one of its two producers, so it declares the
// resource as well as the message — same reason, and `init_resource` is idempotent
// against `UiScriptPlugin`'s own.
.init_resource::<crate::ui_script::LeavingWorldArmed>()
.add_systems(
Update,
(
// FIRST in the chain, so a worldport's leaving edge precedes the entering edge
// the same port raises in `feed_units` once the new descriptor lands.
fire_leaving_world_on_worldport,
feed_units,
feed_unit_reach,
feed_player_control,
feed_farsight_focus,
melee_unit_combat,
fire_unit_combat,
fire_combat_text,
)
.chain()
.in_set(UnitFeed),
)
.add_systems(Update, drain_pvp_toggles.after(UiInput))
.add_systems(Update, drain_worn_display_toggles.after(UiInput))
.add_systems(Update, drain_action_bar_toggles.after(UiInput))
.add_systems(Update, feed_default_language.in_set(UnitFeed))
.add_systems(Update, feed_known_languages.in_set(UnitFeed))
// `load_exhaustion_rows` pushes into the VM, so it runs per VM in `Update` (1290), in
// the feed phase; `load_default_languages` only builds a Bevy resource and stays a
// one-shot.
.add_systems(
Update,
load_exhaustion_rows.in_set(crate::ui_script::UiFeed),
)
.add_systems(PostStartup, (load_default_languages, load_languages));
}
}
/// The race → default-chat-language join, loaded once ([`benilla_formats::DefaultLanguages`]).
/// Absent when the tables would not load — `GetDefaultLanguage()` then answers the reference's own
/// zero-values shape rather than a made-up word.
#[derive(Resource)]
pub(crate) struct DefaultLanguagesRes(pub(crate) benilla_formats::DefaultLanguages);
/// Load `Languages.dbc` × `ChrRaces.dbc` once at startup ([`load_exhaustion_rows`]'s shape).
/// `Languages.dbc` in row order — the walk behind `GetNumLaguages`/`GetLanguageByIndex`.
#[derive(Resource)]
pub(crate) struct LanguagesRes(pub(crate) benilla_formats::Languages);
fn load_languages(mut commands: Commands, assets: Option<Res<benilla_assets::WorldAssets>>) {
let Some(assets) = assets else { return };
let loaded = {
use benilla_assets::LockRecover;
let mut chain = assets.chain.lock_recover();
benilla_formats::load_languages(&mut chain)
};
match loaded {
Ok(langs) => {
info!("ui_unit: {} Languages.dbc rows", langs.len());
commands.insert_resource(LanguagesRes(langs));
}
Err(e) => warn!("ui_unit: Languages.dbc unavailable — {e:#}"),
}
}
/// The languages this character **knows**, folded the reference's way and fed to the VM for
/// `GetNumLaguages`/`GetLanguageByIndex` (wow-re `chat-language-scramble.md` §8, C6):
///
/// 1. `0x4b25b0` runs on spell add and stores `[languageId] = spellId` for a spell whose
/// `Effect_1 == 39` — so the table holds only languages *this character's known spells*
/// declare ([`benilla_formats::SpellCatalog::declared_language`], spell → language, never the
/// other way round: five shipped language spells declare Common, and that anomaly is the
/// reference's).
/// 2. `GetNumLaguages` walks `Languages.dbc` rows and keeps those `0x5ec720` answers non-zero
/// for: the language's spell resolves to a `SkillLine` (`0x6de040`, race/class, spell) that is
/// present in the player's `PLAYER_SKILL_INFO` block. Presence, not value: a found line
/// returns 1 whatever its value.
///
/// One knowingly-unreproduced detail: a later learn overwrites an earlier spell on the same
/// language id in the reference's table. The known-spell set here is unordered, so when two
/// known spells declare one language, either's skill line passes — unobservable on shipped data
/// (every language skill a character holds is 300, and the only shared id is Common's).
pub(crate) fn known_languages(
known: impl IntoIterator<Item = u32>,
spells: &benilla_formats::SpellCatalog,
skill_lines: Option<&benilla_formats::SkillLineCatalog>,
has_skill_line: impl Fn(u32) -> bool,
languages: &benilla_formats::Languages,
) -> Vec<String> {
let mut declared: std::collections::HashMap<u32, Vec<u32>> = Default::default();
for spell in known {
if let Some(lang) = spells.declared_language(spell) {
declared.entry(lang).or_default().push(spell);
}
}
languages
.names(0)
.filter(|(id, _)| {
declared.get(id).is_some_and(|spells| {
spells.iter().any(|&spell| {
skill_lines
.and_then(|sl| sl.spell_to_line(spell))
.is_some_and(&has_skill_line)
})
})
})
.map(|(_, name)| name.to_string())
.collect()
}
fn feed_known_languages(
script: Option<NonSendMut<UiScript>>,
actions: Option<Res<crate::ui_action::PlayerActions>>,
spells: Option<Res<crate::ui_action::Spells>>,
skill_lines: Option<Res<crate::ui_spellbook::SkillLines>>,
languages: Option<Res<LanguagesRes>>,
self_q: Query<Ref<ObjectStore>, With<SelfPlayer>>,
mut pushed: Local<crate::ui_script::VmMemo<Option<Vec<String>>>>,
) {
let Some(mut script) = script else {
return;
};
let (Some(actions), Some(spells), Some(languages)) = (actions, spells, languages) else {
return;
};
let pushed = pushed.get(&script);
let store = self_q.iter().next();
// A pure function of the spell book, the three catalogs and our descriptor — with all still
// and a push already made on this VM, the rebuild (a skill-slot scan per language, a
// `Vec<String>`) can only reproduce the memo.
let inputs_moved = store.as_ref().is_some_and(|s| s.is_changed())
|| actions.is_changed()
|| spells.is_changed()
|| languages.is_changed()
|| skill_lines.as_ref().is_some_and(|l| l.is_changed());
if pushed.is_some() && !inputs_moved {
return;
}
let store: Option<&ObjectStore> = store.as_deref();
let has_skill_line = |line: u32| {
store.is_some_and(|s| {
(0..benilla_protocol::messages::PLAYER_SKILL_SLOTS)
.filter_map(|i| s.0.player_skill(i))
.any(|slot| u32::from(slot.skill_id) == line)
})
};
let names = known_languages(
actions.spells.iter().copied(),
&spells.catalog,
skill_lines.as_deref().map(|s| &s.catalog),
has_skill_line,
&languages.0,
);
if pushed.as_ref() != Some(&names) {
script.set_known_languages(names.clone());
*pushed = Some(names);
}
}
fn load_default_languages(
mut commands: Commands,
assets: Option<Res<benilla_assets::WorldAssets>>,
) {
let Some(assets) = assets else { return };
let loaded = {
use benilla_assets::LockRecover;
let mut chain = assets.chain.lock_recover();
benilla_formats::load_default_languages(&mut chain)
};
match loaded {
Ok(langs) => {
info!("ui_unit: {} race → default-language rows", langs.len());
commands.insert_resource(DefaultLanguagesRes(langs));
}
// Not fatal: the binding's contract already has an answer for "no table".
Err(e) => warn!("ui_unit: default languages unavailable — {e:#}"),
}
}
/// **The player's default language goes into the VM at its birth** (decision 2241, through the
/// seam 2240 established) — because `GetDefaultLanguage()` is read *inside* the load burst, and
/// until now the answer during that burst was `nil` and then a race.
///
/// Two readers, both stock: `ChatEdit_OnLoad` takes it at OnLoad (dead in 1.12, but it is the
/// era's idiom), and `ChatFrame_OnEvent`'s `PLAYER_ENTERING_WORLD` arm stores it as
/// `this.defaultLanguage`, which gates the `[Common]`/`[Orcish]` prefix on every readable chat
/// line for the session. We fire `PLAYER_ENTERING_WORLD` from [`feed_units`] — an `Update` system
/// in the same set as [`feed_default_language`], with no ordering between them — so whether that
/// gate was seeded when the event arrived was Bevy's intra-set order to decide, per run.
///
/// The race comes from the **roster row** rather than the object store, because the avatar does not
/// exist yet at this edge; it is the same value from the same login, and it is the row
/// [`crate::ui_script::seat_from_roster`] builds the player seat from a few lines earlier in the
/// same call. [`feed_default_language`] still runs and still owns the live answer — this only
/// makes sure the burst does not read a nil.
pub(crate) fn seed_default_language(world: &mut World, script: &mut UiScript) {
let (Some(langs), Some(roster)) = (
world.get_resource::<DefaultLanguagesRes>(),
world.get_resource::<crate::char_select::Roster>(),
) else {
return;
};
let Some(row) = roster.pending_row() else {
return;
};
script.set_default_language(langs.0.name(u32::from(row.race), 0).map(str::to_string));
}
/// Push `GetDefaultLanguage()`'s one string, on change only.
///
/// The reference resolves it per call from the live player object; we resolve it once per race
/// change, which is the same answer with none of the per-frame churn — a race cannot change
/// without a new world entry. `None` (no player object, or no table) is the reference's zero-value
/// state, and that is what the VM stores.
///
/// **The locale column is 0.** `[0xc0e080]` is the client's locale slot; only enUS is populated in
/// the 5875 data, and every other DBC catalog here reads column 0 for the same reason.
fn feed_default_language(
script: Option<NonSendMut<UiScript>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
langs: Option<Res<DefaultLanguagesRes>>,
mut pushed: Local<crate::ui_script::VmMemo<Option<Option<String>>>>,
) {
let Some(mut script) = script else {
return;
};
let pushed = pushed.get(&script);
let name = self_q
.iter()
.next()
.and_then(|store| store.0.unit_race())
.zip(langs.as_ref())
.and_then(|(race, langs)| langs.0.name(u32::from(race), 0))
.map(str::to_string);
if pushed.as_ref() != Some(&name) {
script.set_default_language(name.clone());
*pushed = Some(name);
}
}
/// Seed the VM's Exhaustion.dbc table once per VM — the rest bindings' data
/// ([`benilla_ui::script::UiScript::set_exhaustion_rows`]; the ui_macro icon-catalog shape).
/// A failed load keeps the model's shipped-table fallback, so the rest surface still behaves
/// like the shipped enUS client rather than going dark.
///
/// Per VM rather than per process (1290) for the reason every seed here is: a login builds a fresh
/// VM, and this one degrades quietly — the fallback table is close enough that nobody would notice
/// it had stopped being seeded.
fn load_exhaustion_rows(
script: Option<NonSendMut<UiScript>>,
assets: Option<Res<benilla_assets::WorldAssets>>,
mut seeded: Local<crate::ui_script::VmMemo<bool>>,
) {
let (Some(mut script), Some(assets)) = (script, assets) else {
return;
};
if !seeded.claim(&script) {
return;
}
let loaded = {
use benilla_assets::LockRecover;
let mut chain = assets.chain.lock_recover();
benilla_formats::load_exhaustion(&mut chain)
};
match loaded {
Ok(rows) => {
info!("ui_unit: {} Exhaustion.dbc rest states", rows.len());
script.set_exhaustion_rows(
rows.into_iter()
.map(|r| (r.id as u8, r.name, f64::from(r.factor)))
.collect(),
);
}
Err(e) => error!("ui_unit: Exhaustion.dbc failed — shipped-table fallback holds: {e:#}"),
}
}
/// 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,
})
}
/// A playable race id → `UnitFactionGroup`'s token (`"Alliance"`/`"Horde"`), or `None` for a race
/// id that is not one of the eight.
///
/// **A side derived from the RACE, not from the faction template** — deliberately, and only for
/// the one window in which the template does not exist yet. [`faction_group`] is the real answer
/// and reads `UNIT_FIELD_FACTIONTEMPLATE` off the descriptor; during world entry there is no
/// descriptor, and `UnitFactionGroup("player")` answering nil there is not "no faction", it is a
/// state a real player character cannot be in. AceDB-2.0 — embedded across a large slice of the
/// corpus — builds its per-realm key as `realm .. " - " .. faction` at **file scope**, so a nil
/// side is 24 corpus addons stopping on `attempt to concatenate local 'faction'`
/// (`addon_harness::seat_a_session`, decision 1195, which seats exactly this in the survey's VM).
///
/// Every playable race has a fixed side in 1.12, so this is a lookup rather than a guess — and it
/// reads the [`crate::char_create::ALLIANCE`] column the create screen already keeps, so the two
/// cannot drift apart.
pub(crate) fn race_faction_group(race: u8) -> Option<&'static str> {
if !(1..=8).contains(&race) {
return None;
}
Some(if crate::char_create::ALLIANCE.contains(&race) {
"Alliance"
} else {
"Horde"
})
}
/// A unit's **PvP team digit** — `0x5efe00`'s tri-state: `0` Horde, `1` Alliance, `-1` no side.
///
/// **This is NOT [`faction_group`], and the difference is the whole of report B378.** The two read
/// different sources and only agree while nothing has moved a unit off its racial faction:
///
/// * `UnitFactionGroup` (`0x516630`) reads the unit's LIVE `UNIT_FIELD_FACTIONTEMPLATE`
/// (`0x5166b8 mov eax,[eax+0x110]` / `0x5166be mov eax,[eax+0x74]`, byte-read here) — so a
/// vmangos GM, forced to template 35, genuinely has no side and the PvP flag icon genuinely
/// hides. That is faithful.
/// * The rank title's team digit (`0x5efe00`) reads the unit's **RACE** and walks
/// `[obj+0x110]+0x78` → `ChrRaces.dbc` field 2 (FactionTemplate id) → `FactionTemplate.dbc`
/// field 3 (factionGroupMask) → `& 4` ⇒ 0, else `& 2` ⇒ 1, else −1 — never the live template.
/// A GM's race does not change, so the reference names his rank exactly as it always did.
///
/// We had the second wired to the first, which is why a Grand Marshal's Honor tab read `NONE` on
/// a GM-flagged account while the 1.12 client on the same server read "Grand Marshal"
/// (decision 2227). Every `0x5efe00` caller is race-derived: `GetPVPRankInfo`'s team
/// (`0x51a9af`/`0x51a9c8`), `UnitPVPName`'s rank decoration (`0x5efe60`), the battlefield
/// scoreboard's per-row side (`0x4aa200`, which inlines the same walk off the name-cache record).
///
/// The table is the shipped one, frozen: `ChrRaces.dbc` has **nine** rows in 5875 and race 9
/// (Goblin, unplayable) shares Human's faction template 1, so it answers Alliance — not `None`,
/// which is what a "playable races only" table would say.
/// [`tests::race_pvp_team_matches_the_shipped_tables`] walks the real DBCs and pins every row.
pub(crate) fn race_pvp_team(race: u8) -> i8 {
match race {
// factionGroupMask 3 = Player|Alliance → `& 4` clear, `& 2` set.
1 | 3 | 4 | 7 | 9 => 1,
// factionGroupMask 5 = Player|Horde → `& 4` set, tested first.
2 | 5 | 6 | 8 => 0,
// No `ChrRaces` row (a creature's race byte, or an unstreamed descriptor): the engine's
// bounds/NULL failure tail, `-1`. It formats into the key and matches no GlobalString.
_ => -1,
}
}
/// 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),
}
}
/// **The one unit-token resolver** — token → the live object it names, or `None`.
///
/// The reference has exactly one of these (`0x515970`, nine `_strnicmp` compares, then the object
/// manager), and every binding that takes a unit reaches its unit through it. We had three: this
/// one (inlined in `TargetUnit`'s drain), [`player_token_guid`]'s players-only popup map, and the
/// per-token legs [`feed_units`] walks. They disagreed, and the disagreement was report **B304** —
/// the range map was built from the players-only one, so a creature `"target"` never entered it and
/// `CheckInteractDistance("target", 4)` answered a constant for every mob at every distance.
///
/// So: one resolver, and a caller that wants a *typemask* applies it to the resolved unit rather
/// than narrowing the token (the same order 1564 §2 settled for the reach map's own entries, and
/// the one `target::by_name`'s follow drain already keeps for the start gate).
///
/// [`Selection`] is a parameter rather than a field because [`crate::target::SelectCommit`] holds
/// it as `ResMut`, and one system cannot take both.
///
/// **Not resolved:** `partypetN`/`raidpetN` (no feed holds those units at all — a token nothing
/// pushes a `UnitState` for would answer a distance for a unit `UnitExists` denies) and `npc`
/// (the interaction target, which we do not keep). Both are *recognised* by the grammar
/// ([`benilla_ui::script`]'s `token_recognised`), so they stay a quiet nil, not a raise.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct UnitTokens<'w, 's> {
/// `Option` for [`feed_units`]' reason: a UI-only harness runs these feeds with no net stack,
/// and a bare `Res` turns that into a system-validation panic rather than a token that names
/// nobody. Same for the two below, which belong to the pet and targeting plugins.
index: Option<Res<'w, crate::net::GuidIndex>>,
pet: Option<Res<'w, crate::ui_pet::PetBar>>,
hovered: Option<Res<'w, crate::target::Hovered>>,
group: Res<'w, crate::ui_party::GroupState>,
pub(crate) stores: Query<'w, 's, &'static ObjectStore>,
me: Query<'w, 's, (Entity, &'static Guid), With<SelfPlayer>>,
}
impl UnitTokens<'_, '_> {
/// Our own body's entity + guid.
fn me(&self) -> Option<(Entity, u64)> {
self.me.iter().next().map(|(e, g)| (e, g.0))
}
/// The guid → entity map, when the net stack is present — `0x468460`'s lookup. `pub(crate)`
/// for the selection drain's `TargetLastEnemy` arm, which starts from a remembered **guid**
/// rather than a token and must land in the very same index every token arm below does.
pub(crate) fn held(&self, guid: u64) -> Option<(Entity, u64)> {
Some((*self.index.as_ref()?.0.get(&guid)?, guid))
}
/// Resolve `token` to the live object it names. Case-folded (`_strnicmp`, 1247), and the
/// prefix order is the reference's: `raid` before `party` for the same reason its own resolver
/// puts `partypet` before `party` — the first matching compare wins.
pub(crate) fn resolve(&self, token: &str, selection: &Selection) -> Option<(Entity, u64)> {
let token = token.to_ascii_lowercase();
match token.as_str() {
"player" => self.me(),
"target" => selection.target.zip(selection.guid),
// The target's own target, one hop off `UNIT_FIELD_TARGET` — the read `/assist` and
// the `"targettarget"` snapshot both run (decision 1576).
"targettarget" => selection
.target
.and_then(|e| self.stores.get(e).ok())
.and_then(|s| s.0.unit_target())
.filter(|g| *g != 0)
.and_then(|g| self.held(g)),
// The hovered unit — the same `Hovered` pick `ui_tooltip` pushes `"mouseover"` from,
// so `UnitExists("mouseover")` and this can never disagree.
"mouseover" => {
let h = self.hovered.as_ref()?;
h.target.zip(h.guid)
}
// Off the bar's cached guid, the word `"pet"`'s snapshot and `UNIT_PET` read.
"pet" => {
let guid = self.pet.as_ref()?.spells.pet_guid;
(guid != 0).then(|| self.held(guid)).flatten()
}
t if t.starts_with("raid") => t
.strip_prefix("raid")
.and_then(|n| n.parse::<usize>().ok())
.filter(|n| (1..=40).contains(n))
.and_then(|n| {
crate::ui_party::raid_row_guid(&self.group, self.me().map(|(_, g)| g), n)
})
.and_then(|g| self.held(g)),
t => t
.strip_prefix("party")
.and_then(|n| n.parse::<usize>().ok())
.filter(|n| (1..=4).contains(n))
.and_then(|n| self.group.party_slots().nth(n - 1).map(|m| m.guid))
.and_then(|g| self.held(g)),
}
}
}
/// The tokens [`feed_unit_reach`] offers the map each frame — every token [`UnitTokens`] can
/// resolve, reusing the party feed's own two tables so this set can never name a row that feed
/// does not push a `UnitState` for. `"player"` is in it because the reference answers it too
/// (d² = 0, in range of everything); the popup rows that started this map need only the first few.
fn reach_tokens() -> impl Iterator<Item = &'static str> {
["player", "target", "targettarget", "mouseover", "pet"]
.into_iter()
.chain(crate::ui_party::PARTY_TOKENS)
.chain(crate::ui_party::RAID_TOKENS)
}
/// The squared distance between two world positions, in the binary's own accumulation shape: `f32`
/// inputs widened to `f64`, summed `(dz² + dx²) + dy²` (wow-re's transcription of
/// `0x48a26f..0x48a27d`, the kernel `caninspect_dist2` and `check_interact_dist2` share).
///
/// Our axes are Bevy's rather than the client's WoW triple. d² is invariant under that rotation, so
/// the only conceivable divergence from the binary is a last-ulp one — which can only change the
/// verdict for a unit standing *exactly* on a threshold. The thresholds and comparison operators,
/// which are what actually decide each gate, are transcribed exactly at the two bindings
/// (`benilla_ui::script::inspect`).
fn dist_sq(q: Vec3, p: Vec3) -> f64 {
let dx = f64::from(q.x) - f64::from(p.x);
let dy = f64::from(q.y) - f64::from(p.y);
let dz = f64::from(q.z) - f64::from(p.z);
(dz * dz + dx * dx) + dy * dy
}
/// `PLAYER_CONTROL_LOST` / `PLAYER_CONTROL_GAINED` — `SMSG_CLIENT_CONTROL_UPDATE` naming the
/// local player reaches `0x4958e0`, which writes the player-control flag and, **on a change**,
/// fires LOST when the byte is zero and GAINED when it is not; the boot init is "in control"
/// (wow-re `farsight-and-client-control.md` §5, `incoming-trade-request-law.md`). The flag is
/// [`crate::player::Player::control_lost`], which `player::wire_in` writes from that packet; this
/// fires the edge, and a fresh VM's memo is the boot value.
fn feed_player_control(
script: Option<NonSendMut<UiScript>>,
player: Option<Res<crate::player::Player>>,
mut lost: Local<crate::ui_script::VmMemo<bool>>,
) {
let (Some(mut script), Some(player)) = (script, player) else {
return;
};
let lost = lost.get(&script);
if *lost != player.control_lost {
*lost = player.control_lost;
// `HasFullControl`'s flag rides the same edge (1958).
script.set_player_control(!player.control_lost);
let event = if player.control_lost {
"PLAYER_CONTROL_LOST"
} else {
"PLAYER_CONTROL_GAINED"
};
script.fire_event(event, vec![]);
}
}
/// `PLAYER_FARSIGHT_FOCUS_CHANGED` — the `PLAYER_FARSIGHT` field-change callback (`0x5de0d0`)
/// fires it on both of its legs, whether or not the new guid resolves to a streamed object
/// (wow-re `farsight-and-client-control.md` §2). The edge is the FIELD's, so this diffs the
/// descriptor value the camera's `publish_view_subject` reads, never the resolved pose.
fn feed_farsight_focus(
script: Option<NonSendMut<UiScript>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
mut focus: Local<crate::ui_script::VmMemo<Option<u64>>>,
) {
let Some(mut script) = script else {
return;
};
let Some(store) = self_q.iter().next() else {
return;
};
let anchor = store.0.player_farsight();
let focus = focus.get(&script);
if *focus != anchor {
*focus = anchor;
script.fire_event("PLAYER_FARSIGHT_FOCUS_CHANGED", vec![]);
}
}
/// Feed the **unit reach map** — for every token that resolves to a live unit object, its squared
/// distance from us, plus whether that unit passes inspect's own two non-distance refusals.
///
/// **Membership is the object lookup and nothing else** (1564 §2). An absent token is one the
/// object manager holds no unit for, and both bindings answer `nil` there — the reference's own
/// null-object arm. Every typemask therefore rides IN the entry:
///
/// - *attackable* — vmangos's inspect refusal (`IsValidAttackTarget`, `MiscHandler.cpp:945-956`).
/// Keeping the token out of the map instead would make `CheckInteractDistance` — a pure distance
/// test in the binary — gray Follow and Duel on an enemy player the reference leaves live (B316).
/// - *is a player* — vmangos's other one (`sObjectMgr.GetPlayer`). This is the leg that was applied
/// at the token and was report **B304**: a creature `"target"` never entered the map, so the
/// 30-yard rung Quiver's Dead Zone band reads answered a constant for every mob — "in range" at
/// the published tag, "out of range" after 1564 flipped the absent default. Both are the same
/// defect: the map was never asked about mobs at all.
///
/// That the *client* checks those two is INFERRED (the 348-byte `0x48a1b0`'s non-math part isn't in
/// the RE record), but a wrong guess can only cost a request the server would drop.
///
/// Ungated, unlike every other feed here (1439): the numbers change whenever anything moves, so a
/// change gate would fire every frame anyway and cost a comparison for nothing. Nothing keys an
/// event off this map, so a re-push is invisible.
fn feed_unit_reach(
script: Option<NonSendMut<UiScript>>,
tokens: UnitTokens,
selection: Res<Selection>,
self_q: Query<(&Transform, &ObjectStore), With<SelfPlayer>>,
transforms: Query<&Transform>,
factions: Option<Res<Factions>>,
reputations: Res<Reputations>,
) {
let Some(mut script) = script else {
return;
};
let mut reach = HashMap::new();
if let Some((self_tf, self_store)) = self_q.iter().next() {
for token in reach_tokens() {
let Some((entity, guid)) = tokens.resolve(token, &selection) else {
continue;
};
let Ok(tf) = transforms.get(entity) else {
continue;
};
let store = tokens.stores.get(entity).ok();
let inspectable = benilla_protocol::guid::is_player(guid)
&& !crate::target::can_attack(
store,
factions.as_deref(),
&reputations,
Some(self_store),
);
reach.insert(
token.to_string(),
benilla_ui::script::UnitReach {
dist_sq: dist_sq(tf.translation, self_tf.translation),
inspectable,
},
);
}
}
script.set_unit_reach(reach);
}
/// The object stores [`feed_units`] reads, and the change tracking over them.
///
/// One `SystemParam` rather than three, because Bevy's tuple limit is 16 and this feed had grown
/// to sit exactly on it — the next resource it legitimately needed (`ChrClasses.dbc`, for
/// `UnitHasRelicSlot`) turned the ceiling into a compile error with no hint that a ceiling was
/// what it hit. Grouping the three that always move together buys the room back and says why
/// they belong together, which the flat list did not. Same idiom as
/// [`crate::target::lock::GoLockInputs`].
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct UnitStores<'w, 's> {
/// Every streamed object's descriptor, read by guid → entity.
all: Query<'w, 's, &'static ObjectStore>,
/// Whose descriptor moved this frame — the feed's dirty gate. Not an item's: items carry an
/// `ObjectStore` since 2334, and a durability tick or a stack count naming no unit token
/// would open the gate for nothing (2343).
changed: Query<'w, 's, (), (Changed<ObjectStore>, Without<crate::items::ItemObject>)>,
/// Whose object left the manager — the other half of that gate, and cleared every run.
removed: RemovedComponents<'w, 's, ObjectStore>,
/// The per-field edges this run (decision 2297) — [`fire_transitions`]' three mirror-diff
/// arms fire off these, per token naming the moved unit.
edges: MessageReader<'w, 's, FieldChanged>,
}
/// 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`]).
///
/// `classes` is `ChrClasses.dbc`, absent when the client data failed to load. Only the relic column
/// is read off it — the class NAMES are this file's own table, because they are localized display
/// strings rather than a DBC column we can key on.
pub(crate) fn snapshot(
store: &ObjectStore,
name: Option<String>,
reaction: u8,
classes: Option<&ChrClasses>,
) -> UnitState {
let power_type = store.0.unit_power_type();
let race = store.0.unit_race().and_then(race_names);
let class_id = store.0.unit_class();
let class = class_id.and_then(class_names);
UnitState {
exists: true,
// **This function IS the reference's `0x468460` having succeeded.** It is only ever called
// with a live `ObjectStore`, so every snapshot it builds is a unit the object manager holds
// — which is the whole of `UnitIsVisible` (wow-re
// `ui/scratch/unitisvisible-object-presence.md`). Set here rather than at the call sites so
// a future feed cannot forget it: the roster-only legs build a `UnitState` literally and
// get `false` from `Default`, which is the correct out-of-range answer.
has_object: 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(),
// `UnitIsCharmed 0x516cf0` — `UNIT_FIELD_CHARMEDBY != 0`, the same field `ui_aura`'s
// charmed-unit buff leg already reads, so the two cannot disagree about who is charmed.
charmed: store.0.unit_charmed_by().is_some(),
// The tapped pair — `UNIT_DYNAMIC_FLAGS` bits 0x4/0x8. Set here and only here, so the
// object-presence conjunct both predicates carry comes for free: a unit with no live
// descriptor never reaches this function and reads `false` from `Default`.
tapped: store.0.unit_tapped(),
tapped_by_player: store.0.unit_tapped_by_player(),
// `UnitIsPartyLeader`'s descriptor leg. Zero on a creature, which is what the reference's
// TYPEMASK_PLAYER restriction achieves there: a non-player has no PLAYER block at all, so
// the field simply reads absent.
group_leader: store.0.player_is_group_leader(),
// The raw `PLAYER_FLAGS` dword `PLAYER_FLAGS_CHANGED` fires on (decision 2078) — the
// `flags`/`UNIT_FLAGS` pattern one field over. Absent on a creature, which reads 0 and so
// can never produce a delta, exactly as the reference's TYPEID_PLAYER-scoped watcher
// cannot be registered against one.
player_flags: store.0.player_flags(),
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()),
// `UnitHasRelicSlot 0x519e50` — the class byte against `ChrClasses.dbc` field 16, under
// the reference's own gate and in its order: TYPEMASK_PLAYER first (`0x519e8d`,
// `[[obj+8]+8] >> 4 & 1` — which is exactly what [`ObjectType::Player`] decodes), the
// class byte second. The player test is not decoration: a creature carries a class byte
// too and indexes the same table, so without it a class-2 humanoid NPC answers 1.
has_relic_slot: matches!(store.0.object_type(), Some(ObjectType::Player))
&& class_id.is_some_and(|c| classes.is_some_and(|t| t.has_relic_slot(u32::from(c)))),
// 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,
// `UnitPlayerControlled` — the same word, bit 3 (`UNIT_FLAG_PVP_ATTACKABLE 0x8`, which is
// behaviourally "player-controlled"; `target::relations` already carved it for the duel
// leg of the selection ring). Wider than `is_player` above: a pet or a charmed creature
// sets it without being a player.
player_controlled: store.0.unit_flags() & 0x8 != 0,
flags: store.0.unit_flags(),
// The raw `UNIT_DYNAMIC_FLAGS` dword the event of the same name fires on — the `flags`
// arm above, a hundred descriptor fields over. `tapped`/`tapped_by_player` up the struct
// are two of its bits; this is the whole word, because the reference's watch is a memcmp
// over the dword and not a bit test.
dynamic_flags: store.0.unit_dynamic_flags(),
owner: store
.0
.unit_summoned_by()
.or_else(|| store.0.unit_charmed_by())
.or_else(|| store.0.unit_created_by())
.unwrap_or(0),
// `UnitAffectingCombat 0x517e10` — the SAME `UNIT_FIELD_FLAGS` word, bit 19
// (`shr ecx,0x13; test cl,1`). One flag for every token: wow-re's whole-image census of
// that idiom found the local-player readers reading this identical bit, so there is no
// player-specific combat latch to model beside it.
in_combat: store.0.unit_flags() & crate::player::UNIT_FLAG_IN_COMBAT != 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,
// The unit's CURRENT honor rank (decision 1512): `PLAYER_BYTES_3` byte 3, on the internal
// 0..=18 scale. It sits in `snapshot` — not in the caller's guid-keyed enrichment like
// `faction_group` — because it needs nothing but the descriptor, and because it is PUBLIC:
// the server streams it for every player in view, which is the only reason the inspect
// pane's `UnitPVPRank("target")` can answer at all. A creature has no PLAYER block and
// reads 0, the reference's own answer for one.
pvp_rank: store.0.player_pvp_rank().unwrap_or(0),
// `0x5efe00`'s team digit — the second `%d` of `PVP_RANK_<rank>_<team>`. It sits here
// beside the rank byte for the same reason that one does (nothing but the descriptor is
// needed) and it reads the RACE, not `faction_group`: the engine walks the race through
// `ChrRaces`/`FactionTemplate` and never looks at the live `UNIT_FIELD_FACTIONTEMPLATE`
// this unit is carrying, so a GM-flagged player keeps his rank title while losing the PvP
// icon. See [`race_pvp_team`] and decision 2227 (report B378).
pvp_team: store.0.unit_race().map_or(-1, race_pvp_team),
// `PLAYER_BYTES_3` byte 2 — the city-protector title, the same PUBLIC dword as the rank
// byte above. `UnitPVPName` appends a `PVP_MEDAL<n>` line for a non-zero one; 0 is "no
// medal", which is every character on this server (vmangos never writes the byte).
pvp_medal: store.0.player_pvp_medal().unwrap_or(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;
};
// The ask-once creature record — `CGUnit+0xb30`, filled from `SMSG_CREATURE_QUERY_RESPONSE`.
// `None` is the round trip before the answer lands, and it is a REAL state the plate is drawn
// in, not a "nothing is known yet" to render blank: the name reads `UNKNOWNOBJECT` there
// (decision 2040), and the lines below split on exactly what the record does or does not gate.
let rec = names.creature_record(entry);
if let Some(rec) = rec {
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 template →
// Faction.dbc hop, the reputation-slot gate (rep_index ≥ 0), and the race/class slot walk with
// its hidden flag (0x4).
//
// **Its entry gate is NOT the record.** `0x612610` reads `[unit+0xb30]` and returns 1 when
// there is none — that is how a PLAYER passes a gate whose only field lives in CreatureInfo,
// and a creature whose query has not answered takes the identical leg. So the line resolves
// off the DESCRIPTOR's `UNIT_FIELD_FACTIONTEMPLATE`, which is already streamed, and shows
// under a pending name exactly as it does under a known one. It was gated on the record here
// on the premise that "before the query answers we have no name line either" — the premise
// decision 2002 corrected, and 2040 with it.
if rec.is_none_or(|r| r.type_flags & crate::names::type_flags::NO_FACTION_TOOLTIP == 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 ([`UnitFeedMemo::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);
}
}
/// Drain the `ShowHelm`/`ShowCloak` flips into `CMSG_TOGGLE_HELM`/`CMSG_TOGGLE_CLOAK` (decision
/// 1472) — the Options window's two equipment-display rows, and the only callers there are.
///
/// The VM has already decided *whether* a flip is needed (the setter compares the asked-for state
/// against the belief it holds and queues nothing when they agree), because only the VM knows what
/// the row just did optimistically. This end is the pure send, the PvP drain's shape exactly.
fn drain_worn_display_toggles(script: Option<NonSendMut<UiScript>>, commands: Res<NetCommands>) {
let Some(mut script) = script else {
return;
};
for which in script.take_worn_display_toggles() {
let _ = commands.0.send(match which {
WornDisplay::Helm => crate::net::ClientCommand::ToggleHelm,
WornDisplay::Cloak => crate::net::ClientCommand::ToggleCloak,
});
}
}
/// Drain the `SetActionBarToggles` posts into `CMSG_SET_ACTIONBAR_TOGGLES` (wow-re
/// `system/ui/scratch/action-bar-toggles.md` §3) — the Options window's four extra-bar rows, and
/// the only callers there are.
///
/// **Every queued call becomes a packet**, with no did-it-change gate and no coalescing: the real
/// binding has neither (unlike `ShowHelm`/`ShowCloak`, which send only on a difference), so two
/// calls in a frame are two sends. The byte is absolute rather than a flip, so a duplicate is
/// harmless — but dropping one would be an optimisation the reference does not make, and the
/// server is the only store this preference has.
fn drain_action_bar_toggles(script: Option<NonSendMut<UiScript>>, commands: Res<NetCommands>) {
let Some(mut script) = script else {
return;
};
for toggles in script.take_action_bar_toggle_sends() {
let _ = commands
.0
.send(crate::net::ClientCommand::SetActionBarToggles { toggles });
}
}
/// `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;
/// `PLAYER_FLAGS_RESTING` — inside a rest area now (vmangos `Player.h:320`); the bit
/// `IsResting 0x516ea0` tests (`shr 5; test 1` — wow-re rested-xp-bindings.md §3).
const PLAYER_FLAGS_RESTING: u32 = 0x20;
/// `PLAYER_FLAGS` bit 12 / bit 13 — the two **play-time** regimes an anti-addiction realm puts an
/// account into, read by `PartialPlayTime` (`0x48eb70`) and `NoPlayTime` (`0x48ebe0`). Decision
/// 1746 carved both, and settled that `0x1000` is PARTIAL_PLAY_TIME on 5875 rather than the
/// pre-1.6.1 `CAN_SELF_RESURRECT` it had been read as. Stock `PlayerFrame_UpdatePlaytime` tests
/// them at LOAD, so their absence is a raise on the first frame, not a cosmetic gap.
const PLAYER_FLAGS_PARTIAL_PLAY_TIME: u32 = 0x1000;
const PLAYER_FLAGS_NO_PLAY_TIME: u32 = 0x2000;
/// The PvP-preference announcement rule (decision 0652): the `(toast, verbose)` **GlobalStrings
/// keys** 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.
///
/// **Keys, not sentences** (decision 2045), and the pair rides two different routes because the
/// reference gives them two different natures. `ERR_PVP_TOGGLE_ON`/`_OFF` are message-catalog rows
/// (437/438, `kind 1` — the yellow `UI_INFO_MESSAGE`), so the catalog names their surface;
/// `PVP_TOGGLE_ON_VERBOSE`/`_OFF_VERBOSE` are **not** catalog rows at all, so there is no record to
/// read a kind or a sound off and the chat surface is the handler's own — the `/ginfo` shape
/// (`ui_guild::feed::ginfo_lines`, decision 2054). Both are argument-free, so there is no fill step.
fn pvp_announcement(was: Option<bool>, now: bool) -> Option<(&'static str, &'static str)> {
if was? == now {
return None;
}
Some(if now {
("ERR_PVP_TOGGLE_ON", "PVP_TOGGLE_ON_VERBOSE")
} else {
("ERR_PVP_TOGGLE_OFF", "PVP_TOGGLE_OFF_VERBOSE")
})
}
/// The rest-state chat line (decision 1098; wow-re rested-xp-bindings.md §§6-10, byte-verified
/// §5): the rest-state BYTE watcher `0x5de4e0` messages only on a real old≠new transition (the
/// dispatcher's `rep cmpsb` mirror diff at `0x4655bb`), through the hard-coded 3×2 pair table
/// `0x80af50` — state 1 → `ERR_EXHAUSTION_RESTED`, state 2 → `ERR_EXHAUSTION_NORMAL`, state 0 →
/// the table's deliberate no-message sentinel (id 0x1d1), states ≥ 3 gated off before the table
/// (`cmp esi,3; jae`), so the beta tiers never speak even though their strings ship.
///
/// The table holds **message ids**, so the answer here is the row's key and the catalog decides the
/// rest (decision 2045): rows 346/347 are `kind 0` — a plain SYSTEM chat line, never UIErrorsFrame
/// — with no cue and `type_tag 0x44`, so no voice either. Entering rested also arms a one-shot
/// tutorial popup (id 0x19) — the tutorial system isn't built, a named cut.
fn rest_state_message(prev: u8, new: u8) -> Option<&'static str> {
if prev == new {
return None;
}
match new {
1 => Some("ERR_EXHAUSTION_RESTED"),
2 => Some("ERR_EXHAUSTION_NORMAL"),
_ => None,
}
}
/// 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()?)?;
// The ENGLISH name (`InternalName`), because `UnitFactionGroup`'s first return is concatenated
// into a texture path by every stock consumer. The localized twin is below.
catalog
.faction_group_internal_name(template.group_mask & 6)
.map(str::to_string)
}
/// The localized group name for the same unit — `UnitFactionGroup`'s SECOND return, which stock
/// shows as text rather than building a path from.
pub(crate) fn faction_group_localized(
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",
// The shipped `CreatureType.dbc` is **1..11 dense**, not 1..9 — this table stopped two
// rows early. 11 is reachable and wow-re's own nameplate filter tests for it
// (`0x605570 == 0xb`).
11 => "Totem",
// 10 is "Not specified" in the DBC. Deliberately still None: this word is the tooltip's
// level-line class slot and a literal "Not specified" there is noise. The cost is named
// rather than hidden — `UnitCreatureType` shares this field, so it answers nil for a
// type-10 unit where the reference answers the DBC word.
_ => return None,
})
}
/// 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 — except
/// the three arms that are the reference's per-field watch bridge, which fire off `edges`, the
/// descriptor edges this run (decision 2297): those fire per token naming a unit whose dword
/// moved, and a unit's create moves nothing.
pub(crate) fn fire_transitions(
script: &mut UiScript,
token: &str,
prev: Option<&UnitState>,
cur: &UnitState,
edges: &FieldEdges,
) {
let tok = || ScriptValue::Str(token.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()]);
}
// `UNIT_FLAGS` (id 40) — the per-field watch bridge (wow-re `unit-field-event-bridge.md`,
// VERIFIED): `0x51bbb0` registers one watch per named unit field, the notifier `0x465570`
// fires `0x51bd50` → `0x515e50` on any change of the dword's bytes, once per token mapping to
// the unit, `arg1` the token. The create leg runs no notify pass, so a unit's FIRST snapshot
// is not a transition here — unlike the fields above, whose first-appearance fire is this
// feed's own posture (1953, corrected in 1957). Since 2297 that is literally the trigger:
// the field edge for this unit's dword, which a create never emits, and which a retarget
// onto a unit with different flags does not emit either (the old `prev.is_some()` snapshot
// diff fired on that). The stock pet bar filters it for `"pet"`.
if edges.moved(cur.guid, benilla_protocol::field::FIELD_UNIT_FLAGS) {
script.fire_event("UNIT_FLAGS", vec![tok()]);
}
// `PLAYER_FLAGS_CHANGED` (id 407) — the `UNIT_FLAGS` arm above, one descriptor field over, and
// until decision 2078 one of the events a migrated stock window registered and nothing here
// produced. Byte-read out of `WoW.exe` at `0x5eea27`-`0x5eea3d`:
//
// ```
// 5eea27 8b 56 08 8b 02 89 45 f0 8b 4a 04 89 4d f4 ; [ebp-0x10] = this player's own GUID
// 5eea35 ba 97 01 00 00 ; edx = 0x197 = 407
// 5eea3a 8d 4d f0
// 5eea3d e8 0e 74 f2 ff ; call 0x515e50 — the token fan-out
// ```
//
// Three properties, each load-bearing and each verified rather than assumed:
//
// 1. **Unguarded.** No bit test stands between the XOR-diff at `0x5ee9b8` and this fire, so
// *any* `PLAYER_FLAGS` bit moving announces itself — not just the ones this struct decodes.
// That is why the trigger is the raw dword (`UnitState::player_flags`) and not a bool.
// 2. **Above the local-GUID gate** at `0x5eea93` (the ghost/resting/PvP/play-time arms below it
// are self-only; this one is not) — and the trampoline `0x5e2850` resolves the changed
// object by GUID under TYPEMASK_PLAYER, "any player, not the local one" (wow-re
// `object-layer/ledger.tsv`). So a *stranger's* flags fire it, which is the whole reason the
// stock target frame can listen.
// 3. **Once per unit token naming that GUID, `arg1` = the token, no `arg2`** — `0x515e50` walks
// `0x515c50`'s token array and calls `0x703f50(id, "%s", token)` per entry (wow-re
// `ui/scratch/unit-field-event-bridge.md` §2.2). That is exactly this function's own shape,
// which is why the arm belongs here and not beside the self-only feeds below.
//
// **"Fires for any player" is not "reaches Lua", and the difference is free here.**
// `0x515e63 test eax,eax / 0x515e6a jle 0x515e8a` skips the fan-out loop entirely on a zero
// token count, so a remote player who is nobody's target, mouseover, party or raid member
// announces **nothing** to the VM — the handler still runs, and its helm/cloak arms above the
// gate still repaint them, but no event is signalled. This feed gets that for nothing by
// construction: it is only ever called *per token*, so a tokenless player is never reached
// (wow-re `object-layer/scratch/player-flags-delta-arms.md`, the 2078 correction round).
//
// Off the field edge for the same reason `UNIT_FLAGS` is: this is a mirror-diff watcher,
// and a unit's first snapshot is its CREATE, which runs no notify pass (1098 §4).
//
// **The sole 1.12 consumer is the target frame's PARTY-LEADER icon, not an AFK/DND badge** —
// `TargetFrame.lua:88-95` re-runs the `UnitIsPartyLeader("target")` show/hide, and bit `0x1` is
// that predicate's descriptor leg. Nothing in 1.12 FrameXML draws an AFK or DND badge on a unit
// frame, and build 5875 has no `UnitIsAFK`/`UnitIsDND` binding at all (wow-re
// `ui/scratch/unit-predicate-return-shape.md` §5, a zero-hit whole-image byte census); the
// `<AFK>`/`<DND>` the era shows are the chat line's `arg6` flag. This comment is here because
// the gap list said "the AFK/DND badge" for months and sent the first look at the wrong window.
if edges.moved(cur.guid, benilla_protocol::field::FIELD_PLAYER_FLAGS) {
script.fire_event("PLAYER_FLAGS_CHANGED", vec![tok()]);
}
// `UNIT_DYNAMIC_FLAGS` (id 137) — the third arm of the same bridge, and one benilla had
// **never fired at all**, which is a different failure from firing it wrong: an addon that
// registers it hears nothing, forever, and there is no error anywhere to say so.
//
// Nothing in 1.12 FrameXML registers it, which is exactly why no gate saw the hole — the
// producer gate's oracle is what a stock chain file listens for (`reference_ui::
// every_event_a_chain_file_registers_has_a_producer`), and no stock file listens for this one.
// The corpus does: `CT_UnitFrames/CT_TargetFrame.xml:200` and `TipBuddy/TipBuddy.lua:17`
// register it, and both are repaint wires for the tapped/grey-bar state — the same state
// `UnitIsTapped` publishes and that the `UNIT_FACTION` arm below repaints the stock frames on.
//
// Byte-verified, not inherited (see [`UnitState::dynamic_flags`]): the name-table slot for
// 137 resolves to `"UNIT_DYNAMIC_FLAGS"`, and the watch length is one dword, so the trigger
// is the RAW word and not the four bits this struct decodes. Off the field edge for the same
// reason the two arms above are — a unit's first snapshot is its CREATE, and the create
// block runs no notify pass.
if edges.moved(cur.guid, benilla_protocol::field::FIELD_UNIT_DYNAMIC_FLAGS) {
script.fire_event("UNIT_DYNAMIC_FLAGS", vec![tok()]);
}
// The POWER pair is named per resource in 1.12, not once with the token as arg2: the reference's
// `UnitFrameManaBar_Initialize` registers `UNIT_MANA`/`UNIT_RAGE`/`UNIT_FOCUS`/`UNIT_ENERGY`/
// `UNIT_HAPPINESS` and the five `UNIT_MAX*` twins (`UnitFrame.lua:190-199`), and nothing in
// 1.12 FrameXML has ever heard of `UNIT_POWER_UPDATE`. `power_token` already yields exactly the
// suffix, so the name is the token. Health is unaffected — `UNIT_HEALTH`/`UNIT_MAXHEALTH` are
// spelled the same in both eras — and so is `UNIT_DISPLAYPOWER` above.
//
// This was live breakage, not tidiness: while we fired only the Era names, the stock unit frames
// registered only the 1.12 ones, so a mana/rage/energy bar never moved except when something
// else happened to call `UnitFrame_Update` (a target change, a pet summon, a frame show).
// Decision 1819.
if changed(|u| u64::from(u.power)) {
script.fire_event(
&format!("UNIT_{}", power_token(cur.power_type)),
vec![tok()],
);
}
if changed(|u| u64::from(u.max_power)) {
script.fire_event(
&format!("UNIT_MAX{}", power_token(cur.power_type)),
vec![tok()],
);
}
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.
//
// **And on the TAPPED bit, which is not a faction field and fires this event anyway.** The
// reference's `UNIT_DYNAMIC_FLAGS` watcher tests bit `0x4` and dispatches event id **29** —
// `UNIT_FACTION` — at `0x6005a1 test al,4` -> `0x6005b0 mov edx,0x1d` -> `0x515e50` (wow-re
// `ui/scratch/tapped-bits-and-unit-faction.md`, controlled across all 37 fire sites).
//
// This line is the whole reason `UnitIsTapped` is worth publishing. Without it both verbs
// answer CORRECTLY and no frame ever repaints: pfUI's grey-bar branch runs inside
// `RefreshUnit`, which is event-driven, so a mob that becomes someone else's tap would stay
// full-colour until something unrelated happened to refresh it. A passing unit test on the
// predicates would have said nothing about that — the mechanism existing is not the mechanism
// applying (`method.md` step 5).
//
// Bit `0x8` deliberately has NO arm here: the reference's watcher has none for it either
// (proven by enumerating all 122 instructions and 12 branches of `0x600440`). The pair is read
// together, and the `0x4` edge is what moves.
if prev.is_none_or(|p| {
(p.pvp, p.is_pvp_ffa, &p.faction_group, p.tapped)
!= (cur.pvp, cur.is_pvp_ffa, &cur.faction_group, cur.tapped)
}) {
script.fire_event("UNIT_FACTION", vec![tok()]);
}
}
fn feed_units(
script: Option<NonSendMut<UiScript>>,
// `ChrClasses.dbc` field 16, the only thing `UnitHasRelicSlot` reads. Absent when the client
// data failed to load, in which case no class reads as having a relic slot — the reference's
// own bounds-check-fails leg.
classes: Option<Res<crate::chr_classes::ChrClassTable>>,
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
selection: Res<Selection>,
// The guid -> entity map the `"targettarget"` hop lands in, the same index `/assist` resolves
// its basis' `UNIT_FIELD_TARGET` through (`target::by_name`). `Option` because it belongs to
// `NetPlugin` (net.rs's `init_resource`) and this feed does not: a UI-only harness runs this
// system with no net stack at all, and a bare `Res` turns that into a system-validation panic
// rather than a token that names nobody. Same shape as `factions` below, same reason.
index: Option<Res<crate::net::GuidIndex>>,
mut stores: UnitStores,
mut feed: ResMut<UnitFeedState>,
names: Res<NameCache>,
commands: Res<NetCommands>,
factions: Option<Res<Factions>>,
reputations: Res<Reputations>,
group: Res<crate::ui_party::GroupState>,
// The chat window AND the message-sound queue, because this feed shows catalog messages (the
// rest-state pair, the PvP toggle) and `show_messages` writes both on every line.
mut sink: crate::ui_action::MessageSink,
// The guild-identity cache `GetGuildInfo(unit)` reads — `ResMut` because it is a LAZY cache
// (decision 1257): a lookup that misses is what sends the `CMSG_GUILD_QUERY`, exactly as a
// `NameCache::resolve` miss sends the name query above.
mut guild: ResMut<crate::ui_guild::GuildState>,
// The NPC the player is interacting with, for the `"npc"` token below. `Option` for the same
// reason `index` and `factions` are: a UI-only harness runs this feed with no session plugin.
interact: Option<Res<crate::ui_session::InteractNpc>>,
// The account's rested billing minutes ride the world-enter message, which is the only moment
// they ever arrive (decision 1820): the value comes off `SMSG_AUTH_RESPONSE` and the reference
// parks it in a process-lifetime global. `MessageReader` rather than a resource because it is
// an EDGE, and it is read here rather than in a system of its own because this feed already
// holds the script.
// `Option` for the same reason `index`, `factions` and `interact` above are: the message is
// registered by `NetPlugin`, and a UI-only harness runs this feed with no net stack at all —
// a bare reader turns that into a system-validation panic.
entered_world: Option<MessageReader<crate::net::EnteredWorldMessage>>,
) {
let Some(mut script) = script else {
return;
};
// Ahead of the gate below, which is about descriptor deltas: this is a login edge and has no
// snapshot to diff.
if let Some(entered) =
entered_world.and_then(|mut r| r.read().last().map(|m| m.billing_time_rested))
{
script.set_billing_time_rested(entered);
}
let chr = classes.as_deref().map(|t| &t.0);
// One reborrow so the memo (`feed.vm`) and `feed.warned_sideless` can be borrowed as the
// disjoint fields they are — through the `ResMut` deref they would alias.
let feed = &mut *feed;
let (memo, vm_reset) = feed.vm.get_reset(&script);
let edges = FieldEdges::collect(&mut stores.edges);
// The gate (1439): every input the two snapshots and the edge diffs below read — any
// descriptor change or DESPAWN (a removed store is invisible to `Changed`), the selection,
// the group/reputation/faction state, and the two lazy caches by their landed counters.
let names_moved = memo.names_generation.moved(names.generation());
let guild_moved = memo.guild_generation.moved(guild.identity_generation());
let selection_changed = selection.is_changed();
let stores_changed = !stores.changed.is_empty();
let stores_removed = !stores.removed.is_empty();
let group_changed = group.is_changed();
let reps_changed = reputations.is_changed();
let factions_changed = factions.as_ref().is_some_and(|r| r.is_changed());
// The interaction NPC is an input of the `"npc"` snapshot below, and it moves on frames
// nothing else does: a vendor window opening or swapping to a second vendor changes no
// descriptor, no selection, no group. Without this term such a frame skipped the whole
// feed and the window's own `MERCHANT_SHOW` handler read the previous NPC (decision 2022).
let interact_changed = interact.as_ref().is_some_and(|r| r.is_changed());
gate::trace(
"feed_units",
&[
("vm_reset", vm_reset),
("names", names_moved),
("guild", guild_moved),
("selection", selection_changed),
("stores", stores_changed),
("removed", stores_removed),
("group", group_changed),
("reputations", reps_changed),
("factions", factions_changed),
("interact", interact_changed),
],
);
let gate = gate::Gate::new(
vm_reset
|| names_moved
|| guild_moved
|| selection_changed
|| stores_changed
|| stores_removed
|| group_changed
|| reps_changed
|| factions_changed
|| interact_changed,
);
stores.removed.clear();
if gate.skip() {
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, chr);
s.is_player = true;
// The stated `is_connected` gap, closed for every token this feed pushes — the field's own
// doc names the feed as what must set it ("mirroring `exists`"), and a token we push at all
// is one whose descriptor is streamed. It was reachable, not academic: `GREY_ROW["WHISPER"]`
// (UnitPopup.xml) greys on `not UnitIsConnected(unit)`, so Whisper sat greyed in the
// right-click menu of every player you targeted. What stays out of reach is real link-death
// — the only wire that carries it is the group roster's status byte, which the party feed
// already reads for its own tokens; a non-group player logging out lingers here as
// connected until that lands.
s.is_connected = 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.faction_group_localized = faction_group_localized(store, factions.as_deref());
// `GetGuildInfo("player")` — the unit's own PUBLIC descriptor fields (191/192) joined
// against the app's guild-identity cache, which the miss also asks for (decision 1257).
// Filled here rather than in `snapshot` for the reason `faction_group` and `can_attack`
// are: it needs a resource, and `snapshot`'s other six call sites hold none.
s.guild = crate::ui_guild::unit_guild(&store.0, &mut guild, &commands);
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.
//
// The honor arc (1512) was once listed here as a SECOND surface behind this same side, and
// it is not one: the rank title's team digit is `0x5efe00`, which reads the RACE through
// `ChrRaces`/`FactionTemplate` and never the live template, so a GM's Honor tab names his
// rank exactly as it always did (`race_pvp_team`, decision 2227 — the cause of report B378
// was that we had wired the two together, not the GM mode itself). What this warning still
// covers is every genuinely template-derived surface: `UnitFactionGroup` and the icons and
// comparisons built on it.
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). Every UnitFactionGroup-derived surface loses its \
side while this holds — the PvP flag icon stays hidden however flagged you are. \
`.gm off` restores it. (The Honor tab's rank title is NOT one of these: its team \
digit comes from your race, not your template.)"
);
}
feed.warned_sideless = sideless;
}
// (The VM-half memo was taken at the top — the gate needs its reset flag. `warned_sideless`
// stays server memory outside it, which the disjoint field borrows above preserve.)
let target = selection.target.zip(selection.guid).and_then(|(e, guid)| {
let store = stores.all.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, chr);
s.guid = guid;
s.is_connected = true; // see the player leg
s.raid_target = group.raid_target_index(guid);
s.faction_group = faction_group(store, factions.as_deref());
s.faction_group_localized = faction_group_localized(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),
);
// `GetGuildInfo("target")` — see the player leg. PLAYER_GUILDID/RANK are PUBLIC, which is
// the whole reason the binding is per-unit rather than per-player.
s.guild = crate::ui_guild::unit_guild(&store.0, &mut guild, &commands);
enrich_unit(
&mut s,
guid,
&names,
store,
factions.as_deref(),
self_pair.map(|(s, _)| s),
);
Some(s)
});
// `"targettarget"` — the target's own target, one hop off its `UNIT_FIELD_TARGET`. It is the
// same read `/assist` runs (`target::by_name`), and the same shape 1.12's own token resolver
// walks for the `target` SUFFIX: resolve the base unit, then follow the guid its target field
// carries. Only a STREAMED guid resolves — an unstreamed one leaves `UnitExists("targettarget")`
// false, exactly as an out-of-range party member is false.
//
// No `guild` leg, deliberately: `unit_guild` is the LAZY cache whose miss SENDS a
// `CMSG_GUILD_QUERY` (1257), and nothing asks a target-of-target for its guild — the party and
// raid tokens leave it unfilled for the same reason. Everything else here is a pure read of a
// descriptor we already hold.
let tot = selection
.target
.and_then(|e| stores.all.get(e).ok())
.and_then(|s| s.0.unit_target())
.filter(|guid| *guid != 0)
.and_then(|guid| Some((*index.as_ref()?.0.get(&guid)?, guid)))
.and_then(|(entity, guid)| {
let store = stores.all.get(entity).ok()?;
let name = names.resolve(guid, &commands).map(str::to_string);
let reaction = ring_reaction(
factions.as_deref(),
&reputations,
Some(store),
self_pair.map(|(s, _)| s),
) + 1;
let mut s = snapshot(store, name, reaction, chr);
s.guid = guid;
s.is_connected = true; // see the player leg
s.raid_target = group.raid_target_index(guid);
s.faction_group = faction_group(store, factions.as_deref());
s.faction_group_localized = faction_group_localized(store, factions.as_deref());
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)
});
// `"player"` is pushed only while the descriptor EXISTS: its absence is "no data source",
// never "the player stopped existing". The two absent windows are pre-arrival at login —
// where a `None` push would erase the roster seat (`seat_from_roster`) that addon file scopes
// and the loading-screen UI read — and the logout despawn frames, where `PLAYER_LOGOUT`
// handlers still key their saved state by `UnitName("player")` (the reference keeps the unit
// valid through its shutdown; a fresh VM starts with no `"player"` anyway, so nothing needs
// the clear). `"target"` keeps the unconditional push: a selection's absence IS data — the
// deselect/despawn transition the real client also reports.
// Both pushes diff against the SAME memo the event loop below uses (1439): an identical
// snapshot re-pushed is invisible to the VM, so only a real change pays the clone.
if let Some(cur) = &player {
if memo.last.get("player") != Some(cur) {
gate.audit("feed_units", "the player snapshot");
script.set_unit("player", player.clone());
}
}
let target_dirty = match (&target, memo.last.get("target")) {
(Some(cur), Some(prev)) => cur != prev,
(None, None) => false,
_ => true,
};
if target_dirty {
gate.audit("feed_units", "the target snapshot");
script.set_unit("target", target.clone());
}
// The same "absence IS data" push as the target's above: the target dropping ITS target is a
// transition the frame has to hear about, and clearing the token is how it hears it.
let tot_dirty = match (&tot, memo.last.get("targettarget")) {
(Some(cur), Some(prev)) => cur != prev,
(None, None) => false,
_ => true,
};
if tot_dirty {
gate.audit("feed_units", "the target-of-target snapshot");
script.set_unit("targettarget", tot.clone());
}
// `"npc"` — the unit the player is interacting with, resolved through the SAME interaction guid
// the real client's token reads (`CGGameUI`'s `[0xb4e2d0]`, what `CGGameUI::SetInteractNPC`
// writes; wow-re confirms the guild registrar's own opener is one of that function's fourteen
// callers). `crate::ui_session::InteractNpc` is benilla's model of exactly that cell, so the
// token and the portrait booth cannot disagree about who "npc" is.
//
// **It had no feed at all until decision 1678**, and the gap was invisible because nothing
// called it: `TaxiFrame.xml` and `TradeFrame.xml` both take the NPC's name from an event
// argument instead and say in their own comments that they are deviating from the reference's
// `UnitName("NPC")` to do it. `GuildRegistrarFrame.xml` is the first window here to ship the
// reference's line unchanged — and it painted a blank name banner, because
// `GUILD_REGISTRAR_SHOW` is verified to carry no arguments, so there is nothing else for it to
// read. The probe caught it; a one-frame-lag theory was the first (wrong) explanation.
//
// No `guild` leg, for `"targettarget"`'s reason: nothing asks an NPC for its guild, and the
// lookup is the lazy cache whose miss SENDS a query.
let npc = interact
.as_deref()
.and_then(|i| Some((i.0?, i.1?)))
.and_then(|(entity, guid)| {
let store = stores.all.get(entity).ok()?;
let name = names.resolve(guid, &commands).map(str::to_string);
let reaction = ring_reaction(
factions.as_deref(),
&reputations,
Some(store),
self_pair.map(|(s, _)| s),
) + 1;
let mut s = snapshot(store, name, reaction, chr);
s.guid = guid;
s.is_connected = true;
s.raid_target = group.raid_target_index(guid);
s.faction_group = faction_group(store, factions.as_deref());
s.faction_group_localized = faction_group_localized(store, factions.as_deref());
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)
});
// "Absence IS data" again: closing an NPC window must clear the token, or the next window's
// first frame paints the last NPC's name. **The memo is written here, not only read**: for
// its first eight days this diff compared against a row nothing ever inserted, so a `Some`
// re-pushed every frame and a `None` never cleared — `UnitExists("npc")` stayed true after
// the window closed, and the stale name was what the next window's first frame painted
// (decision 2022). No `fire_transitions` leg: the reference's watch bridge fires `UNIT_*`
// for the frames that draw a unit, and nothing draws `"npc"` as a unit frame.
let npc_dirty = match (&npc, memo.last.get("npc")) {
(Some(cur), Some(prev)) => cur != prev,
(None, None) => false,
_ => true,
};
if npc_dirty {
gate.audit("feed_units", "the interaction-NPC snapshot");
script.set_unit("npc", npc.clone());
match &npc {
Some(cur) => {
memo.last.insert("npc".to_string(), cur.clone());
}
None => {
memo.last.remove("npc");
}
}
}
// 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). BEFORE the PLAYER_ENTERING_WORLD fire below, so the first paint reads real
// values (1087 — the tick's handler divides by UnitXPMax).
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 memo.last_xp != Some((xp, next)) {
gate.audit("feed_units", "the XP pair");
memo.last_xp = Some((xp, next));
script.set_player_xp(xp, next);
script.fire_event("PLAYER_XP_UPDATE", vec![]);
}
}
// The rest feed (decisions 1082/1087/2078): the `PLAYER_BYTES_2` rest-state byte, the
// `PLAYER_REST_STATE_EXPERIENCE` pool and PLAYER_FLAGS, pushed as one snapshot. Runs before
// the PLAYER_ENTERING_WORLD fire below, like the XP push: in the real client the descriptor
// always lands before that event, so the first paint reads real state — the model's byte-2
// default (its doc) is the backstop, this ordering is the guarantee itself.
//
// **The self-only arms below are each gated on their OWN bits**, which is `0x5ee990`'s actual
// shape and not what this comment said until 2078. Everything from `0x5eea93` down — where the
// handler compares the changed player's GUID against the local one — is self-only, and inside
// that region each arm carries its own `test`:
//
// ```
// 5eead0 f6 45 fc 20 test byte [ebp-4],0x20 ; the RESTING bit CHANGED?
// 5eead4 74 26 je 0x5eeafc ; no -> skip the whole arm
// 5eead6..5eeaed tutorial popup 0x4b5390(0x1d), only if the bit is now SET
// 5eeaf2 b9 95 01 00 00 / e8 … mov ecx,0x195 ; call 0x703e50 <- PLAYER_UPDATE_RESTING
// 5eeafc ...
// 5eeaff f6 c4 02 test ah,0x2 ; bit 0x200, the PvP-desired arm (0652)
// 5eeb65 f6 c4 30 test ah,0x30 ; bits 0x1000|0x2000, the play-time regimes
// 5eeb6f e8 … call 0x703e50 (ecx=0x212) <- PLAYTIME_CHANGED
// ```
//
// `0x5eeaf2` sits INSIDE the `0x20` arm (`0x5eeaf2 < 0x5eeafc`, the `je`'s target), so
// **PLAYER_UPDATE_RESTING fires only on the resting bit's own edge** — not, as wow-re's
// `rested-xp-bindings.md` §5 and its `0x5ee990` ledger row both say, "on every flags change,
// not only the resting bit". That claim is what this feed was built against, and it made every
// helm toggle, every leadership pass and every AFK flip announce a resting change to the UI.
// The note's "argless unconditionally" is true only of the *direction*: the inner
// `0x5eeae4 je` skips the tutorial popup, never the fire, so both edges of the bit fire it.
// A correction round is dispatched into wow-re; the bytes above are read straight out of
// `WoW.exe` (file offset 0x1eead0, PE imagebase 0x400000, .text RVA 0x1000 → raw 0x1000).
//
// `UPDATE_EXHAUSTION` is unchanged and was already right: two separate watchers, `0x5de4e0` on
// the rest-state byte and `0x5de4b0` on the pool field, neither of them this handler.
if let Some((store, _)) = self_q.iter().next() {
let rest = (
store.0.player_rest_state().unwrap_or(0),
store.0.player_rest_state_experience().unwrap_or(0),
store.0.player_flags(),
);
if memo.last_rest != Some(rest) {
gate.audit("feed_units", "the rest snapshot");
let prev = memo.last_rest;
memo.last_rest = Some(rest);
script.set_rest_state(rest.0, rest.1, rest.2 & PLAYER_FLAGS_RESTING != 0);
// The same descriptor word carries the two play-time bits, and they ride the same
// memo — the PUSH is one snapshot because the wire delivers one dword; only the
// EVENTS below split by bit.
script.set_play_time(
rest.2 & PLAYER_FLAGS_PARTIAL_PLAY_TIME != 0,
rest.2 & PLAYER_FLAGS_NO_PLAY_TIME != 0,
);
if prev.map(|p| (p.0, p.1)) != Some((rest.0, rest.1)) {
script.fire_event("UPDATE_EXHAUSTION", vec![]);
}
// "You feel rested." / "You feel normal." (decision 1098): the BYTE watcher alone
// messages, and only on a real transition — `prev` None is the login descriptor,
// which the real client's fresh-CREATE path never runs through the notify pass
// (byte-verified: login is structurally silent). The pool watcher never messages.
if let Some(p) = prev {
let line = rest_state_message(p.0, rest.0)
.and_then(|key| crate::ui_action::keyed_line(&script, key));
crate::ui_action::show_messages(&mut script, &mut sink, "ui_unit", line);
}
// The two self-only flag events, each on its own bits' edge (`0x5eead0` and
// `0x5eeb65` above). `prev` None is the login descriptor and stays silent for both,
// the same fresh-CREATE reasoning the rest message carries — a mirror-diff watcher
// has nothing to diff against on a create.
if let Some(p) = prev {
let moved = p.2 ^ rest.2;
if moved & PLAYER_FLAGS_RESTING != 0 {
script.fire_event("PLAYER_UPDATE_RESTING", vec![]);
}
// PLAYTIME_CHANGED (id 530) — `0x5eeb65 test ah,0x30`, argless, self-only. Stock
// `PlayerFrame.lua:23` registers it and `PlayerFrame_UpdatePlaytime` reads
// `PartialPlayTime()`/`NoPlayTime()`, both of which we already feed off this very
// snapshot; the event was the one missing half. Decision 2078.
if moved & (PLAYER_FLAGS_PARTIAL_PLAY_TIME | PLAYER_FLAGS_NO_PLAY_TIME) != 0 {
script.fire_event("PLAYTIME_CHANGED", vec![]);
}
}
}
}
// The ding feed: `PLAYER_LEVEL_UP` (arg1 = the new level) when our avatar's
// `UNIT_FIELD_LEVEL` CHANGES — the event the exhaustion tick (1082) and the max-level rail
// (1094) register; it was a dead registration until 1094. Any change, not only a rise:
// vmangos `GiveLevel` runs for demotions too (a GM `.character level` down) and sends
// `SMSG_LEVELUP_INFO` unconditionally, so the real client hears every change — 1094's
// rise-only guard left the rail latched shown after a 60→1 demote (the 1106 live repro).
// Trigger PROVISIONAL (0578's pattern): fired off the descriptor diff, which lands in the
// same update batch as the ding's XP fields, so consumers read a coherent picture. The
// real client plausibly fires it from its `SMSG_LEVELUP_INFO` handler instead, with the
// packet's gain tuple as arg2+.
//
// **THE ARGS ARE NOT OPTIONAL, AND THE CLAIM THAT USED TO STAND HERE WAS WRONG.** It said no
// 1.12 FrameXML consumer reads past arg1, citing `ReputationWatchBar_Update` (arg1) and the
// tick's handler (none). It missed the main one: `ChatFrame.lua:1283-1320` reads **arg1
// through arg9** — the level, the health and mana gains, the talent points, and the five stat
// gains, each printed as its own system line. The reference's own fire site says the same,
// `%d%d%d%d%d%d%d%d%d` (SignalEvent2). Two consumers were surveyed, the conclusion was drawn
// from two, and the third is the one that matters.
//
// We fire arg1 alone, so the stock ChatFrame's `if ( arg3 > 0 )` would compare nil with a
// number and raise — this blocks the ChatFrame window. The gains are on a packet we already
// parse (`SMSG_LEVELUP_INFO`, twelve u32) and already spend: `ui_chat/feed.rs` composes those
// very lines in Rust because the event could not carry them. Decision 1884 scopes plumbing
// the tuple here and retiring the Rust duplicate; this trigger is a descriptor diff and the
// gains arrive on the packet, so it is a join, not a one-liner.
if let Some((store, _)) = self_q.iter().next() {
if let Some(level) = store.0.unit_level() {
let prev = memo.last_level.replace(level);
if prev.is_some_and(|p| level != p) {
gate.audit("feed_units", "the level edge");
// All nine, per the reference's own fire site (`%d%d%d%d%d%d%d%d%d`): level,
// health gain, mana gain, talent points, then the five stat gains in
// `SPELL_STAT0..4` order. Absent gains are ZEROS, not a shorter payload — a
// demotion really did gain nothing, and every consumer guards with `if ( argN > 0 )`
// so zero reads as "no line" while nil raises.
let (info, talent_points) = sink.chat.take_level_up_gains(level).unzip();
let gain = |f: fn(&benilla_protocol::messages::LevelUpInfo) -> u32| {
ScriptValue::Int(i64::from(info.as_ref().map_or(0, f)))
};
script.fire_event(
"PLAYER_LEVEL_UP",
vec![
ScriptValue::Int(i64::from(level)),
gain(|l| l.health),
gain(|l| l.powers[0]),
ScriptValue::Int(i64::from(talent_points.unwrap_or(0))),
gain(|l| l.stats[0]),
gain(|l| l.stats[1]),
gain(|l| l.stats[2]),
gain(|l| l.stats[3]),
gain(|l| l.stats[4]),
],
);
}
}
}
// The action-bar toggle feed: `PLAYER_FIELD_BYTES` byte 2 — which of the four extra bars the
// player has switched on (wow-re `system/ui/scratch/action-bar-toggles.md`). PRIVATE, like the
// combo byte one address down (`+0x1029` vs `+0x102a`), and pushed on the EDGE.
//
// **This push is the ONLY thing that moves the VM's copy**, and that is the mechanism rather
// than our simplification: no instruction in the real client writes this cell (§4.1 — the one
// `+0x102a` access image-wide is `GetActionBarToggles`' read), so `SetActionBarToggles` posts
// the byte and leaves the descriptor alone until the server's UPDATE_OBJECT echoes it. Nothing
// is notified when it lands either (§4.2: all 49 field-change registrations at `0x468070` were
// enumerated; none sits at an offset ≥ `0x1000`), so there is **no event to fire here** — the
// reference reads the binding exactly once, in `UIParent.lua`'s `PLAYER_ENTERING_WORLD`
// handler, and keeps `SHOW_MULTI_ACTIONBAR_1..4` as its optimistic copy in between.
//
// Which is why this sits ABOVE the fire, on 1087's precedent for the XP/rest pushes: the
// handler that reads `GetActionBarToggles()` runs synchronously inside `fire_event`, so a push
// below it would hand the first-paint the previous frame's value — four nils.
//
// `unwrap_or(0)` is faithful, not a shrug: with no local player the reference's chain fails
// soft and the getter returns four `nil`s, which is exactly what a zero byte returns — "not in
// world" and "byte == 0" share the branch and are indistinguishable to Lua (§5).
if let Some((store, _)) = self_q.iter().next() {
let toggles = store.0.player_action_bar_toggles().unwrap_or(0);
if memo.action_bar_toggles != Some(toggles) {
gate.audit("feed_units", "the action-bar toggle byte");
memo.action_bar_toggles = Some(toggles);
script.set_action_bar_toggles(toggles);
}
}
// Initial pull: fire PLAYER_ENTERING_WORLD once PER WORLD ENTRY so frames do their first
// paint on their own — gated on our avatar's descriptor EXISTING. 1087 stated the real
// client's guarantee (the player object lands before this event) and moved the XP/rest
// pushes above the fire, but the fire itself still went out on frame 1, seconds before
// login: every one-shot first-paint read empty state, and only consumers with their own
// diff events recovered. The 1094 live probe caught the one that couldn't — a level-60
// login read UnitLevel()=0 at the fire and kept the XP strip. With the gate the guarantee
// is structural for every consumer.
//
// The absent arm is the world-EXIT edge (logout / char switch — the self entity despawns
// with the streamed world): the real client fires this event on *every* world entry, and
// since 1290 so do we — the frame tree is torn down and rebuilt across that edge, the way
// the reference does it. So re-arm the fire and forget the player-global diff
// memories. Forgetting them makes every next-login first sighting re-seed SILENTLY — the
// byte-verified fresh-CREATE notify silence (1098 §4), now holding per entry: without it a
// 60→1 char switch latched the max-level rail shown over a level-1 body (1106's live
// repro), and a normal→rested char switch would misfire "You feel rested." at login.
if self_pair.is_some() {
if !memo.entered_world {
gate.audit("feed_units", "the PLAYER_ENTERING_WORLD arm");
script.fire_event("PLAYER_ENTERING_WORLD", vec![]);
memo.entered_world = true;
}
} else if memo.entered_world {
gate.audit("feed_units", "the world-exit disarm");
memo.entered_world = false;
memo.last_xp = None;
memo.last_rest = None;
memo.last_level = None;
memo.last_combo = None;
memo.in_combat = None;
memo.pvp_desired = None;
// The worn-display pair is a player-global like the rest, and forgetting it is what makes
// the next character's preference reach the VM at all: the push is an EDGE, so a memo
// carrying the last body's bits would silently skip a new body that happens to disagree
// with the VM's fresh "both shown" default (decision 1472).
memo.worn_hidden = None;
// Same reason as the worn-display pair: the push is an EDGE, so a memo carrying the last
// body's byte would skip a new character whose own toggles happen to match it — and this
// one has no optimistic default to fall back on, only four nils.
memo.action_bar_toggles = None;
}
for (token, snap) in [
("player", &player),
("target", &target),
("targettarget", &tot),
] {
match snap {
Some(cur) => {
let prev = memo.last.get(token);
if prev != Some(cur) {
gate.audit("feed_units", "a unit-token transition");
fire_transitions(&mut script, token, prev, cur, &edges);
memo.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.
if memo.last.remove(token).is_some() {
gate.audit("feed_units", "a unit-token clear");
}
}
}
}
// PLAYER_TARGET_CHANGED (no args, real WoW's shape) when the selection changes.
if selection.guid != memo.target_guid {
gate.audit("feed_units", "the PLAYER_TARGET_CHANGED edge");
memo.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 memo.in_combat != Some(in_combat) {
gate.audit("feed_units", "the combat-flag edge");
let first_sight = memo.in_combat.is_none();
memo.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(memo.pvp_desired, desired) {
gate.audit("feed_units", "the PvP-desired edge");
// The toast is a catalog row, so its surface is read there; the verbose sentence is
// not one, so it is emitted `unkeyed` on the handler's own chat surface rather than
// pushed through `Shown::keyed`, whose unknown-key fallback would turn it RED (2054).
let lines = [
crate::ui_action::keyed_line(&script, toast),
script
.lua()
.globals()
.get::<String>(verbose)
.ok()
.filter(|t| !t.is_empty())
.map(|t| {
crate::ui_action::Shown::unkeyed(benilla_ui::messages::MsgKind::Chat, t)
}),
];
crate::ui_action::show_messages(
&mut script,
&mut sink,
"ui_unit",
lines.into_iter().flatten(),
);
}
memo.pvp_desired = Some(desired);
}
// The worn-display pair (decision 1472): `PLAYER_FLAGS`' two hide bits, mirrored into the VM
// so `ShowingHelm()`/`ShowingCloak()` — the Options rows' getters — read the server's truth.
// On the EDGE, not per frame: the setter flips the VM's belief the instant the box is clicked,
// and the descriptor only catches up a round trip later. Re-pushing the stale pair in between
// would un-click the box and make a second click compute the wrong flip.
if let Some((store, _)) = self_pair {
let hidden = (store.0.player_hides_helm(), store.0.player_hides_cloak());
if memo.worn_hidden != Some(hidden) {
gate.audit("feed_units", "the worn-display pair");
memo.worn_hidden = Some(hidden);
script.set_worn_display(!hidden.0, !hidden.1);
}
}
// 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(memo.last_combo, banked) {
gate.audit("feed_units", "the combo-point edge");
memo.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 DESCRIPTOR leg of the same answer: `snapshot` takes the team digit off
/// `UNIT_FIELD_BYTES_0` byte 0 and **never** off `UNIT_FIELD_FACTIONTEMPLATE`.
///
/// The pane-level regression (`ui_script::honor_frame_tests::a_gm_flagged_player_…`) seats
/// `pvp_team` by hand, so it proves the key is built from the right field and not that the
/// right field is read. This is that half: a template-35 GM — the exact descriptor vmangos
/// gives one — still answers his race's side.
#[test]
fn the_team_digit_comes_off_the_race_byte_not_the_faction_template() {
use benilla_protocol::ObjectFields;
/// `UNIT_FIELD_FACTIONTEMPLATE` / `UNIT_FIELD_BYTES_0`, one dword apart — the reference's
/// own `[obj+0x110]+0x74` and `+0x78`.
const FACTIONTEMPLATE: u16 = 35;
const BYTES_0: u16 = 36;
/// vmangos's GM template: `FactionTemplate.dbc` group mask 0, friendly to everyone.
const GM_TEMPLATE: u32 = 35;
let team = |fields: &[(u16, u32)]| {
snapshot(
&ObjectStore(ObjectFields::from_pairs(fields)),
None,
0,
None,
)
.pvp_team
};
// Byte 0 of BYTES_0 is the race; the class in byte 1 must not disturb it.
let human_warrior = 1 | (1 << 8);
let scourge_mage = 5 | (8 << 8);
assert_eq!(team(&[(BYTES_0, human_warrior)]), 1, "Human → Alliance");
assert_eq!(team(&[(BYTES_0, scourge_mage)]), 0, "Scourge → Horde");
// **The report.** The sideless GM template sits right beside the race byte and is not
// consulted: the answer is the race's, unchanged.
assert_eq!(
team(&[(BYTES_0, human_warrior), (FACTIONTEMPLATE, GM_TEMPLATE)]),
1,
"a GM keeps his race's side (report B378)"
);
assert_eq!(
team(&[(BYTES_0, scourge_mage), (FACTIONTEMPLATE, GM_TEMPLATE)]),
0,
"…on both sides"
);
// A unit whose race byte has not streamed is the engine's bounds-failure −1, and a
// faction template alone cannot stand in for it.
assert_eq!(team(&[]), -1, "no race byte, no team digit");
assert_eq!(
team(&[(FACTIONTEMPLATE, 1)]),
-1,
"and a template is not one"
);
}
/// [`race_pvp_team`]'s frozen table against the **shipped tables it is a copy of** — the walk
/// the engine runs at `0x5efe00`, on the real `ChrRaces.dbc` and `FactionTemplate.dbc`.
///
/// The table is hardcoded because it is nine constant rows of a 2006 file and threading a DBC
/// resource through every unit snapshot to read them would be pure ceremony. This is what
/// makes that safe: the file decides, and a row that ever disagrees — or a race the file has
/// and the table does not (race 9, Goblin, which shares Human's template and is **not** a
/// `None`) — fails here. Skips without client data.
#[test]
fn race_pvp_team_matches_the_shipped_tables() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let want = benilla_formats::load_race_pvp_teams(&mut chain).expect("ChrRaces walk");
// The misparse guard: 5875 ships nine rows, not eight. An empty or truncated map would
// otherwise let this test pass by asserting nothing.
assert_eq!(want.len(), 9, "ChrRaces.dbc row count");
for (&race, &team) in &want {
assert_eq!(race_pvp_team(race), team, "race {race}");
}
// Both sides are actually represented — a walk that answered one digit for everything
// would satisfy the loop above and name every rank off one list.
assert!(want.values().any(|&t| t == 0), "some race is Horde");
assert!(want.values().any(|&t| t == 1), "some race is Alliance");
// Off the end of the file is the engine's bounds-failure tail, not a guess.
for race in [0u8, 10, 255] {
assert!(!want.contains_key(&race));
assert_eq!(race_pvp_team(race), -1, "race {race} has no ChrRaces row");
}
}
/// **The tapped bit fires `UNIT_FACTION`, and without this the verbs are decorative.**
///
/// `UnitIsTapped` answering correctly is only half of it. pfUI's grey-bar branch lives inside
/// `RefreshUnit`, which is event-driven — so a mob that becomes someone else's tap would stay
/// full-colour until something unrelated happened to repaint the frame. The predicate would be
/// right and the screen would be wrong, which no test of the predicate can see (`method.md`
/// step 5: the mechanism existing is not the mechanism applying).
///
/// The reference dispatches event id **29** — `UNIT_FACTION`, not a tapped-specific event —
/// from its `UNIT_DYNAMIC_FLAGS` watcher's bit-`0x4` arm (`0x6005a1 test al,4` ->
/// `0x6005b0 mov edx,0x1d` -> `0x515e50`; wow-re `ui/scratch/tapped-bits-and-unit-faction.md`,
/// controlled across all 37 fire sites image-wide). Reusing the faction event for a
/// non-faction field is not something anyone would invent, which is exactly why it is pinned.
///
/// Bit `0x8` has **no** arm, in the reference or here — proven there by enumerating all 122
/// instructions and 12 branches of the watcher. The negative half is asserted too, because
/// "fire on both, it's cheaper" is the obvious wrong simplification.
/// `UNIT_FLAGS` rides the raw field: any change of `UNIT_FIELD_FLAGS` fires it with the
/// token, an unchanged field does not, and a unit's first snapshot is NOT a transition — the
/// reference's watch bridge has no watch to fire on the create leg (1953, corrected 1957).
#[test]
fn a_flags_change_fires_unit_flags_with_the_token() {
let fired = |prev: Option<UnitState>, cur: UnitState, edges: &FieldEdges| -> Vec<String> {
let mut s = UiScript::new().unwrap();
s.run(
r#"
SEEN = {}
local f = CreateFrame("Frame")
f:RegisterEvent("UNIT_FLAGS")
f:SetScript("OnEvent", function() table.insert(SEEN, event .. ":" .. arg1) end)
"#,
)
.unwrap();
fire_transitions(&mut s, "pet", prev.as_ref(), &cur, edges);
s.eval::<Vec<String>>("return SEEN").unwrap()
};
const PET: u64 = 0xF140_0000_0000_0001;
let base = UnitState {
exists: true,
has_object: true,
guid: PET,
flags: 0x8,
..Default::default()
};
let moved = FieldEdges::of(&[(PET, benilla_protocol::field::FIELD_UNIT_FLAGS)]);
assert_eq!(
fired(
Some(base.clone()),
UnitState {
flags: 0x8 | 0x0400_0000,
..base.clone()
},
&moved,
),
vec!["UNIT_FLAGS:pet".to_string()]
);
assert_eq!(
fired(Some(base.clone()), base.clone(), &FieldEdges::default()),
Vec::<String>::new()
);
// The create block runs no notify pass: no edge, no event — whatever the snapshot says.
assert_eq!(
fired(None, base.clone(), &FieldEdges::default()),
Vec::<String>::new()
);
// …and the trigger is the UNIT'S edge, not the token's history: a token acquired on the
// very frame the field moved hears it (the reference fans out to whoever names the unit
// at notify time), and another unit's edge is not this one's.
assert_eq!(
fired(None, base.clone(), &moved),
vec!["UNIT_FLAGS:pet".to_string()]
);
assert_eq!(
fired(
Some(base.clone()),
base.clone(),
&FieldEdges::of(&[(PET + 1, benilla_protocol::field::FIELD_UNIT_FLAGS)]),
),
Vec::<String>::new()
);
}
/// The two edges on the player's own state (1953): the control flag's, which fires LOST on
/// the way down and GAINED on the way up and nothing while it holds (the boot value is "in
/// control"), and the far-sight field's, which fires on every change including the clear.
#[test]
fn the_control_and_far_sight_edges_fire_once_each_way() {
use bevy::prelude::*;
const FIELD_PLAYER_FARSIGHT: u16 = 712;
let mut app = App::new();
app.init_resource::<crate::player::Player>()
.add_systems(Update, (feed_player_control, feed_farsight_focus));
let script = UiScript::new().unwrap();
script
.run(
r#"
SEEN = {}
local f = CreateFrame("Frame")
f:RegisterEvent("PLAYER_CONTROL_LOST")
f:RegisterEvent("PLAYER_CONTROL_GAINED")
f:RegisterEvent("PLAYER_FARSIGHT_FOCUS_CHANGED")
f:SetScript("OnEvent", function() table.insert(SEEN, event) end)
"#,
)
.unwrap();
app.insert_non_send_resource(script);
let me = app
.world_mut()
.spawn((
SelfPlayer,
Guid(0x77),
ObjectStore(benilla_protocol::ObjectFields::default()),
))
.id();
let seen = |app: &mut App| -> Vec<String> {
app.update();
let mut s = app.world_mut().non_send_resource_mut::<UiScript>();
s.resolve();
let out = s.eval::<Vec<String>>("return SEEN").unwrap();
s.run("SEEN = {}").unwrap();
out
};
assert_eq!(
seen(&mut app),
Vec::<String>::new(),
"in control, no far sight: quiet"
);
app.world_mut()
.resource_mut::<crate::player::Player>()
.control_lost = true;
assert_eq!(seen(&mut app), vec!["PLAYER_CONTROL_LOST".to_string()]);
assert_eq!(seen(&mut app), Vec::<String>::new(), "held, not repeated");
app.world_mut()
.resource_mut::<crate::player::Player>()
.control_lost = false;
assert_eq!(seen(&mut app), vec!["PLAYER_CONTROL_GAINED".to_string()]);
let set_farsight = |app: &mut App, guid: u64| {
app.world_mut().entity_mut(me).insert(ObjectStore(
benilla_protocol::ObjectFields::from_pairs(&[
(FIELD_PLAYER_FARSIGHT, guid as u32),
(FIELD_PLAYER_FARSIGHT + 1, (guid >> 32) as u32),
]),
));
};
set_farsight(&mut app, 0xf130_0000_0000_0042);
assert_eq!(
seen(&mut app),
vec!["PLAYER_FARSIGHT_FOCUS_CHANGED".to_string()],
"set — whether or not the guid resolves"
);
assert_eq!(seen(&mut app), Vec::<String>::new());
set_farsight(&mut app, 0);
assert_eq!(
seen(&mut app),
vec!["PLAYER_FARSIGHT_FOCUS_CHANGED".to_string()],
"cleared — the other leg"
);
}
#[test]
fn the_tapped_bit_fires_unit_faction_and_the_by_player_bit_fires_nothing() {
let fired = |prev: UnitState, cur: UnitState| -> Vec<String> {
let mut s = UiScript::new().unwrap();
s.run(
r#"
SEEN = {}
local f = CreateFrame("Frame")
f:RegisterEvent("UNIT_FACTION")
f:SetScript("OnEvent", function() table.insert(SEEN, event) end)
"#,
)
.unwrap();
fire_transitions(&mut s, "target", Some(&prev), &cur, &FieldEdges::default());
s.eval::<Vec<String>>("return SEEN").unwrap()
};
let base = UnitState {
exists: true,
has_object: true,
..Default::default()
};
// The 0x4 edge fires it.
assert_eq!(
fired(
base.clone(),
UnitState {
tapped: true,
..base.clone()
}
),
vec!["UNIT_FACTION".to_string()],
"a unit becoming tapped must repaint the frames that draw it"
);
// ...and back the other way, when the tap is released.
assert_eq!(
fired(
UnitState {
tapped: true,
..base.clone()
},
base.clone()
),
vec!["UNIT_FACTION".to_string()],
);
// The 0x8 edge fires NOTHING — the reference's watcher has no arm for it.
assert!(
fired(
UnitState {
tapped: true,
..base.clone()
},
UnitState {
tapped: true,
tapped_by_player: true,
..base.clone()
}
)
.is_empty(),
"bit 0x8 has no delta arm — firing on it would be an invention"
);
// Nothing moved: nothing fires. The control that stops this passing by firing always.
assert!(fired(base.clone(), base.clone()).is_empty());
}
/// **`PLAYER_FLAGS_CHANGED` — the event a migrated stock window listened for and nothing
/// fired** (decision 2078), pinned on the three properties that make it what it is.
///
/// The gap was invisible from both ends, which is 1819's shape exactly: `TargetFrame.lua`
/// registered the name and this feed never spoke it, so the target frame's party-leader icon
/// only ever refreshed on a re-target. The census that caught it
/// (`reference_ui::every_event_a_chain_file_registers_has_a_producer`) described it as "the
/// AFK/DND badge"; the handler at `TargetFrame.lua:88-95` is the LEADER icon, and 1.12 has no
/// AFK/DND badge on any unit frame — so the control below is a bit no `UnitState` field
/// decodes, proving the trigger is the raw dword and not the two bools next to it.
#[test]
fn player_flags_changed_fires_per_token_on_any_bit_and_never_on_first_sight() {
let fired = |prev: Option<UnitState>, cur: UnitState, edges: &FieldEdges| -> Vec<String> {
let mut s = UiScript::new().unwrap();
s.run(
r#"
SEEN = {}
local f = CreateFrame("Frame")
f:RegisterEvent("PLAYER_FLAGS_CHANGED")
f:SetScript("OnEvent", function() table.insert(SEEN, event .. ":" .. arg1) end)
"#,
)
.unwrap();
fire_transitions(&mut s, "target", prev.as_ref(), &cur, edges);
s.eval::<Vec<String>>("return SEEN").unwrap()
};
const THEM: u64 = 0x2a;
let base = UnitState {
exists: true,
has_object: true,
guid: THEM,
..Default::default()
};
let with = |flags: u32| UnitState {
player_flags: flags,
group_leader: flags & 0x1 != 0,
ghost: flags & 0x10 != 0,
..base.clone()
};
let moved = FieldEdges::of(&[(THEM, benilla_protocol::field::FIELD_PLAYER_FLAGS)]);
let still = FieldEdges::default();
// The 1.12 consumer's own bit: `PLAYER_FLAGS_GROUP_LEADER 0x1`, `UnitIsPartyLeader`'s
// descriptor leg — leadership passing to the player you are targeting.
assert_eq!(
fired(Some(with(0)), with(0x1), &moved),
vec!["PLAYER_FLAGS_CHANGED:target".to_string()],
"arg1 is the unit token, and there is no arg2 (0x515e50 -> 0x703f50(id, \"%s\", token))"
);
// …and back down. The reference tests the XOR-diff, not the new value.
assert_eq!(
fired(Some(with(0x1)), with(0), &moved),
vec!["PLAYER_FLAGS_CHANGED:target".to_string()]
);
// **The control that proves the trigger is the RAW DWORD.** `PLAYER_FLAGS_HIDE_HELM 0x400`
// is a bit no `UnitState` field decodes — the fire at `0x5eea35` carries no bit test, so
// it announces this exactly as loudly as the leader bit: the edge is the dword's, and the
// snapshot's decoded bools are never consulted.
assert_eq!(
fired(Some(with(0)), with(0x400), &moved),
vec!["PLAYER_FLAGS_CHANGED:target".to_string()],
"an undecoded bit still fires it — the handler tests no bit at all"
);
// First sight is the CREATE, and a CREATE runs no notify pass (1098 §4) — the same
// posture `UNIT_FLAGS` holds one field over: no edge, no event.
assert!(
fired(None, with(0x1), &still).is_empty(),
"a unit's first snapshot is its create, not a transition"
);
// The control that stops all of the above passing by firing always.
assert!(fired(Some(with(0x1)), with(0x1), &still).is_empty());
}
/// **`UNIT_DYNAMIC_FLAGS` — an event benilla had never fired at all** (decision 2140).
///
/// Same bridge, same shape, the third of the three raw-dword arms: id 137 is the unit-window
/// index of descriptor field 143, the name-table slot for 137 has exactly one writer in the
/// image and it points at the string `"UNIT_DYNAMIC_FLAGS"`, and the watch length is 4 — one
/// dword — so the gate is a `repe cmpsb` over the whole word and **any** bit fires it.
///
/// The control below is bit `0x2` (`UNIT_DYNFLAG_TRACK_UNIT`, Hunter's Mark), which no
/// `UnitState` field decodes: an arm driven off `tapped`/`tapped_by_player` passes every other
/// assertion here and fails that one.
#[test]
fn unit_dynamic_flags_fires_per_token_on_any_bit_and_never_on_first_sight() {
let fired = |prev: Option<UnitState>, cur: UnitState, edges: &FieldEdges| -> Vec<String> {
let mut s = UiScript::new().unwrap();
s.run(
r#"
SEEN = {}
local f = CreateFrame("Frame")
f:RegisterEvent("UNIT_DYNAMIC_FLAGS")
f:SetScript("OnEvent", function() table.insert(SEEN, event .. ":" .. arg1) end)
"#,
)
.unwrap();
fire_transitions(&mut s, "target", prev.as_ref(), &cur, edges);
s.eval::<Vec<String>>("return SEEN").unwrap()
};
const MOB: u64 = 0xF130_0000_0000_0007;
let moved = FieldEdges::of(&[(MOB, benilla_protocol::field::FIELD_UNIT_DYNAMIC_FLAGS)]);
let still = FieldEdges::default();
let with = |dyn_flags: u32| UnitState {
exists: true,
has_object: true,
guid: MOB,
dynamic_flags: dyn_flags,
tapped: dyn_flags & 0x4 != 0,
tapped_by_player: dyn_flags & 0x8 != 0,
..Default::default()
};
// The bit the corpus registers this event for: `0x4` TAPPED, the grey-bar state
// (`CT_UnitFrames/CT_TargetFrame.xml:200`, `TipBuddy/TipBuddy.lua:17`).
assert_eq!(
fired(Some(with(0)), with(0x4), &moved),
vec!["UNIT_DYNAMIC_FLAGS:target".to_string()],
"arg1 is the unit token, and there is no arg2"
);
// The control that proves the trigger is the RAW DWORD: a bit this struct never decodes.
assert_eq!(
fired(Some(with(0)), with(0x2), &moved),
vec!["UNIT_DYNAMIC_FLAGS:target".to_string()],
"an undecoded bit still fires it — the watch is a memcmp over the dword"
);
// The create block runs no notify pass (1098 §4), like both arms beside it.
assert!(fired(None, with(0x4), &still).is_empty());
// And the control that stops the rest passing by firing always.
assert!(fired(Some(with(0x4)), with(0x4), &still).is_empty());
}
/// **The two SELF-ONLY arms of the same handler, each on its own bits** — the half that was
/// over-firing, and the half that was missing (decision 2078).
///
/// `0x5ee990`'s local-GUID gate at `0x5eea93` divides it: `PLAYER_FLAGS_CHANGED` above (any
/// player), and below it three arms that each carry their own `test` against the XOR-diff.
/// benilla fired `PLAYER_UPDATE_RESTING` on *any* `PLAYER_FLAGS` delta, because wow-re's
/// `rested-xp-bindings.md` §5 says it does; `0x5eead0 f6 45 fc 20 / 74 26` says otherwise —
/// the `je`'s target is `0x5eeafc` and the fire is at `0x5eeaf2`, inside the arm.
#[test]
fn the_self_flag_events_each_fire_on_their_own_bits() {
use bevy::ecs::system::RunSystemOnce;
const FIELD_PLAYER_FLAGS: u16 = 190;
let mut app = App::new();
app.add_message::<FieldChanged>();
app.init_resource::<Selection>()
.init_resource::<UnitFeedState>()
.init_resource::<NameCache>()
.init_resource::<Reputations>()
.init_resource::<crate::ui_party::GroupState>()
.init_resource::<crate::ui_chat::ChatLog>()
// The feed's `MessageSink` is chat + sounds since the PvP/rest lines became message
// KEYS (decision 2080): the row a key names carries the cue, so the sink reads both.
.init_resource::<crate::sound::MessageSounds>()
.init_resource::<crate::ui_guild::GuildState>();
let (tx, _rx) = crossbeam_channel::unbounded();
app.insert_resource(NetCommands(tx));
let script = UiScript::new().unwrap();
script
.run(
r#"
SEEN = {}
local f = CreateFrame("Frame")
f:RegisterEvent("PLAYER_UPDATE_RESTING")
f:RegisterEvent("PLAYTIME_CHANGED")
f:RegisterEvent("UPDATE_EXHAUSTION")
f:SetScript("OnEvent", function() table.insert(SEEN, event) end)
"#,
)
.unwrap();
app.insert_non_send_resource(script);
let me = app
.world_mut()
.spawn((
SelfPlayer,
Guid(0x77),
ObjectStore(
benilla_protocol::ObjectFields::from_pairs(&[])
.into_created(benilla_protocol::messages::ObjectType::Player),
),
))
.id();
// Set PLAYER_FLAGS, run the feed, read back what fired.
let step = |app: &mut App, flags: u32| -> Vec<String> {
app.world_mut()
.entity_mut(me)
.get_mut::<ObjectStore>()
.unwrap()
.0
.merge(benilla_protocol::ObjectFields::from_pairs(&[(
FIELD_PLAYER_FLAGS,
flags,
)]));
app.world_mut().run_system_once(feed_units).unwrap();
let mut s = app.world_mut().non_send_resource_mut::<UiScript>();
s.resolve();
let out = s.eval::<Vec<String>>("return SEEN").unwrap();
s.run("SEEN = {}").unwrap();
out
};
// Login: the first rest snapshot seeds the memo and says nothing about the flags word.
// (`UPDATE_EXHAUSTION` is a different watcher — `0x5de4e0`/`0x5de4b0`, not this handler —
// and its first-sight fire is 1087's settled posture, so it is filtered, not asserted on.)
let flag_events = |v: Vec<String>| -> Vec<String> {
v.into_iter().filter(|e| e != "UPDATE_EXHAUSTION").collect()
};
assert!(
flag_events(step(&mut app, 0)).is_empty(),
"the login descriptor is a create: structurally silent (1098 §4)"
);
// **The regression.** `PLAYER_FLAGS_HIDE_HELM 0x400` moves and the resting bit does not —
// before 2078 this announced a resting change to every listener in the UI.
assert!(
flag_events(step(&mut app, 0x400)).is_empty(),
"a non-resting, non-playtime bit fires neither self event"
);
// The resting bit's own edge, both ways: `0x5eead0 test byte [ebp-4],0x20`.
assert_eq!(
flag_events(step(&mut app, 0x400 | PLAYER_FLAGS_RESTING)),
vec!["PLAYER_UPDATE_RESTING".to_string()]
);
assert_eq!(
flag_events(step(&mut app, 0x400)),
vec!["PLAYER_UPDATE_RESTING".to_string()],
"the fire is on the XOR-diff, so the clear edge fires it too"
);
// `PLAYTIME_CHANGED` — `0x5eeb65 test ah,0x30`, the two play-time regimes together.
assert_eq!(
flag_events(step(&mut app, 0x400 | PLAYER_FLAGS_PARTIAL_PLAY_TIME)),
vec!["PLAYTIME_CHANGED".to_string()]
);
assert_eq!(
flag_events(step(
&mut app,
0x400 | PLAYER_FLAGS_PARTIAL_PLAY_TIME | PLAYER_FLAGS_NO_PLAY_TIME
)),
vec!["PLAYTIME_CHANGED".to_string()],
"the second regime bit is the same arm, not a second event"
);
// Both arms at once, from one dword: the handler runs them in order and neither masks the
// other.
assert_eq!(
flag_events(step(&mut app, 0x400 | PLAYER_FLAGS_RESTING)),
vec![
"PLAYER_UPDATE_RESTING".to_string(),
"PLAYTIME_CHANGED".to_string()
]
);
}
/// **Report B304 — Quiver's range indicator read Dead Zone on a far target.**
///
/// Its Dead Zone rung is `IsActionInRange(AutoShot) ~= 1` **and**
/// `CheckInteractDistance("target", 4)` truthy (`Quiver.bundle.lua:3136-3150`), i.e. "cannot
/// shoot, but inside 30 yards". The reach map only ever held *players*, so a creature
/// `"target"` never entered it and the 30-yard rung answered a **constant** for every mob at
/// every distance: "in range" at the published tag (the old permissive default), and — after
/// 1564 flipped that default for B316 — "out of range" for a mob standing next to you. Two
/// different wrong answers from one defect: the map was never asked about creatures at all.
///
/// So this drives the feed with a real creature target at three distances and reads the rung
/// itself. The player-only leg it used to apply at the token now rides in the entry, which the
/// `CanInspect` assertions pin: a boar three yards away is in interact range and is still not
/// inspectable.
#[test]
fn a_creature_target_enters_the_reach_map_at_its_real_distance() {
use benilla_protocol::messages::ObjectFields;
use bevy::ecs::system::RunSystemOnce;
/// `UNIT_FIELD_FLAGS` bit 1 (NON_ATTACKABLE) — one of `can_attack`'s five disqualifiers,
/// so a unit carrying it is `inspectable` as far as the entry is concerned.
const NON_ATTACKABLE: u32 = 1 << 1;
const FIELD_UNIT_FLAGS: u16 = 46;
const ME: u64 = 0x0000_0000_0000_0001;
const BOAR: u64 = 0xF130_0000_0000_0002;
const FRIEND: u64 = 0x0000_0000_0000_0003;
/// Seat one target `yards` away and answer `expr` against it.
fn ask(guid: u64, flags: u32, yards: f32, expr: &str) -> bool {
let mut app = App::new();
app.init_resource::<crate::ui_party::GroupState>()
.init_resource::<Reputations>();
app.insert_non_send_resource(UiScript::new().unwrap());
let me = app
.world_mut()
.spawn((
SelfPlayer,
Guid(ME),
ObjectStore(ObjectFields::default()),
Transform::from_xyz(0.0, 0.0, 0.0),
))
.id();
let target = app
.world_mut()
.spawn((
Guid(guid),
ObjectStore(ObjectFields::from_pairs(&[(FIELD_UNIT_FLAGS, flags)])),
// Along one axis, so d² is exactly yards² and the thresholds are readable.
Transform::from_xyz(yards, 0.0, 0.0),
))
.id();
app.insert_resource(crate::net::GuidIndex(
[(ME, me), (guid, target)].into_iter().collect(),
));
app.insert_resource(Selection {
target: Some(target),
guid: Some(guid),
});
app.world_mut().run_system_once(feed_unit_reach).unwrap();
app.world_mut()
.non_send_resource_mut::<UiScript>()
.eval::<bool>(expr)
.unwrap()
}
// The report itself: a mob well past 30 yards is OUT of the 30-yard row, so Quiver's
// "inside 30 but cannot shoot" conjunction is false and the bar reads Out of Range.
assert!(
ask(
BOAR,
0,
40.0,
r#"return CheckInteractDistance("target", 4) == nil"#
),
"a creature 40 yards away is out of the 30-yard row"
);
// …and the same rung still says YES inside the band, which is the half a token that is
// simply absent can never do — before this, no mob at any distance could light it.
assert!(
ask(
BOAR,
0,
15.0,
r#"return CheckInteractDistance("target", 4) ~= nil"#
),
"a creature 15 yards away is inside the 30-yard row"
);
assert!(
ask(
BOAR,
0,
15.0,
r#"return CheckInteractDistance("target", 1) == nil"#
),
"…and outside the 10-yard one: the table is indexed, not a constant"
);
// The typemask moved into the entry rather than vanishing: a creature in interact range is
// still not something `CanInspect` says yes to.
assert!(
ask(
BOAR,
0,
3.0,
r#"return CheckInteractDistance("target", 1) ~= nil"#
),
"a creature 3 yards away is in interact range"
);
assert!(
ask(BOAR, 0, 3.0, r#"return CanInspect("target") == nil"#),
"…and is still not inspectable — the players-only leg rides in the entry"
);
// The control: a non-attackable PLAYER at the same spot is inspectable, so the assertion
// above is about the guid type and not about the map having gone dark.
assert!(
ask(
FRIEND,
NON_ATTACKABLE,
3.0,
r#"return CanInspect("target") ~= nil"#
),
"a non-attackable player 3 yards away is inspectable"
);
}
/// 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,
None,
);
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,
None,
);
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,
display_id: 0,
}),
);
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 faction-name line does not wait for the creature query** (decision 2040).
///
/// Its entry gate `0x612610` reads `[unit+0xb30]` and **returns 1 when there is none** — the
/// leg a PLAYER takes through a gate whose only field lives in CreatureInfo, and the leg a
/// creature takes for the round trip before `SMSG_CREATURE_QUERY_RESPONSE` lands. Everything
/// the line itself needs is on the descriptor (`UNIT_FIELD_FACTIONTEMPLATE`) and already
/// streamed, so it shows under the pending `UNKNOWNOBJECT` title rather than arriving a round
/// trip after it. It was gated on the record here, on the premise that a pending creature had
/// no name line either — the premise decision 2002 corrected.
///
/// The record still owns the line's ONE creature-side gate, `HIDE_FACTION_TOOLTIP` (type flag
/// `0x10`), which is why the answer landing can take the line away again — the reference's
/// own sequence, not a flicker of ours.
#[test]
fn the_faction_line_does_not_wait_for_the_creature_query() {
use benilla_protocol::messages::ObjectFields;
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let catalog = benilla_formats::load_faction_catalog(&mut chain).expect("Faction.dbc");
let factions = crate::target::Factions::from_catalog(catalog);
/// `UNIT_FIELD_FACTIONTEMPLATE` / `UNIT_FIELD_BYTES_0` — absolute descriptor indices.
const FACTIONTEMPLATE: u16 = 35;
const BYTES_0: u16 = 36;
/// The local player the slot walk is matched against: race 1 (human), class 1 (warrior),
/// packed as `UNIT_FIELD_BYTES_0` bytes 0 and 1.
const HUMAN_WARRIOR: u32 = 1 | (1 << 8);
/// A creature entry the cache below is deliberately never told about.
const ENTRY: u32 = 299;
const GUID: u64 = (0xF130u64 << 48) | ((ENTRY as u64) << 24) | 0x7;
let me = ObjectStore(ObjectFields::from_pairs(&[(BYTES_0, HUMAN_WARRIOR)]));
// The first template id whose faction carries a reputation slot this character can see.
// Derived from the real DBC rather than guessed, so the test names no id it cannot justify.
let (template_id, expected) = (1u32..3000)
.find_map(|id| {
let f = factions.catalog().template(id)?.faction;
let info = factions.catalog().reputation_faction(f)?;
info.tooltip_shows_for(1, 1)
.then(|| factions.catalog().faction_name(f))
.flatten()
.map(|n| (id, n.to_string()))
})
.expect("some faction template shows a tooltip line to a human warrior");
let store = ObjectStore(ObjectFields::from_pairs(&[(FACTIONTEMPLATE, template_id)]));
let line_for = |names: &NameCache| {
let mut state = UnitState::default();
enrich_unit(&mut state, GUID, names, &store, Some(&factions), Some(&me));
state
};
// The query is still in flight: no record, so no name, no subtitle and no rank — and the
// faction line all the same.
let pending = line_for(&NameCache::default());
assert_eq!(pending.name, None, "the name is the thing still in flight");
assert_eq!(pending.subtitle, None);
assert_eq!(
pending.faction_name.as_deref(),
Some(expected.as_str()),
"the faction line resolves off the descriptor alone"
);
let record = |type_flags: u32| crate::names::CreatureRecord {
name: "Stormwind Guard".into(),
subname: None,
creature_type: 7,
pet_family: 0,
rank: 0,
type_flags,
civilian: false,
racial_leader: false,
display_id: 0,
};
let mut answered = NameCache::default();
answered.insert_creature(ENTRY, Some(record(0)));
assert_eq!(
line_for(&answered).faction_name.as_deref(),
Some(expected.as_str()),
"the answer landing keeps the line it was already showing"
);
// The one creature-side gate the record does own.
let mut hidden = NameCache::default();
hidden.insert_creature(ENTRY, Some(record(0x10)));
assert_eq!(
line_for(&hidden).faction_name,
None,
"HIDE_FACTION_TOOLTIP takes the line away once the record says so"
);
}
/// The PvP-preference announcement law (decision 0652), as the reference's changed-bits handler
/// runs it: silent on first sight, one pair of KEYS per real edge.
///
/// The assertion is on the identifiers, never the sentences (decision 2045) — an English
/// comparison passes exactly where two keys agree in enUS and diverge everywhere else. The
/// wording lives in the player's own `GlobalStrings.lua` and is checked against it by
/// [`the_pvp_and_rest_keys_resolve_in_the_real_global_strings`].
#[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");
assert_eq!(
pvp_announcement(Some(false), true),
Some(("ERR_PVP_TOGGLE_ON", "PVP_TOGGLE_ON_VERBOSE"))
);
assert_eq!(
pvp_announcement(Some(true), false),
Some(("ERR_PVP_TOGGLE_OFF", "PVP_TOGGLE_OFF_VERBOSE"))
);
}
/// The four keys, against the player's REAL `GlobalStrings.lua` — the guard a key-based
/// assertion needs beside it, since a typo'd key degrades a real line to silence rather than
/// to a wrong sentence. Also pins the one thing the director would actually notice: the OFF
/// verbose sentence is the one that explains the five-minute wait, which is the whole reason
/// the toggle doesn't read as dead. Skips without client data.
#[test]
fn the_pvp_and_rest_keys_resolve_in_the_real_global_strings() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let src = chain
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
.expect("GlobalStrings.lua in the chain");
let s = UiScript::new().expect("VM");
s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
let g = |key: &str| s.lua().globals().get::<String>(key).unwrap_or_default();
for (was, now) in [(false, true), (true, false)] {
let (toast, verbose) = pvp_announcement(Some(was), now).expect("an edge speaks");
assert!(!g(toast).is_empty(), "{toast} missing");
assert!(!g(verbose).is_empty(), "{verbose} missing");
}
assert!(
g("PVP_TOGGLE_OFF_VERBOSE").contains("five minutes"),
"the OFF sentence is what tells the player the flag lingers"
);
for state in [1u8, 2] {
let key = rest_state_message(0, state).expect("states 1 and 2 speak");
assert!(!g(key).is_empty(), "{key} missing");
}
}
/// The rest-state chat law (decision 1098, wow-re §§6-10): a message needs a real byte
/// TRANSITION, and only states 1/2 speak — state 0 is the pair table's no-message sentinel,
/// the beta tiers (≥3) are gated off before the table, and a re-send of the same byte is
/// swallowed by the dispatcher's mirror diff.
#[test]
fn rest_state_message_speaks_only_on_a_real_transition() {
assert_eq!(rest_state_message(2, 1), Some("ERR_EXHAUSTION_RESTED"));
assert_eq!(rest_state_message(1, 2), Some("ERR_EXHAUSTION_NORMAL"));
assert_eq!(
rest_state_message(0, 1),
Some("ERR_EXHAUSTION_RESTED"),
"0→1 IS a transition"
);
assert_eq!(
rest_state_message(1, 1),
None,
"same byte re-sent — the mirror diff eats it"
);
assert_eq!(rest_state_message(2, 2), None);
assert_eq!(
rest_state_message(1, 0),
None,
"state 0 is the 0x1d1 sentinel: no message"
);
assert_eq!(
rest_state_message(2, 3),
None,
"beta tiers are gated off (cmp esi,3; jae)"
);
assert_eq!(rest_state_message(1, 5), None);
}
/// The `"npc"` token follows the interaction NPC on the frame it moves — including a frame
/// on which NOTHING else moves — and is cleared when the window closes (decision 2022). The
/// legs are the vendor-swap probe's first run, in order: a second vendor opened over an
/// open window kept the first vendor's snapshot (the dirty gate had no interact input), and
/// a closed window left `UnitExists("npc")` true (the memo row was never written). The
/// feed runs in a real `Update` schedule rather than `run_system_once`, because a fresh
/// system instance sees every resource as changed and would hold the gate open by itself.
#[test]
fn the_npc_token_follows_the_interaction_npc_and_clears_with_it() {
use crate::ui_session::InteractNpc;
use benilla_protocol::messages::ObjectFields;
const FIELD_UNIT_LEVEL: u16 = 34;
// Two `HIGHGUID_UNIT` guids (the high word decides the family — `guid::is_player`).
const BROG: u64 = 0xF130_0000_9700_0001;
const DOBBINS: u64 = 0xF130_0001_D100_0002;
let mut app = App::new();
app.init_resource::<UnitFeedState>()
.init_resource::<Selection>()
.init_resource::<NameCache>()
.init_resource::<Reputations>()
.init_resource::<crate::ui_party::GroupState>()
.init_resource::<crate::ui_chat::ChatLog>()
// `feed_units` shows catalog messages through `show_messages`, whose sink is the chat
// log AND the message-sound queue.
.init_resource::<crate::sound::MessageSounds>()
.init_resource::<crate::ui_guild::GuildState>()
.init_resource::<InteractNpc>();
app.add_message::<FieldChanged>();
let (tx, _rx) = crossbeam_channel::unbounded();
app.insert_resource(NetCommands(tx));
app.insert_non_send_resource(UiScript::new().unwrap());
app.add_systems(Update, feed_units);
// Two vendors, told apart by level alone — no name cache, no descriptor beyond it.
let mut vendor = |level: u32| {
app.world_mut()
.spawn(ObjectStore(ObjectFields::from_pairs(&[(
FIELD_UNIT_LEVEL,
level,
)])))
.id()
};
let brog = vendor(7);
let dobbins = vendor(9);
let eval = |app: &mut App, expr: &str| -> i64 {
app.world_mut()
.non_send_resource_mut::<UiScript>()
.eval::<i64>(expr)
.unwrap()
};
let exists = |app: &mut App| eval(app, r#"return UnitExists("npc") and 1 or 0"#) == 1;
let level = |app: &mut App| eval(app, r#"return UnitLevel("npc")"#);
// Nothing open: no token.
app.update();
assert!(!exists(&mut app), "no window open, yet UnitExists(\"npc\")");
// Brog's window opens.
*app.world_mut().resource_mut::<InteractNpc>() = InteractNpc(Some(brog), Some(BROG));
app.update();
assert_eq!(
level(&mut app),
7,
"the token names the vendor whose window opened"
);
// Dobbins' window opens OVER it: the interaction NPC is the only thing that moved this
// frame — no descriptor, no selection, no group — and the token must still follow.
*app.world_mut().resource_mut::<InteractNpc>() = InteractNpc(Some(dobbins), Some(DOBBINS));
app.update();
assert_eq!(
level(&mut app),
9,
"a second vendor over an open window swaps the token"
);
// Closed: absence is data.
*app.world_mut().resource_mut::<InteractNpc>() = InteractNpc::default();
app.update();
assert!(
!exists(&mut app),
"the window closed, yet UnitExists(\"npc\")"
);
}
/// **`PLAYER_LEAVING_WORLD` fires on a cross-map worldport, and only on one** (decision 2235).
///
/// Measured live before this existed: an addon counting all three world events across a real
/// mapId 0 → 1 port read `enter=2 leave=0 login=1`. Two of those already matched the
/// reference — `PLAYER_ENTERING_WORLD` re-fires because the port destroys and re-creates the
/// descriptor, and `PLAYER_LOGIN` correctly does not, being armed only by a UI load. The
/// leaving half was simply never wired.
///
/// The `needs_ack` split is the reference's own: `0x111` is fired from the local player
/// object's destructor, which a same-map teleport never reaches, and the one worldport that
/// owes no ack is the initial-login map — an arrival, with nothing behind it to leave.
#[test]
fn a_cross_map_worldport_fires_leaving_world_and_the_login_map_does_not() {
let mut app = App::new();
app.add_message::<crate::net::WorldportMessage>()
.init_resource::<crate::ui_script::LeavingWorldArmed>()
.add_systems(Update, fire_leaving_world_on_worldport);
app.insert_non_send_resource(UiScript::new().expect("VM"));
app.world_mut()
.non_send_resource::<UiScript>()
.run(
"Left = 0 \
local f = CreateFrame(\"Frame\") \
f:RegisterEvent(\"PLAYER_LEAVING_WORLD\") \
f:SetScript(\"OnEvent\", function() Left = Left + 1 end)",
)
.expect("probe frame");
let left = |app: &mut App| -> i64 {
app.world_mut()
.non_send_resource_mut::<UiScript>()
.eval::<i64>("return Left")
.unwrap()
};
let port = |needs_ack: bool| crate::net::WorldportMessage {
map_id: 1,
position: [0.0; 3],
orientation: 0.0,
needs_ack,
transport_entry: None,
};
let arm = |app: &mut App| {
app.world_mut()
.resource_mut::<crate::ui_script::LeavingWorldArmed>()
.arm();
};
// A world began (2239's latch — the reference arms it from the local player's create).
arm(&mut app);
app.world_mut().write_message(port(false));
app.update();
assert_eq!(left(&mut app), 0, "the initial-login map is an arrival");
app.world_mut().write_message(port(true));
app.update();
assert_eq!(left(&mut app), 1, "a cross-map port leaves a world");
app.update();
assert_eq!(
left(&mut app),
1,
"once per port, not once per frame after it"
);
// **And once per WORLD, which is the latch's own law** (2239): the port above spent it,
// and nothing here re-armed — no new avatar was created. A second departure off the same
// world is the window a quit on the loading screen lands in, and the reference fires
// nothing there.
app.world_mut().write_message(port(true));
app.update();
assert_eq!(
left(&mut app),
1,
"a second departure with the latch spent fired again — [0xb4b424] is per world"
);
// Re-armed, as the new world's create does: the next departure is its own.
arm(&mut app);
app.world_mut().write_message(port(true));
app.update();
assert_eq!(left(&mut app), 2, "the next world's departure fires again");
}
}