//! The unit snapshot and event feed: each frame, ahead of the VM's tick, live ECS state becomes a //! [`UnitState`] per token for the engine-free `Unit*` bindings, plus the unit events; the Lua API //! never reaches into the ECS. use std::collections::HashMap; use bevy::prelude::*; use benilla_formats::ChrClasses; use benilla_protocol::messages::ObjectType; use benilla_ui::script::{power_token, ScriptValue, UiScript, UnitState, WornDisplay}; use crate::names::NameCache; use crate::net::{ FieldChanged, FieldEdges, Guid, NetCommands, ObjectStore, Reputations, SelfPlayer, }; use crate::target::{ring_reaction, Factions, Selection}; use crate::ui_script::{gate, UiInput}; /// The unit-feed pass, gated so none of its login one-shots or per-VM memos runs before the in-game /// interface exists; the demo override orders itself after it. #[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) struct UnitFeed; /// [`feed_units`] alone, so a sibling feed in [`UnitFeed`] can order itself **before the /// `PLAYER_ENTERING_WORLD` fire** that lives in it. /// /// Addons read the world in that handler and take what they find as the truth for the session — /// DoiteAuras reads the entire aura block there and then relies on edge events alone /// (`Modules/DoitePlayerAuras.lua`, `UpdateAuras` called nowhere else), so a feed that has not /// pushed yet is not a late frame, it is a wrong answer that stands until something changes. This /// is the same reasoning `feed_units` already applies internally to the XP pair ("ahead of the /// `PLAYER_ENTERING_WORLD` fire so its first update reads it"); without the set the ordering /// between two systems in one `UnitFeed` was simply ambiguous, which is why it held on a fresh /// login and broke after a `/reload`. #[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) struct UnitFeedDispatch; /// One `UNIT_COMBAT` over a unit, the portrait hit indicator's feed. The emitter `0x494600` fires /// `(token, action, descriptor, amount, type)` once per token naming the unit, with no /// self-suppression and no CVar gate; `type` is the school on the melee and spell-damage paths, 0 /// from the miss and heal wrappers, and the 1.12 binary never emits `ENERGIZE`. The melee victim /// event waits for the impact keyframe (`0x6243e0`, reached only from `0x624530`). #[derive(Message, Clone, Copy)] pub(crate) struct UnitCombatFeedback { pub(crate) unit: Entity, /// `arg2`, the action word (`WOUND`, `MISS`, `DODGE`, `PARRY`, `BLOCK`, `HEAL`, …). pub(crate) action: &'static str, /// `arg3`, the descriptor: `CRITICAL`/`CRUSHING`/`GLANCING`/`ABSORB`/`BLOCK`/`RESIST`, or `""`. pub(crate) flags: &'static str, /// `arg4`, the amount; 0 for a word alone. pub(crate) amount: u32, /// `arg5`, the school (0 physical); stock draws a `type > 0` number spell-yellow. pub(crate) school: u32, } /// One `COMBAT_TEXT_UPDATE` (event `0x21E`, via `0x703f50`), fired at packet parse by every /// producer, melee included (`0x6255b0` → `0x629d30`); `data`/`extra` are `arg2`/`arg3`. Fired only /// when the unit the line is about is the player: each emitter tests that unit's class (`0x5efea0`) /// for 0 before its helper (`0x629ef1`, `0x62d046`, `0x62834b`). #[derive(Message, Clone)] pub(crate) struct CombatTextEvent { pub(crate) message_type: &'static str, pub(crate) data: Option, pub(crate) extra: Option, } /// `PLAYER_LEAVING_WORLD` on a cross-map worldport. The reference fires event `0x111` at one site /// (`0x490b48`), gated only by the latch `[0xb4b424]`, from the local player's destructor /// (`0x5dd543`, this system), the shutdown tail `0x490bd0` (ours is `shutdown_ui_state`) and the /// local player's DESTROY or OUT_OF_RANGE (`0x5e9b5a`); a same-map teleport destroys nothing. /// /// Deviation: fires with our descriptor present, so `UnitExists("player")` answers true where the /// reference's (`0x515970`) misses, because matching it breaks `UnitExists` for no stock reader. fn fire_leaving_world_on_worldport( script: Option>, mut armed: ResMut, mut ports: MessageReader, ) { // `needs_ack` false is the initial-login map, an arrival; the whole iterator is read so no // message carries over. let leaving = ports.read().filter(|w| w.needs_ack).count() > 0; if !leaving { return; } // The world latch keeps this producer and the shutdown tail from both claiming one departure; // spent even with no VM, as the reference clears it at `0x490a8d`, ahead of the fire. if !armed.spend() { return; } let Some(mut script) = script else { return; }; script.fire_event("PLAYER_LEAVING_WORLD", Vec::new()); } /// The feed's change-tracking memory: what we last told the VM, plus one server-side log-once. #[derive(Resource, Default)] struct UnitFeedState { /// What we last told the VM, dying with it, so a `/reload` re-fires everything as a login does. vm: crate::ui_script::VmMemo, /// Whether the sideless-template warning is logged; outside the VM memo, so `/reload` keeps it. warned_sideless: bool, } /// The per-VM half of [`UnitFeedState`]: the event-trigger diffs. #[derive(Default)] struct UnitFeedMemo { /// The lazy caches' landing counters: their per-frame `&mut` misses would trip `is_changed`. names_generation: gate::Watch, guild_generation: gate::Watch, /// Whether `PLAYER_ENTERING_WORLD` has fired for this world entry. entered_world: bool, /// Per token, the last snapshot pushed. last: HashMap, target_guid: Option, last_xp: Option<(u32, u32)>, /// `(rest state, pool, PLAYER_FLAGS)`, pushed as one so no binding reads it half-updated. last_rest: Option<(u8, u32, u32)>, /// Our last level; the first sighting is the login descriptor, not a ding. last_level: Option, /// `(count, banked target)`, diffed as a pair because the server writes them as one. last_combo: Option<(u8, u64)>, /// Our last in-combat flag; first sight fires only when already in combat. in_combat: Option, /// Our last PvP-preference bit; the first descriptor is silent, as the reference diffs bits. pvp_desired: Option, /// `(HIDE_HELM, HIDE_CLOAK)`, pushed on the edge only: the Options setter flips the VM's /// belief ahead of the server, and a per-frame push would snap it back. worn_hidden: Option<(bool, bool)>, /// `PLAYER_FIELD_BYTES` byte 2 (`GetActionBarToggles`, `0x4e7660`); no field watch, no event. action_bar_toggles: Option, /// ClassicAPI's focus guid as the VM last held it (`docs/ADDON_COMPAT.md`). A gate input, not /// just a diff: `FocusUnit`/`ClearFocus` move nothing this feed's other inputs watch, so /// without it a `/focus` on a standing-still unit would never push its `"focus"` snapshot. focus_guid: Option, } /// Adds the per-frame unit feed; the `Unit*` bindings live in `benilla-ui`. pub(crate) struct UiUnitPlugin; impl Plugin for UiUnitPlugin { fn build(&self, app: &mut App) { app.configure_sets( Update, UnitFeed .in_set(crate::ui_script::UiFeed) // The whole set: a login one-shot or a per-VM memo spent on the boot VM is lost. .run_if(crate::ui_script::ingame_ui_up), ) .init_resource::() // A UI-only harness has no sound or net stack, and a missing resource or message is a // system-validation panic; declaring twice is idempotent. .init_resource::() .add_message::() .add_message::() .add_message::() .add_message::() .init_resource::() .add_systems( Update, ( // First, so a worldport's leaving edge precedes the entering edge `feed_units` // raises for the same port. fire_leaving_world_on_worldport, // Before `feed_units`, because `fire_event` dispatches into Lua *inside* it: a // handler of its `PLAYER_TARGET_CHANGED` that asks `CanInspect("target")` must be // answered about the new target, and the reference answers from live state // (`0x48a1b0` reads the object manager at call time). Behind it, the map still // held the previous selection, so `InspectFrame_Show` (`Blizzard_InspectUI.lua:8`) // re-showed the inspect window for a creature and `InspectPaperDollFrame_SetLevel` // raised on `UnitRace`'s nil. feed_unit_reach, feed_units.in_set(UnitFeedDispatch), feed_self_movement, feed_player_control, feed_farsight_focus, melee_unit_combat, fire_unit_combat, fire_combat_text, ) .chain() .in_set(UnitFeed), ) .add_systems(Update, drain_pvp_toggles.after(UiInput)) .add_systems(Update, drain_worn_display_toggles.after(UiInput)) .add_systems(Update, drain_action_bar_toggles.after(UiInput)) .add_systems(Update, feed_default_language.in_set(UnitFeed)) .add_systems(Update, feed_known_languages.in_set(UnitFeed)) // `load_exhaustion_rows` pushes into the VM, so it runs per VM; the rest are one-shots. .add_systems( Update, load_exhaustion_rows.in_set(crate::ui_script::UiFeed), ) .add_systems(PostStartup, (load_default_languages, load_languages)); } } /// The race to default-chat-language join; absent when the tables do not load, and /// `GetDefaultLanguage()` then answers the reference's zero-value shape. #[derive(Resource)] pub(crate) struct DefaultLanguagesRes(pub(crate) benilla_formats::DefaultLanguages); /// `Languages.dbc` in row order, the walk behind `GetNumLaguages`/`GetLanguageByIndex`. #[derive(Resource)] pub(crate) struct LanguagesRes(pub(crate) benilla_formats::Languages); fn load_languages(mut commands: Commands, assets: Option>) { let Some(assets) = assets else { return }; let loaded = { use benilla_assets::LockRecover; let mut chain = assets.chain.lock_recover(); benilla_formats::load_languages(&mut chain) }; match loaded { Ok(langs) => { info!("ui_unit: {} Languages.dbc rows", langs.len()); commands.insert_resource(LanguagesRes(langs)); } Err(e) => warn!("ui_unit: Languages.dbc unavailable — {e:#}"), } } /// The languages this character knows, in `Languages.dbc` order: one a known spell declares /// (`Effect_1 == 39`, `0x4b25b0`; spell to language, never the reverse) whose skill line /// (`0x6de040`) is present in `PLAYER_SKILL_INFO`, whatever its value (`0x5ec720`). Two spells on /// one language both count here; the reference keeps the later learn, invisible on shipped data. pub(crate) fn known_languages( known: impl IntoIterator, spells: &benilla_formats::SpellCatalog, skill_lines: Option<&benilla_formats::SkillLineCatalog>, has_skill_line: impl Fn(u32) -> bool, languages: &benilla_formats::Languages, ) -> Vec { let mut declared: std::collections::HashMap> = Default::default(); for spell in known { if let Some(lang) = spells.declared_language(spell) { declared.entry(lang).or_default().push(spell); } } languages .names(0) .filter(|(id, _)| { declared.get(id).is_some_and(|spells| { spells.iter().any(|&spell| { skill_lines .and_then(|sl| sl.spell_to_line(spell)) .is_some_and(&has_skill_line) }) }) }) .map(|(_, name)| name.to_string()) .collect() } fn feed_known_languages( script: Option>, actions: Option>, spells: Option>, skill_lines: Option>, languages: Option>, self_q: Query, With>, mut pushed: Local>>>, ) { let Some(mut script) = script else { return; }; let (Some(actions), Some(spells), Some(languages)) = (actions, spells, languages) else { return; }; let pushed = pushed.get(&script); let store = self_q.iter().next(); // With no input moved since this VM's push, a rebuild could only reproduce the memo. let inputs_moved = store.as_ref().is_some_and(|s| s.is_changed()) || actions.is_changed() || spells.is_changed() || languages.is_changed() || skill_lines.as_ref().is_some_and(|l| l.is_changed()); if pushed.is_some() && !inputs_moved { return; } let store: Option<&ObjectStore> = store.as_deref(); let has_skill_line = |line: u32| { store.is_some_and(|s| { (0..benilla_protocol::messages::PLAYER_SKILL_SLOTS) .filter_map(|i| s.0.player_skill(i)) .any(|slot| u32::from(slot.skill_id) == line) }) }; let names = known_languages( actions.spells.iter().copied(), &spells.catalog, skill_lines.as_deref().map(|s| &s.catalog), has_skill_line, &languages.0, ); if pushed.as_ref() != Some(&names) { script.set_known_languages(names.clone()); *pushed = Some(names); } } fn load_default_languages( mut commands: Commands, assets: Option>, ) { 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 answers the no-table case. Err(e) => warn!("ui_unit: default languages unavailable — {e:#}"), } } /// Seed a new VM's default language from the roster row, before the avatar exists: the load burst /// reads it, and so does `ChatFrame.lua:1276` at a `PLAYER_ENTERING_WORLD` that [`feed_units`] /// fires unordered against [`feed_default_language`]. pub(crate) fn seed_default_language(world: &mut World, script: &mut UiScript) { let (Some(langs), Some(roster)) = ( world.get_resource::(), world.get_resource::(), ) else { return; }; let Some(row) = roster.pending_row() else { return; }; script.set_default_language(langs.0.name(u32::from(row.race), 0).map(str::to_string)); } /// Push `GetDefaultLanguage()`'s string on a change of race; `None` is the reference's zero value. /// Locale column 0 (the client's slot is `[0xc0e080]`): only enUS is populated in the 1.12 data. 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 each VM's `Exhaustion.dbc` table; a failed load keeps the shipped-table fallback. 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 to the `UNIT_COMBAT` action (victim-state table `0x83de28`, `0x4946d0`) and /// descriptor, HitInfo keyed on the amount's sign: above zero CRITICAL `0x80`, GLANCING `0x4000`, /// CRUSHING `0x8000`; else ABSORB `0x20`, BLOCK `0x800`, RESIST `0x40`. 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") } else if hit_info & 0x800 != 0 { ("WOUND", "BLOCK") // a full block the parse did not already make state 5 } else if hit_info & 0x40 != 0 { ("WOUND", "RESIST") } else { ("MISS", "") } } } } /// The melee `UNIT_COMBAT` producer, on the swing's impact keyframe, `text_only` flushes included; /// the center combat text is not here, as the reference fires it at packet parse. 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, // the reference passes sub-damage 0's school (`0x4946fc`), not carried here }); } } /// Drain [`CombatTextEvent`] into `COMBAT_TEXT_UPDATE(messageType, data, extra)`. 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 `UNIT_COMBAT` for `"player"` and `"target"` only; the /// reference fires once per token naming the unit, and stock `PetFrame.lua:13` listens for `"pet"`. 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 race id to `UnitRace`'s display name and `raceFile` token (`"Scourge"`, `"NightElf"`); the /// display name is also what `$R`/`$r` expand to. Also the one race-caption table the glue screens /// read, through [`crate::char_select::race_name`]. /// /// The reference resolves this through `ChrRaces.dbc` at call time, so a realm that renames a row /// renames it everywhere, and benilla does the same: the chain's row wins /// ([`crate::ui_chr_races::chain_race_names`]) and [`frozen_race_names`] answers only what the /// chain cannot. On OctoWoW that is the difference between "Blood Elf" and the realm's own /// **"High Elf"** for row 10 — whose `clientFileString` stays `BloodElf`, because the art paths /// are keyed on it. pub(crate) fn race_names(race: u8) -> Option<(&'static str, &'static str)> { crate::ui_chr_races::chain_race_names(race).or_else(|| frozen_race_names(race)) } /// The spellings benilla carries for a run with no `ChrRaces.dbc` to read — no install, or an /// unreadable table. Blizzard's eight plus TurtleWoW's two. /// /// Deviation: races 9 (Goblin) and 10 (Blood Elf) are TurtleWoW's additions, absent from stock /// 1.12's eight; they resolve because the reference reads `ChrRaces.dbc` at call time and /// TurtleWoW's patched copy carries the rows, so a 1.12 client on that chain answers them too. /// Without the rows `UnitRace("player")` answers nil and TurtleWoW's `PaperDollFrame_SetLevel` /// raises in its three-spec `PLAYER_LEVEL` format. (The same justification as the `wipe`/`trim`/ /// `strsplit` additions in the UI stdlib.) The fileString is `ChrRaces`' own token, which the /// race-icon and background paths key on (`UI_BloodElf`, not the display name's space). fn frozen_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"), 9 => ("Goblin", "Goblin"), 10 => ("Blood Elf", "BloodElf"), _ => return None, }) } /// The class id to `UnitClass`'s display name and uppercase `classFileName`. 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's fixed side, for where no faction template is at hand, as at world entry, /// where addons concatenate `UnitFactionGroup("player")` at file scope; [`faction_group`] reads /// the live template. /// /// Deviation: races 9 (Goblin) and 10 (Blood Elf) are TurtleWoW's additions, absent from stock /// 1.12's eight; their sides come from TurtleWoW's `FactionTemplate.dbc` group masks (Goblin's /// template 1609 is mask 5, Horde; Blood Elf's 1610 is mask 3, Alliance). Without a side here the /// roster seat's `UnitFactionGroup("player")` is nil, and a 2.0-era addon that concatenates it at /// load (aux-addon's `history.lua` `format('%s|%s', realm, UnitFactionGroup'player')`) raises and /// loses its whole load phase. pub(crate) fn race_faction_group(race: u8) -> Option<&'static str> { if !(1..=10).contains(&race) { return None; } Some(if crate::char_create::ALLIANCE.contains(&race) { "Alliance" } else { "Horde" }) } /// A unit's PvP team digit, `0x5efe00`'s `0` Horde, `1` Alliance, `-1` no side, from the race and /// never the live template: `ChrRaces.dbc` field 2 to `FactionTemplate.dbc` field 3's group mask, /// `& 4` Horde, else `& 2` Alliance, so a template-35 GM keeps his rank title. Read by /// `GetPVPRankInfo` (`0x51a9af`, `0x51a9c8`), `UnitPVPName` (`0x5efe60`) and the scoreboard /// (`0x4aa200`). A frozen copy of the shipped nine rows; race 9 (Goblin) answers Alliance. pub(crate) fn race_pvp_team(race: u8) -> i8 { match race { // Group mask 3, Player|Alliance: `& 4` clear, `& 2` set. 1 | 3 | 4 | 7 | 9 => 1, // Group mask 5, Player|Horde: `& 4` set, tested first. 2 | 5 | 6 | 8 => 0, // No `ChrRaces` row: the engine's bounds-failure `-1`, which names no GlobalString. _ => -1, } } /// Resolve a UnitPopup token to the other player's guid it names: `"target"` when it is a player, /// `"partyN"` through the roster; `"player"` and anything unresolved answer `None`. 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, the reference's `0x515970`: case-insensitive compares, then the /// object manager; a caller wanting a type tests the resolved unit. [`Selection`] is a parameter /// because [`crate::target::SelectCommit`] holds it as `ResMut`. `partypetN`, `raidpetN` and `npc` /// are recognised but unresolved here, a quiet nil. #[derive(bevy::ecs::system::SystemParam)] pub(crate) struct UnitTokens<'w, 's> { /// `Option`, like the two below: a UI-only harness lacks their plugins; a bare `Res` panics. 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<'_, '_> { fn me(&self) -> Option<(Entity, u64)> { self.me.iter().next().map(|(e, g)| (e, g.0)) } /// The guid lookup (`0x468460`), `pub(crate)` for `TargetLastEnemy`, which starts from a guid. pub(crate) fn held(&self, guid: u64) -> Option<(Entity, u64)> { Some((*self.index.as_ref()?.0.get(&guid)?, guid)) } /// Resolve `token` case-insensitively (`_strnicmp`); the first matching prefix 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), // One hop off the target's `UNIT_FIELD_TARGET`, the read `/assist` runs. "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 same `Hovered` pick `ui_tooltip` pushes `"mouseover"` from. "mouseover" => { let h = self.hovered.as_ref()?; h.target.zip(h.guid) } // Off the pet bar's cached guid, as the `"pet"` snapshot reads it. "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)), } } } /// Every token [`UnitTokens`] resolves, `"player"` included (the reference answers d² = 0). pub(crate) fn reach_tokens() -> impl Iterator { ["player", "target", "targettarget", "mouseover", "pet"] .into_iter() .chain(crate::ui_party::PARTY_TOKENS) .chain(crate::ui_party::RAID_TOKENS) } /// Squared distance as the reference sums it: `f32` widened to `f64`, `(dz² + dx²) + dy²` /// (`0x48a26f..0x48a27d`, shared by `CanInspect` `0x48a1b0` and `CheckInteractDistance` /// `0x48ba00`). Bevy's axes change it by at most a last ulp, which matters only on a threshold. fn dist_sq(q: Vec3, p: Vec3) -> f64 { let dx = f64::from(q.x) - f64::from(p.x); let dy = f64::from(q.y) - f64::from(p.y); let dz = f64::from(q.z) - f64::from(p.z); (dz * dz + dx * dx) + dy * dy } /// `PLAYER_CONTROL_LOST`/`GAINED` on a change of the flag `SMSG_CLIENT_CONTROL_UPDATE` writes /// (`0x4958e0`); the boot value, and a fresh VM's memo, is in control (`0x48f626`). fn feed_player_control( script: Option>, player: Option>, mut lost: Local>, ) { let (Some(mut script), Some(player)) = (script, player) else { return; }; let lost = lost.get(&script); if *lost != player.control_lost { *lost = player.control_lost; // `HasFullControl`'s flag rides the same edge. script.set_player_control(!player.control_lost); let event = if player.control_lost { "PLAYER_CONTROL_LOST" } else { "PLAYER_CONTROL_GAINED" }; script.fire_event(event, vec![]); } } /// `PLAYER_FARSIGHT_FOCUS_CHANGED`: the `PLAYER_FARSIGHT` field callback (`0x5de0d0`) fires it on /// every change, whether or not the new guid resolves, so this diffs the field, never the pose. fn feed_farsight_focus( script: Option>, self_q: Query<&ObjectStore, With>, mut focus: Local>>, ) { let Some(mut script) = script else { return; }; let Some(store) = self_q.iter().next() else { return; }; let anchor = store.0.player_farsight(); let focus = focus.get(&script); if *focus != anchor { *focus = anchor; script.fire_event("PLAYER_FARSIGHT_FOCUS_CHANGED", vec![]); } } /// Feed the unit reach map: per token naming a live unit, its squared distance and whether it /// passes inspect's two other refusals, a non-player and an attackable one (vmangos /// `MiscHandler.cpp:945-956`; that the client checks them is inferred, `0x48a1b0` being partly /// undecoded). Ungated: distances move every frame, and no event keys off the map. /// Feed the self player's posture snapshot for ClassicAPI's `State` namespace /// (`benilla_ui::script::compat::classic_api::state`): swim and fall off the mover's movement /// flags, mount off the descriptor, indoor off the area pass's own down-ray, shapeshift off /// `UNIT_FIELD_BYTES_2`. Not 1.12 data — the reference client exposes none of these to Lua at all /// (`docs/ADDON_COMPAT.md`). Ungated: every field is a per-frame fact and nothing keys an event /// off the snapshot, the same reasoning [`feed_unit_reach`] runs on. fn feed_self_movement( script: Option>, player: Option>, // `Option`, like `player` and the queries: a UI-only harness has no world at all, and the // indoor bit is the one field that needs one. Everything else still feeds without it. world: Option, // The speed block is `Option` on its own account, not just for a harness: it arrives with the // create block's LIVING half, so there is a window at world entry where the player exists and // its speeds do not. self_q: Query<(&ObjectStore, Option<&crate::net::UnitSpeeds>), With>, // The camera rig, for `IsMouselooking()`. `Option` for the same reason `world` is: a UI-only // harness builds no camera, and every other field still feeds without one. rig: Option>, ) { let Some(mut script) = script else { return; }; use crate::creature_anim::move_flags; let flags = player.as_deref().map_or(0, |p| p.move_flags()); let (store, speeds) = match self_q.iter().next() { Some((store, speeds)) => (Some(store), speeds), None => (None, None), }; script.set_self_movement(benilla_ui::script::SelfMovement { swimming: flags & move_flags::SWIMMING != 0, mounted: store.is_some_and(|s| s.0.unit_mount_display_id() > 0), indoors: world.is_some_and(|w| w.area_interior().is_some()), falling: flags & move_flags::FALLING != 0, rooted: flags & move_flags::ROOT != 0, moving: flags & (move_flags::FORWARD | move_flags::BACKWARD | move_flags::STRAFE_LEFT | move_flags::STRAFE_RIGHT | move_flags::TURN_LEFT | move_flags::TURN_RIGHT) != 0, shapeshift_form: store.map_or(0, |s| s.0.unit_shapeshift_form()), // SuperWoW's `GetSpeed()`: the reference's own `CMovement::GetCurrentSpeed` walk over our // speed block and the same move flags every field above reads, so the number cannot // disagree with `swimming` or `moving` about what the player is doing this tick. speed: speeds.map_or(0.0, |s| crate::net::current_speed(&s.0, flags)), mouselooking: rig.is_some_and(|r| r.mouselooking()), }); } fn feed_unit_reach( script: Option>, // UnitXP_SP3's `inSight`: the same body-layer ray the nameplate occlusion casts, from the // player's chest rather than the camera's eye. Here rather than in the binding because only // the app can trace the world — the whole reason that command sat blocked. // // `Option`, like `feed_self_movement`'s world point: a UI-only harness builds no physics world // at all, and without one nothing can be in the way, which is also `camera_can_see`'s own // answer for a ray it declines to cast. collide: 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; }; // ClassicAPI's focus pair is not in `reach_tokens()`, which stays exactly the set // [`UnitTokens::resolve`] knows: that resolver is a plain `SystemParam` with no reach into the // VM, and the focus guid lives in the VM (`docs/ADDON_COMPAT.md`). Resolved here instead, // where the script is already held, so `CheckInteractDistance("focus")` and UnitXP_SP3's // `distanceBetween` answer a real distance rather than "nothing there" — pfUI's // `api.UnitInRange` reads the first for every frame whose `faderange` is on. let focus = script.focus_guid().and_then(|g| tokens.held(g)); let focus_target = focus .and_then(|(e, _)| tokens.stores.get(e).ok()) .and_then(|s| s.0.unit_target()) .filter(|g| *g != 0) .and_then(|g| tokens.held(g)); let mut reach = HashMap::new(); if let Some((self_tf, self_store)) = self_q.iter().next() { for (token, resolved) in reach_tokens() .map(|t| (t, tokens.resolve(t, &selection))) .chain([("focus", focus), ("focustarget", focus_target)]) { let Some((entity, guid)) = resolved 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, // Chest to chest: `camera_can_see` lifts and backs off the *target* end // itself, so lifting the eye here puts both ends at body height and neither // unit's own floor can count as the thing in the way. in_sight: collide.as_ref().is_none_or(|c| { crate::vplates::camera_can_see( c, self_tf.translation + Vec3::Y * crate::vplates::OCCLUSION_AIM_LIFT, tf.translation, ) }), player_behind_angle: rear_angle(tf, self_tf.translation), unit_behind_angle: rear_angle(self_tf, tf.translation), }, ); } } script.set_unit_reach(reach); } /// The angle in radians between `subject`'s facing and the direction to `other`: UnitXP_SP3's /// `behind` geometry, 0 dead ahead and π dead astern. /// /// The **angle**, not a verdict, because `UnitXP("behindThreshold", "set", v)` moves the line at /// runtime (`compat/unitxp.rs`). The default threshold on both sides is π/2, which makes behind /// the rear **hemisphere** — what this realm's own server means by it: vmangos tests /// `!target->HasInArc(M_PI, attacker)`, a π arc centred on the facing, and that is the rule it /// already enforces for the abilities that care (Backstab's "You must be behind your target"). The /// mod publishes no arc of its own, so matching the authority that will accept or refuse the cast /// is the one choice that cannot disagree with the game. /// /// Flat: taken in the horizontal plane, so standing on a ledge above a mob is not "behind" it. A /// zero-length separation (the two at one point) answers 0, which no threshold calls behind — and /// a unit exactly beside the subject answers exactly π/2, which the strict `>` in the reader /// refuses, as the strict inequality in the server's own test does. fn rear_angle(subject: &Transform, other: Vec3) -> f32 { let flat = |v: Vec3| Vec3::new(v.x, 0.0, v.z); let facing = flat(subject.forward().as_vec3()); let to_other = flat(other - subject.translation); if to_other.length_squared() == 0.0 || facing.length_squared() == 0.0 { return 0.0; } facing .normalize() .dot(to_other.normalize()) .clamp(-1.0, 1.0) .acos() } /// [`feed_units`]' stores and change tracking, grouped to stay under Bevy's 16-parameter limit. #[derive(bevy::ecs::system::SystemParam)] pub(crate) struct UnitStores<'w, 's> { all: Query<'w, 's, &'static ObjectStore>, /// The dirty gate: whose descriptor moved, items excluded (their writes name no unit token). changed: Query<'w, 's, (), (Changed, Without)>, /// Whose object left the manager, by [`Guid`]: a fading model keeps its store 2 s longer. removed: RemovedComponents<'w, 's, Guid>, /// This run's per-field edges, which [`fire_transitions`]' watch-bridge arms fire off. edges: MessageReader<'w, 's, FieldChanged>, /// `UnitState::position`, SuperWoW's `UnitPosition`/UnitXP_SP3's `distanceBetween`: not a 1.12 /// read, so bundled here rather than widening `all`'s own query shape. `With` keeps the /// ambiguity surface to network-synced objects, not every `Transform` in the game (UI nodes, /// particles, …), which the plain query otherwise collides with. positions: Query<'w, 's, &'static Transform, With>, } /// `UnitReaction(unit, "player")` (`0x5167e0`): [`ring_reaction`] plus one (`0x51683e`), so /// `1..=7`, Hated to Revered, and Exalted reads 7 too (`0x606439`). Stock `UnitReactionColor` /// has seven entries (`TargetFrame.lua:6-14`), one per value. pub(crate) fn unit_reaction( factions: Option<&Factions>, reputations: &Reputations, store: &ObjectStore, self_store: Option<&ObjectStore>, ) -> u8 { ring_reaction(factions, reputations, Some(store), self_store) + 1 } /// Build a unit snapshot from a streamed descriptor, its cached name and its `UnitReaction` /// (`1..=7`, or `0` where none is resolved, as for `"player"`); `classes` feeds the relic column. /// The descriptor columns [`UnitState::fields`] carries: the unit block /// (`ui_script/unit_rec.rs`) plus the four this client knows and that block does not. /// /// The four: `guid` (`OBJECT_FIELD_GUID`, an object column rather than a unit one), `posX`/`posY` /// (movement, which the descriptor never carries) and `power`/`maxPower` — the **active** power /// type's own slot, which upstream lists among its computed columns and which saves a caller /// reading `bytes0` to learn which of `power1..5` to look at. /// /// `status` and `zoneId`, upstream's other two computed columns, are deliberately absent: /// nothing in the string pool or the corpus says what `status` is a status *of*, and a zone is /// only knowable for the player (`GetZoneText` is that answer). `unit_rec.rs`'s own doc carries /// the same list. fn raw_unit_fields( store: &ObjectStore, position: Option<(f32, f32, f32)>, ) -> Vec<(&'static str, benilla_ui::script::RecValue)> { use benilla_ui::script::RecValue; let mut out = crate::ui_script::unit_rec::unit_fields(&store.0); // `OBJECT_FIELD_GUID` is index 0/1, the one column every object type carries. Both halves // or neither, as `unit_rec`'s own GUID columns are read. if let (Some(lo), Some(hi)) = (store.0.at(0), store.0.at(1)) { out.push(( crate::ui_script::unit_rec::GUID_KEY, RecValue::Str(benilla_ui::script::guid_token( u64::from(lo) | u64::from(hi) << 32, )), )); } // The active power type's slot, 0..=4 as `UNIT_FIELD_BYTES_0`'s power byte numbers them. let slot = u16::from(store.0.unit_power_type()); if slot < 5 { if let Some(v) = store.0.at(23 + slot) { out.push(("power", RecValue::Int(i64::from(v)))); } if let Some(v) = store.0.at(29 + slot) { out.push(("maxPower", RecValue::Int(i64::from(v)))); } } if let Some((x, y, _)) = position { out.push(("posX", RecValue::Num(f64::from(x)))); out.push(("posY", RecValue::Num(f64::from(y)))); } out } /// A streamed object's `Transform` as [`UnitState::position`]: **world yards**, not Bevy's frame. /// /// A network-synced object's transform is Bevy space (`wow_to_bevy`: `(-y, z, -x)`), and every /// reader of that field wants what the real DLLs answer — SuperWoW's `UnitPosition` and /// UnitXP_SP3's `getUnitPosition` both hand back the game's own `(x, y, z)`, and /// `C_Map.GetPlayerMapPosition` un-lerps the player's world point against `WorldMapArea`'s yard /// rect. Read raw, the two map axes are swapped and negated and the third is the *height*, so a /// zone's rect rejects the point outright — `GetPlayerMapPosition` answers nil, which is /// OpenMapClassic's "You aren't on the world map here" and every stitched-map arrow declining to /// place itself — while a continent's wide rect accepts a plausible wrong one and the arrow lands /// somewhere else on the map. Distance was the one read this did not break, the transform being /// rigid: `minimap::blips::party_member_pos` had the conversion all along, three feet away. fn wow_position(t: &Transform) -> (f32, f32, f32) { let w = benilla_assets::coords::bevy_to_wow(t.translation); (w[0], w[1], w[2]) } pub(crate) fn snapshot( store: &ObjectStore, name: Option, reaction: u8, classes: Option<&ChrClasses>, position: Option<(f32, f32, f32)>, ) -> 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 { // nampower's raw descriptor columns under its own names, for `GetUnitData` and // `GetUnitField` (`ui_script/unit_rec.rs`). Built here because this is the one place a // token's `ObjectStore` and its resolved snapshot are both in hand, and because a column // being *absent* has to mean "the wire has not sent it" — which only the store knows. fields: raw_unit_fields(store, position), exists: true, // A live descriptor is `0x468460` having succeeded, all of `UnitIsVisible` (`0x516030`). has_object: true, name, position, // The UI getters: `UNIT_DYNFLAG_DEAD` (feign death) zeroes `UnitHealth` (`0x5174d0`) and // `UnitMana` (`0x517670`) but not the maxima, so its edge fires the reference's pair // (`0x6004c5`, `0x6004f0`); the power getters divide rage by 10 and happiness by 1000. 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), // All five slots, for ClassicAPI's two-argument `UnitPower(unit, powerType)` — which exists // precisely to read a slot the unit is not currently on (a shapeshifted druid's mana). // Same getters as the pair above, so the dead gate and the rage/happiness display divisors // apply identically; a slot the descriptor has no field for reads 0. powers: std::array::from_fn(|i| store.0.unit_shown_power(i as u8).unwrap_or(0)), max_powers: std::array::from_fn(|i| store.0.unit_shown_max_power(i as u8).unwrap_or(0)), // `UnitIsDead` (`0x517ac0`): health ≤ 0 or the dead-looking flag. dead: store.0.unit_reads_dead(), // `PLAYER_FLAGS` bit `0x10`; a ghost's health is 1, so `dead` is false for it. ghost: store.0.player_is_ghost(), // `UnitIsCharmed` (`0x516cf0`): `UNIT_FIELD_CHARMEDBY != 0`. charmed: store.0.unit_charmed_by().is_some(), // `UNIT_DYNAMIC_FLAGS` bits `0x4`/`0x8`; a unit with no descriptor reads `false`. tapped: store.0.unit_tapped(), tapped_by_player: store.0.unit_tapped_by_player(), // `UnitIsPartyLeader`'s descriptor leg; a creature has no PLAYER block and reads absent. group_leader: store.0.player_is_group_leader(), // The raw dword `PLAYER_FLAGS_CHANGED` fires on; 0 on a creature, as in the reference. player_flags: store.0.player_flags(), reaction, race: race.map(|(n, _)| n.to_string()), race_file: race.map(|(_, f)| f.to_string()), class: class.map(|(n, _)| n.to_string()), class_file: class.map(|(_, f)| f.to_string()), // `UnitHasRelicSlot` (`0x519e50`): TYPEMASK_PLAYER first (`0x519e8d`), then the class // byte against `ChrClasses.dbc` field 16; without the player test a class-2 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)))), // Gender byte 0 male, 1 female, on `UnitSex`'s scale: 2 male, 3 female, 0 unknown (nil). sex: match store.0.unit_gender() { Some(0) => 2, Some(1) => 3, _ => 0, }, // `UNIT_FIELD_FLAGS` PvP `0x1000` and Skinnable `0x04000000` (vmangos `UnitDefines.h`). pvp: store.0.unit_flags() & 0x1000 != 0, skinnable: store.0.unit_flags() & 0x0400_0000 != 0, // `UnitPlayerControlled`: bit `0x8`, set by pets and charmed creatures as well as players. player_controlled: store.0.unit_flags() & 0x8 != 0, flags: store.0.unit_flags(), // The whole dword: the reference's watch is a memcmp over it, not a bit test. dynamic_flags: store.0.unit_dynamic_flags(), owner: store .0 .unit_summoned_by() .or_else(|| store.0.unit_charmed_by()) .or_else(|| store.0.unit_created_by()) .unwrap_or(0), // `UnitAffectingCombat` (`0x517e10`): bit 19, the one combat bit for every token. in_combat: store.0.unit_flags() & crate::player::UNIT_FLAG_IN_COMBAT != 0, // Free-for-all PvP, `PLAYER_FLAGS` bit 7 (vmangos `Player.h:322`); false on a creature. is_pvp_ffa: store.0.player_flags() & 0x80 != 0, // The honor rank, `PLAYER_BYTES_3` byte 3 (0..=18), public for every player in view. pvp_rank: store.0.player_pvp_rank().unwrap_or(0), // The team digit of `PVP_RANK__`, from the race ([`race_pvp_team`]). pvp_team: store.0.unit_race().map_or(-1, race_pvp_team), // `PLAYER_BYTES_3` byte 2, the city-protector title (`PVP_MEDAL`), unset by vmangos. pvp_medal: store.0.player_pvp_medal().unwrap_or(0), // `is_player` and the creature-record fields come from [`enrich_unit`]. ..Default::default() } } /// Everything a streamed unit's snapshot needs besides the unit's own descriptor and position: /// the shared half of [`SnapshotCtx::full`], bundled because it is the same seven borrows at every /// call site and threading them individually is seven more arguments each time. #[derive(Copy, Clone)] pub(crate) struct SnapshotCtx<'a> { pub(crate) names: &'a NameCache, /// The name cache is lazy; a miss queues a `CMSG_NAME_QUERY` through this. pub(crate) commands: &'a NetCommands, pub(crate) factions: Option<&'a Factions>, pub(crate) reputations: &'a Reputations, /// The player's own descriptor, the other side of every reaction and attack test; `None` /// before it streams, which each of those answers its own neutral value for. pub(crate) self_store: Option<&'a ObjectStore>, /// `None` when `ChrClasses.dbc` failed to load: no class has a relic slot, the reference's /// own bounds leg. pub(crate) classes: Option<&'a ChrClasses>, pub(crate) group: &'a crate::ui_party::GroupState, } impl SnapshotCtx<'_> { /// The full snapshot for a streamed unit: [`snapshot`]'s descriptor fields plus the reaction, /// faction, attack and creature-record legs a unit frame reads. /// /// One body, shared by every token that names a *streamed* unit rather than a roster seat — /// `"targettarget"`, `"npc"`, ClassicAPI's `"focus"`/`"focustarget"` and the `nameplate` /// pair (`vplates.rs`). A second implementation of this is the one way those tokens could come /// to disagree about the same unit, which is why it is a function and not a closure. /// /// No guild leg, unlike `"player"`/`"target"`: nothing asks any of these for one, and the /// guild cache is lazy, so a miss would send a `CMSG_GUILD_QUERY` every frame. pub(crate) fn full( &self, guid: u64, store: &ObjectStore, position: Option<(f32, f32, f32)>, ) -> UnitState { let name = self .names .resolve_unit(guid, Some(store), self.commands) .map(str::to_string); let reaction = unit_reaction(self.factions, self.reputations, store, self.self_store); let mut s = snapshot(store, name, reaction, self.classes, position); s.guid = guid; s.is_connected = true; s.raid_target = self.group.raid_target_index(guid); s.faction_group = faction_group(store, self.factions); s.faction_group_localized = faction_group_localized(store, self.factions); s.can_attack = crate::target::can_attack( Some(store), self.factions, self.reputations, self.self_store, ); enrich_unit( &mut s, guid, self.names, store, self.factions, self.self_store, ); s } } /// Fill the guid-keyed tooltip fields: `is_player`, or a creature's record fields (the type word /// from `CreatureType.dbc`'s enUS names) and its faction-name line. pub(crate) fn enrich_unit( state: &mut UnitState, guid: u64, names: &NameCache, store: &ObjectStore, factions: Option<&Factions>, self_store: Option<&ObjectStore>, ) { // The identity itself, not just the fields derived from it. Every caller already holds the // guid — that is why this takes one — and a caller that fills the derived fields but leaves // `guid` at 0 produces a snapshot that answers `UnitGUID` with nil and, through // `tooltip_unit.rs`'s `set_live_token`, makes `GameTooltip:GetUnitGUID()` return *no values* // for the unit under the cursor. That was the mouseover tooltip's state: `SnapshotCtx::full` // assigns it, the world-hover path (`ui_tooltip/mod.rs`) builds its snapshot the long way and // did not, and pfQuest's objective annotation — keyed on that getter's first return — never // ran. Assigned here so the two paths cannot disagree again. state.guid = guid; if benilla_protocol::guid::is_player(guid) { state.is_player = true; // No faction line for players: their factions have no reputation slot. return; } let Some(entry) = benilla_protocol::guid::entry(guid) else { return; }; // The creature record (`CGUnit+0xb30`); `None` until the query answers, a real state the // plate draws under the name `UNKNOWNOBJECT`. let rec = names.creature_record(entry); if let Some(rec) = rec { state.subtitle = rec.subname.clone(); state.creature_type_name = creature_type_word(rec.creature_type).map(str::to_string); // The raw number alongside the word: `UnitCreatureTypeID` needs it, and the word cannot be // reversed into it on a localized realm. state.creature_type_id = Some(rec.creature_type); // The client's one rank getter, never `rec.rank`: an enslaved elite reads rank 0. state.rank = crate::names::gated_rank(Some(rec), Some(store)); state.civilian = rec.civilian; state.racial_leader = rec.racial_leader; } // The faction-name line, every gate of the tooltip builder: the record's `HIDE_FACTION_TOOLTIP` // (`0x10`), a reputation slot, and the race/class slot walk with its hidden flag (`0x4`). Its // entry gate `0x612610` passes with no record, so the line shows before the query answers. if rec.is_none_or(|r| r.type_flags & crate::names::type_flags::NO_FACTION_TOOLTIP == 0) { state.faction_name = (|| { let catalog = factions?.catalog(); let faction_id = catalog.template(store.0.unit_faction_template()?)?.faction; let info = catalog.reputation_faction(faction_id)?; let self_store = self_store?; let race = self_store.0.unit_race().unwrap_or(0); let class = self_store.0.unit_class().unwrap_or(0); info.tooltip_shows_for(race, class) .then(|| catalog.faction_name(faction_id).map(str::to_string)) .flatten() })(); } } /// Drain `TogglePVP` into `CMSG_TOGGLE_PVP`; `/pvp` is the reference's only caller. 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`; the VM, /// which alone knows what the Options row did, has already decided each is needed. 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 `SetActionBarToggles` into `CMSG_SET_ACTIONBAR_TOGGLES` (sent at `0x4e771d`), one packet /// per call: the reference binding neither checks for a change nor coalesces. 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 }); } } /// The PvP preference bit `CMSG_TOGGLE_PVP` flips (vmangos `Player.h:324`); not the /// `UNIT_FIELD_FLAGS` PvP bit `0x1000` the icon draws, which lingers for the server's timer. const PLAYER_FLAGS_PVP_DESIRED: u32 = 0x200; /// Inside a rest area (vmangos `Player.h:320`), the bit `IsResting` (`0x516ea0`) tests. const PLAYER_FLAGS_RESTING: u32 = 0x20; /// `PLAYER_FLAGS` bits 12 and 13, a realm's two play-time limits, read by `PartialPlayTime` /// (`0x48eb70`) and `NoPlayTime` (`0x48ebe0`); not the pre-1.6.1 `CAN_SELF_RESURRECT`. const PLAYER_FLAGS_PARTIAL_PLAY_TIME: u32 = 0x1000; const PLAYER_FLAGS_NO_PLAY_TIME: u32 = 0x2000; /// The `(toast, verbose)` GlobalStrings keys for a real change of the PvP-preference bit, silent on /// first sight (the reference diffs bits). The toasts are catalog rows 437/438 (kind 1, yellow /// `UI_INFO_MESSAGE`); the verbose keys are no rows, so the handler picks their chat surface. fn pvp_announcement(was: Option, now: bool) -> Option<(&'static str, &'static str)> { if was? == now { return None; } Some(if now { ("ERR_PVP_TOGGLE_ON", "PVP_TOGGLE_ON_VERBOSE") } else { ("ERR_PVP_TOGGLE_OFF", "PVP_TOGGLE_OFF_VERBOSE") }) } /// The rest-state chat key: `0x5de4e0` messages only on a real byte change (the `rep cmpsb` diff at /// `0x4655bb`), through the pair table `0x80af50`: 1 rested, 2 normal, 0 the no-message sentinel /// (`0x1d1`), ≥ 3 gated off (`cmp esi,3; jae`). Rows 346/347 are kind 0, a system chat line with /// no cue or voice (`type_tag 0x44`). fn rest_state_message(prev: u8, new: u8) -> Option<&'static str> { if prev == new { return None; } match new { 1 => Some("ERR_EXHAUSTION_RESTED"), 2 => Some("ERR_EXHAUSTION_NORMAL"), _ => None, } } /// `UnitFactionGroup`'s (`0x516630`) first return: the faction template's group mask `& 6`, named /// by `FactionGroup.dbc`. Only the side bits: player and city-guard templates carry /// `Player|`, and the Player and Monster rows have no name and no icon art. pub(crate) fn faction_group(store: &ObjectStore, factions: Option<&Factions>) -> Option { let catalog = factions?.catalog(); let template = catalog.template(store.0.unit_faction_template()?)?; // The English `InternalName`: every stock consumer concatenates it into a texture path. catalog .faction_group_internal_name(template.group_mask & 6) .map(str::to_string) } /// `UnitFactionGroup`'s second return, the localized name stock shows as text. pub(crate) fn faction_group_localized( 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 to the enUS word, a creature's level-line class slot. 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 table runs 1..11; the nameplate filter tests 11 (`0x605570`). 11 => "Totem", // Deviation: 10, "Not specified", answers None because the word would print in the // tooltip's level line; `UnitCreatureType` then answers nil where the reference names it. _ => return None, }) } /// Diff a token's snapshot against the last pushed and fire the per-field `UNIT_*` events. The /// watch-bridge arms fire off `edges`, as the reference's create runs no notify pass; the rest fire /// on a token's first snapshot (`prev = None`) too, where the reference's behaviour is untraced. pub(crate) fn fire_transitions( script: &mut UiScript, token: &str, prev: Option<&UnitState>, cur: &UnitState, edges: &FieldEdges, ) { let tok = || ScriptValue::Str(token.to_string()); let changed = |f: fn(&UnitState) -> u64| prev.is_none_or(|p| f(p) != f(cur)); if changed(|u| u64::from(u.health)) { script.fire_event("UNIT_HEALTH", vec![tok()]); } if changed(|u| u64::from(u.max_health)) { script.fire_event("UNIT_MAXHEALTH", vec![tok()]); } if changed(|u| u64::from(u.level)) { script.fire_event("UNIT_LEVEL", vec![tok()]); } if changed(|u| u64::from(u.power_type)) { script.fire_event("UNIT_DISPLAYPOWER", vec![tok()]); } // `UNIT_FLAGS` (id 40), the per-field watch bridge: `0x51bbb0` registers one watch per named // unit field, and on any change of the dword the notifier `0x465570` fires `0x51bd50` → // `0x515e50`, once per token naming the unit, `arg1` the token. The stock pet bar reads it. if edges.moved(cur.guid, benilla_protocol::field::FIELD_UNIT_FLAGS) { script.fire_event("UNIT_FLAGS", vec![tok()]); } // `PLAYER_FLAGS_CHANGED` (id 407, `0x5eea35` into `0x515e50`): no bit test after the XOR diff // (`0x5ee9b8`) and above the local-GUID gate (`0x5eea93`), so any bit of any player fires it, // once per token naming it (none, nothing: `0x515e63`). The sole 1.12 consumer is the target // frame's leader icon (`TargetFrame.lua:88-95`); 1.12 has no AFK/DND unit-frame badge. if edges.moved(cur.guid, benilla_protocol::field::FIELD_PLAYER_FLAGS) { script.fire_event("PLAYER_FLAGS_CHANGED", vec![tok()]); } // `UNIT_DYNAMIC_FLAGS` (id 137), the bridge's third arm: the watch is one dword, so any bit // fires it. No stock file registers it; addons do, to repaint the tapped state. if edges.moved(cur.guid, benilla_protocol::field::FIELD_UNIT_DYNAMIC_FLAGS) { script.fire_event("UNIT_DYNAMIC_FLAGS", vec![tok()]); } // 1.12 names the power events per resource (`UNIT_MANA`, `UNIT_MAXRAGE`, …; // `UnitFrame.lua:190-199`), and `power_token` yields the suffix. 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`, the target frame's border repaint, on a change of the gated // rank, which is the classification: the creature query landing, a mob enslaved or released. if prev.is_none_or(|p| p.rank != cur.rank) { script.fire_event("UNIT_CLASSIFICATION_CHANGED", vec![tok()]); } // `UNIT_FACTION`, the PvP-icon repaint, on the fields the icon reads and on the tapped bit: the // reference's dynamic-flags watcher (`0x600440`) fires event 29 on bit `0x4` (`0x6005a1` → // `0x6005b0`) and has no arm for `0x8`. if prev.is_none_or(|p| { (p.pvp, p.is_pvp_ffa, &p.faction_group, p.tapped) != (cur.pvp, cur.is_pvp_ffa, &cur.faction_group, cur.tapped) }) { script.fire_event("UNIT_FACTION", vec![tok()]); } } fn feed_units( script: Option>, // Absent when the data failed to load: no class has a relic slot, the reference's bounds leg. classes: Option>, self_q: Query<(Entity, &ObjectStore, &Guid), With>, selection: Res, // `Option`, as `factions`, `interact` and `entered_world`: a UI-only harness lacks their // plugins, and a bare `Res` is a system-validation panic. index: Option>, mut stores: UnitStores, mut feed: ResMut, names: Res, commands: Res, factions: Option>, reputations: Res, group: Res, // Chat and the message-sound queue, for the rest and PvP lines. mut sink: crate::ui_action::MessageSink, // `ResMut` because the guild-identity cache is lazy: a miss sends `CMSG_GUILD_QUERY`. mut guild: ResMut, interact: Option>, // Rested billing minutes, sent only in `SMSG_AUTH_RESPONSE`; the reference keeps a global. entered_world: Option>, ) { let Some(mut script) = script else { return; }; // Ahead of the gate: a login edge, with 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 and `warned_sideless` borrow disjointly rather than alias. let feed = &mut *feed; let (memo, vm_reset) = feed.vm.get_reset(&script); let edges = FieldEdges::collect(&mut stores.edges); // The gate: every input read below, despawns included (invisible to `Changed`). let names_moved = memo.names_generation.moved(names.generation()); let guild_moved = memo.guild_generation.moved(guild.identity_generation()); let selection_changed = selection.is_changed(); let stores_changed = !stores.changed.is_empty(); let stores_removed = !stores.removed.is_empty(); let group_changed = group.is_changed(); let reps_changed = reputations.is_changed(); let factions_changed = factions.as_ref().is_some_and(|r| r.is_changed()); // The interaction NPC moves when nothing else does, and `MERCHANT_SHOW` reads `"npc"`. let interact_changed = interact.as_ref().is_some_and(|r| r.is_changed()); // ClassicAPI's focus (`docs/ADDON_COMPAT.md`): the VM owns the guid, so a `/focus` or // `/clearfocus` is an input this feed can only see by reading it back. Nothing else here // moves on one — focusing a unit that is standing still touches no descriptor, no selection // and no roster. let focus_guid = script.focus_guid(); let focus_moved = memo.focus_guid != focus_guid; gate::trace( "feed_units", &[ ("vm_reset", vm_reset), ("names", names_moved), ("guild", guild_moved), ("selection", selection_changed), ("stores", stores_changed), ("removed", stores_removed), ("group", group_changed), ("reputations", reps_changed), ("factions", factions_changed), ("interact", interact_changed), ("focus", focus_moved), ], ); let gate = gate::Gate::new( vm_reset || names_moved || guild_moved || selection_changed || stores_changed || stores_removed || group_changed || reps_changed || factions_changed || interact_changed || focus_moved, ); stores.removed.clear(); if gate.skip() { return; } memo.focus_guid = focus_guid; // A missing unit is `None`, which `set_unit` clears; a name miss lands on a later frame. let self_pair = self_q.iter().next(); let player = self_pair.map(|(entity, store, guid)| { let name = names .resolve_unit(guid.0, Some(store), &commands) .map(str::to_string); let position = stores.positions.get(entity).ok().map(wow_position); let mut s = snapshot(store, name, 0, chr, position); s.is_player = true; // Every token pushed here is streamed, so connected; real link-death rides only the group // roster's status byte, which the party feed reads for its own tokens. s.is_connected = true; s.guid = guid.0; s.raid_target = group.raid_target_index(guid.0); s.faction_group = faction_group(store, factions.as_deref()); s.faction_group_localized = faction_group_localized(store, factions.as_deref()); // `GetGuildInfo`: the public guild fields (191/192) joined against the lazy guild cache. s.guild = crate::ui_guild::unit_guild(&store.0, &mut guild, &commands); s }); // Log once when our template names no side, almost always GM mode (vmangos forces template // 35, group mask 0): every `UnitFactionGroup` surface loses its side, as in the reference. if let Some(p) = &player { let sideless = p.faction_group.is_none(); if sideless && !feed.warned_sideless { warn!( "faction: our own template names no side (usually GM mode — vmangos forces \ template 35, group mask 0). Every UnitFactionGroup-derived surface loses its \ side while this holds — the PvP flag icon stays hidden however flagged you are. \ `.gm off` restores it. (The Honor tab's rank title is NOT one of these: its team \ digit comes from your race, not your template.)" ); } feed.warned_sideless = sideless; } let target = selection.target.zip(selection.guid).and_then(|(e, guid)| { let store = stores.all.get(e).ok()?; let name = names .resolve_unit(guid, Some(store), &commands) .map(str::to_string); let reaction = unit_reaction( factions.as_deref(), &reputations, store, self_pair.map(|(_, s, _)| s), ); let position = stores.positions.get(e).ok().map(wow_position); let mut s = snapshot(store, name, reaction, chr, position); s.guid = guid; s.is_connected = true; s.raid_target = group.raid_target_index(guid); s.faction_group = faction_group(store, factions.as_deref()); s.faction_group_localized = faction_group_localized(store, factions.as_deref()); // `CanAttack` (`0x606980`); `UnitCanAttack` gates the target frame's level colour. s.can_attack = crate::target::can_attack( Some(store), factions.as_deref(), &reputations, self_pair.map(|(_, s, _)| s), ); 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"`, `"npc"` and ClassicAPI's `"focus"`/`"focustarget"` are one build, now // shared with `vplates.rs`' `nameplate` pair: see [`SnapshotCtx::full`]. let snapshot_ctx = SnapshotCtx { names: &names, commands: &commands, factions: factions.as_deref(), reputations: &reputations, self_store: self_pair.map(|(_, s, _)| s), classes: chr, group: &group, }; let token_snapshot = |entity: Entity, guid: u64| -> Option { let store = stores.all.get(entity).ok()?; let position = stores.positions.get(entity).ok().map(wow_position); Some(snapshot_ctx.full(guid, store, position)) }; // The guid→entity hop (`0x468460`); `index` is absent in a UI-only harness, which resolves // nothing rather than panicking. let held = |guid: u64| -> Option<(Entity, u64)> { Some((*index.as_ref()?.0.get(&guid)?, guid)) }; // One hop off a unit's own `UNIT_FIELD_TARGET`, the read `/assist` runs, streamed guids only. let target_of = |entity: Entity| -> Option<(Entity, u64)> { stores .all .get(entity) .ok() .and_then(|s| s.0.unit_target()) .filter(|guid| *guid != 0) .and_then(held) }; // `"targettarget"`: one hop off the selection's own `UNIT_FIELD_TARGET`. let tot = selection .target .and_then(target_of) .and_then(|(e, g)| token_snapshot(e, g)); // ClassicAPI's `"focus"` and `"focustarget"` (`docs/ADDON_COMPAT.md`): the same two shapes one // hop apart, off the VM's stored focus guid instead of the selection. A focus whose unit is no // longer streamed resolves to nothing and the token clears, exactly as a target out of the // object manager does — the guid itself survives until `ClearFocus`, so walking back into // range re-seats both tokens with no reassign. let focus_held = focus_guid.and_then(held); let focus_target = focus_held .and_then(|(e, _)| target_of(e)) .and_then(|(e, g)| token_snapshot(e, g)); let focus = focus_held.and_then(|(e, g)| token_snapshot(e, g)); // `"player"` is pushed only while its descriptor exists: the roster seat stands in before // arrival, and `PLAYER_LOGOUT` handlers still read `UnitName("player")`, as the reference's do. // `"target"` pushes its absence too; both diff against the event loop's memo. 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()); } // Absence is data here too: the target dropping its target clears the token. 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()); } // ClassicAPI's focus pair pushes and clears exactly as the two above do, absence included: a // `/clearfocus` has to take the token away, and the focus dropping ITS target has to take // `"focustarget"` away, or pfUI's frames keep drawing a unit that is no longer referenced. for (token, snap) in [("focus", &focus), ("focustarget", &focus_target)] { if memo.last.get(token) != snap.as_ref() { gate.audit("feed_units", "a focus snapshot"); script.set_unit(token, snap.clone()); } } // `"npc"`: the interaction NPC, the reference's `[0xb4e2d0]` that `CGGameUI::SetInteractNPC` // (`0x4930d0`) writes, which `InteractNpc` models. let npc = interact .as_deref() .and_then(|i| Some((i.0?, i.1?))) .and_then(|(e, g)| token_snapshot(e, g)); // Closing the window must clear the token, so the memo is written here, not only read. No // `fire_transitions`: nothing draws `"npc"` as a unit frame. let npc_dirty = match (&npc, memo.last.get("npc")) { (Some(cur), Some(prev)) => cur != prev, (None, None) => false, _ => true, }; if npc_dirty { gate.audit("feed_units", "the interaction-NPC snapshot"); script.set_unit("npc", npc.clone()); match &npc { Some(cur) => { memo.last.insert("npc".to_string(), cur.clone()); } None => { memo.last.remove("npc"); } } } // The XP pair and `PLAYER_XP_UPDATE`, ahead of the `PLAYER_ENTERING_WORLD` fire so its // handlers read real values. The event fires on first sight too, at login and after a // `/reload`, where the reference's field watchers stay silent. 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 snapshot (rest-state byte, pool, `PLAYER_FLAGS`), ahead of the entering-world fire // as the reference's descriptor is. Below `0x5ee990`'s local-GUID gate (`0x5eea93`) each arm // tests its own bits: `PLAYER_UPDATE_RESTING` on either edge of `0x20` (`0x5eead0`, fire // `0x5eeaf2`), `PLAYTIME_CHANGED` on `0x3000` (`0x5eeb65`, fire `0x5eeb6f`). // `UPDATE_EXHAUSTION` is two other watchers, `0x5de4e0` on the byte and `0x5de4b0` on the pool; // here it also fires on first sight, where they stay silent on a create and on `/reload`. 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 play-time bits share the dword and the memo; only the events split by bit. script.set_play_time( rest.2 & PLAYER_FLAGS_PARTIAL_PLAY_TIME != 0, rest.2 & PLAYER_FLAGS_NO_PLAY_TIME != 0, ); if prev.map(|p| (p.0, p.1)) != Some((rest.0, rest.1)) { script.fire_event("UPDATE_EXHAUSTION", vec![]); } // Only the byte watcher messages, and only on a real change: `prev` None is the login // descriptor, a create the reference never runs through the notify pass. if let Some(p) = prev { let line = rest_state_message(p.0, rest.0) .and_then(|key| crate::ui_action::keyed_line(&script, key)); crate::ui_action::show_messages(&mut script, &mut sink, "ui_unit", line); } // The self-only flag events on their own bits' edges; the login create is silent. if let Some(p) = prev { let moved = p.2 ^ rest.2; if moved & PLAYER_FLAGS_RESTING != 0 { script.fire_event("PLAYER_UPDATE_RESTING", vec![]); } // `PLAYTIME_CHANGED` (id 530), argless; stock `PlayerFrame.lua:23` registers it. if moved & (PLAYER_FLAGS_PARTIAL_PLAY_TIME | PLAYER_FLAGS_NO_PLAY_TIME) != 0 { script.fire_event("PLAYTIME_CHANGED", vec![]); } } } } // `PLAYER_LEVEL_UP` on any level change off the descriptor, demotions included (vmangos // `GiveLevel` sends `SMSG_LEVELUP_INFO` for those too); the reference's trigger is untraced. if let Some((_, store, _)) = self_q.iter().next() { if let Some(level) = store.0.unit_level() { let prev = memo.last_level.replace(level); if prev.is_some_and(|p| level != p) { gate.audit("feed_units", "the level edge"); // All nine, as the reference formats them (`%d` nine times) and // `ChatFrame.lua:1283-1320` reads them; missing gains are zeros, since stock's // `if ( argN > 0 )` raises on nil. let (info, talent_points) = sink.chat.take_level_up_gains(level).unzip(); let gain = |f: fn(&benilla_protocol::messages::LevelUpInfo) -> i32| { ScriptValue::Int(i64::from(info.as_ref().map_or(0, f))) }; script.fire_event( "PLAYER_LEVEL_UP", vec![ ScriptValue::Int(i64::from(level)), gain(|l| l.health), gain(|l| l.powers[0]), ScriptValue::Int(i64::from(talent_points.unwrap_or(0))), gain(|l| l.stats[0]), gain(|l| l.stats[1]), gain(|l| l.stats[2]), gain(|l| l.stats[3]), gain(|l| l.stats[4]), ], ); } } } // `PLAYER_FIELD_BYTES` byte 2, the four extra bars, on the edge: the reference never writes the // cell (its one access is the read at `0x4e768c`) or watches it (`0x468070`), and stock reads // it once, at `PLAYER_ENTERING_WORLD` (`UIParent.lua:364`), so this goes ahead of that fire. No // player and a zero byte share the four-nil branch (`0x4e7684`). 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); } } // `PLAYER_ENTERING_WORLD` once per world entry, after our descriptor lands, as the reference's. // At world exit, re-arm it and forget the player-global memos so the next character seeds them. 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; // Edge-only pushes: a stale memo would skip a new character whose values match it. memo.worn_hidden = None; memo.action_bar_toggles = None; } // `PLAYER_TARGET_CHANGED`, argless, when the selection changes. A `/reload`'s fresh memo fires // it for a kept selection, where the reference's shutdown clears the target (`0x490bdf`). // // Ahead of the token transitions below, not after them, because the reference fires this one // from the selection setter itself and has no token-diff at all: re-pointing `"target"` at // another unit writes no descriptor field, so its `UNIT_*` watches stay silent and // `PLAYER_TARGET_CHANGED` is the only event a new target raises. Ours fans out `UNIT_LEVEL`, // `UNIT_HEALTH` and the rest off the snapshot diff, and behind this fire they reached handlers // that still believed the old target: `InspectPaperDollFrame_OnEvent`'s `UNIT_LEVEL` arm ran // against an `InspectFrame.unit` that `InspectFrame_Show` was about to clear, and // `InspectPaperDollFrame_SetLevel` raised on a creature's nil `UnitRace`. Every snapshot is // already pushed above, so a handler here reads the new target, not the old one. 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![]); } for (token, snap) in [ ("player", &player), ("target", &target), ("targettarget", &tot), // The focus pair rides the same `UNIT_*` fan-out, which is the whole contract pfUI's // `modules/focus.lua` documents: between `PLAYER_FOCUS_CHANGED` fires, its two frames // update off `UNIT_HEALTH`/`UNIT_MANA`/… with `arg1` the token, and poll nothing. ("focus", &focus), ("focustarget", &focus_target), ] { match snap { Some(cur) => { let prev = memo.last.get(token); if prev != Some(cur) { gate.audit("feed_units", "a unit-token transition"); fire_transitions(&mut script, token, prev, cur, &edges); memo.last.insert(token.to_string(), cur.clone()); } } None => { // A clear fires nothing; the target frame hears `PLAYER_TARGET_CHANGED` below. if memo.last.remove(token).is_some() { gate.audit("feed_units", "a unit-token clear"); } } } } // `PLAYER_REGEN_DISABLED`/`ENABLED` on our in-combat flag's edge (`UNIT_FLAG_IN_COMBAT` // `0x00080000`, vmangos `UnitDefines.h:564`); the reference's own trigger is untraced. 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 reference's local `PLAYER_FLAGS` handler answers a preference change (`0x5eeaff`), not // the icon's flag, with a yellow toast and a system chat line. if let Some((_, store, _)) = self_pair { let desired = store.0.player_flags() & PLAYER_FLAGS_PVP_DESIRED != 0; if let Some((toast, verbose)) = pvp_announcement(memo.pvp_desired, desired) { gate.audit("feed_units", "the PvP-desired edge"); // The verbose sentence is no catalog row, so it goes `unkeyed` to chat: // `Shown::keyed`'s unknown-key fallback would show it red. let lines = [ crate::ui_action::keyed_line(&script, toast), script .lua() .globals() .get::(verbose) .ok() .filter(|t| !t.is_empty()) .map(|t| { crate::ui_action::Shown::unkeyed(benilla_ui::messages::MsgKind::Chat, t) }), ]; crate::ui_action::show_messages( &mut script, &mut sink, "ui_unit", lines.into_iter().flatten(), ); } memo.pvp_desired = Some(desired); } // The hide bits for `ShowingHelm()`/`ShowingCloak()`, on the edge only: the setter flips the // VM's belief on the click, and a stale push before the server answers would undo it. 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 count and its banked target, pushed as a pair since `GetComboPoints` reads both; // only the count fires `PLAYER_COMBO_POINTS` (`0x5ddff0`). 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: `None` when nothing moved, else whether `PLAYER_COMBO_POINTS` fires. /// Event 202 is a one-byte watch on the count (`+0x1029`, registered at `0x5dd9d9`) with no value /// test, so the drop to zero fires; the target has no watch, so a same-count re-bank is silent. 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::*; /// [`UnitState::position`] is published in **world yards**, the frame every reader of it is /// written against: SuperWoW's `UnitPosition` and UnitXP_SP3's `getUnitPosition` answer the /// game's own `(x, y, z)`, and `C_Map.GetPlayerMapPosition` un-lerps the point against /// `WorldMapArea`'s yard rect. /// /// A streamed object's `Transform` is Bevy's frame. Read raw, as this did, the two map axes /// are swapped and negated and the third is the *height* — so `GetPlayerMapPosition` answered /// nil against a zone's rect (OpenMapClassic's "You aren't on the world map here", and every /// stitched-map arrow declining to place itself) and answered a plausible wrong fraction /// against a continent's. Live, the 1.12 verb and the modern one then disagreed about where /// the same player was, which is not a state a real client can be in. #[test] fn a_units_published_position_is_world_yards_not_bevys_frame() { use bevy::ecs::system::RunSystemOnce; /// Northshire Abbey, the capture's own parking spot. const WOW: [f32; 3] = [-8949.95, -132.49, 83.53]; let mut app = App::new(); app.add_message::(); app.init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::(); let (tx, _rx) = crossbeam_channel::unbounded(); app.insert_resource(NetCommands(tx)); app.insert_non_send_resource(UiScript::new().unwrap()); app.world_mut().spawn(( SelfPlayer, Guid(0x77), ObjectStore( benilla_protocol::ObjectFields::from_pairs(&[]) .into_created(benilla_protocol::messages::ObjectType::Player), ), Transform::from_translation(benilla_assets::coords::wow_to_bevy(WOW)), )); app.world_mut().run_system_once(feed_units).unwrap(); let mut script = app.world_mut().non_send_resource_mut::(); script.resolve(); let (x, y, z): (f32, f32, f32) = script .eval(r#"return UnitPosition("player")"#) .expect("the player's position is published"); for (got, want, axis) in [(x, WOW[0], "x"), (y, WOW[1], "y"), (z, WOW[2], "z")] { assert!( (got - want).abs() < 0.01, "{axis}: published {got}, the player is at {want} — a Bevy-frame read answers \ ({}, {}, {})", -WOW[1], WOW[2], -WOW[0], ); } } /// The installed `ChrRaces.dbc` outranks the table benilla carries: every race the chain has /// a row for answers that row's `name_lang`, in the world (`UnitRace`) and in the glue /// caption alike. On this OctoWoW install race 10's row reads **"High Elf"** where the /// carried pair says "Blood Elf", and the `clientFileString` stays `BloodElf` either way — /// the exact disagreement a frozen pair cannot express. /// /// Seeds the process-wide chain table, which is write-once; no other test reads it, and this /// one skips entirely without an install. #[test] fn the_installed_chr_races_outranks_the_carried_race_table() { let data = benilla_formats::wow_data_or_skip!(); let mut chain = benilla_formats::open_chain(&data).expect("open chain"); let table = benilla_formats::load_chr_races(&mut chain).expect("ChrRaces.dbc"); crate::ui_chr_races::remember_chain_names(&table); let mut disagreements = 0; for id in 1..=10u8 { let Some(row) = table.get(u32::from(id)) else { continue; }; if row.name.is_empty() || row.client_file_string.is_empty() { continue; } assert_eq!( race_names(id), Some((row.name.as_str(), row.client_file_string.as_str())), "race {id} must answer its own ChrRaces row" ); assert_eq!( crate::char_select::race_name(id), row.name, "the glue caption for race {id} must be the same one UnitRace gives" ); if frozen_race_names(id).map(|(d, _)| d) != Some(row.name.as_str()) { disagreements += 1; } } // Not asserted as a count: a stock chain agrees with the carried table everywhere, and // the test is still meaningful there. Printed so a run on a modified realm says what it // caught. eprintln!("ChrRaces: {disagreements} race name(s) the carried table would have got wrong"); } /// TurtleWoW's added races resolve like the shipped eight even with no `ChrRaces.dbc` to /// read: `UnitRace("player")` would return nil without them, and its /// `PaperDollFrame_SetLevel` (`PLAYER_LEVEL = "Level %d %s %s"`) raises on a nil race in /// `string.format`. Asserted on the frozen table by name, because `race_names` prefers /// whatever the chain this binary opened says — which on OctoWoW is "High Elf" for 10. #[test] fn turtlewow_races_nine_and_ten_resolve_without_a_chain() { assert_eq!( frozen_race_names(9), Some(("Goblin", "Goblin")), "race 9 is TurtleWoW's Goblin" ); assert_eq!( frozen_race_names(10), Some(("Blood Elf", "BloodElf")), "race 10 is TurtleWoW's Blood Elf; the fileString is `UI_BloodElf`'s token" ); assert_eq!(frozen_race_names(0), None, "race 0 has no ChrRaces row"); assert_eq!(frozen_race_names(11), None, "race 11 has no ChrRaces row"); // Sides for the roster seat's `UnitFactionGroup("player")`, per TurtleWoW's // `FactionTemplate` group masks (Goblin 5, Blood Elf 3). assert_eq!(race_faction_group(9), Some("Horde"), "race 9 is Goblin"); assert_eq!( race_faction_group(10), Some("Alliance"), "race 10 is Blood Elf" ); assert_eq!(race_faction_group(0), None, "race 0 has no side"); assert_eq!(race_faction_group(11), None, "race 11 has no side"); } /// The team digit comes off the race byte, whatever template sits beside it (a GM's 35). #[test] fn the_team_digit_comes_off_the_race_byte_not_the_faction_template() { use benilla_protocol::ObjectFields; /// `UNIT_FIELD_FACTIONTEMPLATE` and `UNIT_FIELD_BYTES_0` (`[obj+0x110]+0x74`, `+0x78`). const FACTIONTEMPLATE: u16 = 35; const BYTES_0: u16 = 36; /// vmangos's GM template: `FactionTemplate.dbc` group mask 0. const GM_TEMPLATE: u32 = 35; let team = |fields: &[(u16, u32)]| { snapshot( &ObjectStore(ObjectFields::from_pairs(fields)), None, 0, None, None, ) .pvp_team }; // Byte 0 of BYTES_0 is the race; the class in byte 1 must not disturb it. let human_warrior = 1 | (1 << 8); let scourge_mage = 5 | (8 << 8); assert_eq!(team(&[(BYTES_0, human_warrior)]), 1, "Human → Alliance"); assert_eq!(team(&[(BYTES_0, scourge_mage)]), 0, "Scourge → Horde"); assert_eq!( team(&[(BYTES_0, human_warrior), (FACTIONTEMPLATE, GM_TEMPLATE)]), 1, "a GM keeps his race's side" ); assert_eq!( team(&[(BYTES_0, scourge_mage), (FACTIONTEMPLATE, GM_TEMPLATE)]), 0, "…on both sides" ); // No race byte is the engine's bounds-failure −1, and a template cannot stand in for it. assert_eq!(team(&[]), -1, "no race byte, no team digit"); assert_eq!( team(&[(FACTIONTEMPLATE, 1)]), -1, "and a template is not one" ); } /// [`race_pvp_team`] against the shipped DBCs, walked as `0x5efe00` walks them. #[test] fn race_pvp_team_matches_the_shipped_tables() { let data = benilla_formats::wow_data_or_skip!(); let mut chain = benilla_formats::open_chain(&data).expect("open chain"); let want = benilla_formats::load_race_pvp_teams(&mut chain).expect("ChrRaces walk"); // 1.12 ships nine rows; a truncated map would pass by asserting nothing. assert_eq!(want.len(), 9, "ChrRaces.dbc row count"); for (&race, &team) in &want { assert_eq!(race_pvp_team(race), team, "race {race}"); } assert!(want.values().any(|&t| t == 0), "some race is Horde"); assert!(want.values().any(|&t| t == 1), "some race is Alliance"); for race in [0u8, 10, 255] { assert!(!want.contains_key(&race)); assert_eq!(race_pvp_team(race), -1, "race {race} has no ChrRaces row"); } } /// A unit's first snapshot is no transition: the reference's create runs no notify pass. #[test] fn a_flags_change_fires_unit_flags_with_the_token() { let fired = |prev: Option, cur: UnitState, edges: &FieldEdges| -> Vec { let mut s = UiScript::new().unwrap(); s.run( r#" SEEN = {} local f = CreateFrame("Frame") f:RegisterEvent("UNIT_FLAGS") f:SetScript("OnEvent", function() table.insert(SEEN, event .. ":" .. arg1) end) "#, ) .unwrap(); fire_transitions(&mut s, "pet", prev.as_ref(), &cur, edges); s.eval::>("return SEEN").unwrap() }; const PET: u64 = 0xF140_0000_0000_0001; let base = UnitState { exists: true, has_object: true, guid: PET, flags: 0x8, ..Default::default() }; let moved = FieldEdges::of(&[(PET, benilla_protocol::field::FIELD_UNIT_FLAGS)]); assert_eq!( fired( Some(base.clone()), UnitState { flags: 0x8 | 0x0400_0000, ..base.clone() }, &moved, ), vec!["UNIT_FLAGS:pet".to_string()] ); assert_eq!( fired(Some(base.clone()), base.clone(), &FieldEdges::default()), Vec::::new() ); assert_eq!( fired(None, base.clone(), &FieldEdges::default()), Vec::::new() ); // The unit's edge, not the token's history: a token acquired as the field moved hears it // (the reference fans out at notify time), and another unit's edge does not. assert_eq!( fired(None, base.clone(), &moved), vec!["UNIT_FLAGS:pet".to_string()] ); assert_eq!( fired( Some(base.clone()), base.clone(), &FieldEdges::of(&[(PET + 1, benilla_protocol::field::FIELD_UNIT_FLAGS)]), ), Vec::::new() ); } /// LOST going down, GAINED going up (boot is in control); far sight on every change. #[test] fn the_control_and_far_sight_edges_fire_once_each_way() { use bevy::prelude::*; const FIELD_PLAYER_FARSIGHT: u16 = 712; let mut app = App::new(); app.init_resource::() .add_systems(Update, (feed_player_control, feed_farsight_focus)); let script = UiScript::new().unwrap(); script .run( r#" SEEN = {} local f = CreateFrame("Frame") f:RegisterEvent("PLAYER_CONTROL_LOST") f:RegisterEvent("PLAYER_CONTROL_GAINED") f:RegisterEvent("PLAYER_FARSIGHT_FOCUS_CHANGED") f:SetScript("OnEvent", function() table.insert(SEEN, event) end) "#, ) .unwrap(); app.insert_non_send_resource(script); let me = app .world_mut() .spawn(( SelfPlayer, Guid(0x77), ObjectStore(benilla_protocol::ObjectFields::default()), )) .id(); let seen = |app: &mut App| -> Vec { app.update(); let mut s = app.world_mut().non_send_resource_mut::(); s.resolve(); let out = s.eval::>("return SEEN").unwrap(); s.run("SEEN = {}").unwrap(); out }; assert_eq!( seen(&mut app), Vec::::new(), "in control, no far sight: quiet" ); app.world_mut() .resource_mut::() .control_lost = true; assert_eq!(seen(&mut app), vec!["PLAYER_CONTROL_LOST".to_string()]); assert_eq!(seen(&mut app), Vec::::new(), "held, not repeated"); app.world_mut() .resource_mut::() .control_lost = false; assert_eq!(seen(&mut app), vec!["PLAYER_CONTROL_GAINED".to_string()]); let set_farsight = |app: &mut App, guid: u64| { app.world_mut().entity_mut(me).insert(ObjectStore( benilla_protocol::ObjectFields::from_pairs(&[ (FIELD_PLAYER_FARSIGHT, guid as u32), (FIELD_PLAYER_FARSIGHT + 1, (guid >> 32) as u32), ]), )); }; set_farsight(&mut app, 0xf130_0000_0000_0042); assert_eq!( seen(&mut app), vec!["PLAYER_FARSIGHT_FOCUS_CHANGED".to_string()], "set — whether or not the guid resolves" ); assert_eq!(seen(&mut app), Vec::::new()); set_farsight(&mut app, 0); assert_eq!( seen(&mut app), vec!["PLAYER_FARSIGHT_FOCUS_CHANGED".to_string()], "cleared — the other leg" ); } #[test] fn the_tapped_bit_fires_unit_faction_and_the_by_player_bit_fires_nothing() { let fired = |prev: UnitState, cur: UnitState| -> Vec { let mut s = UiScript::new().unwrap(); s.run( r#" SEEN = {} local f = CreateFrame("Frame") f:RegisterEvent("UNIT_FACTION") f:SetScript("OnEvent", function() table.insert(SEEN, event) end) "#, ) .unwrap(); fire_transitions(&mut s, "target", Some(&prev), &cur, &FieldEdges::default()); s.eval::>("return SEEN").unwrap() }; let base = UnitState { exists: true, has_object: true, ..Default::default() }; // The `0x4` edge fires `UNIT_FACTION`, the reference's event 29 (`0x6005a1`). 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" ); 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" ); // The control against firing always. assert!(fired(base.clone(), base.clone()).is_empty()); } /// The control is a bit no `UnitState` field decodes, so the trigger is the raw dword. #[test] fn player_flags_changed_fires_per_token_on_any_bit_and_never_on_first_sight() { let fired = |prev: Option, cur: UnitState, edges: &FieldEdges| -> Vec { let mut s = UiScript::new().unwrap(); s.run( r#" SEEN = {} local f = CreateFrame("Frame") f:RegisterEvent("PLAYER_FLAGS_CHANGED") f:SetScript("OnEvent", function() table.insert(SEEN, event .. ":" .. arg1) end) "#, ) .unwrap(); fire_transitions(&mut s, "target", prev.as_ref(), &cur, edges); s.eval::>("return SEEN").unwrap() }; const THEM: u64 = 0x2a; let base = UnitState { exists: true, has_object: true, guid: THEM, ..Default::default() }; let with = |flags: u32| UnitState { player_flags: flags, group_leader: flags & 0x1 != 0, ghost: flags & 0x10 != 0, ..base.clone() }; let moved = FieldEdges::of(&[(THEM, benilla_protocol::field::FIELD_PLAYER_FLAGS)]); let still = FieldEdges::default(); // `PLAYER_FLAGS_GROUP_LEADER`, the bit the 1.12 target frame reads. assert_eq!( fired(Some(with(0)), with(0x1), &moved), vec!["PLAYER_FLAGS_CHANGED:target".to_string()], "arg1 is the unit token, and there is no arg2 (0x515e50 -> 0x703f50(id, \"%s\", token))" ); // And back down: the reference tests the XOR diff, not the new value. assert_eq!( fired(Some(with(0x1)), with(0), &moved), vec!["PLAYER_FLAGS_CHANGED:target".to_string()] ); // `PLAYER_FLAGS_HIDE_HELM`: no `UnitState` field decodes it, and `0x5eea35` tests no bit. assert_eq!( fired(Some(with(0)), with(0x400), &moved), vec!["PLAYER_FLAGS_CHANGED:target".to_string()], "an undecoded bit still fires it — the handler tests no bit at all" ); assert!( fired(None, with(0x1), &still).is_empty(), "a unit's first snapshot is its create, not a transition" ); // The control against firing always. assert!(fired(Some(with(0x1)), with(0x1), &still).is_empty()); } /// Event 137 watches descriptor field 143 over one dword (`repe cmpsb`), so any bit fires it; /// the control is `0x2` (`UNIT_DYNFLAG_TRACK_UNIT`), which no `UnitState` field decodes. #[test] fn unit_dynamic_flags_fires_per_token_on_any_bit_and_never_on_first_sight() { let fired = |prev: Option, cur: UnitState, edges: &FieldEdges| -> Vec { let mut s = UiScript::new().unwrap(); s.run( r#" SEEN = {} local f = CreateFrame("Frame") f:RegisterEvent("UNIT_DYNAMIC_FLAGS") f:SetScript("OnEvent", function() table.insert(SEEN, event .. ":" .. arg1) end) "#, ) .unwrap(); fire_transitions(&mut s, "target", prev.as_ref(), &cur, edges); s.eval::>("return SEEN").unwrap() }; const MOB: u64 = 0xF130_0000_0000_0007; let moved = FieldEdges::of(&[(MOB, benilla_protocol::field::FIELD_UNIT_DYNAMIC_FLAGS)]); let still = FieldEdges::default(); let with = |dyn_flags: u32| UnitState { exists: true, has_object: true, guid: MOB, dynamic_flags: dyn_flags, tapped: dyn_flags & 0x4 != 0, tapped_by_player: dyn_flags & 0x8 != 0, ..Default::default() }; // `0x4` TAPPED, the grey-bar state addons register this event for. assert_eq!( fired(Some(with(0)), with(0x4), &moved), vec!["UNIT_DYNAMIC_FLAGS:target".to_string()], "arg1 is the unit token, and there is no arg2" ); assert_eq!( fired(Some(with(0)), with(0x2), &moved), vec!["UNIT_DYNAMIC_FLAGS:target".to_string()], "an undecoded bit still fires it — the watch is a memcmp over the dword" ); // The create runs no notify pass. assert!(fired(None, with(0x4), &still).is_empty()); // The control against firing always. assert!(fired(Some(with(0x4)), with(0x4), &still).is_empty()); } /// `PLAYER_UPDATE_RESTING` fires at `0x5eeaf2`, inside the `0x20` arm whose `je` (`0x5eead4`) /// skips to `0x5eeafc`. #[test] fn the_self_flag_events_each_fire_on_their_own_bits() { use bevy::ecs::system::RunSystemOnce; const FIELD_PLAYER_FLAGS: u16 = 190; let mut app = App::new(); app.add_message::(); app.init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() // The feed's `MessageSink` reads chat and sounds: a message key's row carries its cue. .init_resource::() .init_resource::(); let (tx, _rx) = crossbeam_channel::unbounded(); app.insert_resource(NetCommands(tx)); let script = UiScript::new().unwrap(); script .run( r#" SEEN = {} local f = CreateFrame("Frame") f:RegisterEvent("PLAYER_UPDATE_RESTING") f:RegisterEvent("PLAYTIME_CHANGED") f:RegisterEvent("UPDATE_EXHAUSTION") f:SetScript("OnEvent", function() table.insert(SEEN, event) end) "#, ) .unwrap(); app.insert_non_send_resource(script); let me = app .world_mut() .spawn(( SelfPlayer, Guid(0x77), ObjectStore( benilla_protocol::ObjectFields::from_pairs(&[]) .into_created(benilla_protocol::messages::ObjectType::Player), ), )) .id(); // Set PLAYER_FLAGS, run the feed, read back what fired. let step = |app: &mut App, flags: u32| -> Vec { app.world_mut() .entity_mut(me) .get_mut::() .unwrap() .0 .merge(benilla_protocol::ObjectFields::from_pairs(&[( FIELD_PLAYER_FLAGS, flags, )])); app.world_mut().run_system_once(feed_units).unwrap(); let mut s = app.world_mut().non_send_resource_mut::(); s.resolve(); let out = s.eval::>("return SEEN").unwrap(); s.run("SEEN = {}").unwrap(); out }; // `UPDATE_EXHAUSTION` is other watchers (`0x5de4e0`/`0x5de4b0`) and fires on first sight // here, so it is filtered out. let flag_events = |v: Vec| -> Vec { v.into_iter().filter(|e| e != "UPDATE_EXHAUSTION").collect() }; assert!( flag_events(step(&mut app, 0)).is_empty(), "the login descriptor is a create: structurally silent" ); // `PLAYER_FLAGS_HIDE_HELM` `0x400` moves and the resting bit does not. assert!( flag_events(step(&mut app, 0x400)).is_empty(), "a non-resting, non-playtime bit fires neither self event" ); // The resting bit's own edge, both ways: `0x5eead0 test byte [ebp-4],0x20`. assert_eq!( flag_events(step(&mut app, 0x400 | PLAYER_FLAGS_RESTING)), vec!["PLAYER_UPDATE_RESTING".to_string()] ); assert_eq!( flag_events(step(&mut app, 0x400)), vec!["PLAYER_UPDATE_RESTING".to_string()], "the fire is on the XOR-diff, so the clear edge fires it too" ); // `PLAYTIME_CHANGED`: `0x5eeb65 test ah,0x30`, the two play-time regimes together. assert_eq!( flag_events(step(&mut app, 0x400 | PLAYER_FLAGS_PARTIAL_PLAY_TIME)), vec!["PLAYTIME_CHANGED".to_string()] ); assert_eq!( flag_events(step( &mut app, 0x400 | PLAYER_FLAGS_PARTIAL_PLAY_TIME | PLAYER_FLAGS_NO_PLAY_TIME )), vec!["PLAYTIME_CHANGED".to_string()], "the second regime bit is the same arm, not a second event" ); // Both arms from one dword, in the handler's order. assert_eq!( flag_events(step(&mut app, 0x400 | PLAYER_FLAGS_RESTING)), vec![ "PLAYER_UPDATE_RESTING".to_string(), "PLAYTIME_CHANGED".to_string() ] ); } /// The player-only refusal rides in the entry, so a boar in interact range is 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), which `can_attack` refuses. 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². 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() } // UnitXP_SP3's two player-anchored predicates ride the same record, so one assertion // through this harness proves the feed carries them and the binding reads them; the arc // law itself is `the_rear_arc_is_the_servers_own_half_circle` below. assert!( ask( BOAR, 0, 15.0, r#"return type(UnitXP("behind", "player", "target")) == "boolean" and type(UnitXP("inSight", "player", "target")) == "boolean""# ), "both answer a boolean for a live token — nil would mean the record never arrived, \ and the macro conditionals compare against `true` literally" ); assert!( ask( BOAR, 0, 40.0, r#"return CheckInteractDistance("target", 4) == nil"# ), "a creature 40 yards away is out of the 30-yard row" ); 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" ); 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 there is inspectable, so the refusal is the type. assert!( ask( FRIEND, NON_ATTACKABLE, 3.0, r#"return CanInspect("target") ~= nil"# ), "a non-attackable player 3 yards away is inspectable" ); } /// `PLAYER_TARGET_CHANGED` leads the token fan-out. The reference has no token diff at all — /// re-pointing `"target"` writes no descriptor field, so its `UNIT_*` watches stay silent and /// this is the only event a new target raises. Ours fans out off the snapshot diff, and behind /// the fire those events reached handlers that still believed the old target. #[test] fn a_new_target_raises_player_target_changed_before_the_token_unit_events() { use benilla_protocol::messages::ObjectFields; const FIELD_UNIT_LEVEL: u16 = 34; const FIRST: u64 = 0x0000_0000_0000_0011; const SECOND: u64 = 0x0000_0000_0000_0012; let mut app = App::new(); app.init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::(); app.add_message::(); let (tx, _rx) = crossbeam_channel::unbounded(); app.insert_resource(NetCommands(tx)); app.insert_non_send_resource(UiScript::new().unwrap()); app.add_systems(Update, feed_units); // Two players told apart by level alone, so the repoint is a `UNIT_LEVEL` transition. let mut player = |level: u32| { app.world_mut() .spawn(ObjectStore(ObjectFields::from_pairs(&[( FIELD_UNIT_LEVEL, level, )]))) .id() }; let first = player(10); let second = player(20); let select = |app: &mut App, entity, guid| { let mut selection = app.world_mut().resource_mut::(); selection.target = Some(entity); selection.guid = Some(guid); app.update(); }; let run = |app: &mut App, src: &str| { app.world_mut() .non_send_resource_mut::() .run(src) .unwrap(); }; select(&mut app, first, FIRST); run( &mut app, r#" ORDER = {} local f = CreateFrame("Frame") f:RegisterEvent("PLAYER_TARGET_CHANGED") f:RegisterEvent("UNIT_LEVEL") f:SetScript("OnEvent", function() table.insert(ORDER, event) end) "#, ); select(&mut app, second, SECOND); let order = app .world_mut() .non_send_resource_mut::() .eval::(r#"return table.concat(ORDER, ",")"#) .unwrap(); assert!( order.starts_with("PLAYER_TARGET_CHANGED"), "a new target fired {order:?}, so a handler read the old target first" ); } /// Either half moving pushes; only the count, the one byte the client watches, fires. #[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" ); } /// `UNIT_DYNFLAG_DEAD` alone makes empty bars over real maxima, through `snapshot`'s wiring. #[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, 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, 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" ); // The flag moves only health and power, the pair the reference's watcher fires // (`0x6004c5`, `0x6004f0`). 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`), through `enrich_unit`'s wiring. #[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 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 }; // The identity, not just the fields derived from it. A snapshot left at guid 0 answers // `UnitGUID` with nil and makes `GameTooltip:GetUnitGUID()` return no values at all, which // is what silently disabled pfQuest's objective annotation on hovered mobs: its scan keys // on that getter's first return. The player leg returns early, so it is asserted too. { let store = ObjectStore(ObjectFields::from_pairs(&[])); let mut s = UnitState::default(); enrich_unit(&mut s, GUID, &names, &store, None, None); assert_eq!(s.guid, GUID, "a creature snapshot carries its own guid"); let player = 0x0000_0000_0000_002A; // HIGHGUID_PLAYER: a zero high word let mut p = UnitState::default(); enrich_unit(&mut p, player, &names, &store, None, None); assert!(p.is_player, "the early-return leg"); assert_eq!( p.guid, player, "and it still records the guid before returning" ); } 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: rank 0 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 entry gate `0x612610` passes with no record; the record's `HIDE_FACTION_TOOLTIP` /// (`0x10`) can then take the line away, as in the reference. #[test] fn the_faction_line_does_not_wait_for_the_creature_query() { use benilla_protocol::messages::ObjectFields; let data = benilla_formats::wow_data_or_skip!(); let mut chain = benilla_formats::open_chain(&data).expect("open chain"); let catalog = benilla_formats::load_faction_catalog(&mut chain).expect("Faction.dbc"); let factions = crate::target::Factions::from_catalog(catalog); /// `UNIT_FIELD_FACTIONTEMPLATE` and `UNIT_FIELD_BYTES_0`, absolute descriptor indices. const FACTIONTEMPLATE: u16 = 35; const BYTES_0: u16 = 36; /// Race 1 and class 1 in `UNIT_FIELD_BYTES_0` bytes 0 and 1, for the slot walk. const HUMAN_WARRIOR: u32 = 1 | (1 << 8); /// A creature entry the cache is never told about. const ENTRY: u32 = 299; const GUID: u64 = (0xF130u64 << 48) | ((ENTRY as u64) << 24) | 0x7; let me = ObjectStore(ObjectFields::from_pairs(&[(BYTES_0, HUMAN_WARRIOR)])); // The first template whose faction shows this character a line, from the real DBC. let (template_id, expected) = (1u32..3000) .find_map(|id| { let f = factions.catalog().template(id)?.faction; let info = factions.catalog().reputation_faction(f)?; info.tooltip_shows_for(1, 1) .then(|| factions.catalog().faction_name(f)) .flatten() .map(|n| (id, n.to_string())) }) .expect("some faction template shows a tooltip line to a human warrior"); let store = ObjectStore(ObjectFields::from_pairs(&[(FACTIONTEMPLATE, template_id)])); let line_for = |names: &NameCache| { let mut state = UnitState::default(); enrich_unit(&mut state, GUID, names, &store, Some(&factions), Some(&me)); state }; // The query in flight: no record, and the faction line all the same. let pending = line_for(&NameCache::default()); assert_eq!(pending.name, None, "the name is the thing still in flight"); assert_eq!(pending.subtitle, None); assert_eq!( pending.faction_name.as_deref(), Some(expected.as_str()), "the faction line resolves off the descriptor alone" ); let record = |type_flags: u32| crate::names::CreatureRecord { name: "Stormwind Guard".into(), subname: None, creature_type: 7, pet_family: 0, rank: 0, type_flags, civilian: false, racial_leader: false, display_id: 0, }; let mut answered = NameCache::default(); answered.insert_creature(ENTRY, Some(record(0))); assert_eq!( line_for(&answered).faction_name.as_deref(), Some(expected.as_str()), "the answer landing keeps the line it was already showing" ); // The one creature-side gate the record does own. let mut hidden = NameCache::default(); hidden.insert_creature(ENTRY, Some(record(0x10))); assert_eq!( line_for(&hidden).faction_name, None, "HIDE_FACTION_TOOLTIP takes the line away once the record says so" ); } /// Asserted on keys: English text cannot tell apart two keys that agree in enUS. #[test] fn pvp_announcement_speaks_only_on_an_edge() { assert_eq!( pvp_announcement(None, false), None, "first sight, unflagged" ); assert_eq!(pvp_announcement(None, true), None, "first sight, flagged"); assert_eq!(pvp_announcement(Some(true), true), None, "no change"); assert_eq!(pvp_announcement(Some(false), false), None, "no change"); assert_eq!( pvp_announcement(Some(false), true), Some(("ERR_PVP_TOGGLE_ON", "PVP_TOGGLE_ON_VERBOSE")) ); assert_eq!( pvp_announcement(Some(true), false), Some(("ERR_PVP_TOGGLE_OFF", "PVP_TOGGLE_OFF_VERBOSE")) ); } /// A mistyped key silences a line; the OFF verbose sentence explains the five-minute wait. #[test] fn the_pvp_and_rest_keys_resolve_in_the_real_global_strings() { let data = benilla_formats::wow_data_or_skip!(); let mut chain = benilla_formats::open_chain(&data).expect("open chain"); let src = chain .read_file("Interface\\FrameXML\\GlobalStrings.lua") .expect("GlobalStrings.lua in the chain"); let s = UiScript::new().expect("VM"); s.run(&String::from_utf8_lossy(&src)).expect("runs clean"); let g = |key: &str| s.lua().globals().get::(key).unwrap_or_default(); for (was, now) in [(false, true), (true, false)] { let (toast, verbose) = pvp_announcement(Some(was), now).expect("an edge speaks"); assert!(!g(toast).is_empty(), "{toast} missing"); assert!(!g(verbose).is_empty(), "{verbose} missing"); } assert!( g("PVP_TOGGLE_OFF_VERBOSE").contains("five minutes"), "the OFF sentence is what tells the player the flag lingers" ); for state in [1u8, 2] { let key = rest_state_message(0, state).expect("states 1 and 2 speak"); assert!(!g(key).is_empty(), "{key} missing"); } } /// Only states 1 and 2 speak, and only on a real byte change (`0x5de4e0`). #[test] fn rest_state_message_speaks_only_on_a_real_transition() { assert_eq!(rest_state_message(2, 1), Some("ERR_EXHAUSTION_RESTED")); assert_eq!(rest_state_message(1, 2), Some("ERR_EXHAUSTION_NORMAL")); assert_eq!( rest_state_message(0, 1), Some("ERR_EXHAUSTION_RESTED"), "0→1 IS a transition" ); assert_eq!( rest_state_message(1, 1), None, "same byte re-sent — the mirror diff eats it" ); assert_eq!(rest_state_message(2, 2), None); assert_eq!( rest_state_message(1, 0), None, "state 0 is the 0x1d1 sentinel: no message" ); assert_eq!( rest_state_message(2, 3), None, "beta tiers are gated off (cmp esi,3; jae)" ); assert_eq!(rest_state_message(1, 5), None); } /// A real `Update` schedule: a fresh `run_system_once` sees every resource as changed and /// would hold the gate open. #[test] fn the_npc_token_follows_the_interaction_npc_and_clears_with_it() { use crate::ui_session::InteractNpc; use benilla_protocol::messages::ObjectFields; const FIELD_UNIT_LEVEL: u16 = 34; // Two `HIGHGUID_UNIT` guids: the high word decides the family. const BROG: u64 = 0xF130_0000_9700_0001; const DOBBINS: u64 = 0xF130_0001_D100_0002; let mut app = App::new(); app.init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() // `show_messages`' sink is the chat log and the message-sound queue. .init_resource::() .init_resource::() .init_resource::(); app.add_message::(); let (tx, _rx) = crossbeam_channel::unbounded(); app.insert_resource(NetCommands(tx)); app.insert_non_send_resource(UiScript::new().unwrap()); app.add_systems(Update, feed_units); // Two vendors, told apart by level alone. let mut vendor = |level: u32| { app.world_mut() .spawn(ObjectStore(ObjectFields::from_pairs(&[( FIELD_UNIT_LEVEL, level, )]))) .id() }; let brog = vendor(7); let dobbins = vendor(9); let eval = |app: &mut App, expr: &str| -> i64 { app.world_mut() .non_send_resource_mut::() .eval::(expr) .unwrap() }; let exists = |app: &mut App| eval(app, r#"return UnitExists("npc") and 1 or 0"#) == 1; let level = |app: &mut App| eval(app, r#"return UnitLevel("npc")"#); app.update(); assert!(!exists(&mut app), "no window open, yet UnitExists(\"npc\")"); *app.world_mut().resource_mut::() = InteractNpc(Some(brog), Some(BROG)); app.update(); assert_eq!( level(&mut app), 7, "the token names the vendor whose window opened" ); // Dobbins' window opens over it: the interaction NPC is the only thing that moved. *app.world_mut().resource_mut::() = InteractNpc(Some(dobbins), Some(DOBBINS)); app.update(); assert_eq!( level(&mut app), 9, "a second vendor over an open window swaps the token" ); *app.world_mut().resource_mut::() = InteractNpc::default(); app.update(); assert!( !exists(&mut app), "the window closed, yet UnitExists(\"npc\")" ); } /// The focus pair is NOT in [`reach_tokens`] — [`UnitTokens`] cannot see the VM's focus guid — /// so [`feed_unit_reach`] resolves it itself. Without that, `CheckInteractDistance("focus")` /// answers nil for a unit standing next to you, which is a wrong answer rather than an absent /// one: pfUI's `api.UnitInRange` reads it first and would fade a focus frame that is in range. #[test] fn the_focus_pair_gets_a_reach_entry_so_the_range_verbs_do_not_answer_wrongly() { use benilla_protocol::messages::ObjectFields; const FIELD_UNIT_TARGET_LOW: u16 = 16; const FIELD_UNIT_TARGET_HIGH: u16 = 17; const ME: u64 = 0x0000_0000_0000_0007; const NEAR: u64 = 0xF130_0000_9700_0008; const FAR: u64 = 0xF130_0001_D100_0009; let mut app = App::new(); app.init_resource::() .init_resource::() .init_resource::() .init_resource::(); app.insert_non_send_resource(UiScript::new().unwrap()); app.add_systems(Update, feed_unit_reach); let me = app .world_mut() .spawn(( crate::net::SelfPlayer, ObjectStore(ObjectFields::default()), crate::net::Guid(ME), Transform::default(), )) .id(); // 5 yards away, inside every `CheckInteractDistance` threshold, and its own target is // 100 yards out, past all four — so the two tokens must get their own entries. let near = app .world_mut() .spawn(( ObjectStore(ObjectFields::from_pairs(&[ (FIELD_UNIT_TARGET_LOW, FAR as u32), (FIELD_UNIT_TARGET_HIGH, (FAR >> 32) as u32), ])), crate::net::Guid(NEAR), Transform::from_xyz(5.0, 0.0, 0.0), )) .id(); let far = app .world_mut() .spawn(( ObjectStore(ObjectFields::default()), crate::net::Guid(FAR), Transform::from_xyz(100.0, 0.0, 0.0), )) .id(); let mut index = app.world_mut().resource_mut::(); index.0.insert(ME, me); index.0.insert(NEAR, near); index.0.insert(FAR, far); let ask = |app: &mut App, expr: &str| -> i64 { app.world_mut() .non_send_resource_mut::() .eval::(expr) .unwrap() }; let in_range = |app: &mut App, token: &str| { ask( app, &format!(r#"return CheckInteractDistance("{token}", 4) and 1 or 0"#), ) == 1 }; app.update(); assert!(!in_range(&mut app, "focus"), "no focus, nothing in range"); // The focus is stored as a guid, so some token has to name the unit first. app.world_mut() .non_send_resource_mut::() .set_unit( "target", Some(UnitState { exists: true, guid: NEAR, ..Default::default() }), ); app.world_mut() .non_send_resource_mut::() .run(r#"FocusUnit("target")"#) .unwrap(); app.update(); assert!( in_range(&mut app, "focus"), "a focus 5 yards away is inside every interact threshold" ); assert!( !in_range(&mut app, "focustarget"), "and its own target, 100 yards out, is past all four — so the entry is per token" ); } /// ClassicAPI's focus (`docs/ADDON_COMPAT.md`): the focus is a *persistent secondary* target, /// so the one property worth a real schedule is that it survives a retarget — the token keeps /// naming the unit once the selection has moved on and nothing else names it. Also pins the /// `UNIT_*` fan-out per token, the `"focustarget"` hop, and that both tokens clear together. #[test] fn the_focus_tokens_outlive_a_retarget_and_clear_together() { use benilla_protocol::messages::ObjectFields; const FIELD_UNIT_LEVEL: u16 = 34; // `UNIT_FIELD_TARGET` is a guid, so a low/high pair (`fields/mod.rs`'s index 16). const FIELD_UNIT_TARGET_LOW: u16 = 16; const FIELD_UNIT_TARGET_HIGH: u16 = 17; const HOGGER: u64 = 0xF130_0000_9700_0003; const MANKRIK: u64 = 0xF130_0001_D100_0004; let mut app = App::new(); app.init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::(); app.add_message::(); let (tx, _rx) = crossbeam_channel::unbounded(); app.insert_resource(NetCommands(tx)); app.insert_non_send_resource(UiScript::new().unwrap()); app.add_systems(Update, feed_units); // Hogger, whose own `UNIT_FIELD_TARGET` is Mankrik: one feed drives both focus tokens. let hogger = app .world_mut() .spawn(ObjectStore(ObjectFields::from_pairs(&[ (FIELD_UNIT_LEVEL, 11), (FIELD_UNIT_TARGET_LOW, MANKRIK as u32), (FIELD_UNIT_TARGET_HIGH, (MANKRIK >> 32) as u32), ]))) .id(); let mankrik = app .world_mut() .spawn(ObjectStore(ObjectFields::from_pairs(&[( FIELD_UNIT_LEVEL, 29, )]))) .id(); let mut index = app.world_mut().resource_mut::(); index.0.insert(HOGGER, hogger); index.0.insert(MANKRIK, mankrik); let run = |app: &mut App, body: &str| { app.world_mut() .non_send_resource_mut::() .run(body) .unwrap(); }; let eval = |app: &mut App, expr: &str| -> i64 { app.world_mut() .non_send_resource_mut::() .eval::(expr) .unwrap() }; let level = |app: &mut App, token: &str| eval(app, &format!(r#"return UnitLevel("{token}")"#)); // Every `UNIT_LEVEL` fire with its token, so the per-token fan-out is observable. run( &mut app, r#" SEEN = {} local f = CreateFrame("Frame") f:RegisterEvent("UNIT_LEVEL") f:SetScript("OnEvent", function() table.insert(SEEN, arg1) end) "#, ); let seen = |app: &mut App| -> Vec { app.world_mut() .non_send_resource_mut::() .eval::>("local out = SEEN SEEN = {} return out") .unwrap() }; app.update(); assert_eq!(level(&mut app, "focus"), 0, "no focus, no level"); // Target Hogger, then focus the target: the only way a 1.12 client can name a unit. app.world_mut().resource_mut::().target = Some(hogger); app.world_mut().resource_mut::().guid = Some(HOGGER); app.update(); run(&mut app, r#"FocusUnit("target")"#); let _ = seen(&mut app); app.update(); assert_eq!(level(&mut app, "focus"), 11, "the focus is Hogger"); assert_eq!( level(&mut app, "focustarget"), 29, "one hop off the focus's own UNIT_FIELD_TARGET" ); let mut tokens = seen(&mut app); tokens.sort(); assert_eq!( tokens, vec!["focus".to_string(), "focustarget".to_string()], "the fan-out names each token, which is how pfUI's two frames tell them apart" ); // The retarget: the selection moves to Mankrik, so nothing but the focus names Hogger. app.world_mut().resource_mut::().target = Some(mankrik); app.world_mut().resource_mut::().guid = Some(MANKRIK); app.update(); assert_eq!(level(&mut app, "target"), 29); assert_eq!( level(&mut app, "focus"), 11, "a focus that died with the selection would not be a focus" ); // And a retarget is not a focus change: `"target"` re-fires for its new level, the focus // pair stays quiet because nothing about the unit it names moved. let after: Vec = seen(&mut app) .into_iter() .filter(|t| t.starts_with("focus")) .collect(); assert_eq!(after, Vec::::new()); run(&mut app, "ClearFocus()"); app.update(); assert_eq!(level(&mut app, "focus"), 0); assert_eq!( level(&mut app, "focustarget"), 0, "the clear takes the hop with it" ); } /// Event `0x111` fires from the local player's destructor, which a same-map teleport never /// reaches, and the initial-login map needs no ack. #[test] fn a_cross_map_worldport_fires_leaving_world_and_the_login_map_does_not() { let mut app = App::new(); app.add_message::() .init_resource::() .add_systems(Update, fire_leaving_world_on_worldport); app.insert_non_send_resource(UiScript::new().expect("VM")); app.world_mut() .non_send_resource::() .run( "Left = 0 \ local f = CreateFrame(\"Frame\") \ f:RegisterEvent(\"PLAYER_LEAVING_WORLD\") \ f:SetScript(\"OnEvent\", function() Left = Left + 1 end)", ) .expect("probe frame"); let left = |app: &mut App| -> i64 { app.world_mut() .non_send_resource_mut::() .eval::("return Left") .unwrap() }; let port = |needs_ack: bool| crate::net::WorldportMessage { map_id: 1, position: [0.0; 3], orientation: 0.0, needs_ack, transport_entry: None, }; let arm = |app: &mut App| { app.world_mut() .resource_mut::() .arm(); }; // A world began: the reference arms the latch from the local player's create. arm(&mut app); app.world_mut().write_message(port(false)); app.update(); assert_eq!(left(&mut app), 0, "the initial-login map is an arrival"); app.world_mut().write_message(port(true)); app.update(); assert_eq!(left(&mut app), 1, "a cross-map port leaves a world"); app.update(); assert_eq!( left(&mut app), 1, "once per port, not once per frame after it" ); // Once per world: the port above spent the latch and no new avatar re-armed it, so a // second departure (a quit on the loading screen) fires nothing, as in the reference. app.world_mut().write_message(port(true)); app.update(); assert_eq!( left(&mut app), 1, "a second departure with the latch spent fired again — [0xb4b424] is per world" ); // Re-armed, as the new world's create does: the next departure is its own. arm(&mut app); app.world_mut().write_message(port(true)); app.update(); assert_eq!(left(&mut app), 2, "the next world's departure fires again"); } /// The rear **hemisphere**, which is what this realm's server means by behind /// (`!HasInArc(M_PI, ...)`), taken flat and strictly: beside is not behind, and above is not /// behind either. /// /// The feed publishes the angle and the reader compares it against UnitXP_SP3's threshold /// (`compat/unitxp.rs`), so this asserts through that same comparison at the mod's default, /// π/2 — which is the hemisphere. #[test] fn the_rear_arc_is_the_servers_own_half_circle() { let in_rear_arc = |subject: &Transform, other: Vec3| { rear_angle(subject, other) > std::f32::consts::FRAC_PI_2 }; // A unit at the origin facing north. Bevy's identity rotation looks down −Z, and WoW's // north is −Z here too, so "in front" is −Z and "behind" is +Z. let facing_north = Transform::from_xyz(0.0, 0.0, 0.0); let at = |x: f32, y: f32, z: f32| Vec3::new(x, y, z); assert!( in_rear_arc(&facing_north, at(0.0, 0.0, 5.0)), "directly behind" ); assert!( !in_rear_arc(&facing_north, at(0.0, 0.0, -5.0)), "directly in front" ); assert!( !in_rear_arc(&facing_north, at(5.0, 0.0, 0.0)), "exactly beside is NOT behind: the server's own test is a strict one, and a rogue \ standing on the flank gets \"You must be behind your target\"" ); assert!( in_rear_arc(&facing_north, at(5.0, 0.0, 0.1)), "a hair past the flank is behind — the hemisphere, not a narrow cone" ); assert!( in_rear_arc(&facing_north, at(0.0, 30.0, 5.0)), "the test is flat: standing on a ledge above a mob's back is still behind it" ); assert!( !in_rear_arc(&facing_north, at(0.0, 30.0, 0.0)), "…and directly overhead is not, which a 3-D dot would have called behind or in front \ by whichever way the model happened to tilt" ); assert!( !in_rear_arc(&facing_north, at(0.0, 0.0, 0.0)), "the same point is not behind itself" ); // Turning the subject turns the arc with it. let facing_east = Transform::from_xyz(0.0, 0.0, 0.0) .with_rotation(Quat::from_rotation_y(-std::f32::consts::FRAC_PI_2)); assert!( in_rear_arc(&facing_east, at(-5.0, 0.0, 0.0)), "facing east (+X), behind is −X" ); assert!(!in_rear_arc(&facing_east, at(5.0, 0.0, 0.0))); } }