//! 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::{Guid, NetCommands, ObjectStore, Reputations, SelfPlayer}; use crate::target::{ring_reaction, Factions, Selection}; use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog}; use crate::ui_script::{gate, UiInput}; /// The feed pass — runs **after [`benilla_world::schedule::WorldStage::Net`]** (the feeds snapshot state /// the net apply writes; unordered, `apply_net_updates` could land BETWEEN two feeds, and a /// synchronous event fired by the later one then re-read the earlier one's pre-mutation push — /// the spellbook's cooldown pie stayed cold until a manual reopen, reproduced live 2026-07-31) /// and before [`UiInput`], so the snapshot + events it produces are in place when the VM ticks /// and dispatches this frame. A named set so the demo override ([`crate::ui_script`]) can order /// itself after it. Configured in [`UiUnitPlugin`] — the set's home. #[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) struct UnitFeed; /// One combat occurrence over a unit — the `UNIT_COMBAT` event feed (decision 0576: the portrait /// hit indicator's wire; the shipped `CombatFeedback.lua` is the consumer). **§5-verified** /// (wow-re `object-layer/scratch/unit-combat-event-law.md`): the one emitter `0x494600` fires /// `(token, action, descriptor, amount, type)` once per live token the unit maps to, with **no /// self-suppression and no cvar gate** — the worldtext Gate A's inverse. `type` (arg5) is the /// damage school on the melee/spell-damage paths; the miss and heal wrappers hard-code 0. The /// melee victim event is **deferred to the swing impact keyframe** (it rides inside `0x6243e0`, /// reached only from the `0x624530` victim dispatcher — C2 CONFIRMED). ENERGIZE never fires in /// 5875 (string absent binary-wide). Producers: melee at [`melee_unit_combat`], spells/heals at /// packet receive (`net/apply/combat_log.rs`). Consumed by [`fire_unit_combat`], which resolves /// the entity to its live unit tokens. #[derive(Message, Clone, Copy)] pub(crate) struct UnitCombatFeedback { pub(crate) unit: Entity, /// `arg2` — the action: `WOUND`/`MISS`/`DODGE`/`PARRY`/`BLOCK`/`EVADE`/`IMMUNE`/`DEFLECT`/ /// `RESIST`/`ABSORB`/`REFLECT`/`HEAL`/`ENERGIZE`. pub(crate) action: &'static str, /// `arg3` — the descriptor: `CRITICAL`/`CRUSHING`/`GLANCING`/`ABSORB`/`BLOCK`/`RESIST`, or `""`. pub(crate) flags: &'static str, /// `arg4` — the amount (damage/heal/energize; 0 for pure words). pub(crate) amount: u32, /// `arg5` — the school int (0 = physical; the Lua's `type > 0` draws the number spell-yellow). pub(crate) school: u32, } /// One center-combat-text message — the `COMBAT_TEXT_UPDATE` event feed (decision 0578; the /// Blizzard_CombatText transcription is the consumer). **§5-verified** (wow-re /// `playername/scratch/combat-text-update-emission-law.md`): event id 0x21E, fired via the /// formatted SignalEvent `0x703f50` from the UnitCombatLog_C.cpp emit helpers — every producer /// fires **at packet parse** (the melee one too: `0x6255b0 → 0x629d30`, one call stack — NOT the /// impact-keyframe deferral, which belongs to the worldtext/UNIT_COMBAT victim dispatch). /// `message_type` is the addon's vocabulary (`DAMAGE`/`DAMAGE_CRIT`/`SPELL_DAMAGE`/`HEAL`/…); /// `data`/`extra` mirror `arg2`/`arg3` (all strings on the real wire — the fmt is `"%s..%d.."`). /// Producers gate on the SELF recipient — the ref's emit is co-gated with the chat combat-log /// category scope (participants beyond self CAN fire it there); the exact participant rule is an /// open residual (decision 0580), and self-only is the display-equivalent conservative cut. #[derive(Message, Clone)] pub(crate) struct CombatTextEvent { pub(crate) message_type: &'static str, pub(crate) data: Option, pub(crate) extra: Option, } /// The feed's change-tracking memory: what we last told the VM, plus one server-side log-once. /// /// 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, /// 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, /// The last selection guid, for the `PLAYER_TARGET_CHANGED` trigger. target_guid: Option, /// 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` /// and `PLAYER_UPDATE_RESTING` triggers — player-globals like the XP pair (decisions /// 1082/1087). The whole flags dword, not just the resting bit: the client's `0x5ee990` /// fires `PLAYER_UPDATE_RESTING` on any PLAYER_FLAGS delta. Pushed as one snapshot so /// `GetRestState`/`GetXPExhaustion`/`IsResting` never read it half-updated. 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, /// 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, /// 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, /// 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, } /// Adds the per-frame unit feed. The `Unit*` bindings themselves live in `benilla-ui`; this only /// supplies their data (and the events) from ECS state. pub(crate) struct UiUnitPlugin; impl Plugin for UiUnitPlugin { fn build(&self, app: &mut App) { app.configure_sets( Update, UnitFeed .after(benilla_world::schedule::WorldStage::Net) // the set's own doc: the why // …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_pending`. .run_if(bevy::ecs::schedule::common_conditions::not( crate::ui_script::ingame_ui_pending, )), ) .init_resource::() .add_message::() .add_message::() .add_systems( Update, ( feed_units, feed_unit_reach, melee_unit_combat, fire_unit_combat, fire_combat_text, ) .chain() .in_set(UnitFeed) .before(UiInput), ) .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)) // `load_exhaustion_rows` pushes into the VM, so it runs per VM in `Update` (1290); // `load_default_languages` only builds a Bevy resource and stays a one-shot. .add_systems(Update, load_exhaustion_rows) .add_systems(PostStartup, load_default_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). fn load_default_languages( mut commands: Commands, assets: Option>, ) { 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:#}"), } } /// 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>, self_q: Query<&ObjectStore, With>, langs: Option>, mut pushed: Local>>>, ) { 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>, assets: Option>, mut seeded: Local>, ) { 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, mut out: MessageWriter, ) { 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>, mut events: MessageReader, ) { let Some(mut script) = script else { return; }; for ev in events.read() { let arg = |v: &Option| 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>, mut events: MessageReader, self_q: Query<(), With>, selection: Res, ) { 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" }) } /// 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 { 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::().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>, pet: Option>, hovered: Option>, group: Res<'w, crate::ui_party::GroupState>, pub(crate) stores: Query<'w, 's, &'static ObjectStore>, me: Query<'w, 's, (Entity, &'static Guid), With>, } 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::().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::().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 { ["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 } /// 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>, tokens: UnitTokens, selection: Res, self_q: Query<(&Transform, &ObjectStore), With>, transforms: Query<&Transform>, factions: Option>, reputations: Res, ) { 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. changed: Query<'w, 's, (), Changed>, /// Whose object left the manager — the other half of that gate, and cleared every run. removed: RemovedComponents<'w, 's, ObjectStore>, } /// 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, 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(), 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, // `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), // `PLAYER_BYTES_3` byte 2 — the city-protector title, the same PUBLIC dword as the rank // byte above. `UnitPVPName` appends a `PVP_MEDAL` 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; }; if let Some(rec) = names.creature_record(entry) { state.subtitle = rec.subname.clone(); state.creature_type_name = creature_type_word(rec.creature_type).map(str::to_string); // The client's one rank getter, both gates (`gated_rank`, decision 0782) — never `rec.rank` // directly: an enslaved elite reads rank 0, so it loses its border dragon, its ELITE // tooltip word and its world-boss skull together, exactly as in the reference. state.rank = crate::names::gated_rank(Some(rec), Some(store)); state.civilian = rec.civilian; state.racial_leader = rec.racial_leader; // The faction-name line ("Stormwind", between level and PvP) — the unit builder's tail // block, every gate transcribed: the record's HIDE_FACTION_TOOLTIP type flag (0x10, the // `0x612610` gate), the template → Faction.dbc hop, the reputation-slot gate // (rep_index ≥ 0), and the race/class slot walk with its hidden flag (0x4). The record // gate also stands in for the bytes' "no creature info → pass": before the query // answers we have no name line either, and the tooltip rebuilds when it lands. if rec.type_flags & 0x10 == 0 { state.faction_name = (|| { let catalog = factions?.catalog(); let faction_id = catalog.template(store.0.unit_faction_template()?)?.faction; let info = catalog.reputation_faction(faction_id)?; let self_store = self_store?; let race = self_store.0.unit_race().unwrap_or(0); let class = self_store.0.unit_class().unwrap_or(0); info.tooltip_shows_for(race, class) .then(|| catalog.faction_name(faction_id).map(str::to_string)) .flatten() })(); } } } /// Drain the `TogglePVP` intents into `CMSG_TOGGLE_PVP` (decision 0646 §3) — `/pvp` is the only /// caller the reference has (`SlashCmdList["PVP"]`), and the only one we have. /// /// This rides the unit feed's plugin rather than a `ui_pvp` of its own: the family's whole client /// state is one remembered bit ([`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>, commands: Res) { 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>, commands: Res) { 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>, commands: Res) { 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): `(toast, verbose)` on a real change of the /// bit, `None` otherwise. /// /// `was: None` is first sight and stays silent — the reference's handler is driven by a /// *changed-bits* mask (`new ^ old`), so the descriptor that first carries the flag at login says /// nothing. The two texts are verbatim 1.12 `GlobalStrings.lua`: the `ERR_PVP_TOGGLE_*` toast /// (l.1788-1789) and the `PVP_TOGGLE_*_VERBOSE` chat sentence (l.3221-3222). Both are /// argument-free, so there is no format step. fn pvp_announcement(was: Option, now: bool) -> Option<(&'static str, &'static str)> { if was? == now { return None; } Some(if now { ( "PvP combat toggled on", "You are now flagged for PvP combat and will remain so until toggled off.", ) } else { ( "PvP combat toggled off", "You will be unflagged for PvP combat after five minutes of non-PvP action in friendly territory.", ) }) } /// 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 line is /// a plain yellow SYSTEM chat message (`CHAT_MSG_SYSTEM`) — never UIErrorsFrame, no sound. /// enUS literals like every app-side chat line (`level_up_lines`' shape); the keys above are the /// GlobalStrings homes. 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("You feel rested."), 2 => Some("You feel 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|` (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 { 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. pub(crate) fn fire_transitions( script: &mut UiScript, token: &str, prev: Option<&UnitState>, cur: &UnitState, ) { 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()]); } // 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()]); } } #[allow(clippy::too_many_arguments)] fn feed_units( script: Option>, // `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>, self_q: Query<(&ObjectStore, &Guid), With>, selection: Res, // 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>, mut stores: UnitStores, mut feed: ResMut, mut names: ResMut, commands: Res, factions: Option>, reputations: Res, group: Res, mut chat: ResMut, // 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, // 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>, // 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>, ) { 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); // 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()); 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), ], ); let gate = gate::Gate::new( vm_reset || names_moved || guild_moved || selection_changed || stores_changed || stores_removed || group_changed || reps_changed || factions_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()); // `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) put a SECOND surface behind this same side: a rank's title is the // GlobalString `PVP_RANK__`, and with no side there is no team digit, so // `GetPVPRankInfo` answers nil and the Honor tab renders `NONE` at every rank — for a Grand // Marshal. That reads exactly like an unbuilt pane, which is why it is named in the warning // rather than left for the next investigation to rediscover. if let Some(p) = &player { let sideless = p.faction_group.is_none(); if sideless && !feed.warned_sideless { warn!( "faction: our own template names no side (usually GM mode — vmangos forces \ template 35, group mask 0). Faction-derived UI cannot resolve a side while this \ holds: the PvP flag icon stays hidden however flagged you are, and the Honor tab's rank \ title reads NONE at every rank (PVP_RANK__ has no team digit). \ `.gm off` restores both." ); } 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()); // 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.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.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. 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()); } // 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): the `PLAYER_BYTES_2` rest-state byte, the // `PLAYER_REST_STATE_EXPERIENCE` pool and PLAYER_FLAGS, pushed as one snapshot. Two watches, // the byte-verified grain (wow-re rested-xp-bindings.md §5): `UPDATE_EXHAUSTION` on a // state-or-pool change (the client installs a watcher on each — `0x5de4e0` on the byte, // `0x5de4b0` on the pool field), `PLAYER_UPDATE_RESTING` on **any PLAYER_FLAGS delta** // (`0x5ee990` fires it beside PLAYER_FLAGS_CHANGED without testing which bit moved — the // resting bit is just its loudest consumer). 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. 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: `0x5ee990` fires PLAYER_FLAGS_CHANGED on ANY PLAYER_FLAGS delta without // testing which bit moved, so one snapshot is the faithful granularity for all three. 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 { if let Some(text) = rest_state_message(p.0, rest.0) { chat.push_event(ChatEvent::text_only( ChatEventKind::System, text.to_string(), )); } } if prev.map(|p| p.2) != Some(rest.2) { script.fire_event("PLAYER_UPDATE_RESTING", 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+ — unpinned, and no 1.12 FrameXML consumer reads past arg1 // (`ReputationWatchBar_Update` takes arg1; the tick's handler takes none), so the extra // args wait for a consumer. 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"); script.fire_event("PLAYER_LEVEL_UP", vec![ScriptValue::Int(i64::from(level))]); } } } // 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); 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"); script.fire_event("UI_INFO_MESSAGE", vec![ScriptValue::Str(toast.to_string())]); chat.push_event(ChatEvent::text_only( ChatEventKind::System, verbose.to_string(), )); } 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 { (last != Some(now)).then(|| last.map(|(count, _)| count) != Some(now.0)) } #[cfg(test)] mod tests { use super::*; /// **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. #[test] fn the_tapped_bit_fires_unit_faction_and_the_by_player_bit_fires_nothing() { let fired = |prev: UnitState, cur: UnitState| -> Vec { 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); s.eval::>("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()); } /// **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::() .init_resource::(); 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::() .eval::(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 PvP-preference announcement law (decision 0652), as the reference's changed-bits handler /// runs it: silent on first sight, one pair per real edge, and the OFF text is the one that /// explains the five-minute wait — the whole reason the toggle doesn't read as dead. #[test] fn pvp_announcement_speaks_only_on_an_edge() { assert_eq!( pvp_announcement(None, false), None, "first sight, unflagged" ); assert_eq!(pvp_announcement(None, true), None, "first sight, flagged"); assert_eq!(pvp_announcement(Some(true), true), None, "no change"); assert_eq!(pvp_announcement(Some(false), false), None, "no change"); let (toast, verbose) = pvp_announcement(Some(false), true).expect("turning it on speaks"); assert_eq!(toast, "PvP combat toggled on"); // GlobalStrings ERR_PVP_TOGGLE_ON assert_eq!( verbose, "You are now flagged for PvP combat and will remain so until toggled off." ); let (toast, verbose) = pvp_announcement(Some(true), false).expect("turning it off speaks"); assert_eq!(toast, "PvP combat toggled off"); // GlobalStrings ERR_PVP_TOGGLE_OFF assert!( verbose.contains("five minutes"), "the OFF sentence is what tells the player the flag lingers: {verbose}" ); } /// 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("You feel rested.")); assert_eq!(rest_state_message(1, 2), Some("You feel normal.")); assert_eq!( rest_state_message(0, 1), Some("You feel 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); } }