Not a gap: a defect. Defining `UnitXP` at all is what makes
SuperCleveRoidMacros believe the whole family is present —
`hasUnitXP = pcall(UnitXP, "nop", "nop")` (`Init.lua:274`), which
succeeds here because the dispatcher's fall-through does not raise — so
four macro conditionals have been running against commands this client
did not implement: `[insight]`, `[noinsight]`, `[behind]`, `[nobehind]`,
each with a count mode too.
The fall-through answers the stock `UnitXP(unit)` getter's 0, and every
one of those conditionals compares against `true` LITERALLY (`== true`,
`~= true`). So `[insight]` and `[behind]` could never fire while
`[noinsight]` and `[nobehind]` always did, with nothing to notice it by.
That is why both commands answer a boolean rather than 1.12's `1`/nil
flag shape, and why a pair they cannot measure answers nil rather than a
fabricated `false`: the two directions are read separately, so "cannot
say" has to be distinguishable from "no".
`inSight`'s blocker is gone, and it was gone before this: the row said
"needs a real raycast with no current binding-layer access", and the
nameplate-occlusion work built exactly that raycast. This is the same
body-layer ray from the player's chest instead of the camera's eye, fed
into the per-token `UnitReach` record `distanceBetween` already reads —
only the app can trace the world, which is the whole reason the command
sat blocked in the binding.
`behind` this file called *unconfirmed* ("treat as unconfirmed until seen
elsewhere"). It is now seen elsewhere: ten call sites, with the argument
order documented by its own readers, so `behind(A, B)` asks whether A
stands in B's rear arc. The arc is the rear HEMISPHERE, which is what
this realm's server means by behind — vmangos tests `!HasInArc(M_PI,
attacker)`, the same rule that accepts or refuses Backstab — taken flat,
so a ledge above a mob's back counts and directly overhead does not.
Two things my own tests caught, both worth keeping:
- A two-argument `inSight` answered `true`. These are recognised
commands, so they now match on the NAME alone and say nil when the
arguments make no pair; the arity fall-through is right for an
unrecognised command and wrong here, because neither name is a unit
token so the stock getter can only answer a meaningless 0 — which is
truthy in Lua, i.e. a confident "yes, in sight".
- A `#[cfg(test)]` module placed mid-file hid every `fire_event` after it
from `event_shape_gate`, which truncates each file at the first one:
six unit-field bridge events read as unproduced. The test moved to the
file's own tests module, where the one-test-module-per-file convention
that gate assumes puts it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
3457 lines
147 KiB
Rust
3457 lines
147 KiB
Rust
//! 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<String>,
|
||
pub(crate) extra: Option<String>,
|
||
}
|
||
|
||
/// `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<NonSendMut<UiScript>>,
|
||
mut armed: ResMut<crate::ui_script::LeavingWorldArmed>,
|
||
mut ports: MessageReader<crate::net::WorldportMessage>,
|
||
) {
|
||
// `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<UnitFeedMemo>,
|
||
/// 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<String, UnitState>,
|
||
target_guid: Option<u64>,
|
||
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<u32>,
|
||
/// `(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<bool>,
|
||
/// Our last PvP-preference bit; the first descriptor is silent, as the reference diffs bits.
|
||
pvp_desired: Option<bool>,
|
||
/// `(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<u8>,
|
||
/// 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<u64>,
|
||
}
|
||
|
||
/// 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::<UnitFeedState>()
|
||
// 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::<crate::sound::MessageSounds>()
|
||
.add_message::<UnitCombatFeedback>()
|
||
.add_message::<CombatTextEvent>()
|
||
.add_message::<crate::net::WorldportMessage>()
|
||
.add_message::<crate::net::FieldChanged>()
|
||
.init_resource::<crate::ui_script::LeavingWorldArmed>()
|
||
.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<Res<benilla_assets::WorldAssets>>) {
|
||
let Some(assets) = assets else { return };
|
||
let loaded = {
|
||
use benilla_assets::LockRecover;
|
||
let mut chain = assets.chain.lock_recover();
|
||
benilla_formats::load_languages(&mut chain)
|
||
};
|
||
match loaded {
|
||
Ok(langs) => {
|
||
info!("ui_unit: {} Languages.dbc rows", langs.len());
|
||
commands.insert_resource(LanguagesRes(langs));
|
||
}
|
||
Err(e) => warn!("ui_unit: Languages.dbc unavailable — {e:#}"),
|
||
}
|
||
}
|
||
|
||
/// The languages this character knows, 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<Item = u32>,
|
||
spells: &benilla_formats::SpellCatalog,
|
||
skill_lines: Option<&benilla_formats::SkillLineCatalog>,
|
||
has_skill_line: impl Fn(u32) -> bool,
|
||
languages: &benilla_formats::Languages,
|
||
) -> Vec<String> {
|
||
let mut declared: std::collections::HashMap<u32, Vec<u32>> = Default::default();
|
||
for spell in known {
|
||
if let Some(lang) = spells.declared_language(spell) {
|
||
declared.entry(lang).or_default().push(spell);
|
||
}
|
||
}
|
||
languages
|
||
.names(0)
|
||
.filter(|(id, _)| {
|
||
declared.get(id).is_some_and(|spells| {
|
||
spells.iter().any(|&spell| {
|
||
skill_lines
|
||
.and_then(|sl| sl.spell_to_line(spell))
|
||
.is_some_and(&has_skill_line)
|
||
})
|
||
})
|
||
})
|
||
.map(|(_, name)| name.to_string())
|
||
.collect()
|
||
}
|
||
|
||
fn feed_known_languages(
|
||
script: Option<NonSendMut<UiScript>>,
|
||
actions: Option<Res<crate::ui_action::PlayerActions>>,
|
||
spells: Option<Res<crate::ui_action::Spells>>,
|
||
skill_lines: Option<Res<crate::ui_spellbook::SkillLines>>,
|
||
languages: Option<Res<LanguagesRes>>,
|
||
self_q: Query<Ref<ObjectStore>, With<SelfPlayer>>,
|
||
mut pushed: Local<crate::ui_script::VmMemo<Option<Vec<String>>>>,
|
||
) {
|
||
let Some(mut script) = script else {
|
||
return;
|
||
};
|
||
let (Some(actions), Some(spells), Some(languages)) = (actions, spells, languages) else {
|
||
return;
|
||
};
|
||
let pushed = pushed.get(&script);
|
||
let store = self_q.iter().next();
|
||
// 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<Res<benilla_assets::WorldAssets>>,
|
||
) {
|
||
let Some(assets) = assets else { return };
|
||
let loaded = {
|
||
use benilla_assets::LockRecover;
|
||
let mut chain = assets.chain.lock_recover();
|
||
benilla_formats::load_default_languages(&mut chain)
|
||
};
|
||
match loaded {
|
||
Ok(langs) => {
|
||
info!("ui_unit: {} race → default-language rows", langs.len());
|
||
commands.insert_resource(DefaultLanguagesRes(langs));
|
||
}
|
||
// Not fatal: the binding 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::<DefaultLanguagesRes>(),
|
||
world.get_resource::<crate::char_select::Roster>(),
|
||
) else {
|
||
return;
|
||
};
|
||
let Some(row) = roster.pending_row() else {
|
||
return;
|
||
};
|
||
script.set_default_language(langs.0.name(u32::from(row.race), 0).map(str::to_string));
|
||
}
|
||
|
||
/// Push `GetDefaultLanguage()`'s 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<NonSendMut<UiScript>>,
|
||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||
langs: Option<Res<DefaultLanguagesRes>>,
|
||
mut pushed: Local<crate::ui_script::VmMemo<Option<Option<String>>>>,
|
||
) {
|
||
let Some(mut script) = script else {
|
||
return;
|
||
};
|
||
let pushed = pushed.get(&script);
|
||
let name = self_q
|
||
.iter()
|
||
.next()
|
||
.and_then(|store| store.0.unit_race())
|
||
.zip(langs.as_ref())
|
||
.and_then(|(race, langs)| langs.0.name(u32::from(race), 0))
|
||
.map(str::to_string);
|
||
if pushed.as_ref() != Some(&name) {
|
||
script.set_default_language(name.clone());
|
||
*pushed = Some(name);
|
||
}
|
||
}
|
||
|
||
/// Seed each VM's `Exhaustion.dbc` table; a failed load keeps the shipped-table fallback.
|
||
fn load_exhaustion_rows(
|
||
script: Option<NonSendMut<UiScript>>,
|
||
assets: Option<Res<benilla_assets::WorldAssets>>,
|
||
mut seeded: Local<crate::ui_script::VmMemo<bool>>,
|
||
) {
|
||
let (Some(mut script), Some(assets)) = (script, assets) else {
|
||
return;
|
||
};
|
||
if !seeded.claim(&script) {
|
||
return;
|
||
}
|
||
let loaded = {
|
||
use benilla_assets::LockRecover;
|
||
let mut chain = assets.chain.lock_recover();
|
||
benilla_formats::load_exhaustion(&mut chain)
|
||
};
|
||
match loaded {
|
||
Ok(rows) => {
|
||
info!("ui_unit: {} Exhaustion.dbc rest states", rows.len());
|
||
script.set_exhaustion_rows(
|
||
rows.into_iter()
|
||
.map(|r| (r.id as u8, r.name, f64::from(r.factor)))
|
||
.collect(),
|
||
);
|
||
}
|
||
Err(e) => error!("ui_unit: Exhaustion.dbc failed — shipped-table fallback holds: {e:#}"),
|
||
}
|
||
}
|
||
|
||
/// Melee swing 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<crate::creature_anim::SwingImpact>,
|
||
mut out: MessageWriter<UnitCombatFeedback>,
|
||
) {
|
||
for crate::creature_anim::SwingImpact { swing: s, .. } in impacts.read() {
|
||
let Some(victim) = s.victim else { continue };
|
||
let (action, flags) = melee_feedback(s.hit_info, s.victim_state, s.damage);
|
||
out.write(UnitCombatFeedback {
|
||
unit: victim,
|
||
action,
|
||
flags,
|
||
amount: s.damage,
|
||
school: 0, // 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<NonSendMut<UiScript>>,
|
||
mut events: MessageReader<CombatTextEvent>,
|
||
) {
|
||
let Some(mut script) = script else {
|
||
return;
|
||
};
|
||
for ev in events.read() {
|
||
let arg = |v: &Option<String>| v.clone().map_or(ScriptValue::Nil, ScriptValue::Str);
|
||
script.fire_event(
|
||
"COMBAT_TEXT_UPDATE",
|
||
vec![
|
||
ScriptValue::Str(ev.message_type.to_string()),
|
||
arg(&ev.data),
|
||
arg(&ev.extra),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Drain [`UnitCombatFeedback`] into `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<NonSendMut<UiScript>>,
|
||
mut events: MessageReader<UnitCombatFeedback>,
|
||
self_q: Query<(), With<SelfPlayer>>,
|
||
selection: Res<Selection>,
|
||
) {
|
||
let Some(mut script) = script else {
|
||
return;
|
||
};
|
||
for ev in events.read() {
|
||
let mut fire = |token: &str| {
|
||
script.fire_event(
|
||
"UNIT_COMBAT",
|
||
vec![
|
||
ScriptValue::Str(token.to_string()),
|
||
ScriptValue::Str(ev.action.to_string()),
|
||
ScriptValue::Str(ev.flags.to_string()),
|
||
ScriptValue::Int(i64::from(ev.amount)),
|
||
ScriptValue::Int(i64::from(ev.school)),
|
||
],
|
||
);
|
||
};
|
||
if self_q.contains(ev.unit) {
|
||
fire("player");
|
||
}
|
||
if selection.target == Some(ev.unit) {
|
||
fire("target");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The 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<u64> {
|
||
match token {
|
||
"target" => selection
|
||
.guid
|
||
.filter(|g| benilla_protocol::guid::is_player(*g)),
|
||
"player" => None,
|
||
tok => tok
|
||
.strip_prefix("party")
|
||
.and_then(|n| n.parse::<usize>().ok())
|
||
.and_then(|n| n.checked_sub(1))
|
||
.and_then(|n| group.party_slots().nth(n))
|
||
.map(|m| m.guid),
|
||
}
|
||
}
|
||
|
||
/// The one unit-token resolver, 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<Res<'w, crate::net::GuidIndex>>,
|
||
pet: Option<Res<'w, crate::ui_pet::PetBar>>,
|
||
hovered: Option<Res<'w, crate::target::Hovered>>,
|
||
group: Res<'w, crate::ui_party::GroupState>,
|
||
pub(crate) stores: Query<'w, 's, &'static ObjectStore>,
|
||
me: Query<'w, 's, (Entity, &'static Guid), With<SelfPlayer>>,
|
||
}
|
||
|
||
impl UnitTokens<'_, '_> {
|
||
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::<usize>().ok())
|
||
.filter(|n| (1..=40).contains(n))
|
||
.and_then(|n| {
|
||
crate::ui_party::raid_row_guid(&self.group, self.me().map(|(_, g)| g), n)
|
||
})
|
||
.and_then(|g| self.held(g)),
|
||
t => t
|
||
.strip_prefix("party")
|
||
.and_then(|n| n.parse::<usize>().ok())
|
||
.filter(|n| (1..=4).contains(n))
|
||
.and_then(|n| self.group.party_slots().nth(n - 1).map(|m| m.guid))
|
||
.and_then(|g| self.held(g)),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Every token [`UnitTokens`] resolves, `"player"` included (the reference answers d² = 0).
|
||
pub(crate) fn reach_tokens() -> impl Iterator<Item = &'static str> {
|
||
["player", "target", "targettarget", "mouseover", "pet"]
|
||
.into_iter()
|
||
.chain(crate::ui_party::PARTY_TOKENS)
|
||
.chain(crate::ui_party::RAID_TOKENS)
|
||
}
|
||
|
||
/// 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<NonSendMut<UiScript>>,
|
||
player: Option<Res<crate::player::Player>>,
|
||
mut lost: Local<crate::ui_script::VmMemo<bool>>,
|
||
) {
|
||
let (Some(mut script), Some(player)) = (script, player) else {
|
||
return;
|
||
};
|
||
let lost = lost.get(&script);
|
||
if *lost != player.control_lost {
|
||
*lost = player.control_lost;
|
||
// `HasFullControl`'s flag rides the same edge.
|
||
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<NonSendMut<UiScript>>,
|
||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||
mut focus: Local<crate::ui_script::VmMemo<Option<u64>>>,
|
||
) {
|
||
let Some(mut script) = script else {
|
||
return;
|
||
};
|
||
let Some(store) = self_q.iter().next() else {
|
||
return;
|
||
};
|
||
let anchor = store.0.player_farsight();
|
||
let focus = focus.get(&script);
|
||
if *focus != anchor {
|
||
*focus = anchor;
|
||
script.fire_event("PLAYER_FARSIGHT_FOCUS_CHANGED", vec![]);
|
||
}
|
||
}
|
||
|
||
/// Feed the unit reach map: 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<NonSendMut<UiScript>>,
|
||
player: Option<Res<crate::player::Player>>,
|
||
// `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<benilla_world::world_point::WorldPoint>,
|
||
// 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<SelfPlayer>>,
|
||
// 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<Res<crate::player::CameraControl>>,
|
||
) {
|
||
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<NonSendMut<UiScript>>,
|
||
// 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<benilla_world::collision::WorldCollision>,
|
||
tokens: UnitTokens,
|
||
selection: Res<Selection>,
|
||
self_q: Query<(&Transform, &ObjectStore), With<SelfPlayer>>,
|
||
transforms: Query<&Transform>,
|
||
factions: Option<Res<Factions>>,
|
||
reputations: Res<Reputations>,
|
||
) {
|
||
let Some(mut script) = script else {
|
||
return;
|
||
};
|
||
// 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_at_its_back: in_rear_arc(tf, self_tf.translation),
|
||
at_player_back: in_rear_arc(self_tf, tf.translation),
|
||
},
|
||
);
|
||
}
|
||
}
|
||
script.set_unit_reach(reach);
|
||
}
|
||
|
||
/// Whether `other` stands in `subject`'s **rear arc**: UnitXP_SP3's `behind`.
|
||
///
|
||
/// The rear **hemisphere**, which is what this realm's own server means by behind: vmangos tests
|
||
/// it as `!target->HasInArc(M_PI, attacker)` — a π arc centred on the facing, so anything outside
|
||
/// it is behind — and that is the rule the server 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: the dot is taken in the horizontal plane, so standing on a ledge above a mob is not
|
||
/// "behind" it. A unit exactly beside the subject (dot 0) is **not** behind, as the strict
|
||
/// inequality in the server's own test is not.
|
||
fn in_rear_arc(subject: &Transform, other: Vec3) -> bool {
|
||
let facing = subject.forward().as_vec3();
|
||
let to_other = other - subject.translation;
|
||
let flat = |v: Vec3| Vec3::new(v.x, 0.0, v.z);
|
||
let (facing, to_other) = (flat(facing), flat(to_other));
|
||
// A zero-length separation (the subject and the other at one point) is not behind.
|
||
to_other.length_squared() > 0.0 && facing.dot(to_other) < 0.0
|
||
}
|
||
|
||
/// [`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<ObjectStore>, Without<crate::items::ItemObject>)>,
|
||
/// 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<Guid>` 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<Guid>>,
|
||
}
|
||
|
||
/// `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
|
||
}
|
||
|
||
pub(crate) fn snapshot(
|
||
store: &ObjectStore,
|
||
name: Option<String>,
|
||
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_<rank>_<team>`, 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<n>`), 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<N>`
|
||
/// 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<NonSendMut<UiScript>>, commands: Res<NetCommands>) {
|
||
let Some(mut script) = script else {
|
||
return;
|
||
};
|
||
for _ in 0..script.take_pvp_toggles() {
|
||
let _ = commands.0.send(crate::net::ClientCommand::TogglePvp);
|
||
}
|
||
}
|
||
|
||
/// Drain the `ShowHelm`/`ShowCloak` flips into `CMSG_TOGGLE_HELM`/`CMSG_TOGGLE_CLOAK`; the VM,
|
||
/// which alone knows what the Options row did, has already decided each is needed.
|
||
fn drain_worn_display_toggles(script: Option<NonSendMut<UiScript>>, commands: Res<NetCommands>) {
|
||
let Some(mut script) = script else {
|
||
return;
|
||
};
|
||
for which in script.take_worn_display_toggles() {
|
||
let _ = commands.0.send(match which {
|
||
WornDisplay::Helm => crate::net::ClientCommand::ToggleHelm,
|
||
WornDisplay::Cloak => crate::net::ClientCommand::ToggleCloak,
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Drain `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<NonSendMut<UiScript>>, commands: Res<NetCommands>) {
|
||
let Some(mut script) = script else {
|
||
return;
|
||
};
|
||
for toggles in script.take_action_bar_toggle_sends() {
|
||
let _ = commands
|
||
.0
|
||
.send(crate::net::ClientCommand::SetActionBarToggles { toggles });
|
||
}
|
||
}
|
||
|
||
/// 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<bool>, now: bool) -> Option<(&'static str, &'static str)> {
|
||
if was? == now {
|
||
return None;
|
||
}
|
||
Some(if now {
|
||
("ERR_PVP_TOGGLE_ON", "PVP_TOGGLE_ON_VERBOSE")
|
||
} else {
|
||
("ERR_PVP_TOGGLE_OFF", "PVP_TOGGLE_OFF_VERBOSE")
|
||
})
|
||
}
|
||
|
||
/// The rest-state chat 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|<side>`, and the Player and Monster rows have no name and no icon art.
|
||
pub(crate) fn faction_group(store: &ObjectStore, factions: Option<&Factions>) -> Option<String> {
|
||
let catalog = factions?.catalog();
|
||
let template = catalog.template(store.0.unit_faction_template()?)?;
|
||
// The English `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<String> {
|
||
let catalog = factions?.catalog();
|
||
let template = catalog.template(store.0.unit_faction_template()?)?;
|
||
catalog
|
||
.faction_group_name(template.group_mask & 6)
|
||
.map(str::to_string)
|
||
}
|
||
|
||
/// `CreatureType.dbc` id 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<NonSendMut<UiScript>>,
|
||
// Absent when the data failed to load: no class has a relic slot, the reference's bounds leg.
|
||
classes: Option<Res<crate::chr_classes::ChrClassTable>>,
|
||
|
||
self_q: Query<(Entity, &ObjectStore, &Guid), With<SelfPlayer>>,
|
||
selection: Res<Selection>,
|
||
// `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<Res<crate::net::GuidIndex>>,
|
||
mut stores: UnitStores,
|
||
mut feed: ResMut<UnitFeedState>,
|
||
names: Res<NameCache>,
|
||
commands: Res<NetCommands>,
|
||
factions: Option<Res<Factions>>,
|
||
reputations: Res<Reputations>,
|
||
group: Res<crate::ui_party::GroupState>,
|
||
// 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<crate::ui_guild::GuildState>,
|
||
interact: Option<Res<crate::ui_session::InteractNpc>>,
|
||
// Rested billing minutes, sent only in `SMSG_AUTH_RESPONSE`; the reference keeps a global.
|
||
entered_world: Option<MessageReader<crate::net::EnteredWorldMessage>>,
|
||
) {
|
||
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(|t| (t.translation.x, t.translation.y, t.translation.z));
|
||
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(|t| (t.translation.x, t.translation.y, t.translation.z));
|
||
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<N>` 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<UnitState> {
|
||
let store = stores.all.get(entity).ok()?;
|
||
let position = stores
|
||
.positions
|
||
.get(entity)
|
||
.ok()
|
||
.map(|t| (t.translation.x, t.translation.y, t.translation.z));
|
||
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::<String>(verbose)
|
||
.ok()
|
||
.filter(|t| !t.is_empty())
|
||
.map(|t| {
|
||
crate::ui_action::Shown::unkeyed(benilla_ui::messages::MsgKind::Chat, t)
|
||
}),
|
||
];
|
||
crate::ui_action::show_messages(
|
||
&mut script,
|
||
&mut sink,
|
||
"ui_unit",
|
||
lines.into_iter().flatten(),
|
||
);
|
||
}
|
||
memo.pvp_desired = Some(desired);
|
||
}
|
||
|
||
// The 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<bool> {
|
||
(last != Some(now)).then(|| last.map(|(count, _)| count) != Some(now.0))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// 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<UnitState>, cur: UnitState, edges: &FieldEdges| -> Vec<String> {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.run(
|
||
r#"
|
||
SEEN = {}
|
||
local f = CreateFrame("Frame")
|
||
f:RegisterEvent("UNIT_FLAGS")
|
||
f:SetScript("OnEvent", function() table.insert(SEEN, event .. ":" .. arg1) end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
fire_transitions(&mut s, "pet", prev.as_ref(), &cur, edges);
|
||
s.eval::<Vec<String>>("return SEEN").unwrap()
|
||
};
|
||
const PET: u64 = 0xF140_0000_0000_0001;
|
||
let base = UnitState {
|
||
exists: true,
|
||
has_object: true,
|
||
guid: PET,
|
||
flags: 0x8,
|
||
..Default::default()
|
||
};
|
||
let moved = FieldEdges::of(&[(PET, benilla_protocol::field::FIELD_UNIT_FLAGS)]);
|
||
assert_eq!(
|
||
fired(
|
||
Some(base.clone()),
|
||
UnitState {
|
||
flags: 0x8 | 0x0400_0000,
|
||
..base.clone()
|
||
},
|
||
&moved,
|
||
),
|
||
vec!["UNIT_FLAGS:pet".to_string()]
|
||
);
|
||
assert_eq!(
|
||
fired(Some(base.clone()), base.clone(), &FieldEdges::default()),
|
||
Vec::<String>::new()
|
||
);
|
||
assert_eq!(
|
||
fired(None, base.clone(), &FieldEdges::default()),
|
||
Vec::<String>::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::<String>::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::<crate::player::Player>()
|
||
.add_systems(Update, (feed_player_control, feed_farsight_focus));
|
||
let script = UiScript::new().unwrap();
|
||
script
|
||
.run(
|
||
r#"
|
||
SEEN = {}
|
||
local f = CreateFrame("Frame")
|
||
f:RegisterEvent("PLAYER_CONTROL_LOST")
|
||
f:RegisterEvent("PLAYER_CONTROL_GAINED")
|
||
f:RegisterEvent("PLAYER_FARSIGHT_FOCUS_CHANGED")
|
||
f:SetScript("OnEvent", function() table.insert(SEEN, event) end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
app.insert_non_send_resource(script);
|
||
let me = app
|
||
.world_mut()
|
||
.spawn((
|
||
SelfPlayer,
|
||
Guid(0x77),
|
||
ObjectStore(benilla_protocol::ObjectFields::default()),
|
||
))
|
||
.id();
|
||
let seen = |app: &mut App| -> Vec<String> {
|
||
app.update();
|
||
let mut s = app.world_mut().non_send_resource_mut::<UiScript>();
|
||
s.resolve();
|
||
let out = s.eval::<Vec<String>>("return SEEN").unwrap();
|
||
s.run("SEEN = {}").unwrap();
|
||
out
|
||
};
|
||
assert_eq!(
|
||
seen(&mut app),
|
||
Vec::<String>::new(),
|
||
"in control, no far sight: quiet"
|
||
);
|
||
app.world_mut()
|
||
.resource_mut::<crate::player::Player>()
|
||
.control_lost = true;
|
||
assert_eq!(seen(&mut app), vec!["PLAYER_CONTROL_LOST".to_string()]);
|
||
assert_eq!(seen(&mut app), Vec::<String>::new(), "held, not repeated");
|
||
app.world_mut()
|
||
.resource_mut::<crate::player::Player>()
|
||
.control_lost = false;
|
||
assert_eq!(seen(&mut app), vec!["PLAYER_CONTROL_GAINED".to_string()]);
|
||
|
||
let set_farsight = |app: &mut App, guid: u64| {
|
||
app.world_mut().entity_mut(me).insert(ObjectStore(
|
||
benilla_protocol::ObjectFields::from_pairs(&[
|
||
(FIELD_PLAYER_FARSIGHT, guid as u32),
|
||
(FIELD_PLAYER_FARSIGHT + 1, (guid >> 32) as u32),
|
||
]),
|
||
));
|
||
};
|
||
set_farsight(&mut app, 0xf130_0000_0000_0042);
|
||
assert_eq!(
|
||
seen(&mut app),
|
||
vec!["PLAYER_FARSIGHT_FOCUS_CHANGED".to_string()],
|
||
"set — whether or not the guid resolves"
|
||
);
|
||
assert_eq!(seen(&mut app), Vec::<String>::new());
|
||
set_farsight(&mut app, 0);
|
||
assert_eq!(
|
||
seen(&mut app),
|
||
vec!["PLAYER_FARSIGHT_FOCUS_CHANGED".to_string()],
|
||
"cleared — the other leg"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn the_tapped_bit_fires_unit_faction_and_the_by_player_bit_fires_nothing() {
|
||
let fired = |prev: UnitState, cur: UnitState| -> Vec<String> {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.run(
|
||
r#"
|
||
SEEN = {}
|
||
local f = CreateFrame("Frame")
|
||
f:RegisterEvent("UNIT_FACTION")
|
||
f:SetScript("OnEvent", function() table.insert(SEEN, event) end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
fire_transitions(&mut s, "target", Some(&prev), &cur, &FieldEdges::default());
|
||
s.eval::<Vec<String>>("return SEEN").unwrap()
|
||
};
|
||
let base = UnitState {
|
||
exists: true,
|
||
has_object: true,
|
||
..Default::default()
|
||
};
|
||
|
||
// The `0x4` edge fires `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<UnitState>, cur: UnitState, edges: &FieldEdges| -> Vec<String> {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.run(
|
||
r#"
|
||
SEEN = {}
|
||
local f = CreateFrame("Frame")
|
||
f:RegisterEvent("PLAYER_FLAGS_CHANGED")
|
||
f:SetScript("OnEvent", function() table.insert(SEEN, event .. ":" .. arg1) end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
fire_transitions(&mut s, "target", prev.as_ref(), &cur, edges);
|
||
s.eval::<Vec<String>>("return SEEN").unwrap()
|
||
};
|
||
const THEM: u64 = 0x2a;
|
||
let base = UnitState {
|
||
exists: true,
|
||
has_object: true,
|
||
guid: THEM,
|
||
..Default::default()
|
||
};
|
||
let with = |flags: u32| UnitState {
|
||
player_flags: flags,
|
||
group_leader: flags & 0x1 != 0,
|
||
ghost: flags & 0x10 != 0,
|
||
..base.clone()
|
||
};
|
||
let moved = FieldEdges::of(&[(THEM, benilla_protocol::field::FIELD_PLAYER_FLAGS)]);
|
||
let still = FieldEdges::default();
|
||
|
||
// `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<UnitState>, cur: UnitState, edges: &FieldEdges| -> Vec<String> {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.run(
|
||
r#"
|
||
SEEN = {}
|
||
local f = CreateFrame("Frame")
|
||
f:RegisterEvent("UNIT_DYNAMIC_FLAGS")
|
||
f:SetScript("OnEvent", function() table.insert(SEEN, event .. ":" .. arg1) end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
fire_transitions(&mut s, "target", prev.as_ref(), &cur, edges);
|
||
s.eval::<Vec<String>>("return SEEN").unwrap()
|
||
};
|
||
const MOB: u64 = 0xF130_0000_0000_0007;
|
||
let moved = FieldEdges::of(&[(MOB, benilla_protocol::field::FIELD_UNIT_DYNAMIC_FLAGS)]);
|
||
let still = FieldEdges::default();
|
||
let with = |dyn_flags: u32| UnitState {
|
||
exists: true,
|
||
has_object: true,
|
||
guid: MOB,
|
||
dynamic_flags: dyn_flags,
|
||
tapped: dyn_flags & 0x4 != 0,
|
||
tapped_by_player: dyn_flags & 0x8 != 0,
|
||
..Default::default()
|
||
};
|
||
|
||
// `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::<FieldChanged>();
|
||
app.init_resource::<Selection>()
|
||
.init_resource::<UnitFeedState>()
|
||
.init_resource::<NameCache>()
|
||
.init_resource::<Reputations>()
|
||
.init_resource::<crate::ui_party::GroupState>()
|
||
.init_resource::<crate::ui_chat::ChatLog>()
|
||
// The feed's `MessageSink` reads chat and sounds: a message key's row carries its cue.
|
||
.init_resource::<crate::sound::MessageSounds>()
|
||
.init_resource::<crate::ui_guild::GuildState>();
|
||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||
app.insert_resource(NetCommands(tx));
|
||
let script = UiScript::new().unwrap();
|
||
script
|
||
.run(
|
||
r#"
|
||
SEEN = {}
|
||
local f = CreateFrame("Frame")
|
||
f:RegisterEvent("PLAYER_UPDATE_RESTING")
|
||
f:RegisterEvent("PLAYTIME_CHANGED")
|
||
f:RegisterEvent("UPDATE_EXHAUSTION")
|
||
f:SetScript("OnEvent", function() table.insert(SEEN, event) end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
app.insert_non_send_resource(script);
|
||
let me = app
|
||
.world_mut()
|
||
.spawn((
|
||
SelfPlayer,
|
||
Guid(0x77),
|
||
ObjectStore(
|
||
benilla_protocol::ObjectFields::from_pairs(&[])
|
||
.into_created(benilla_protocol::messages::ObjectType::Player),
|
||
),
|
||
))
|
||
.id();
|
||
|
||
// Set PLAYER_FLAGS, run the feed, read back what fired.
|
||
let step = |app: &mut App, flags: u32| -> Vec<String> {
|
||
app.world_mut()
|
||
.entity_mut(me)
|
||
.get_mut::<ObjectStore>()
|
||
.unwrap()
|
||
.0
|
||
.merge(benilla_protocol::ObjectFields::from_pairs(&[(
|
||
FIELD_PLAYER_FLAGS,
|
||
flags,
|
||
)]));
|
||
app.world_mut().run_system_once(feed_units).unwrap();
|
||
let mut s = app.world_mut().non_send_resource_mut::<UiScript>();
|
||
s.resolve();
|
||
let out = s.eval::<Vec<String>>("return SEEN").unwrap();
|
||
s.run("SEEN = {}").unwrap();
|
||
out
|
||
};
|
||
|
||
// `UPDATE_EXHAUSTION` is other watchers (`0x5de4e0`/`0x5de4b0`) and fires on first sight
|
||
// here, so it is filtered out.
|
||
let flag_events = |v: Vec<String>| -> Vec<String> {
|
||
v.into_iter().filter(|e| e != "UPDATE_EXHAUSTION").collect()
|
||
};
|
||
assert!(
|
||
flag_events(step(&mut app, 0)).is_empty(),
|
||
"the login descriptor is a create: structurally silent"
|
||
);
|
||
|
||
// `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::<crate::ui_party::GroupState>()
|
||
.init_resource::<Reputations>();
|
||
app.insert_non_send_resource(UiScript::new().unwrap());
|
||
|
||
let me = app
|
||
.world_mut()
|
||
.spawn((
|
||
SelfPlayer,
|
||
Guid(ME),
|
||
ObjectStore(ObjectFields::default()),
|
||
Transform::from_xyz(0.0, 0.0, 0.0),
|
||
))
|
||
.id();
|
||
let target = app
|
||
.world_mut()
|
||
.spawn((
|
||
Guid(guid),
|
||
ObjectStore(ObjectFields::from_pairs(&[(FIELD_UNIT_FLAGS, flags)])),
|
||
// Along one axis, so d² is exactly yards².
|
||
Transform::from_xyz(yards, 0.0, 0.0),
|
||
))
|
||
.id();
|
||
app.insert_resource(crate::net::GuidIndex(
|
||
[(ME, me), (guid, target)].into_iter().collect(),
|
||
));
|
||
app.insert_resource(Selection {
|
||
target: Some(target),
|
||
guid: Some(guid),
|
||
});
|
||
|
||
app.world_mut().run_system_once(feed_unit_reach).unwrap();
|
||
app.world_mut()
|
||
.non_send_resource_mut::<UiScript>()
|
||
.eval::<bool>(expr)
|
||
.unwrap()
|
||
}
|
||
|
||
// 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::<UnitFeedState>()
|
||
.init_resource::<Selection>()
|
||
.init_resource::<NameCache>()
|
||
.init_resource::<Reputations>()
|
||
.init_resource::<crate::ui_party::GroupState>()
|
||
.init_resource::<crate::ui_chat::ChatLog>()
|
||
.init_resource::<crate::sound::MessageSounds>()
|
||
.init_resource::<crate::ui_guild::GuildState>()
|
||
.init_resource::<crate::ui_session::InteractNpc>();
|
||
app.add_message::<FieldChanged>();
|
||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||
app.insert_resource(NetCommands(tx));
|
||
app.insert_non_send_resource(UiScript::new().unwrap());
|
||
app.add_systems(Update, feed_units);
|
||
|
||
// Two 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>();
|
||
selection.target = Some(entity);
|
||
selection.guid = Some(guid);
|
||
app.update();
|
||
};
|
||
let run = |app: &mut App, src: &str| {
|
||
app.world_mut()
|
||
.non_send_resource_mut::<UiScript>()
|
||
.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::<UiScript>()
|
||
.eval::<String>(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::<String>(key).unwrap_or_default();
|
||
|
||
for (was, now) in [(false, true), (true, false)] {
|
||
let (toast, verbose) = pvp_announcement(Some(was), now).expect("an edge speaks");
|
||
assert!(!g(toast).is_empty(), "{toast} missing");
|
||
assert!(!g(verbose).is_empty(), "{verbose} missing");
|
||
}
|
||
assert!(
|
||
g("PVP_TOGGLE_OFF_VERBOSE").contains("five minutes"),
|
||
"the OFF sentence is what tells the player the flag lingers"
|
||
);
|
||
for state in [1u8, 2] {
|
||
let key = rest_state_message(0, state).expect("states 1 and 2 speak");
|
||
assert!(!g(key).is_empty(), "{key} missing");
|
||
}
|
||
}
|
||
|
||
/// 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::<UnitFeedState>()
|
||
.init_resource::<Selection>()
|
||
.init_resource::<NameCache>()
|
||
.init_resource::<Reputations>()
|
||
.init_resource::<crate::ui_party::GroupState>()
|
||
.init_resource::<crate::ui_chat::ChatLog>()
|
||
// `show_messages`' sink is the chat log and the message-sound queue.
|
||
.init_resource::<crate::sound::MessageSounds>()
|
||
.init_resource::<crate::ui_guild::GuildState>()
|
||
.init_resource::<InteractNpc>();
|
||
app.add_message::<FieldChanged>();
|
||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||
app.insert_resource(NetCommands(tx));
|
||
app.insert_non_send_resource(UiScript::new().unwrap());
|
||
app.add_systems(Update, feed_units);
|
||
// Two vendors, told apart by level alone.
|
||
let mut vendor = |level: u32| {
|
||
app.world_mut()
|
||
.spawn(ObjectStore(ObjectFields::from_pairs(&[(
|
||
FIELD_UNIT_LEVEL,
|
||
level,
|
||
)])))
|
||
.id()
|
||
};
|
||
let brog = vendor(7);
|
||
let dobbins = vendor(9);
|
||
let eval = |app: &mut App, expr: &str| -> i64 {
|
||
app.world_mut()
|
||
.non_send_resource_mut::<UiScript>()
|
||
.eval::<i64>(expr)
|
||
.unwrap()
|
||
};
|
||
let exists = |app: &mut App| eval(app, r#"return UnitExists("npc") and 1 or 0"#) == 1;
|
||
let level = |app: &mut App| eval(app, r#"return UnitLevel("npc")"#);
|
||
|
||
app.update();
|
||
assert!(!exists(&mut app), "no window open, yet UnitExists(\"npc\")");
|
||
|
||
*app.world_mut().resource_mut::<InteractNpc>() = InteractNpc(Some(brog), Some(BROG));
|
||
app.update();
|
||
assert_eq!(
|
||
level(&mut app),
|
||
7,
|
||
"the token names the vendor whose window opened"
|
||
);
|
||
|
||
// Dobbins' window opens over it: the interaction NPC is the only thing that moved.
|
||
*app.world_mut().resource_mut::<InteractNpc>() = InteractNpc(Some(dobbins), Some(DOBBINS));
|
||
app.update();
|
||
assert_eq!(
|
||
level(&mut app),
|
||
9,
|
||
"a second vendor over an open window swaps the token"
|
||
);
|
||
|
||
*app.world_mut().resource_mut::<InteractNpc>() = 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::<Selection>()
|
||
.init_resource::<Reputations>()
|
||
.init_resource::<crate::ui_party::GroupState>()
|
||
.init_resource::<crate::net::GuidIndex>();
|
||
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::<crate::net::GuidIndex>();
|
||
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::<UiScript>()
|
||
.eval::<i64>(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::<UiScript>()
|
||
.set_unit(
|
||
"target",
|
||
Some(UnitState {
|
||
exists: true,
|
||
guid: NEAR,
|
||
..Default::default()
|
||
}),
|
||
);
|
||
app.world_mut()
|
||
.non_send_resource_mut::<UiScript>()
|
||
.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::<UnitFeedState>()
|
||
.init_resource::<Selection>()
|
||
.init_resource::<NameCache>()
|
||
.init_resource::<Reputations>()
|
||
.init_resource::<crate::ui_party::GroupState>()
|
||
.init_resource::<crate::ui_chat::ChatLog>()
|
||
.init_resource::<crate::sound::MessageSounds>()
|
||
.init_resource::<crate::ui_guild::GuildState>()
|
||
.init_resource::<crate::net::GuidIndex>();
|
||
app.add_message::<FieldChanged>();
|
||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||
app.insert_resource(NetCommands(tx));
|
||
app.insert_non_send_resource(UiScript::new().unwrap());
|
||
app.add_systems(Update, feed_units);
|
||
|
||
// 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::<crate::net::GuidIndex>();
|
||
index.0.insert(HOGGER, hogger);
|
||
index.0.insert(MANKRIK, mankrik);
|
||
|
||
let run = |app: &mut App, body: &str| {
|
||
app.world_mut()
|
||
.non_send_resource_mut::<UiScript>()
|
||
.run(body)
|
||
.unwrap();
|
||
};
|
||
let eval = |app: &mut App, expr: &str| -> i64 {
|
||
app.world_mut()
|
||
.non_send_resource_mut::<UiScript>()
|
||
.eval::<i64>(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<String> {
|
||
app.world_mut()
|
||
.non_send_resource_mut::<UiScript>()
|
||
.eval::<Vec<String>>("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::<Selection>().target = Some(hogger);
|
||
app.world_mut().resource_mut::<Selection>().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::<Selection>().target = Some(mankrik);
|
||
app.world_mut().resource_mut::<Selection>().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<String> = seen(&mut app)
|
||
.into_iter()
|
||
.filter(|t| t.starts_with("focus"))
|
||
.collect();
|
||
assert_eq!(after, Vec::<String>::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::<crate::net::WorldportMessage>()
|
||
.init_resource::<crate::ui_script::LeavingWorldArmed>()
|
||
.add_systems(Update, fire_leaving_world_on_worldport);
|
||
app.insert_non_send_resource(UiScript::new().expect("VM"));
|
||
app.world_mut()
|
||
.non_send_resource::<UiScript>()
|
||
.run(
|
||
"Left = 0 \
|
||
local f = CreateFrame(\"Frame\") \
|
||
f:RegisterEvent(\"PLAYER_LEAVING_WORLD\") \
|
||
f:SetScript(\"OnEvent\", function() Left = Left + 1 end)",
|
||
)
|
||
.expect("probe frame");
|
||
let left = |app: &mut App| -> i64 {
|
||
app.world_mut()
|
||
.non_send_resource_mut::<UiScript>()
|
||
.eval::<i64>("return Left")
|
||
.unwrap()
|
||
};
|
||
let port = |needs_ack: bool| crate::net::WorldportMessage {
|
||
map_id: 1,
|
||
position: [0.0; 3],
|
||
orientation: 0.0,
|
||
needs_ack,
|
||
transport_entry: None,
|
||
};
|
||
|
||
let arm = |app: &mut App| {
|
||
app.world_mut()
|
||
.resource_mut::<crate::ui_script::LeavingWorldArmed>()
|
||
.arm();
|
||
};
|
||
|
||
// A world began: 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.
|
||
#[test]
|
||
fn the_rear_arc_is_the_servers_own_half_circle() {
|
||
// 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)));
|
||
}
|
||
}
|