the combat log speaks, and the swim line follows your native form
- the client synthesises its combat-log chat lines: 44 chat types over 34 GlobalString families from 7 packets, the reference's own four hops (classify both endpoints → chat type → string key → format), text resolved out of the player's own GlobalStrings — so damage meters and Quiver's TranqAnnouncer have something to parse; wow-re's byte round corrected six gates (owner is charmed-by then created-by, "party" is your subgroup, friend-or-foe is CanAttack, the range gate, the miss defaults, the damage-shield chat type) - the collision prism follows the NATIVE display, not the rendered one: a druid in bear form keeps the druid's swim, splash and wade lines and a morph keeps the human's — worst divergence on shipped data 0.72 yd (tauren moonkin), the sign flipping by form - the creature swim gate is on both legs: a unit that loses its flag mid-water is driven out of swim at any depth, as the reference drops a charmed creature to the seabed on the next tick Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::net::NetEntity;
|
||||
use crate::net::{NetEntity, ObjectStore};
|
||||
|
||||
use super::Creatures;
|
||||
|
||||
@@ -47,6 +47,21 @@ use super::Creatures;
|
||||
/// `display_scale` override undercuts it (by up to 6×). Those units swam at a fraction of their
|
||||
/// real depth. The floor can only ever *raise* `h`, so no unit that swims correctly today stops.
|
||||
///
|
||||
/// **The display it is derived from is the unit's NATIVE one** — `UNIT_FIELD_NATIVEDISPLAYID`, not
|
||||
/// the one being rendered. `0x60b270` fetches its `CreatureDisplayInfo` row at
|
||||
/// `[unit+0x110]+0x1f8` (index 132), while its sibling `0x60ae10` reads `+0x1f4` (index 131 =
|
||||
/// `UNIT_FIELD_DISPLAYID`) off the same array; the cached-row arm is taken only when the two
|
||||
/// fields are equal (`[unit+0xc58] & 0x100` = "not transformed", set from their equality at
|
||||
/// `0x60b166`), so **every arm yields the native model's row**. VERIFIED — wow-re
|
||||
/// `mover-collision-scalars.md` and `remote-swim-decision.md` §4.
|
||||
///
|
||||
/// So **a transform does not resize the collision prism**: a druid in bear form keeps the druid's
|
||||
/// swim, splash, foam and wade lines; a GM `.modify morph` into a gnome keeps the human's. That is
|
||||
/// the reference deliberately letting the collision box and the drawn body disagree — and nothing
|
||||
/// here needs them to agree, because every consumer of this number is a *depth line* (a fraction of
|
||||
/// `h`), never a rendered size. Decision 0695 restamped it from the rendered display on the
|
||||
/// opposite premise ("the collision box and the drawn body can never disagree"); 1574 corrects that.
|
||||
///
|
||||
/// **Not** the movement capsule: that stays the constant-height tunable it has always been
|
||||
/// ([`crate::player::CAPSULE_HEIGHT`] — see its doc for why the two are deliberately separate).
|
||||
#[derive(Component, Clone, Copy, Debug, PartialEq)]
|
||||
@@ -61,6 +76,19 @@ impl Default for CollisionHeight {
|
||||
}
|
||||
}
|
||||
|
||||
/// The display id the collision prism is derived from: **`UNIT_FIELD_NATIVEDISPLAYID`**, falling
|
||||
/// back to the rendered display when the field is absent (a unit with no descriptor block yet, a
|
||||
/// synthetic entity like a mount child). `0` is treated as absent — the wire's own "unset".
|
||||
///
|
||||
/// This is the whole of decision 1574: one field swap, at the two sites that derive the prism.
|
||||
pub(crate) fn prism_display_id(store: Option<&ObjectStore>, rendered: Option<u32>) -> Option<u32> {
|
||||
store
|
||||
.and_then(|s| s.0.unit_native_displayid())
|
||||
.and_then(|id| u32::try_from(id).ok())
|
||||
.filter(|id| *id != 0)
|
||||
.or(rendered)
|
||||
}
|
||||
|
||||
/// A unit's collision height from its display id and render scale — [`CollisionHeight`]'s one
|
||||
/// derivation, shared by the ECS stamp below and the avatar's mirror of it
|
||||
/// (`player::wire_in`), so the two can never drift into different answers.
|
||||
@@ -97,22 +125,66 @@ fn prism_scale(scale: f32, display_scale: Option<f32>) -> f32 {
|
||||
/// carrying none at all — is still stamped, at the ctor default, so no consumer has to tell
|
||||
/// "not resolved yet" apart from "resolved to nothing".
|
||||
///
|
||||
/// This system stamps only the **first** value. A live change to either input — a display-id swap
|
||||
/// (druid form, GM morph) or a `SCALE_X` change — restamps through
|
||||
/// [`super::live_display::refresh_live_display`] (decision 0695), in the same commit as the model
|
||||
/// swap it rides with: the collision box and the drawn body can never disagree, which is exactly
|
||||
/// why neither restamped alone before the swap existed (the old F04 deferral).
|
||||
/// This system stamps only the **first** value. A live change to either input — the unit's
|
||||
/// **native** display id or its `SCALE_X` — restamps through
|
||||
/// [`super::live_display::refresh_live_display`]. A *rendered* display swap (druid form, GM morph)
|
||||
/// deliberately does **not**: see [`CollisionHeight`] for why the reference lets the collision box
|
||||
/// and the drawn body disagree.
|
||||
pub(super) fn stamp_collision_heights(
|
||||
mut commands: Commands,
|
||||
creatures: Option<Res<Creatures>>,
|
||||
units: Query<(Entity, &NetEntity), Without<CollisionHeight>>,
|
||||
units: Query<(Entity, &NetEntity, Option<&ObjectStore>), Without<CollisionHeight>>,
|
||||
) {
|
||||
for (entity, net) in &units {
|
||||
let h = collision_height_for(creatures.as_deref(), net.display_id, net.scale);
|
||||
for (entity, net, store) in &units {
|
||||
let display = prism_display_id(store, net.display_id);
|
||||
let h = collision_height_for(creatures.as_deref(), display, net.scale);
|
||||
commands.entity(entity).insert(h);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod native_display {
|
||||
use super::prism_display_id;
|
||||
use crate::net::ObjectStore;
|
||||
use benilla_protocol::ObjectFields;
|
||||
|
||||
/// `UNIT_FIELD_NATIVEDISPLAYID`, index 132 — the gap between DISPLAYID (131) and
|
||||
/// MOUNTDISPLAYID (133), both of which this crate already pins independently.
|
||||
const NATIVE: u16 = 132;
|
||||
|
||||
fn store(pairs: &[(u16, u32)]) -> ObjectStore {
|
||||
ObjectStore(ObjectFields::from_pairs(pairs))
|
||||
}
|
||||
|
||||
/// A druid in bear form: rendered display 2281, native still the night elf's 55. The prism
|
||||
/// follows the native one — decision 1574's whole observable.
|
||||
#[test]
|
||||
fn a_shapeshifted_unit_keeps_its_native_display() {
|
||||
let shifted = store(&[(NATIVE, 55)]);
|
||||
assert_eq!(prism_display_id(Some(&shifted), Some(2281)), Some(55));
|
||||
}
|
||||
|
||||
/// The fallbacks, all three of them, all landing on the rendered display: no descriptor block
|
||||
/// yet, a descriptor without the field, and the wire's own "unset" zero.
|
||||
#[test]
|
||||
fn an_absent_or_zero_native_falls_back_to_the_rendered_display() {
|
||||
assert_eq!(prism_display_id(None, Some(2281)), Some(2281));
|
||||
let empty = store(&[]);
|
||||
assert_eq!(prism_display_id(Some(&empty), Some(2281)), Some(2281));
|
||||
let zeroed = store(&[(NATIVE, 0)]);
|
||||
assert_eq!(prism_display_id(Some(&zeroed), Some(2281)), Some(2281));
|
||||
// …and a unit with neither stays None, which `collision_height_for` reads as "no row".
|
||||
assert_eq!(prism_display_id(Some(&empty), None), None);
|
||||
}
|
||||
|
||||
/// An unshifted unit — native == rendered — is the common case and must be untouched.
|
||||
#[test]
|
||||
fn an_unshifted_unit_is_unaffected() {
|
||||
let plain = store(&[(NATIVE, 4945)]);
|
||||
assert_eq!(prism_display_id(Some(&plain), Some(4945)), Some(4945));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod prism {
|
||||
use super::prism_scale;
|
||||
|
||||
@@ -107,8 +107,6 @@ pub(super) fn refresh_live_display(
|
||||
mut swapped: MessageWriter<DisplaySwapped>,
|
||||
) {
|
||||
for (entity, guid, mut net, store, applied, height, tf) in &mut entities {
|
||||
let mut restamp = false;
|
||||
|
||||
// ── The display-id swap ──────────────────────────────────────────────────────────────
|
||||
let live = live_display_id(net.kind, store);
|
||||
if let Some(live) = live {
|
||||
@@ -118,7 +116,6 @@ pub(super) fn refresh_live_display(
|
||||
guid.0, net.kind, applied.0, live
|
||||
);
|
||||
net.display_id = Some(live);
|
||||
restamp = true;
|
||||
swapped.write(DisplaySwapped { entity });
|
||||
// The full visual teardown set + our own diff key: children (parts, anchors,
|
||||
// held roots, mount child) despawn, the per-instance visual components strip, and
|
||||
@@ -166,7 +163,6 @@ pub(super) fn refresh_live_display(
|
||||
guid.0, net.scale, live
|
||||
);
|
||||
net.scale = live;
|
||||
restamp = true;
|
||||
commands.entity(entity).insert(ScaleEase {
|
||||
from: tf.scale.x,
|
||||
to: live,
|
||||
@@ -175,19 +171,31 @@ pub(super) fn refresh_live_display(
|
||||
}
|
||||
}
|
||||
|
||||
// One restamp per frame however many inputs moved: both CollisionHeight inputs live here.
|
||||
// ── The collision prism ──────────────────────────────────────────────────────────────
|
||||
// Derived from the unit's **native** display, so the rendered swap above is NOT one of its
|
||||
// inputs (decision 1574 — the reference reads `NATIVEDISPLAYID` at `[unit+0x110]+0x1f8`).
|
||||
// Its two real inputs are `UNIT_FIELD_NATIVEDISPLAYID` and `SCALE_X`, and neither has a
|
||||
// change-gate we can piggyback on, so it is recomputed and diffed against the stamped
|
||||
// component instead: two `HashMap` lookups per attached unit per frame, the same order as
|
||||
// the two store reads this loop already does above (~0.02 ms at 300 units — a twentieth of
|
||||
// the standing-idler cost decision 1445 gated away, and unlike a gate it cannot miss a
|
||||
// native-display change that moved no pixel).
|
||||
//
|
||||
// Snapped to the TARGET scale immediately (the swim/wade/splash lines move once) — easing
|
||||
// a collision plane would drag the resolver through two seconds of intermediate depths.
|
||||
if restamp {
|
||||
let h = collision_height_for(creatures.as_deref(), net.display_id, net.scale);
|
||||
if height != Some(&h) {
|
||||
debug!(
|
||||
"collision height restamp: guid {:016x} {:?} -> {:.3}",
|
||||
guid.0,
|
||||
height.map(|c| c.0),
|
||||
h.0
|
||||
);
|
||||
}
|
||||
let prism = super::collision_height::prism_display_id(Some(store), net.display_id);
|
||||
let h = collision_height_for(creatures.as_deref(), prism, net.scale);
|
||||
if height != Some(&h) {
|
||||
debug!(
|
||||
"collision height restamp: guid {:016x} {:?} -> {:.3} (native display {:?}, \
|
||||
rendered {:?}, scale {:.3})",
|
||||
guid.0,
|
||||
height.map(|c| c.0),
|
||||
h.0,
|
||||
prism,
|
||||
net.display_id,
|
||||
net.scale
|
||||
);
|
||||
commands.entity(entity).insert(h);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ mod anim;
|
||||
mod auction;
|
||||
mod chat;
|
||||
mod combat;
|
||||
mod combat_chat;
|
||||
mod combat_log;
|
||||
mod death;
|
||||
mod group;
|
||||
@@ -230,11 +231,16 @@ pub(super) fn apply_net_updates(
|
||||
// the container feed to pick up).
|
||||
mut ui_actions: (
|
||||
ResMut<crate::ui_action::PlayerActions>,
|
||||
// Nested pair (the tuple is at the 16-param ceiling): the cast + mount error queues,
|
||||
// both drained into the red error line by `ui_action::feed_actions`.
|
||||
// Nested group (the tuple is at the 16-param ceiling): the cast + mount error queues,
|
||||
// both drained into the red error line by `ui_action::feed_actions`, plus the
|
||||
// FactionTemplate catalog the combat log's friend/foe split reads (B297). The catalog has
|
||||
// nothing to do with the other two — it rides here purely because this is where the
|
||||
// ceiling left room. Absent if the DBC failed to load, which degrades an unresolved unit
|
||||
// to the friendly side of the classifier rather than dropping its lines.
|
||||
(
|
||||
ResMut<crate::ui_action::CastErrors>,
|
||||
ResMut<crate::ui_action::MountErrors>,
|
||||
Option<Res<crate::target::ring::Factions>>,
|
||||
),
|
||||
ResMut<crate::ui_items::EquipErrors>,
|
||||
ResMut<crate::ui_merchant::MerchantErrors>,
|
||||
@@ -406,6 +412,23 @@ pub(super) fn apply_net_updates(
|
||||
// `UnitSpeeds` insert is a Command, so a `SMSG_FORCE_*_SPEED_CHANGE` arriving later in this
|
||||
// same drain could not land on top of it. Both stage here in packet order (decision 1478).
|
||||
let mut speed_stage = objects::SpeedStage::default();
|
||||
// The combat log's classification inputs (B297). Built per use rather than once: the arms
|
||||
// around these ones take `&mut` to `index`, `group` and `reputations`, so a borrow held across
|
||||
// the whole drain would not compile. `macro_rules!` here is hygienic against the locals it
|
||||
// names because it is defined after them, so this is one expression in seven call sites rather
|
||||
// than seven copies of six fields.
|
||||
macro_rules! chat_ctx {
|
||||
() => {
|
||||
combat_chat::ChatCtx {
|
||||
self_guid: &self_guid,
|
||||
group: Some(&group),
|
||||
index: &index,
|
||||
factions: ui_actions.1 .2.as_deref(),
|
||||
reputations: &reputations,
|
||||
spells: ui_actions.11.as_deref(),
|
||||
}
|
||||
};
|
||||
}
|
||||
for ev in events.0.try_iter() {
|
||||
match ev {
|
||||
SessionEvent::LoginStage { stage } => session::login_stage(stage, &mut login_stages),
|
||||
@@ -1067,67 +1090,98 @@ pub(super) fn apply_net_updates(
|
||||
SessionEvent::AiReaction { unit, reaction } => {
|
||||
combat::ai_reaction(unit, reaction, &index, &mut audio.11)
|
||||
}
|
||||
SessionEvent::AttackerState(s) => combat::attacker_state(
|
||||
s,
|
||||
&index,
|
||||
&self_guid,
|
||||
&mut audio.3,
|
||||
&mut audio.8,
|
||||
&mut audio.15 .1,
|
||||
&mut audio.15 .2,
|
||||
play_seq.next(),
|
||||
),
|
||||
SessionEvent::SpellDamageLog(s) => combat_log::spell_damage_log(
|
||||
s,
|
||||
&index,
|
||||
&self_guid,
|
||||
&stores,
|
||||
ui_actions.11.as_deref(),
|
||||
&mut audio.7,
|
||||
&mut audio.15 .0,
|
||||
&mut audio.15 .1,
|
||||
),
|
||||
SessionEvent::PeriodicAuraLog(s) => combat_log::periodic_aura_log(
|
||||
s,
|
||||
&index,
|
||||
&self_guid,
|
||||
&stores,
|
||||
ui_actions.11.as_deref(),
|
||||
&mut audio.7,
|
||||
&mut audio.15 .0,
|
||||
&mut audio.15 .1,
|
||||
&mut names,
|
||||
&net_commands,
|
||||
),
|
||||
SessionEvent::SpellHealLog(s) => combat_log::spell_heal_log(
|
||||
s,
|
||||
&index,
|
||||
&self_guid,
|
||||
&mut audio.15 .0,
|
||||
&mut audio.15 .1,
|
||||
&mut names,
|
||||
&net_commands,
|
||||
),
|
||||
SessionEvent::AttackerState(s) => {
|
||||
combat_chat::attacker_state(s, &chat_ctx!(), &stores, &transforms, &mut chat_log);
|
||||
combat::attacker_state(
|
||||
s,
|
||||
&index,
|
||||
&self_guid,
|
||||
&mut audio.3,
|
||||
&mut audio.8,
|
||||
&mut audio.15 .1,
|
||||
&mut audio.15 .2,
|
||||
play_seq.next(),
|
||||
)
|
||||
}
|
||||
SessionEvent::SpellDamageLog(s) => {
|
||||
combat_chat::spell_damage_log(s, &chat_ctx!(), &stores, &transforms, &mut chat_log);
|
||||
combat_log::spell_damage_log(
|
||||
s,
|
||||
&index,
|
||||
&self_guid,
|
||||
&stores,
|
||||
ui_actions.11.as_deref(),
|
||||
&mut audio.7,
|
||||
&mut audio.15 .0,
|
||||
&mut audio.15 .1,
|
||||
)
|
||||
}
|
||||
SessionEvent::PeriodicAuraLog(s) => {
|
||||
combat_chat::periodic_aura_log(
|
||||
&s,
|
||||
&chat_ctx!(),
|
||||
&stores,
|
||||
&transforms,
|
||||
&mut chat_log,
|
||||
);
|
||||
combat_log::periodic_aura_log(
|
||||
s,
|
||||
&index,
|
||||
&self_guid,
|
||||
&stores,
|
||||
ui_actions.11.as_deref(),
|
||||
&mut audio.7,
|
||||
&mut audio.15 .0,
|
||||
&mut audio.15 .1,
|
||||
&mut names,
|
||||
&net_commands,
|
||||
)
|
||||
}
|
||||
SessionEvent::SpellHealLog(s) => {
|
||||
combat_chat::spell_heal_log(s, &chat_ctx!(), &stores, &transforms, &mut chat_log);
|
||||
combat_log::spell_heal_log(
|
||||
s,
|
||||
&index,
|
||||
&self_guid,
|
||||
&mut audio.15 .0,
|
||||
&mut audio.15 .1,
|
||||
&mut names,
|
||||
&net_commands,
|
||||
)
|
||||
}
|
||||
SessionEvent::SpellEnergizeLog(s) => {
|
||||
combat_chat::spell_energize_log(
|
||||
s,
|
||||
&chat_ctx!(),
|
||||
&stores,
|
||||
&transforms,
|
||||
&mut chat_log,
|
||||
);
|
||||
combat_log::spell_energize_log(s, &self_guid, &mut audio.15 .1)
|
||||
}
|
||||
SessionEvent::DamageShield(s) => combat_log::damage_shield(
|
||||
s,
|
||||
&index,
|
||||
&self_guid,
|
||||
&stores,
|
||||
&mut audio.7,
|
||||
&mut audio.15 .0,
|
||||
),
|
||||
SessionEvent::SpellLogMiss(s) => combat_log::spell_log_miss(
|
||||
s,
|
||||
&index,
|
||||
&self_guid,
|
||||
&stores,
|
||||
&mut audio.7,
|
||||
&mut audio.15 .0,
|
||||
&mut audio.15 .1,
|
||||
),
|
||||
SessionEvent::DamageShield(s) => {
|
||||
combat_chat::damage_shield(s, &chat_ctx!(), &stores, &transforms, &mut chat_log);
|
||||
combat_log::damage_shield(
|
||||
s,
|
||||
&index,
|
||||
&self_guid,
|
||||
&stores,
|
||||
&mut audio.7,
|
||||
&mut audio.15 .0,
|
||||
)
|
||||
}
|
||||
SessionEvent::SpellLogMiss(s) => {
|
||||
combat_chat::spell_log_miss(&s, &chat_ctx!(), &stores, &transforms, &mut chat_log);
|
||||
combat_log::spell_log_miss(
|
||||
s,
|
||||
&index,
|
||||
&self_guid,
|
||||
&stores,
|
||||
&mut audio.7,
|
||||
&mut audio.15 .0,
|
||||
&mut audio.15 .1,
|
||||
)
|
||||
}
|
||||
SessionEvent::XpGain(x) => {
|
||||
combat_log::xp_gain(x, &index, &self_guid, &mut audio.7, &mut chat_log)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
//! The combat-log **chat** arms (B297) — the second consumer of the packets
|
||||
//! [`super::combat_log`] and [`super::combat`] already read for the floating numbers.
|
||||
//!
|
||||
//! The split is the reference's own. `0x629b60` is the display dispatcher: one classification of
|
||||
//! both endpoints, then a branch per outcome into a formatter that emits *text*. The floating
|
||||
//! number comes off a different path entirely (`0x625010`, the world-anchored spawn), gated by
|
||||
//! different CVars, with a different source law — which is why our two live in different files
|
||||
//! rather than in one arm with two tails. Everything here composes; the law it composes with is
|
||||
//! [`crate::ui_chat::combat`].
|
||||
//!
|
||||
//! **Every arm has the same three steps**: classify both endpoints, pick the family and the chat
|
||||
//! type, queue the line for its names. What differs between them is only which packet field is the
|
||||
//! sentence's subject, which is not always the packet's own `attacker`
|
||||
//! (`SMSG_SPELLDAMAGESHIELD` is the standing counter-example).
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_protocol::messages::{
|
||||
AttackerState, DamageShield, PeriodicAuraLog, PeriodicTick, SpellDamageLog, SpellEnergizeLog,
|
||||
SpellHealLog, SpellLogMiss,
|
||||
};
|
||||
|
||||
use crate::ui_chat::combat::{self, Family, Fills, UnitClass};
|
||||
use crate::ui_chat::ChatLog;
|
||||
|
||||
use super::super::{GuidIndex, ObjectStore, Reputations, SelfGuid};
|
||||
|
||||
/// The classification inputs every line needs, bundled so seven arms do not each grow six
|
||||
/// parameters. Assembled once per drain in [`super::apply_net_updates`].
|
||||
pub(super) struct ChatCtx<'a> {
|
||||
pub self_guid: &'a SelfGuid,
|
||||
pub group: Option<&'a crate::ui_party::GroupState>,
|
||||
pub index: &'a GuidIndex,
|
||||
pub factions: Option<&'a crate::target::ring::Factions>,
|
||||
pub reputations: &'a Reputations,
|
||||
pub spells: Option<&'a crate::ui_action::Spells>,
|
||||
}
|
||||
|
||||
impl ChatCtx<'_> {
|
||||
fn classify(&self, guid: u64, stores: &Query<&mut ObjectStore>) -> UnitClass {
|
||||
combat::classify(
|
||||
guid,
|
||||
self.self_guid,
|
||||
self.group,
|
||||
self.index,
|
||||
stores,
|
||||
self.factions,
|
||||
self.reputations,
|
||||
)
|
||||
}
|
||||
|
||||
/// One endpoint's half of the reference's display-range gate (`0x626630` → the per-class range
|
||||
/// getter `0x626810`): a 3-D squared distance from the ACTIVE PLAYER to the endpoint, against
|
||||
/// the class's range squared.
|
||||
///
|
||||
/// **The comparison is strictly `<`** — `fcomp; fnstsw ax; test ah,5; jnp`, so `dist² == range²`
|
||||
/// is OUT and a NaN is out. 1571 used `<=`; the boundary is exact in the binary and there is no
|
||||
/// reason for us to be looser.
|
||||
///
|
||||
/// **The ranges are the compiled-in defaults, not live CVars** — the same standing shape as
|
||||
/// [`crate::combat_text`]'s `COMBAT_DAMAGE`/`PET_*` gates, and for the same reason: the values
|
||||
/// are byte-read and correct, and the CVars (`CombatLogRangeParty` and its six siblings) can be
|
||||
/// wired to the live table without changing anything here. `UnitClass::default_range` carries
|
||||
/// them, including the two sentinels that make the gate a no-op for you and your pet
|
||||
/// (`100000.0`) and unconditional for an unresolvable unit (`0.0`).
|
||||
///
|
||||
/// A pose we do not hold is treated as **in** range: dropping a line because a unit's transform
|
||||
/// had not landed yet would silently lose the killing blow on a mob that despawns, which is a
|
||||
/// worse failure than logging one fight too far away.
|
||||
fn in_range(&self, guid: u64, class: UnitClass, poses: &Query<&mut Transform>) -> bool {
|
||||
let range = class.default_range();
|
||||
if range >= 100_000.0 {
|
||||
return true;
|
||||
}
|
||||
if range <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let pose = |g: u64| {
|
||||
self.index
|
||||
.0
|
||||
.get(&g)
|
||||
.copied()
|
||||
.and_then(|e| poses.get(e).ok())
|
||||
.map(|t| t.translation)
|
||||
};
|
||||
let (Some(me), Some(them)) = (self.self_guid.0.and_then(pose), pose(guid)) else {
|
||||
return true;
|
||||
};
|
||||
me.distance_squared(them) < range * range
|
||||
}
|
||||
|
||||
/// A spell's display name, or `None` when the reference would emit **no line at all** for this
|
||||
/// spell (§5.5 of the §5 verdict). Two gates, both of which 1571 was missing:
|
||||
///
|
||||
/// - **`Attributes & 0x180`** — `SPELL_ATTR_DO_NOT_DISPLAY | SPELL_ATTR_DO_NOT_LOG` (the mask
|
||||
/// and the `SpellRec+0x18` offset are VERIFIED; the two enum names are wow-re's INFERRED
|
||||
/// corroboration from vmangos). This is what keeps the invisible book-keeping spells every
|
||||
/// server casts constantly — proc triggers, aura tickers — out of the log entirely.
|
||||
/// - **An empty localized name.** The reference gates on the name it is about to print, so a
|
||||
/// row with no text in this locale produces silence rather than a sentence with a hole in it.
|
||||
///
|
||||
/// A spell id the catalog cannot answer at all is *not* gated: that is our own missing data,
|
||||
/// not the server's intent, and a nameless sentence still carries the numbers a damage meter
|
||||
/// needs. The reference degrades the same way through `GetObjectName`'s `"UKNOWNOBJECT"` tail.
|
||||
fn spell_name(&self, spell_id: u32) -> Option<String> {
|
||||
let Some(display) = self.spells.and_then(|s| s.catalog.get(spell_id)) else {
|
||||
return Some(String::new());
|
||||
};
|
||||
const DO_NOT_DISPLAY_OR_LOG: u32 = 0x180;
|
||||
if display.attributes & DO_NOT_DISPLAY_OR_LOG != 0 || display.name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(display.name.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_ATTACKERSTATEUPDATE` → the melee line. The whole `COMBAT_*` half of the block comes from
|
||||
/// this one packet.
|
||||
///
|
||||
/// `school` is the swing's first sub-damage school, which is what selects the `…SCHOOL` template
|
||||
/// ("You hit X for 5 fire damage." against "You hit X for 5."). Physical (0) takes the plain form.
|
||||
pub(super) fn attacker_state(
|
||||
s: AttackerState,
|
||||
ctx: &ChatCtx,
|
||||
stores: &Query<&mut ObjectStore>,
|
||||
poses: &Query<&mut Transform>,
|
||||
log: &mut ChatLog,
|
||||
) {
|
||||
let attacker = ctx.classify(s.attacker, stores);
|
||||
let victim = ctx.classify(s.victim, stores);
|
||||
let family = combat::melee_family(s.hit_info, s.victim_state, s.damage, s.school);
|
||||
// A swing is a MISS line only for the outcomes the reference words as one; everything the
|
||||
// victim did about it (dodge, parry, block, …) is still a "misses" chat TYPE, because the type
|
||||
// pair is hits/misses and only a landed hit is "hits".
|
||||
let landed = family.stem.starts_with("COMBATHIT");
|
||||
let Some(kind) = combat::combat_kind(attacker, victim, !landed) else {
|
||||
return;
|
||||
};
|
||||
let fills = Fills {
|
||||
spell: String::new(),
|
||||
school: (s.school != 0).then_some(s.school),
|
||||
amount: i64::from(s.damage),
|
||||
..Default::default()
|
||||
};
|
||||
queue(
|
||||
log,
|
||||
ctx,
|
||||
poses,
|
||||
kind,
|
||||
family,
|
||||
(s.attacker, attacker),
|
||||
(s.victim, victim),
|
||||
fills,
|
||||
);
|
||||
}
|
||||
|
||||
/// `SMSG_SPELLNONMELEEDAMAGELOG` → a spell's damage line, or the fully-absorbed / fully-resisted
|
||||
/// wording when nothing got through.
|
||||
///
|
||||
/// The packet's own `periodic` flag routes the line to the `SPELL_PERIODIC_*` types and the
|
||||
/// `PERIODICAURADAMAGE` wording — a DoT tick the server chose to report here rather than through
|
||||
/// `SMSG_PERIODICAURALOG` is still a periodic tick, and the reference tells them apart the same way.
|
||||
pub(super) fn spell_damage_log(
|
||||
s: SpellDamageLog,
|
||||
ctx: &ChatCtx,
|
||||
stores: &Query<&mut ObjectStore>,
|
||||
poses: &Query<&mut Transform>,
|
||||
log: &mut ChatLog,
|
||||
) {
|
||||
let attacker = ctx.classify(s.attacker, stores);
|
||||
let victim = ctx.classify(s.target, stores);
|
||||
// `SPELL_HIT_TYPE_CRIT` (vmangos `SpellDefines.h:179`) — the same bit the floating text reads.
|
||||
let crit = s.hit_info & 0x2 != 0;
|
||||
let Some(spell) = ctx.spell_name(s.spell_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if s.periodic {
|
||||
let Some(kind) = combat::periodic_kind(attacker, false) else {
|
||||
return;
|
||||
};
|
||||
let fills = Fills {
|
||||
spell,
|
||||
school: Some(s.school),
|
||||
amount: i64::from(s.damage),
|
||||
..Default::default()
|
||||
};
|
||||
return queue(
|
||||
log,
|
||||
ctx,
|
||||
poses,
|
||||
kind,
|
||||
combat::PERIODICAURADAMAGE,
|
||||
(s.attacker, attacker),
|
||||
(s.target, victim),
|
||||
fills,
|
||||
);
|
||||
}
|
||||
|
||||
let Some(kind) = combat::spell_kind(attacker, victim, false) else {
|
||||
return;
|
||||
};
|
||||
// Nothing through: the reference words it as the reason rather than as a zero.
|
||||
let family = if s.damage == 0 && s.absorb > 0 {
|
||||
combat::SPELLLOGABSORB
|
||||
} else if s.damage == 0 && s.resist > 0 {
|
||||
combat::SPELLRESIST
|
||||
} else {
|
||||
match (crit, s.school != 0) {
|
||||
(false, false) => combat::SPELLLOG,
|
||||
(true, false) => combat::SPELLLOGCRIT,
|
||||
(false, true) => combat::SPELLLOGSCHOOL,
|
||||
(true, true) => combat::SPELLLOGCRITSCHOOL,
|
||||
}
|
||||
};
|
||||
let fills = Fills {
|
||||
spell,
|
||||
school: Some(s.school),
|
||||
amount: i64::from(s.damage),
|
||||
..Default::default()
|
||||
};
|
||||
queue(
|
||||
log,
|
||||
ctx,
|
||||
poses,
|
||||
kind,
|
||||
family,
|
||||
(s.attacker, attacker),
|
||||
(s.target, victim),
|
||||
fills,
|
||||
);
|
||||
}
|
||||
|
||||
/// `SMSG_SPELLLOGMISS` → one line per target a cast failed to land on, each worded by its own
|
||||
/// `SpellMissInfo`.
|
||||
///
|
||||
/// The packet is a *list*, and each entry is its own sentence with its own victim — so the chat
|
||||
/// type is recomputed per entry, not once for the cast.
|
||||
pub(super) fn spell_log_miss(
|
||||
s: &SpellLogMiss,
|
||||
ctx: &ChatCtx,
|
||||
stores: &Query<&mut ObjectStore>,
|
||||
poses: &Query<&mut Transform>,
|
||||
log: &mut ChatLog,
|
||||
) {
|
||||
let attacker = ctx.classify(s.caster, stores);
|
||||
let Some(spell) = ctx.spell_name(s.spell_id) else {
|
||||
return;
|
||||
};
|
||||
// **Every line off this packet is typed as a DAMAGE-SHIELD line**, and that is not a guess:
|
||||
// the `SMSG_SPELLLOGMISS` caller passes the formatter's 5th argument as 1 (`0x5e7f31 push 1`),
|
||||
// which routes `0x62bc5a` to `0x62c140` — the damage-shield two-way selector — instead of the
|
||||
// usual eight-row spell matrix. Its five other callers pass 0 and take the normal path.
|
||||
//
|
||||
// Whether that is deliberate or a 1.12 bug is **not derivable from the binary**, and wow-re
|
||||
// says so rather than guessing. We reproduce the behaviour, because an addon filtering on
|
||||
// chat type has to see what the reference shows; the discriminator, if anyone wants it, is a
|
||||
// live cast that misses and a look at which ChatFrame filter catches the line. 1571 sent these
|
||||
// through `spell_kind` and had them land in `SPELL_*_DAMAGE`.
|
||||
let kind = if matches!(attacker, UnitClass::Me | UnitClass::MyPet) {
|
||||
crate::ui_chat::ChatEventKind::SpellDamageShieldsOnSelf
|
||||
} else {
|
||||
crate::ui_chat::ChatEventKind::SpellDamageShieldsOnOthers
|
||||
};
|
||||
for &(target, miss_info) in &s.misses {
|
||||
let family = combat::miss_family(miss_info);
|
||||
let victim = ctx.classify(target, stores);
|
||||
let fills = Fills {
|
||||
spell: spell.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
queue(
|
||||
log,
|
||||
ctx,
|
||||
poses,
|
||||
kind,
|
||||
family,
|
||||
(s.caster, attacker),
|
||||
(target, victim),
|
||||
fills,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_SPELLHEALLOG` → a heal line. A heal is a **BUFF** type, not a damage one.
|
||||
pub(super) fn spell_heal_log(
|
||||
s: SpellHealLog,
|
||||
ctx: &ChatCtx,
|
||||
stores: &Query<&mut ObjectStore>,
|
||||
poses: &Query<&mut Transform>,
|
||||
log: &mut ChatLog,
|
||||
) {
|
||||
let healer = ctx.classify(s.healer, stores);
|
||||
let target = ctx.classify(s.target, stores);
|
||||
let Some(kind) = combat::spell_kind(healer, target, true) else {
|
||||
return;
|
||||
};
|
||||
let family = if s.critical {
|
||||
combat::HEALEDCRIT
|
||||
} else {
|
||||
combat::HEALED
|
||||
};
|
||||
let Some(spell) = ctx.spell_name(s.spell_id) else {
|
||||
return;
|
||||
};
|
||||
let fills = Fills {
|
||||
spell,
|
||||
amount: i64::from(s.amount),
|
||||
..Default::default()
|
||||
};
|
||||
queue(
|
||||
log,
|
||||
ctx,
|
||||
poses,
|
||||
kind,
|
||||
family,
|
||||
(s.healer, healer),
|
||||
(s.target, target),
|
||||
fills,
|
||||
);
|
||||
}
|
||||
|
||||
/// `SMSG_SPELLENERGIZELOG` → a power-gain line, also a BUFF type.
|
||||
pub(super) fn spell_energize_log(
|
||||
s: SpellEnergizeLog,
|
||||
ctx: &ChatCtx,
|
||||
stores: &Query<&mut ObjectStore>,
|
||||
poses: &Query<&mut Transform>,
|
||||
log: &mut ChatLog,
|
||||
) {
|
||||
let caster = ctx.classify(s.caster, stores);
|
||||
let target = ctx.classify(s.target, stores);
|
||||
let Some(kind) = combat::spell_kind(caster, target, true) else {
|
||||
return;
|
||||
};
|
||||
let Some(spell) = ctx.spell_name(s.spell_id) else {
|
||||
return;
|
||||
};
|
||||
let fills = Fills {
|
||||
spell,
|
||||
power: Some(s.power),
|
||||
amount: i64::from(s.amount),
|
||||
..Default::default()
|
||||
};
|
||||
queue(
|
||||
log,
|
||||
ctx,
|
||||
poses,
|
||||
kind,
|
||||
combat::POWERGAIN,
|
||||
(s.caster, caster),
|
||||
(s.target, target),
|
||||
fills,
|
||||
);
|
||||
}
|
||||
|
||||
/// `SMSG_PERIODICAURALOG` → one line per tick, worded by the tick's aura type.
|
||||
///
|
||||
/// The periodic chat types split DAMAGE from BUFFS and **ignore the victim entirely** (their two
|
||||
/// selectors take one argument), which is why the kind is picked from `caster` alone here.
|
||||
pub(super) fn periodic_aura_log(
|
||||
s: &PeriodicAuraLog,
|
||||
ctx: &ChatCtx,
|
||||
stores: &Query<&mut ObjectStore>,
|
||||
poses: &Query<&mut Transform>,
|
||||
log: &mut ChatLog,
|
||||
) {
|
||||
let caster = ctx.classify(s.caster, stores);
|
||||
let target = ctx.classify(s.target, stores);
|
||||
let Some(spell) = ctx.spell_name(s.spell_id) else {
|
||||
return;
|
||||
};
|
||||
for tick in &s.ticks {
|
||||
let (family, buff, fills) = match *tick {
|
||||
PeriodicTick::Damage { amount, school, .. } => (
|
||||
combat::PERIODICAURADAMAGE,
|
||||
false,
|
||||
Fills {
|
||||
spell: spell.clone(),
|
||||
// The periodic packet's school is a full u32; the template's slot is the same
|
||||
// `SPELL_SCHOOL<n>_NAME` index the direct-damage one takes.
|
||||
school: u8::try_from(school).ok(),
|
||||
amount: i64::from(amount),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
PeriodicTick::Heal { amount } => (
|
||||
combat::PERIODICAURAHEAL,
|
||||
true,
|
||||
Fills {
|
||||
spell: spell.clone(),
|
||||
amount: i64::from(amount),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
PeriodicTick::Energize { power, amount } => (
|
||||
combat::POWERGAIN,
|
||||
true,
|
||||
Fills {
|
||||
spell: spell.clone(),
|
||||
power: Some(power),
|
||||
amount: i64::from(amount),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
// A mana leech is one sentence about two transfers: what the victim lost and what the
|
||||
// caster gained. vmangos sends the drained amount and a multiplier, not the gain, so
|
||||
// the gained figure is `amount * multiplier` — the same product the server applies.
|
||||
PeriodicTick::ManaLeech {
|
||||
power,
|
||||
amount,
|
||||
multiplier,
|
||||
} => (
|
||||
combat::SPELLPOWERLEECH,
|
||||
true,
|
||||
Fills {
|
||||
spell: spell.clone(),
|
||||
power: Some(power),
|
||||
amount: i64::from(amount),
|
||||
amount2: (f64::from(amount) * f64::from(multiplier)) as i64,
|
||||
power2: Some(power),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
};
|
||||
let Some(kind) = combat::periodic_kind(caster, buff) else {
|
||||
continue;
|
||||
};
|
||||
queue(
|
||||
log,
|
||||
ctx,
|
||||
poses,
|
||||
kind,
|
||||
family,
|
||||
(s.caster, caster),
|
||||
(s.target, target),
|
||||
fills,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_SPELLDAMAGESHIELD` → the Thorns-style return hit.
|
||||
///
|
||||
/// **The sentence's subject is the packet's `victim`.** The wire names the fields from the original
|
||||
/// swing's point of view — `victim` wears the shield, `attacker` struck them and now takes the
|
||||
/// damage back — while `DAMAGESHIELDSELFOTHER` is "You reflect %d %s damage to %s.", whose subject
|
||||
/// is the reflector. Getting this backwards would name both endpoints in the wrong halves of every
|
||||
/// line, and nothing downstream would notice.
|
||||
///
|
||||
/// The chat type is a plain two-way split on the **bearer**, not the usual eight-row matrix:
|
||||
/// `0x62c140` returns `0x3f` (`SPELL_DAMAGESHIELDS_ON_SELF`) for class 0 or 1 and `0x40`
|
||||
/// (`…ON_OTHERS`) for everything else — "damage shields on self" meaning the shield that is on you.
|
||||
pub(super) fn damage_shield(
|
||||
s: DamageShield,
|
||||
ctx: &ChatCtx,
|
||||
stores: &Query<&mut ObjectStore>,
|
||||
poses: &Query<&mut Transform>,
|
||||
log: &mut ChatLog,
|
||||
) {
|
||||
let bearer = ctx.classify(s.victim, stores);
|
||||
let struck = ctx.classify(s.attacker, stores);
|
||||
let kind = if matches!(bearer, UnitClass::Me | UnitClass::MyPet) {
|
||||
crate::ui_chat::ChatEventKind::SpellDamageShieldsOnSelf
|
||||
} else {
|
||||
crate::ui_chat::ChatEventKind::SpellDamageShieldsOnOthers
|
||||
};
|
||||
let fills = Fills {
|
||||
school: u8::try_from(s.school).ok(),
|
||||
amount: i64::from(s.damage),
|
||||
..Default::default()
|
||||
};
|
||||
queue(
|
||||
log,
|
||||
ctx,
|
||||
poses,
|
||||
kind,
|
||||
combat::DAMAGESHIELD,
|
||||
(s.victim, bearer),
|
||||
(s.attacker, struck),
|
||||
fills,
|
||||
);
|
||||
}
|
||||
|
||||
/// The one tail every arm ends in: build the queued line (dropping a class-9 endpoint) and park it
|
||||
/// for its names.
|
||||
#[allow(clippy::too_many_arguments)] // the tail's args ARE the line: sink, context, and the five
|
||||
// facts a queued line is made of. Bundling any of them would only move the list somewhere else.
|
||||
fn queue(
|
||||
log: &mut ChatLog,
|
||||
ctx: &ChatCtx,
|
||||
poses: &Query<&mut Transform>,
|
||||
kind: crate::ui_chat::ChatEventKind,
|
||||
family: Family,
|
||||
subject: (u64, UnitClass),
|
||||
object: (u64, UnitClass),
|
||||
fills: Fills,
|
||||
) {
|
||||
// **The gate is an OR over the two endpoints, not an AND** — the §5 verdict's own wording:
|
||||
// `dist²(player, src) < range(srcClass)²` **OR** `dist²(player, tgt) < range(tgtClass)²`. 1571
|
||||
// required both and therefore dropped lines the client shows: your own pet (range 100000)
|
||||
// fighting something 80 yards away is logged by the reference and was silent here.
|
||||
if !ctx.in_range(subject.0, subject.1, poses) && !ctx.in_range(object.0, object.1, poses) {
|
||||
return;
|
||||
}
|
||||
if let Some(line) = combat::queue(kind, family, subject, object, fills) {
|
||||
log.push_combat(line);
|
||||
}
|
||||
}
|
||||
@@ -674,7 +674,7 @@ fn clamp_seat_disabled() -> bool {
|
||||
|
||||
/// What [`mark_swimming_creatures`] reads per unit: identity, kind, pose, its own collision height
|
||||
/// (`None` only on a unit's first frame, before the stamp), its `UNIT_FIELD_FLAGS` (the enter gate
|
||||
/// — [`crate::player::may_enter_swim`]), its own WMO-room claim (whose liquid may answer for it —
|
||||
/// — [`crate::player::may_swim`]), its own WMO-room claim (whose liquid may answer for it —
|
||||
/// decision 0696), and whether it is already marked.
|
||||
/// The gate: a stamped unit re-enters the query only when its `Transform` moved (spline ticks,
|
||||
/// teleports, clamp writes) or its descriptors changed (a `UNIT_FIELD_FLAGS` delta — a pet losing
|
||||
@@ -718,21 +718,28 @@ const CREATURE_SWIM_EXIT_BAND: f32 = 1.0 / 36.0;
|
||||
/// the wire never carries for creatures: vmangos sets `MOVEFLAG_SWIMMING` only from player packets,
|
||||
/// and a water creature's create block + splines carry no swim marker at all (verified
|
||||
/// `vmangos-src`, 2026-07-17). The real client runs its own depth decision `0x6030c0` on **remote
|
||||
/// units and creatures too**, not only on the body it steers (wow-re
|
||||
/// `models/scratch/wmo-liquid-scoping.md` §4.5: nothing in the chain is pinned to a local-player
|
||||
/// global; `0x60df70`'s active-mover GUID compare only chooses whether the transition is
|
||||
/// additionally *reported* to the server, and its not-the-mover arm still enqueues StartSwim
|
||||
/// `0x61a030` / sets it outright `0x61a130`). So deriving it here is faithful, and always was.
|
||||
/// units and creatures too**, not only on the body it steers — VERIFIED, wow-re
|
||||
/// `collision/scratch/remote-swim-decision.md` §1. `0x6030c0` has exactly one caller and it reaches
|
||||
/// per-unit three ways: a **per-frame registry walk** (`0x616800`, a registered frame callback →
|
||||
/// `0x615b10` over the movement manager's intrusive CMovement list → `0x616620` per node, whose own
|
||||
/// active-mover GUID compare gates only the `0xee` heartbeat), the inbound move-message apply
|
||||
/// (`0x618c30`), and `CGUnit_C::Initialize` for a unit that streams in already dead. A
|
||||
/// spline-driven creature is spliced into that list by the `SMSG_MONSTER_MOVE` apply itself
|
||||
/// (`0x6018f0 → 0x6187a0 → 0x619ca0`), and `0x60df70`'s not-the-mover arm sets SWIMMING
|
||||
/// **synchronously** (`0x61a130 → 0x61a230 → 0x7c6e50`) — the GUID compare only chooses whether the
|
||||
/// transition is additionally *reported* to the server. So deriving it here is faithful, and always
|
||||
/// was. Evaluating **every streamed unit**, as this does, is a safe superset of the reference's
|
||||
/// population: the walk covers a unit only while its CMovement is linked and `+0x40 & 0x8000000`
|
||||
/// is clear.
|
||||
///
|
||||
/// **What was missing is the gate** (B311, decision 1568). `0x6030c0` enters swim only when
|
||||
/// `depth > 0.75·h` **and** the unit's `UNIT_FIELD_FLAGS` carry one of the three bits
|
||||
/// [`crate::player::may_enter_swim`] tests — PLAYER_CONTROLLED, PET_IN_COMBAT or
|
||||
/// USE_SWIM_ANIMATION. A creature with none of them **walks the lakebed at any depth**, which is
|
||||
/// what vmangos means by *"Giant type creatures walk underwater"*: the Shore Strider off the
|
||||
/// Forgotten Coast is a sea giant with no `CREATURE_STATIC_FLAG_CAN_SWIM`, so the reference wades
|
||||
/// it chest-deep on its legs while benilla, gating on depth alone, slid it along on a swim gait.
|
||||
/// The gate is **enter-only** — the exit leg `0x6031b4` reads no flags — so a marked unit still
|
||||
/// leaves on depth.
|
||||
/// **What was missing is the gate** (B311, decisions 1568 + 1572). `0x6030c0` reads the unit's
|
||||
/// `UNIT_FIELD_FLAGS` on **both** legs of its decision ([`crate::player::may_swim`] —
|
||||
/// PLAYER_CONTROLLED, PET_IN_COMBAT, USE_SWIM_ANIMATION): a set bit permits entry, and on the exit
|
||||
/// leg a set bit *prevents* the stop, so a unit with none of them **walks the lakebed at any
|
||||
/// depth** and is driven out of swim if anything else ever put the flag on it. That is what
|
||||
/// vmangos means by *"Giant type creatures walk underwater"*: the Shore Strider off the Forgotten
|
||||
/// Coast is a sea giant with no `CREATURE_STATIC_FLAG_CAN_SWIM`, so the reference wades it
|
||||
/// chest-deep on its legs while benilla, gating on depth alone, slid it along on a swim gait.
|
||||
///
|
||||
/// Depth is measured on the creature's **own** collision height: marked once its feet sit deeper
|
||||
/// than `0.75·h` ([`swim_enter_depth`]), unmarked once they rise a [`CREATURE_SWIM_EXIT_BAND`]
|
||||
@@ -740,10 +747,14 @@ const CREATURE_SWIM_EXIT_BAND: f32 = 1.0 / 36.0;
|
||||
/// retired (0464's `collisionHeight` plumb was what it was waiting on); what stays open is the
|
||||
/// *reference point* the real client measures a creature's depth from.
|
||||
///
|
||||
/// `0x6030c0`'s other guard — bail entirely while `MOVEFLAG_LEVITATING` (`0x400`) is set — has no
|
||||
/// creature-side expression here because a spline-walked creature carries no live move-flag word
|
||||
/// at all (`creature_anim::select::unify`'s creature leg synthesises one); the local avatar's copy
|
||||
/// of the same guard is `player::swim::update_swimming`'s first branch.
|
||||
/// `0x6030c0`'s other guard — bail entirely while `MOVEFLAG_LEVITATING` (`0x400`) is set, so
|
||||
/// neither leg runs — has no creature-side expression here, for two independent reasons: a
|
||||
/// spline-walked creature carries no live move-flag word at all (`creature_anim::select::unify`'s
|
||||
/// creature leg synthesises one), and vmangos never puts the bit on a creature anyway
|
||||
/// (`Unit::SetLevitate` has **zero callers**; the only writer is the GM-fly path on a *player*).
|
||||
/// The bail is real and ungated in the reference — its bit arrives through `0x618c30`'s wire
|
||||
/// flag-merge mask, which has no active-mover compare — so this is a "nothing to read", not a
|
||||
/// "doesn't apply". The local avatar's copy of the same guard is `update_swimming`'s first branch.
|
||||
///
|
||||
/// Consumers: the swim-gait leg of the animation selector (`creature_anim::select::unify`), the
|
||||
/// swimming body pitch ([`sample_splines`]), and the [`ground_clamp_creatures`] exemption — which
|
||||
@@ -783,19 +794,31 @@ pub(crate) struct GroundClamped {
|
||||
}
|
||||
|
||||
/// The reference's `0x6030c0` decision for one creature, as a pure function of everything it
|
||||
/// reads — [`CreatureSwimming`]'s law in one place, so the enter/exit asymmetry is assertable
|
||||
/// without a world.
|
||||
/// reads — [`CreatureSwimming`]'s law in one place, so both asymmetries are assertable without a
|
||||
/// world. Byte-VERIFIED whole (wow-re `collision/scratch/remote-swim-decision.md` §2/§3):
|
||||
///
|
||||
/// **Enter** needs both halves: deep enough (`depth > 0.75·h`, strictly — `0x603106 test ah,0x41`)
|
||||
/// *and* a `UNIT_FIELD_FLAGS` that permits swimming at all ([`crate::player::may_enter_swim`]).
|
||||
/// **Leave** is depth alone, a [`CREATURE_SWIM_EXIT_BAND`] lower: the exit leg `0x6031b4` reads no
|
||||
/// unit flags, so a unit that loses its swim bit mid-water still leaves properly rather than being
|
||||
/// stranded in a gait it can no longer re-enter.
|
||||
/// ```text
|
||||
/// enter iff flags ∧ depth > 0.75·h (0x603106 test ah,0x41 + jne — a ZF test, STRICT)
|
||||
/// stay iff flags ∧ depth >= 0.75·h − 1/36 (0x6031c5 test ah,5 + jnp — parity, INCLUSIVE at ==)
|
||||
/// ```
|
||||
///
|
||||
/// **The flag term is on both legs**, which is the part decision 1568 shipped wrong and 1572
|
||||
/// corrects: [`crate::player::may_swim`] is not an entry permit but the predicate *"may this unit
|
||||
/// be locally SWIMMING at all"*, and its false value forces a stop **at any depth**
|
||||
/// (`0x6031eb → 0x60dff0`), not merely a refusal to start. So a creature that loses its bit
|
||||
/// mid-water leaves the gait immediately, exactly as the reference drives it out on the next tick.
|
||||
///
|
||||
/// The two depth boundaries are deliberately different comparisons, not a rounding accident — the
|
||||
/// enter compare is strict and the stay compare is inclusive at equality, which is what makes the
|
||||
/// 1/36-yd band a band rather than a knife edge.
|
||||
fn creature_swim_state(marked: bool, depth: f32, boundary: f32, unit_flags: u32) -> bool {
|
||||
if !crate::player::may_swim(unit_flags) {
|
||||
return false; // both legs — a permit-less unit cannot start AND cannot stay
|
||||
}
|
||||
if marked {
|
||||
depth >= boundary - CREATURE_SWIM_EXIT_BAND
|
||||
} else {
|
||||
depth > boundary && crate::player::may_enter_swim(unit_flags)
|
||||
depth > boundary
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1216,28 +1239,49 @@ mod swim_gate {
|
||||
}
|
||||
}
|
||||
|
||||
/// The gate is **enter-only** — the reference's exit leg reads no unit flags. A unit that is
|
||||
/// already swimming when its bit clears must still leave on depth, or it would be stranded in
|
||||
/// a gait it can never re-enter.
|
||||
/// Leaving keeps the 1/36-yd hysteresis, and its compare is **inclusive at equality** while
|
||||
/// enter's is strict — `0x6031c5 test ah,5` + `jnp` is a parity test on C0|C2, so `depth ==
|
||||
/// thr` does not stop, whereas `0x603106 test ah,0x41` + `jne` is a ZF test and `depth ==
|
||||
/// boundary` does not start. Deliberately asymmetric; that asymmetry is the band.
|
||||
#[test]
|
||||
fn leaving_is_depth_alone_and_keeps_its_hysteresis() {
|
||||
fn leaving_keeps_its_hysteresis_and_is_inclusive_where_entering_is_strict() {
|
||||
let boundary = swim_enter_depth(SEA_GIANT_H);
|
||||
let flags = USE_SWIM_ANIMATION;
|
||||
assert!(
|
||||
creature_swim_state(true, boundary - CREATURE_SWIM_EXIT_BAND, boundary, NO_FLAGS),
|
||||
"inside the band it holds, flags or no flags"
|
||||
creature_swim_state(true, boundary - CREATURE_SWIM_EXIT_BAND, boundary, flags),
|
||||
"exactly at the lower edge it holds (the stay compare is inclusive)"
|
||||
);
|
||||
assert!(
|
||||
!creature_swim_state(
|
||||
true,
|
||||
boundary - CREATURE_SWIM_EXIT_BAND - 1e-4,
|
||||
boundary,
|
||||
NO_FLAGS
|
||||
flags
|
||||
),
|
||||
"below the band it leaves"
|
||||
);
|
||||
assert!(
|
||||
!creature_swim_state(true, f32::MIN, boundary, USE_SWIM_ANIMATION),
|
||||
"no liquid at all leaves, flag or no flag"
|
||||
!creature_swim_state(true, f32::MIN, boundary, flags),
|
||||
"no liquid at all leaves"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The correction 1572 makes to 1568.** The three bits are one predicate — *may this unit be
|
||||
/// locally SWIMMING at all* — and `0x6030c0` tests it on the **exit** leg too, inverted: with
|
||||
/// all three clear the exit leg falls straight through to `StopSwim` (`0x6031eb → 0x60dff0`)
|
||||
/// however deep the water. A charm ending over deep water is the case: the creature loses
|
||||
/// PLAYER_CONTROLLED and must drop to the seabed on the next tick, not keep swimming until it
|
||||
/// finds a shallow.
|
||||
#[test]
|
||||
fn losing_the_flag_mid_water_stops_the_swim_at_any_depth() {
|
||||
let boundary = swim_enter_depth(SEA_GIANT_H);
|
||||
assert!(
|
||||
creature_swim_state(true, 100.0, boundary, PLAYER_CONTROLLED),
|
||||
"charmed and deep: swimming"
|
||||
);
|
||||
assert!(
|
||||
!creature_swim_state(true, 100.0, boundary, NO_FLAGS),
|
||||
"charm ends, still 100 yd down: the reference stops it anyway"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ pub(crate) use state::{
|
||||
/// The swim boundary `0.75·h` — and therefore the **wade ceiling**, since wading is the implicit
|
||||
/// in-liquid-but-not-swimming state and the two cannot be different numbers. Read by the creature
|
||||
/// swim marker and the footstep splash slot, which have no `Player` of their own.
|
||||
pub(crate) use swim::{may_enter_swim, swim_enter_depth};
|
||||
pub(crate) use swim::{may_swim, swim_enter_depth};
|
||||
|
||||
/// **`UNIT_FLAG_STUNNED`** — the `UNIT_FIELD_FLAGS` bit that freezes a character's *turning*
|
||||
/// (decision 0872). Not a movement flag and not an aura: the reference reads it straight off the
|
||||
|
||||
@@ -81,44 +81,56 @@ const SWIM_DEPTH_FRAC: f32 = 0.75;
|
||||
/// `0.75·h`, leave against `0.75·h − 1/36`, so between them the swim state holds (`0x603100`/`0x6031c0`).
|
||||
const SWIM_HYSTERESIS: f32 = 1.0 / 36.0;
|
||||
|
||||
/// The `UNIT_FIELD_FLAGS` bits that permit a unit to **enter** swim at all — **VERIFIED** in the
|
||||
/// shipped 5875 image, the gate `0x6030c0` runs *after* `depth > 0.75·h` and *before* TryStartSwim
|
||||
/// `0x60df70`:
|
||||
/// The `UNIT_FIELD_FLAGS` bits that decide whether a unit **may be locally SWIMMING at all** —
|
||||
/// **VERIFIED** in the shipped 5875 image (wow-re `collision/scratch/remote-swim-decision.md`, §5
|
||||
/// trio). `0x6030c0` tests them on **both** legs of its decision, with **opposite sense**:
|
||||
///
|
||||
/// ```text
|
||||
/// 60310b mov ecx,[esi+0x110] ; the unit's descriptor block base (&m_uint32Values[OBJECT_END])
|
||||
/// 603111 mov eax,[ecx+0xa0] ; +0xa0 = field OBJECT_END+0x28 = UNIT_FIELD_FLAGS (1.12.1 idx 46)
|
||||
/// 603117 test ah,ah ; js 60312c ; bit 15 set → proceed
|
||||
/// 60311b shr edx,3 ; test dl,1 ; jne 60312c ; bit 3 set → proceed
|
||||
/// 603125 shr eax,0xb ; test al,1 ; je 60314a ; bit 11 clear → SKIP TryStartSwim
|
||||
/// ENTER (after depth > 0.75·R):
|
||||
/// 60310b mov ecx,[esi+0x110] ; the unit descriptor block base — writer 0x5fad25
|
||||
/// 603111 mov eax,[ecx+0xa0] ; `add ebx,0x18` ⇒ &values[OBJECT_END=6], so +0xa0 = index 46
|
||||
/// 603117 test ah,ah ; js 60312c ; bit 15 SET → permit entry
|
||||
/// 60311b shr ecx,3 ; test cl,1 ; jne 60312c ; bit 3 SET → permit
|
||||
/// 603125 shr eax,0xb ; test al,1 ; je 60314a ; ALL clear → skip TryStartSwim
|
||||
///
|
||||
/// EXIT (0x6031b4, after !inLiquid and depth < 0.75·R − 1/36 have both passed):
|
||||
/// 6031d6 test ah,ah ; js 6031fa ; bit 15 SET → RETURN, stay swimming
|
||||
/// 6031df test cl,1 ; jne 6031fa ; bit 3 SET → stay
|
||||
/// 6031e9 test al,1 ; jne 6031fa ; bit 11 SET → stay
|
||||
/// 6031eb ... call 0x60dff0 ; ALL clear → StopSwim ANYWAY, at any depth
|
||||
/// ```
|
||||
///
|
||||
/// Any **one** of the three permits entry; with none of them the unit stays in walk mode however
|
||||
/// deep the water, and **walks the lakebed**. What each bit is (vmangos `UnitDefines.h`, and its
|
||||
/// own writers):
|
||||
/// So the three bits are **one predicate**, not an entry permit: false blocks entry *and* forces an
|
||||
/// exit however deep the water. (Decision 1568 shipped this as enter-only, on the reading that the
|
||||
/// exit leg was flag-free — `wmo-liquid-scoping.md` §4.6 had never re-derived it. The §5 that
|
||||
/// re-derived it cold says otherwise; corrected in 1572.)
|
||||
///
|
||||
/// - `0x8` **PLAYER_CONTROLLED** — every body a player can steer: players (set at creation,
|
||||
/// `Player.cpp:462`), pets, totems, guardians, companions, charms (`SpellAuras.cpp` charm/possess).
|
||||
/// - `0x800` **PET_IN_COMBAT**.
|
||||
/// - `0x8000` **USE_SWIM_ANIMATION** — the server's "this creature swims" bit, applied on spawn from
|
||||
/// `creature_template.static_flags1 & CREATURE_STATIC_FLAG_CAN_SWIM` (`Creature::
|
||||
/// ToggleUnitFlagsFromStaticFlags`). vmangos names the client behaviour it is naming exactly:
|
||||
/// *"Without it units walk on the sea floor instead of swimming"*, and, at its pathing site,
|
||||
/// *"Giant type creatures walk underwater"*. **956 of the 10912** rows on our server carry it.
|
||||
/// What each bit is:
|
||||
///
|
||||
/// The gate is on **ENTER only** — the exit leg `0x6031b4` reads no flags, so a unit that is
|
||||
/// already swimming leaves on depth alone.
|
||||
/// - `0x8` **PLAYER_CONTROLLED** (bit 3) — every body a player can steer: players (set at creation,
|
||||
/// vmangos `Player.cpp:462`), pets, totems, guardians, companions, charms. Named by the *client*,
|
||||
/// not just the server: the `UnitPlayerControlled` binding pair.
|
||||
/// - `0x800` **PET_IN_COMBAT** (bit 11) — likewise client-named, by the `PET_ATTACK_START/STOP`
|
||||
/// event firing.
|
||||
/// - `0x8000` **USE_SWIM_ANIMATION** (bit 15) — applied on spawn from
|
||||
/// `creature_template.static_flags1 & CREATURE_STATIC_FLAG_CAN_SWIM`. **956 of 10912** rows carry
|
||||
/// it. The client gives this bit no other meaning whatever: a whole-image census finds exactly
|
||||
/// three readers (`0x5fb922`, `0x603111`, `0x6031d0`), all of them this predicate — so vmangos's
|
||||
/// name for it (*"Without it units walk on the sea floor instead of swimming"*, and at its
|
||||
/// pathing site *"Giant type creatures walk underwater"*) describes the server's intent, and the
|
||||
/// client's three reads are the whole of its effect.
|
||||
///
|
||||
/// **The local avatar does not consult this**, and that is a verified no-op rather than an
|
||||
/// omission: bit 3 is set on every body a player can be [`crate::net::Embodied`] in — its own
|
||||
/// character, a charmed creature, a possessed one — so the gate always passes there. It bites only
|
||||
/// on the creature marker (`crate::net::motion::spline::mark_swimming_creatures`), which is where
|
||||
/// benilla derives a creature's swim state the wire never carries.
|
||||
const SWIM_ENTER_UNIT_FLAGS: u32 = 0x8 | 0x800 | 0x8000;
|
||||
/// character, a charmed creature, a possessed one. It bites on the creature marker
|
||||
/// (`crate::net::motion::spline::mark_swimming_creatures`), which is where benilla derives the swim
|
||||
/// state the wire never carries for a creature.
|
||||
const SWIM_UNIT_FLAGS: u32 = 0x8 | 0x800 | 0x8000;
|
||||
|
||||
/// May this unit **enter** swim from its own depth? — [`SWIM_ENTER_UNIT_FLAGS`]'s one spelling.
|
||||
pub(crate) fn may_enter_swim(unit_flags: u32) -> bool {
|
||||
unit_flags & SWIM_ENTER_UNIT_FLAGS != 0
|
||||
/// May this unit be locally SWIMMING at all? — [`SWIM_UNIT_FLAGS`]'s one spelling. False blocks
|
||||
/// entry **and** forces an exit at any depth; see the constant for the two byte sites.
|
||||
pub(crate) fn may_swim(unit_flags: u32) -> bool {
|
||||
unit_flags & SWIM_UNIT_FLAGS != 0
|
||||
}
|
||||
|
||||
/// Submersion depth (yd, water surface above the feet) to **start** swimming — VERIFIED `0.75·h`
|
||||
@@ -459,28 +471,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The ENTER gate's three bits, named — `0x6030c0`'s `js` / `shr 3` / `shr 0xb` tests.
|
||||
/// The gate's three bits, named — `0x6030c0`'s `js` / `shr 3` / `shr 0xb` tests, which appear
|
||||
/// **twice**: once permitting entry (`0x60310b`) and once preventing the stop (`0x6031ca`).
|
||||
/// PET_IN_COMBAT is the one that reads like a typo and is not: the reference really does let a
|
||||
/// pet's combat bit admit it to swim, and it is bit 11 of `UNIT_FIELD_FLAGS` in 1.12.1.
|
||||
/// pet's combat bit keep it swimming, and it is bit 11 of `UNIT_FIELD_FLAGS` in 1.12.1.
|
||||
#[test]
|
||||
fn the_swim_enter_gate_is_those_three_unit_flags_and_no_others() {
|
||||
fn the_swim_gate_is_those_three_unit_flags_and_no_others() {
|
||||
const PLAYER_CONTROLLED: u32 = 0x8;
|
||||
const PET_IN_COMBAT: u32 = 0x800;
|
||||
const USE_SWIM_ANIMATION: u32 = 0x8000;
|
||||
assert_eq!(
|
||||
SWIM_ENTER_UNIT_FLAGS,
|
||||
SWIM_UNIT_FLAGS,
|
||||
PLAYER_CONTROLLED | PET_IN_COMBAT | USE_SWIM_ANIMATION
|
||||
);
|
||||
for bit in [PLAYER_CONTROLLED, PET_IN_COMBAT, USE_SWIM_ANIMATION] {
|
||||
assert!(
|
||||
may_enter_swim(bit),
|
||||
"any ONE of the three admits ({bit:#x})"
|
||||
);
|
||||
assert!(may_swim(bit), "any ONE of the three admits ({bit:#x})");
|
||||
}
|
||||
assert!(!may_enter_swim(0), "no flags at all → walks the lakebed");
|
||||
assert!(!may_swim(0), "no flags at all → walks the lakebed");
|
||||
// Not vacuous: a unit carrying every OTHER unit flag still cannot enter. IN_COMBAT,
|
||||
// SKINNABLE, PVP, NOT_SELECTABLE — none of them is a swim permit.
|
||||
assert!(!may_enter_swim(!SWIM_ENTER_UNIT_FLAGS));
|
||||
assert!(!may_swim(!SWIM_UNIT_FLAGS));
|
||||
}
|
||||
|
||||
/// Swim mode latches with the verified 1/36-yd hysteresis: enter is a strict `depth > 0.75·h`, leave
|
||||
|
||||
@@ -15,6 +15,9 @@ use crate::ui_script::UiInput;
|
||||
#[cfg(test)]
|
||||
mod ace_gate_tests;
|
||||
mod channels;
|
||||
/// The combat log's chat lines (B297) — classification, chat type, and the GlobalString key each
|
||||
/// combat packet's sentence is built from.
|
||||
pub(crate) mod combat;
|
||||
pub(crate) mod commands;
|
||||
mod edit;
|
||||
mod event;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,461 @@
|
||||
//! The combat log's own tests. Two of them are the ones that matter: the slot orders checked
|
||||
//! against the *shipped* `GlobalStrings.lua`, and the three msgType matrices checked against the
|
||||
//! byte-read table they transcribe. Everything else is a spot check on a line that was reported.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The `%s`/`%d` conversion sequence of a template, in order — its "type signature". Two format
|
||||
/// strings with the same signature accept the same argument list, which is exactly the property a
|
||||
/// declared slot order has to have.
|
||||
fn signature(template: &str) -> Vec<char> {
|
||||
let mut out = Vec::new();
|
||||
let mut chars = template.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '%' {
|
||||
continue;
|
||||
}
|
||||
match chars.next() {
|
||||
Some('%') => {}
|
||||
Some(k) => out.push(k),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The signature our declared slot list produces for one variant — `Attacker`/`Victim` present only
|
||||
/// where the variant spells that endpoint out.
|
||||
fn declared_signature(family: Family, variant: Variant) -> Vec<char> {
|
||||
family
|
||||
.slots
|
||||
.iter()
|
||||
.filter_map(|slot| match slot {
|
||||
Slot::Attacker => variant.names_subject().then_some('s'),
|
||||
Slot::Victim => variant.names_object().then_some('s'),
|
||||
Slot::Spell | Slot::School | Slot::Power | Slot::Power2 => Some('s'),
|
||||
Slot::Amount | Slot::Amount2 => Some('d'),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// **The gate on the one thing this module authors.** Every family × every variant: if the install
|
||||
/// defines the key, our declared slot order must produce exactly the template's own `%s`/`%d`
|
||||
/// sequence.
|
||||
///
|
||||
/// This is what makes the slot lists safe to hand-declare. A transposed pair of different types
|
||||
/// (`Amount` before `School`, `Spell` after `Victim` where the string puts it first) changes the
|
||||
/// signature and fails here. A transposition of two *same-type* slots does not — that residue is
|
||||
/// covered by [`the_reported_sentences_read_correctly`], which pins whole sentences for the shapes
|
||||
/// a player actually sees, and by each family's doc comment quoting its line.
|
||||
///
|
||||
/// A key the install does **not** define is not a failure: several families genuinely have no
|
||||
/// `…SELFSELF` (`COMBATHIT`, `MISSED`, every `VS*`, `DAMAGESHIELD`, `SPELLPOWERLEECH`), and the
|
||||
/// reference's own selector returns NULL for exactly those. [`every_family_has_the_three_core_keys`]
|
||||
/// is what stops "absent" from silently covering a typo'd stem.
|
||||
///
|
||||
/// Skips without client data.
|
||||
#[test]
|
||||
fn every_family_matches_the_shipped_template() {
|
||||
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 script = benilla_ui::script::UiScript::new().expect("VM");
|
||||
script
|
||||
.run(&String::from_utf8_lossy(&src))
|
||||
.expect("GlobalStrings runs clean");
|
||||
|
||||
let variants = [
|
||||
Variant::SelfOther,
|
||||
Variant::OtherSelf,
|
||||
Variant::OtherOther,
|
||||
Variant::SelfSelf,
|
||||
];
|
||||
let mut checked = 0usize;
|
||||
for &family in ALL_FAMILIES {
|
||||
for variant in variants {
|
||||
let key = format!("{}{}", family.stem, variant.suffix());
|
||||
let Some(template) = global_string(&script, &key) else {
|
||||
continue;
|
||||
};
|
||||
assert_eq!(
|
||||
declared_signature(family, variant),
|
||||
signature(&template),
|
||||
"{key}: our slots {:?} do not match the shipped {template:?}",
|
||||
family.slots,
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
// A stem typo'd across the board would make every lookup miss and leave this at zero, which
|
||||
// would otherwise pass silently.
|
||||
assert!(
|
||||
checked >= ALL_FAMILIES.len() * 3,
|
||||
"only {checked} keys resolved"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every family defines the three variants that are not `…SELFSELF`. Those three are the ones the
|
||||
/// reference's selectors always return a key for, so an absent one is a wrong stem, not a data
|
||||
/// condition — the failure mode [`every_family_matches_the_shipped_template`] cannot see because
|
||||
/// it skips what it cannot find. Skips without client data.
|
||||
#[test]
|
||||
fn every_family_has_the_three_core_keys() {
|
||||
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 script = benilla_ui::script::UiScript::new().expect("VM");
|
||||
script
|
||||
.run(&String::from_utf8_lossy(&src))
|
||||
.expect("GlobalStrings runs clean");
|
||||
|
||||
for &family in ALL_FAMILIES {
|
||||
for variant in [Variant::SelfOther, Variant::OtherSelf, Variant::OtherOther] {
|
||||
let key = format!("{}{}", family.stem, variant.suffix());
|
||||
assert!(
|
||||
global_string(&script, &key).is_some(),
|
||||
"{key} is not a GlobalString — wrong stem?"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The school words and power words the `…SCHOOL…` and `POWERGAIN` slots take actually resolve.
|
||||
/// Skips without client data.
|
||||
#[test]
|
||||
fn the_school_and_power_words_resolve() {
|
||||
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 script = benilla_ui::script::UiScript::new().expect("VM");
|
||||
script
|
||||
.run(&String::from_utf8_lossy(&src))
|
||||
.expect("GlobalStrings runs clean");
|
||||
|
||||
// The seven 1.12 schools (`SpellSchools`, 0 physical … 6 arcane).
|
||||
for school in 0..=6u8 {
|
||||
assert!(
|
||||
school_word(&script, school).is_some(),
|
||||
"SPELL_SCHOOL{school}_NAME missing"
|
||||
);
|
||||
}
|
||||
for power in 0..=3u32 {
|
||||
assert!(
|
||||
power_word(&script, power).is_some(),
|
||||
"power {power} missing"
|
||||
);
|
||||
}
|
||||
// Happiness has no combat-log word, deliberately.
|
||||
assert!(power_word(&script, 4).is_none());
|
||||
}
|
||||
|
||||
/// The sentences a player actually reads, end to end, on the real strings — the residue
|
||||
/// [`every_family_matches_the_shipped_template`]'s type check cannot cover (two adjacent `%s`
|
||||
/// slots), pinned as whole sentences rather than as an argument order.
|
||||
/// Skips without client data.
|
||||
#[test]
|
||||
fn the_reported_sentences_read_correctly() {
|
||||
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 script = benilla_ui::script::UiScript::new().expect("VM");
|
||||
script
|
||||
.run(&String::from_utf8_lossy(&src))
|
||||
.expect("GlobalStrings runs clean");
|
||||
|
||||
let fills = Fills {
|
||||
attacker: "Attacker".into(),
|
||||
victim: "Victim".into(),
|
||||
spell: "Fireball".into(),
|
||||
// Resolved through the real strings by `compose_line`: school 2 is fire, power 0 is mana.
|
||||
school: Some(2),
|
||||
power: Some(0),
|
||||
amount: 120,
|
||||
amount2: 40,
|
||||
power2: Some(0),
|
||||
};
|
||||
let line = |family, variant| compose_line(&script, family, variant, &fills).expect("composes");
|
||||
|
||||
// The ledger's own example line, and the one a damage meter parses — both directions.
|
||||
assert_eq!(
|
||||
line(COMBATHIT, Variant::SelfOther),
|
||||
"You hit Victim for 120."
|
||||
);
|
||||
assert_eq!(
|
||||
line(COMBATHIT, Variant::OtherSelf),
|
||||
"Attacker hits you for 120."
|
||||
);
|
||||
// The spell name comes BEFORE the victim in this family and AFTER the amount in the periodic
|
||||
// one — the two orders that would look identical to a type check if both were `s,s,d`.
|
||||
assert_eq!(
|
||||
line(SPELLLOG, Variant::SelfOther),
|
||||
"Your Fireball hits Victim for 120."
|
||||
);
|
||||
assert_eq!(
|
||||
line(PERIODICAURADAMAGE, Variant::SelfOther),
|
||||
"Victim suffers 120 fire damage from your Fireball."
|
||||
);
|
||||
assert_eq!(
|
||||
line(POWERGAIN, Variant::SelfSelf),
|
||||
"You gain 120 Mana from Fireball."
|
||||
);
|
||||
assert_eq!(
|
||||
line(HEALED, Variant::OtherOther),
|
||||
"Attacker's Fireball heals Victim for 120."
|
||||
);
|
||||
assert_eq!(
|
||||
line(DAMAGESHIELD, Variant::SelfOther),
|
||||
"You reflect 120 fire damage to Victim."
|
||||
);
|
||||
// The double-subject family: the drainer is named twice and the second gain has its own pair.
|
||||
assert_eq!(
|
||||
line(SPELLPOWERLEECH, Variant::OtherOther),
|
||||
"Attacker's Fireball drains 120 Mana from Victim. Attacker gains 40 Mana."
|
||||
);
|
||||
assert_eq!(line(MISSED, Variant::SelfOther), "You miss Victim.");
|
||||
assert_eq!(
|
||||
line(VSDODGE, Variant::OtherSelf),
|
||||
"Attacker attacks. You dodge."
|
||||
);
|
||||
}
|
||||
|
||||
/// The `vsnprintf` subset, including the two ways it is allowed to refuse.
|
||||
#[test]
|
||||
fn the_fill_is_vsnprintf_and_refuses_a_mismatch() {
|
||||
fn s(v: &str) -> Arg<'_> {
|
||||
Arg::Str(v)
|
||||
}
|
||||
assert_eq!(
|
||||
fill("%s hits %s for %d.", &[s("A"), s("B"), Arg::Num(7)]).as_deref(),
|
||||
Some("A hits B for 7.")
|
||||
);
|
||||
assert_eq!(fill("100%% sure", &[]).as_deref(), Some("100% sure"));
|
||||
// A `%d` where a string is queued, and vice versa.
|
||||
assert_eq!(fill("%d", &[s("A")]), None);
|
||||
assert_eq!(fill("%s", &[Arg::Num(1)]), None);
|
||||
// Too few, and too many.
|
||||
assert_eq!(fill("%s %s", &[s("A")]), None);
|
||||
assert_eq!(fill("%s", &[s("A"), s("B")]), None);
|
||||
// A conversion outside the vocabulary is a mismatch, not a passthrough.
|
||||
assert_eq!(fill("%f", &[Arg::Num(1)]), None);
|
||||
}
|
||||
|
||||
/// The melee family dispatcher, arm for arm against `0x629b60`'s order — including the two overlaps
|
||||
/// that make the order load-bearing: a blocked swing that also carries damage is `VSBLOCK` (the
|
||||
/// VictimState test runs before the damage test), and a fully-absorbed swing is `VSABSORB` and not
|
||||
/// `MISSED` (the MISS bit is not set for an absorb).
|
||||
#[test]
|
||||
fn the_melee_dispatcher_follows_the_reference_order() {
|
||||
// A plain landed swing, and its crit and school variants.
|
||||
assert_eq!(melee_family(0, 1, 120, 0).stem, "COMBATHIT");
|
||||
assert_eq!(melee_family(0x80, 1, 120, 0).stem, "COMBATHITCRIT");
|
||||
assert_eq!(melee_family(0, 1, 120, 2).stem, "COMBATHITSCHOOL");
|
||||
assert_eq!(melee_family(0x80, 1, 120, 2).stem, "COMBATHITCRITSCHOOL");
|
||||
// MISS wins over everything, including a VictimState that would say otherwise.
|
||||
assert_eq!(melee_family(0x10, 2, 0, 0).stem, "MISSED");
|
||||
// BLOCKS wins over the damage test — a partial block still lands damage.
|
||||
assert_eq!(melee_family(0, 5, 90, 0).stem, "VSBLOCK");
|
||||
// Zero damage + the absorb/resist bits.
|
||||
assert_eq!(melee_family(0x20, 1, 0, 0).stem, "VSABSORB");
|
||||
assert_eq!(melee_family(0x40, 1, 0, 0).stem, "VSRESIST");
|
||||
// ...but the same bits with damage through are a landed hit, not a full absorb.
|
||||
assert_eq!(melee_family(0x20, 1, 90, 0).stem, "COMBATHIT");
|
||||
// The VictimState words.
|
||||
assert_eq!(melee_family(0, 2, 0, 0).stem, "VSDODGE");
|
||||
assert_eq!(melee_family(0, 3, 0, 0).stem, "VSPARRY");
|
||||
assert_eq!(melee_family(0, 6, 0, 0).stem, "VSEVADE");
|
||||
assert_eq!(melee_family(0, 7, 0, 0).stem, "VSIMMUNE");
|
||||
assert_eq!(melee_family(0, 8, 0, 0).stem, "VSDEFLECT");
|
||||
}
|
||||
|
||||
/// The melee msgType matrix, against `0x62a0d0`/`0x62a2e0` as decompiled and against wow-re's
|
||||
/// byte-verified 94-entry type table (whose 1-based index is one more than the selector's return).
|
||||
#[test]
|
||||
fn the_melee_matrix_is_the_reference_selector() {
|
||||
use ChatEventKind as K;
|
||||
use UnitClass as C;
|
||||
let hits = |a, v| combat_kind(a, v, false).unwrap();
|
||||
let misses = |a, v| combat_kind(a, v, true).unwrap();
|
||||
|
||||
// src 0 → 0x1b, src 1 → 0x1d — no victim dependence.
|
||||
assert_eq!(hits(C::Me, C::Creature), K::CombatSelfHits);
|
||||
assert_eq!(misses(C::Me, C::Creature), K::CombatSelfMisses);
|
||||
assert_eq!(hits(C::MyPet, C::Creature), K::CombatPetHits);
|
||||
// src 3 and 5 are unconditional; src 2 and 4 are the two reclassifying arms.
|
||||
assert_eq!(hits(C::PartyPet, C::Me), K::CombatPartyHits);
|
||||
assert_eq!(hits(C::FriendlyPet, C::Me), K::CombatFriendlyPlayerHits);
|
||||
assert_eq!(hits(C::Party, C::Creature), K::CombatPartyHits);
|
||||
assert_eq!(
|
||||
hits(C::FriendlyPlayer, C::Creature),
|
||||
K::CombatFriendlyPlayerHits
|
||||
);
|
||||
// ...a party or friendly player attacking me or mine reads as HOSTILE — the duel case.
|
||||
for victim in [C::Me, C::MyPet, C::Party, C::PartyPet] {
|
||||
assert_eq!(hits(C::Party, victim), K::CombatHostilePlayerHits);
|
||||
assert_eq!(hits(C::FriendlyPlayer, victim), K::CombatHostilePlayerHits);
|
||||
}
|
||||
assert_eq!(hits(C::HostilePlayer, C::Me), K::CombatHostilePlayerHits);
|
||||
assert_eq!(hits(C::HostilePet, C::Me), K::CombatHostilePlayerHits);
|
||||
// src 8/9 split on the VICTIM, into three buckets.
|
||||
assert_eq!(hits(C::Creature, C::Me), K::CombatCreatureVsSelfHits);
|
||||
assert_eq!(hits(C::Creature, C::MyPet), K::CombatCreatureVsSelfHits);
|
||||
assert_eq!(hits(C::Creature, C::Party), K::CombatCreatureVsPartyHits);
|
||||
assert_eq!(hits(C::Creature, C::PartyPet), K::CombatCreatureVsPartyHits);
|
||||
for victim in [C::FriendlyPlayer, C::HostilePlayer, C::Creature] {
|
||||
assert_eq!(hits(C::Creature, victim), K::CombatCreatureVsCreatureHits);
|
||||
}
|
||||
}
|
||||
|
||||
/// The spell matrix is the melee one's shape with a different base row (`0x627820`), and the
|
||||
/// periodic matrix is a genuinely different, smaller one: no PET row, no `CREATURE_VS_*` split, and
|
||||
/// **the victim is not consulted at all**.
|
||||
#[test]
|
||||
fn the_spell_and_periodic_matrices() {
|
||||
use ChatEventKind as K;
|
||||
use UnitClass as C;
|
||||
assert_eq!(
|
||||
spell_kind(C::Me, C::Creature, false).unwrap(),
|
||||
K::SpellSelfDamage
|
||||
);
|
||||
assert_eq!(
|
||||
spell_kind(C::Me, C::Creature, true).unwrap(),
|
||||
K::SpellSelfBuff
|
||||
);
|
||||
assert_eq!(
|
||||
spell_kind(C::FriendlyPlayer, C::Me, false).unwrap(),
|
||||
K::SpellHostilePlayerDamage
|
||||
);
|
||||
assert_eq!(
|
||||
spell_kind(C::Creature, C::Party, true).unwrap(),
|
||||
K::SpellCreatureVsPartyBuff
|
||||
);
|
||||
|
||||
// A pet folds into its owner's periodic row, and every victim gives the same answer.
|
||||
for victim in [C::Me, C::Party, C::HostilePlayer, C::Creature] {
|
||||
assert_eq!(
|
||||
periodic_kind(C::MyPet, false).unwrap(),
|
||||
K::SpellPeriodicSelfDamage,
|
||||
"victim {victim:?} must not matter"
|
||||
);
|
||||
assert_eq!(
|
||||
periodic_kind(C::Creature, false).unwrap(),
|
||||
K::SpellPeriodicCreatureDamage
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
periodic_kind(C::HostilePet, true).unwrap(),
|
||||
K::SpellPeriodicHostilePlayerBuffs
|
||||
);
|
||||
}
|
||||
|
||||
/// The variant picker: only class 0 is `SELF`. Your own pet is an `OTHER` for string selection even
|
||||
/// though the msgType matrix gives it its own row — the two classifications are independent, and
|
||||
/// conflating them is the obvious way to get "Your pet hits X" wrong.
|
||||
#[test]
|
||||
fn only_the_player_is_self_for_string_selection() {
|
||||
use UnitClass as C;
|
||||
assert_eq!(Variant::of(C::Me, C::Creature), Variant::SelfOther);
|
||||
assert_eq!(Variant::of(C::Creature, C::Me), Variant::OtherSelf);
|
||||
assert_eq!(Variant::of(C::MyPet, C::Creature), Variant::OtherOther);
|
||||
assert_eq!(Variant::of(C::Creature, C::MyPet), Variant::OtherOther);
|
||||
assert_eq!(Variant::of(C::Me, C::Me), Variant::SelfSelf);
|
||||
}
|
||||
|
||||
/// **One `Unknown` endpoint does not drop the line; two do.** 1571 dropped on either, which was a
|
||||
/// consequence of reading the range gate as an AND — class 9's range of `0.0` means it can never
|
||||
/// satisfy its own half, but the other endpoint's half still carries the line.
|
||||
#[test]
|
||||
fn an_unknown_endpoint_alone_does_not_drop_the_line() {
|
||||
use UnitClass as C;
|
||||
let f = Fills::default();
|
||||
let q = |a, b| {
|
||||
queue(
|
||||
ChatEventKind::CombatSelfHits,
|
||||
COMBATHIT,
|
||||
(1, a),
|
||||
(2, b),
|
||||
f.clone(),
|
||||
)
|
||||
};
|
||||
assert!(
|
||||
q(C::Me, C::Unknown).is_some(),
|
||||
"a resolvable attacker carries it"
|
||||
);
|
||||
assert!(
|
||||
q(C::Unknown, C::Creature).is_some(),
|
||||
"so does a resolvable victim"
|
||||
);
|
||||
assert!(q(C::Me, C::Creature).is_some());
|
||||
assert!(
|
||||
q(C::Unknown, C::Unknown).is_none(),
|
||||
"neither end resolvable — nothing to say"
|
||||
);
|
||||
}
|
||||
|
||||
/// The miss-code table, against the reference's own jump table (`0x62bb50`, table `0x62bde8`).
|
||||
///
|
||||
/// The default arm is the interesting one: `lea eax,[edi-2]; cmp eax,9; ja default` puts **0, 1,
|
||||
/// 10 and everything out of range** on `SPELLMISS*`. 1571 had 10 (ABSORB) on `SPELLLOGABSORB` —
|
||||
/// a family this switch never reaches — and dropped 0/1/out-of-range entirely.
|
||||
#[test]
|
||||
fn the_miss_codes_map_to_their_families() {
|
||||
assert_eq!(miss_family(2).stem, "SPELLRESIST");
|
||||
assert_eq!(miss_family(3).stem, "SPELLDODGED");
|
||||
assert_eq!(miss_family(4).stem, "SPELLPARRIED");
|
||||
assert_eq!(miss_family(5).stem, "SPELLBLOCKED");
|
||||
assert_eq!(miss_family(6).stem, "SPELLEVADED");
|
||||
// Both IMMUNE spellings are one outcome.
|
||||
assert_eq!(miss_family(7).stem, "SPELLIMMUNE");
|
||||
assert_eq!(miss_family(8).stem, "SPELLIMMUNE");
|
||||
assert_eq!(miss_family(9).stem, "SPELLDEFLECTED");
|
||||
assert_eq!(miss_family(11).stem, "SPELLREFLECT");
|
||||
// The default arm, all four ways into it.
|
||||
for code in [0u8, 1, 10, 12, 255] {
|
||||
assert_eq!(
|
||||
miss_family(code).stem,
|
||||
"SPELLMISS",
|
||||
"{code} belongs on the default arm"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The range table, read out of `WoW.exe` at `0x8629e0` — the evidence the class indices are what
|
||||
/// they are named. Pinned here so a later reshuffle of the enum has to face it.
|
||||
#[test]
|
||||
fn the_class_range_table_is_the_binarys() {
|
||||
use UnitClass as C;
|
||||
assert_eq!(C::Me.range_cvar(), None);
|
||||
assert_eq!(C::MyPet.range_cvar(), None);
|
||||
assert_eq!(C::Party.range_cvar(), Some("CombatLogRangeParty"));
|
||||
assert_eq!(C::PartyPet.range_cvar(), Some("CombatLogRangePartyPet"));
|
||||
assert_eq!(
|
||||
C::FriendlyPlayer.range_cvar(),
|
||||
Some("CombatLogRangeFriendlyPlayers")
|
||||
);
|
||||
assert_eq!(
|
||||
C::FriendlyPet.range_cvar(),
|
||||
Some("CombatLogRangeFriendlyPlayersPets")
|
||||
);
|
||||
assert_eq!(
|
||||
C::HostilePlayer.range_cvar(),
|
||||
Some("CombatLogRangeHostilePlayers")
|
||||
);
|
||||
assert_eq!(
|
||||
C::HostilePet.range_cvar(),
|
||||
Some("CombatLogRangeHostilePlayersPets")
|
||||
);
|
||||
assert_eq!(C::Creature.range_cvar(), Some("CombatLogRangeCreature"));
|
||||
assert_eq!(C::Unknown.range_cvar(), None);
|
||||
|
||||
assert_eq!(C::Me.default_range(), 100_000.0);
|
||||
assert_eq!(C::Party.default_range(), 50.0);
|
||||
assert_eq!(C::Creature.default_range(), 30.0);
|
||||
assert_eq!(C::Unknown.default_range(), 0.0);
|
||||
}
|
||||
@@ -69,9 +69,118 @@ pub(crate) enum ChatEventKind {
|
||||
BgSystemNeutral,
|
||||
BgSystemAlliance,
|
||||
BgSystemHorde,
|
||||
// ── the combat log (B297) ──────────────────────────────────────────────────────────────
|
||||
// The `COMBAT_*`/`SPELL_*` block 0288 §3 held back as "the combat-log content arc". All 44
|
||||
// kinds are produced by [`super::combat`]; the rest of the reference's block (the death,
|
||||
// dispel, enchant, tradeskill and cast-failure leaves) has no decoded wire source yet and is
|
||||
// deliberately still absent — a kind with no producer is dead weight the sweeps cannot police.
|
||||
CombatSelfHits,
|
||||
CombatSelfMisses,
|
||||
CombatPetHits,
|
||||
CombatPetMisses,
|
||||
CombatPartyHits,
|
||||
CombatPartyMisses,
|
||||
CombatFriendlyPlayerHits,
|
||||
CombatFriendlyPlayerMisses,
|
||||
CombatHostilePlayerHits,
|
||||
CombatHostilePlayerMisses,
|
||||
CombatCreatureVsSelfHits,
|
||||
CombatCreatureVsSelfMisses,
|
||||
CombatCreatureVsPartyHits,
|
||||
CombatCreatureVsPartyMisses,
|
||||
CombatCreatureVsCreatureHits,
|
||||
CombatCreatureVsCreatureMisses,
|
||||
SpellSelfDamage,
|
||||
SpellSelfBuff,
|
||||
SpellPetDamage,
|
||||
SpellPetBuff,
|
||||
SpellPartyDamage,
|
||||
SpellPartyBuff,
|
||||
SpellFriendlyPlayerDamage,
|
||||
SpellFriendlyPlayerBuff,
|
||||
SpellHostilePlayerDamage,
|
||||
SpellHostilePlayerBuff,
|
||||
SpellCreatureVsSelfDamage,
|
||||
SpellCreatureVsSelfBuff,
|
||||
SpellCreatureVsPartyDamage,
|
||||
SpellCreatureVsPartyBuff,
|
||||
SpellCreatureVsCreatureDamage,
|
||||
SpellCreatureVsCreatureBuff,
|
||||
SpellDamageShieldsOnSelf,
|
||||
SpellDamageShieldsOnOthers,
|
||||
SpellPeriodicSelfDamage,
|
||||
SpellPeriodicSelfBuffs,
|
||||
SpellPeriodicPartyDamage,
|
||||
SpellPeriodicPartyBuffs,
|
||||
SpellPeriodicFriendlyPlayerDamage,
|
||||
SpellPeriodicFriendlyPlayerBuffs,
|
||||
SpellPeriodicHostilePlayerDamage,
|
||||
SpellPeriodicHostilePlayerBuffs,
|
||||
SpellPeriodicCreatureDamage,
|
||||
SpellPeriodicCreatureBuffs,
|
||||
}
|
||||
|
||||
impl ChatEventKind {
|
||||
/// The `COMBAT_*` / `SPELL_*` block — the combat log's own types.
|
||||
///
|
||||
/// The reference asks this by string prefix (`ChatFrame_OnEvent` l.1397/1399:
|
||||
/// `strsub(type,1,7) == "COMBAT_"`, `strsub(type,1,6) == "SPELL_"`), which over a typed enum is
|
||||
/// a membership test. Both `COMBAT_XP_GAIN` and `COMBAT_HONOR_GAIN` are inside the reference's
|
||||
/// prefix and are deliberately **outside** this one: they were already modeled by the ding and
|
||||
/// honour arcs, already listed in the verbatim arm by name, and answer to a different composer
|
||||
/// path — including them here would change nothing but would make the predicate lie about what
|
||||
/// [`super::combat`] produces.
|
||||
pub(crate) fn is_combat_log(self) -> bool {
|
||||
use ChatEventKind as K;
|
||||
matches!(
|
||||
self,
|
||||
K::CombatSelfHits
|
||||
| K::CombatSelfMisses
|
||||
| K::CombatPetHits
|
||||
| K::CombatPetMisses
|
||||
| K::CombatPartyHits
|
||||
| K::CombatPartyMisses
|
||||
| K::CombatFriendlyPlayerHits
|
||||
| K::CombatFriendlyPlayerMisses
|
||||
| K::CombatHostilePlayerHits
|
||||
| K::CombatHostilePlayerMisses
|
||||
| K::CombatCreatureVsSelfHits
|
||||
| K::CombatCreatureVsSelfMisses
|
||||
| K::CombatCreatureVsPartyHits
|
||||
| K::CombatCreatureVsPartyMisses
|
||||
| K::CombatCreatureVsCreatureHits
|
||||
| K::CombatCreatureVsCreatureMisses
|
||||
| K::SpellSelfDamage
|
||||
| K::SpellSelfBuff
|
||||
| K::SpellPetDamage
|
||||
| K::SpellPetBuff
|
||||
| K::SpellPartyDamage
|
||||
| K::SpellPartyBuff
|
||||
| K::SpellFriendlyPlayerDamage
|
||||
| K::SpellFriendlyPlayerBuff
|
||||
| K::SpellHostilePlayerDamage
|
||||
| K::SpellHostilePlayerBuff
|
||||
| K::SpellCreatureVsSelfDamage
|
||||
| K::SpellCreatureVsSelfBuff
|
||||
| K::SpellCreatureVsPartyDamage
|
||||
| K::SpellCreatureVsPartyBuff
|
||||
| K::SpellCreatureVsCreatureDamage
|
||||
| K::SpellCreatureVsCreatureBuff
|
||||
| K::SpellDamageShieldsOnSelf
|
||||
| K::SpellDamageShieldsOnOthers
|
||||
| K::SpellPeriodicSelfDamage
|
||||
| K::SpellPeriodicSelfBuffs
|
||||
| K::SpellPeriodicPartyDamage
|
||||
| K::SpellPeriodicPartyBuffs
|
||||
| K::SpellPeriodicFriendlyPlayerDamage
|
||||
| K::SpellPeriodicFriendlyPlayerBuffs
|
||||
| K::SpellPeriodicHostilePlayerDamage
|
||||
| K::SpellPeriodicHostilePlayerBuffs
|
||||
| K::SpellPeriodicCreatureDamage
|
||||
| K::SpellPeriodicCreatureBuffs
|
||||
)
|
||||
}
|
||||
|
||||
/// Every kind, for the sweeps that must be exhaustive to be worth anything — chiefly
|
||||
/// `ui_script::chat_tests::fired_event_names_are_all_chat_type_info_keys`, which checks each
|
||||
/// name we fire against the live `ChatTypeInfo` table rather than against a second copy of the
|
||||
@@ -120,6 +229,50 @@ impl ChatEventKind {
|
||||
K::BgSystemNeutral,
|
||||
K::BgSystemAlliance,
|
||||
K::BgSystemHorde,
|
||||
K::CombatSelfHits,
|
||||
K::CombatSelfMisses,
|
||||
K::CombatPetHits,
|
||||
K::CombatPetMisses,
|
||||
K::CombatPartyHits,
|
||||
K::CombatPartyMisses,
|
||||
K::CombatFriendlyPlayerHits,
|
||||
K::CombatFriendlyPlayerMisses,
|
||||
K::CombatHostilePlayerHits,
|
||||
K::CombatHostilePlayerMisses,
|
||||
K::CombatCreatureVsSelfHits,
|
||||
K::CombatCreatureVsSelfMisses,
|
||||
K::CombatCreatureVsPartyHits,
|
||||
K::CombatCreatureVsPartyMisses,
|
||||
K::CombatCreatureVsCreatureHits,
|
||||
K::CombatCreatureVsCreatureMisses,
|
||||
K::SpellSelfDamage,
|
||||
K::SpellSelfBuff,
|
||||
K::SpellPetDamage,
|
||||
K::SpellPetBuff,
|
||||
K::SpellPartyDamage,
|
||||
K::SpellPartyBuff,
|
||||
K::SpellFriendlyPlayerDamage,
|
||||
K::SpellFriendlyPlayerBuff,
|
||||
K::SpellHostilePlayerDamage,
|
||||
K::SpellHostilePlayerBuff,
|
||||
K::SpellCreatureVsSelfDamage,
|
||||
K::SpellCreatureVsSelfBuff,
|
||||
K::SpellCreatureVsPartyDamage,
|
||||
K::SpellCreatureVsPartyBuff,
|
||||
K::SpellCreatureVsCreatureDamage,
|
||||
K::SpellCreatureVsCreatureBuff,
|
||||
K::SpellDamageShieldsOnSelf,
|
||||
K::SpellDamageShieldsOnOthers,
|
||||
K::SpellPeriodicSelfDamage,
|
||||
K::SpellPeriodicSelfBuffs,
|
||||
K::SpellPeriodicPartyDamage,
|
||||
K::SpellPeriodicPartyBuffs,
|
||||
K::SpellPeriodicFriendlyPlayerDamage,
|
||||
K::SpellPeriodicFriendlyPlayerBuffs,
|
||||
K::SpellPeriodicHostilePlayerDamage,
|
||||
K::SpellPeriodicHostilePlayerBuffs,
|
||||
K::SpellPeriodicCreatureDamage,
|
||||
K::SpellPeriodicCreatureBuffs,
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -295,6 +448,50 @@ pub(crate) fn event_name(kind: ChatEventKind) -> &'static str {
|
||||
K::BgSystemNeutral => "CHAT_MSG_BG_SYSTEM_NEUTRAL",
|
||||
K::BgSystemAlliance => "CHAT_MSG_BG_SYSTEM_ALLIANCE",
|
||||
K::BgSystemHorde => "CHAT_MSG_BG_SYSTEM_HORDE",
|
||||
K::CombatSelfHits => "CHAT_MSG_COMBAT_SELF_HITS",
|
||||
K::CombatSelfMisses => "CHAT_MSG_COMBAT_SELF_MISSES",
|
||||
K::CombatPetHits => "CHAT_MSG_COMBAT_PET_HITS",
|
||||
K::CombatPetMisses => "CHAT_MSG_COMBAT_PET_MISSES",
|
||||
K::CombatPartyHits => "CHAT_MSG_COMBAT_PARTY_HITS",
|
||||
K::CombatPartyMisses => "CHAT_MSG_COMBAT_PARTY_MISSES",
|
||||
K::CombatFriendlyPlayerHits => "CHAT_MSG_COMBAT_FRIENDLYPLAYER_HITS",
|
||||
K::CombatFriendlyPlayerMisses => "CHAT_MSG_COMBAT_FRIENDLYPLAYER_MISSES",
|
||||
K::CombatHostilePlayerHits => "CHAT_MSG_COMBAT_HOSTILEPLAYER_HITS",
|
||||
K::CombatHostilePlayerMisses => "CHAT_MSG_COMBAT_HOSTILEPLAYER_MISSES",
|
||||
K::CombatCreatureVsSelfHits => "CHAT_MSG_COMBAT_CREATURE_VS_SELF_HITS",
|
||||
K::CombatCreatureVsSelfMisses => "CHAT_MSG_COMBAT_CREATURE_VS_SELF_MISSES",
|
||||
K::CombatCreatureVsPartyHits => "CHAT_MSG_COMBAT_CREATURE_VS_PARTY_HITS",
|
||||
K::CombatCreatureVsPartyMisses => "CHAT_MSG_COMBAT_CREATURE_VS_PARTY_MISSES",
|
||||
K::CombatCreatureVsCreatureHits => "CHAT_MSG_COMBAT_CREATURE_VS_CREATURE_HITS",
|
||||
K::CombatCreatureVsCreatureMisses => "CHAT_MSG_COMBAT_CREATURE_VS_CREATURE_MISSES",
|
||||
K::SpellSelfDamage => "CHAT_MSG_SPELL_SELF_DAMAGE",
|
||||
K::SpellSelfBuff => "CHAT_MSG_SPELL_SELF_BUFF",
|
||||
K::SpellPetDamage => "CHAT_MSG_SPELL_PET_DAMAGE",
|
||||
K::SpellPetBuff => "CHAT_MSG_SPELL_PET_BUFF",
|
||||
K::SpellPartyDamage => "CHAT_MSG_SPELL_PARTY_DAMAGE",
|
||||
K::SpellPartyBuff => "CHAT_MSG_SPELL_PARTY_BUFF",
|
||||
K::SpellFriendlyPlayerDamage => "CHAT_MSG_SPELL_FRIENDLYPLAYER_DAMAGE",
|
||||
K::SpellFriendlyPlayerBuff => "CHAT_MSG_SPELL_FRIENDLYPLAYER_BUFF",
|
||||
K::SpellHostilePlayerDamage => "CHAT_MSG_SPELL_HOSTILEPLAYER_DAMAGE",
|
||||
K::SpellHostilePlayerBuff => "CHAT_MSG_SPELL_HOSTILEPLAYER_BUFF",
|
||||
K::SpellCreatureVsSelfDamage => "CHAT_MSG_SPELL_CREATURE_VS_SELF_DAMAGE",
|
||||
K::SpellCreatureVsSelfBuff => "CHAT_MSG_SPELL_CREATURE_VS_SELF_BUFF",
|
||||
K::SpellCreatureVsPartyDamage => "CHAT_MSG_SPELL_CREATURE_VS_PARTY_DAMAGE",
|
||||
K::SpellCreatureVsPartyBuff => "CHAT_MSG_SPELL_CREATURE_VS_PARTY_BUFF",
|
||||
K::SpellCreatureVsCreatureDamage => "CHAT_MSG_SPELL_CREATURE_VS_CREATURE_DAMAGE",
|
||||
K::SpellCreatureVsCreatureBuff => "CHAT_MSG_SPELL_CREATURE_VS_CREATURE_BUFF",
|
||||
K::SpellDamageShieldsOnSelf => "CHAT_MSG_SPELL_DAMAGESHIELDS_ON_SELF",
|
||||
K::SpellDamageShieldsOnOthers => "CHAT_MSG_SPELL_DAMAGESHIELDS_ON_OTHERS",
|
||||
K::SpellPeriodicSelfDamage => "CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE",
|
||||
K::SpellPeriodicSelfBuffs => "CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS",
|
||||
K::SpellPeriodicPartyDamage => "CHAT_MSG_SPELL_PERIODIC_PARTY_DAMAGE",
|
||||
K::SpellPeriodicPartyBuffs => "CHAT_MSG_SPELL_PERIODIC_PARTY_BUFFS",
|
||||
K::SpellPeriodicFriendlyPlayerDamage => "CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_DAMAGE",
|
||||
K::SpellPeriodicFriendlyPlayerBuffs => "CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_BUFFS",
|
||||
K::SpellPeriodicHostilePlayerDamage => "CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE",
|
||||
K::SpellPeriodicHostilePlayerBuffs => "CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_BUFFS",
|
||||
K::SpellPeriodicCreatureDamage => "CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE",
|
||||
K::SpellPeriodicCreatureBuffs => "CHAT_MSG_SPELL_PERIODIC_CREATURE_BUFFS",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,8 +560,17 @@ pub(crate) fn notice_token(byte: u8) -> Option<&'static str> {
|
||||
|
||||
/// The message groups a window registers — `ChatTypeGroup`'s keys (transcribed; the chat-cache
|
||||
/// WINDOW blocks list these names). Only the groups the current kind set can carry.
|
||||
///
|
||||
/// **Most of `ChatTypeGroup` is 1:1 with a chat type**, and the whole combat-log block is: ref
|
||||
/// `ChatFrame.lua` l.181-330 is thirty-odd `ChatTypeGroup["X"] = { "CHAT_MSG_X" }` one-liners.
|
||||
/// [`Self::Own`] is that shape, so those groups cost one variant between them instead of one each —
|
||||
/// the enum below carries only the genuinely *multi*-kind groups, which is the information a reader
|
||||
/// wants from it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum ChatGroup {
|
||||
/// A group whose `ChatTypeGroup` list is exactly the one kind it is named for — every
|
||||
/// `COMBAT_*`/`SPELL_*` group, and the two `COMBAT_*_GAIN` ones that used to be spelled out.
|
||||
Own(ChatEventKind),
|
||||
System,
|
||||
Say,
|
||||
Yell,
|
||||
@@ -376,10 +582,16 @@ pub(crate) enum ChatGroup {
|
||||
Skill,
|
||||
Loot,
|
||||
Money,
|
||||
/// `COMBAT_XP_GAIN` — one kind, its own group (ref ChatFrame.lua l.235-237).
|
||||
CombatXpGain,
|
||||
/// `COMBAT_HONOR_GAIN` — one kind, its own group (ref ChatFrame.lua l.238-240).
|
||||
CombatHonorGain,
|
||||
}
|
||||
|
||||
/// Does this group registration subscribe a window to `kind`? The one predicate the router asks,
|
||||
/// so that [`ChatGroup::Own`] — whose "list" is its own kind and needs no slice to live in — reads
|
||||
/// the same as every multi-kind group.
|
||||
pub(crate) fn group_wants(group: ChatGroup, kind: ChatEventKind) -> bool {
|
||||
match group {
|
||||
ChatGroup::Own(k) => k == kind,
|
||||
multi => group_kinds(multi).contains(&kind),
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChatTypeGroup` transcribed (ref ChatFrame.lua l.116-174): which kinds a group registration
|
||||
@@ -388,6 +600,8 @@ pub(crate) enum ChatGroup {
|
||||
pub(crate) fn group_kinds(group: ChatGroup) -> &'static [ChatEventKind] {
|
||||
use ChatEventKind as K;
|
||||
match group {
|
||||
// Answered by [`group_wants`] without a slice — the kind is the group.
|
||||
ChatGroup::Own(_) => &[],
|
||||
ChatGroup::System => &[
|
||||
K::System,
|
||||
K::Afk,
|
||||
@@ -426,8 +640,6 @@ pub(crate) fn group_kinds(group: ChatGroup) -> &'static [ChatEventKind] {
|
||||
ChatGroup::Skill => &[K::Skill],
|
||||
ChatGroup::Loot => &[K::Loot, K::Money],
|
||||
ChatGroup::Money => &[K::Money],
|
||||
ChatGroup::CombatXpGain => &[K::CombatXpGain],
|
||||
ChatGroup::CombatHonorGain => &[K::CombatHonorGain],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,6 +680,56 @@ pub(crate) fn default_color(kind: ChatEventKind) -> [u8; 3] {
|
||||
K::BgSystemNeutral => [255, 120, 10],
|
||||
K::BgSystemAlliance => [0, 174, 239],
|
||||
K::BgSystemHorde => [255, 0, 0],
|
||||
// ── the combat log ───────────────────────────────────────────────────────────────
|
||||
// The shipped defaults are overwhelmingly plain white; the three that are not are the
|
||||
// ones about YOU being hit, and they are the reason the block is grouped by colour
|
||||
// rather than listed in table order — the exceptions are the content.
|
||||
// Your own spell work: the one gold pair in the block.
|
||||
K::SpellSelfDamage | K::SpellSelfBuff => [255, 255, 0],
|
||||
// A creature hitting YOU — the red that makes incoming melee read.
|
||||
K::CombatCreatureVsSelfHits | K::CombatCreatureVsSelfMisses => [255, 47, 47],
|
||||
// A creature's spell landing on YOU.
|
||||
K::SpellCreatureVsSelfDamage => [202, 76, 217],
|
||||
// Everything else in the block: plain white.
|
||||
K::CombatSelfHits
|
||||
| K::CombatSelfMisses
|
||||
| K::CombatPetHits
|
||||
| K::CombatPetMisses
|
||||
| K::CombatPartyHits
|
||||
| K::CombatPartyMisses
|
||||
| K::CombatFriendlyPlayerHits
|
||||
| K::CombatFriendlyPlayerMisses
|
||||
| K::CombatHostilePlayerHits
|
||||
| K::CombatHostilePlayerMisses
|
||||
| K::CombatCreatureVsPartyHits
|
||||
| K::CombatCreatureVsPartyMisses
|
||||
| K::CombatCreatureVsCreatureHits
|
||||
| K::CombatCreatureVsCreatureMisses
|
||||
| K::SpellPetDamage
|
||||
| K::SpellPetBuff
|
||||
| K::SpellPartyDamage
|
||||
| K::SpellPartyBuff
|
||||
| K::SpellFriendlyPlayerDamage
|
||||
| K::SpellFriendlyPlayerBuff
|
||||
| K::SpellHostilePlayerDamage
|
||||
| K::SpellHostilePlayerBuff
|
||||
| K::SpellCreatureVsSelfBuff
|
||||
| K::SpellCreatureVsPartyDamage
|
||||
| K::SpellCreatureVsPartyBuff
|
||||
| K::SpellCreatureVsCreatureDamage
|
||||
| K::SpellCreatureVsCreatureBuff
|
||||
| K::SpellDamageShieldsOnSelf
|
||||
| K::SpellDamageShieldsOnOthers
|
||||
| K::SpellPeriodicSelfDamage
|
||||
| K::SpellPeriodicSelfBuffs
|
||||
| K::SpellPeriodicPartyDamage
|
||||
| K::SpellPeriodicPartyBuffs
|
||||
| K::SpellPeriodicFriendlyPlayerDamage
|
||||
| K::SpellPeriodicFriendlyPlayerBuffs
|
||||
| K::SpellPeriodicHostilePlayerDamage
|
||||
| K::SpellPeriodicHostilePlayerBuffs
|
||||
| K::SpellPeriodicCreatureDamage
|
||||
| K::SpellPeriodicCreatureBuffs => [255, 255, 255],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,11 @@ enum Pending {
|
||||
/// An area discovery (`SMSG_EXPLORATION_EXPERIENCE`): the toast + conditional chat line pair
|
||||
/// (the drain fires them — the toast needs the script, which only [`feed_chat`] holds).
|
||||
Discovery { area: String, xp: u32 },
|
||||
/// One combat-log line, classified and family-picked at the packet, waiting only on the two
|
||||
/// endpoint names (B297). The reference's deferred-message queue `DAT_00c4e208`, whose records
|
||||
/// carry a type tag and two guids for exactly this reason and are replayed by the name-ready
|
||||
/// callback `0x6294b0`.
|
||||
Combat(Box<super::combat::PendingCombat>),
|
||||
/// A ready event (client-composed lines; name-carrying notices).
|
||||
Event(ChatEvent),
|
||||
}
|
||||
@@ -201,6 +206,12 @@ impl ChatLog {
|
||||
self.pending.push(Pending::Event(event));
|
||||
}
|
||||
|
||||
/// Queue one combat-log line ([`super::combat`] did the classification at the packet; this
|
||||
/// only parks it for its names).
|
||||
pub(crate) fn push_combat(&mut self, line: super::combat::PendingCombat) {
|
||||
self.pending.push(Pending::Combat(Box::new(line)));
|
||||
}
|
||||
|
||||
/// Queue a decoded `SMSG_CHANNEL_NOTIFY`. JOINED/LEFT become the ref's CHANNEL_JOIN/LEAVE
|
||||
/// *events* (a member line, hyperlinked — ChatFrame.lua's ChatTypeGroup["CHANNEL"]); every
|
||||
/// other notice becomes a CHANNEL_NOTICE composed by the `CHAT_<X>_NOTICE` law. Guid tails
|
||||
@@ -1024,6 +1035,41 @@ pub(super) fn feed_chat(
|
||||
let Some(event) = event else { continue };
|
||||
route(&mut script, &mut windows, &event);
|
||||
}
|
||||
Pending::Combat(mut line) => {
|
||||
// Both names, ask-once. The reference's queue holds the record until its
|
||||
// creature-name DBC row is loaded and replays it then; ours holds it until the
|
||||
// name query answers, bounded by the same `tries` budget every other pending item
|
||||
// uses so a guid the server will never name cannot pin the queue.
|
||||
// Guid 0 means the arm already put the name in the fills (`/chattest`); any other
|
||||
// guid is asked for, once, and the line waits.
|
||||
let mut wait = false;
|
||||
for (guid, slot) in [(line.subject, 0usize), (line.object, 1usize)] {
|
||||
if guid == 0 {
|
||||
continue;
|
||||
}
|
||||
match super::combat::object_name(guid, &mut names, &commands) {
|
||||
Some(name) if slot == 0 => line.fills.attacker = name,
|
||||
Some(name) => line.fills.victim = name,
|
||||
None => wait = true,
|
||||
}
|
||||
}
|
||||
if wait {
|
||||
if line.tries < NAME_MAX_TRIES {
|
||||
line.tries += 1;
|
||||
still.push(Pending::Combat(line));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let composed =
|
||||
super::combat::compose_line(&script, line.family, line.variant, &line.fills);
|
||||
if let Some(text) = composed {
|
||||
route(
|
||||
&mut script,
|
||||
&mut windows,
|
||||
&ChatEvent::text_only(line.kind, text),
|
||||
);
|
||||
}
|
||||
}
|
||||
Pending::Discovery { area, xp } => {
|
||||
// The toast fires every time; the chat line only rides XP (decisions 0828/0829).
|
||||
script.fire_event(
|
||||
|
||||
@@ -12,7 +12,7 @@ use bevy::prelude::*;
|
||||
|
||||
use benilla_protocol::messages::channel_notice as notice;
|
||||
|
||||
use super::event::{event_name, group_kinds, resolved_color, ChatEvent, ChatEventKind, ChatGroup};
|
||||
use super::event::{event_name, group_wants, resolved_color, ChatEvent, ChatEventKind, ChatGroup};
|
||||
|
||||
/// How long after a received whisper the `TellMessage` alert stays silent
|
||||
/// (`CHAT_TELL_ALERT_TIME = 300` — ref ChatFrame.lua l.4: only a tell arriving ≥5 min after the
|
||||
@@ -58,15 +58,52 @@ impl Default for ChatWindows {
|
||||
ChatGroup::Skill,
|
||||
ChatGroup::Loot,
|
||||
],
|
||||
// WINDOW 2 "Combat Log": of its chat-cache list, MONEY, COMBAT_XP_GAIN and
|
||||
// COMBAT_HONOR_GAIN are the groups the current kind set carries (the rest of the
|
||||
// combat/spell block is the combat-log arc; XP joined with the ding arc, 0304,
|
||||
// and honour with the honor arc, 1512 — the reference registers the two on the
|
||||
// same frame one line apart, ChatFrame.lua l.2428-2429).
|
||||
// WINDOW 2 "Combat Log" — the chat-cache WINDOW 2 MESSAGES block, in its own
|
||||
// order (read off the reference install's own
|
||||
// `WTF/Account/<A>/<realm>/<char>/chat-cache.txt`).
|
||||
//
|
||||
// **The default Combat Log is self-relevant only**, and that is a deliberate
|
||||
// shape, not an omission: the shipped registration takes SELF, PET,
|
||||
// HOSTILEPLAYER and CREATURE_VS_SELF and leaves PARTY, FRIENDLYPLAYER,
|
||||
// CREATURE_VS_PARTY and CREATURE_VS_CREATURE off. Those types are still produced
|
||||
// and still reach addons — the *event* fire is unconditional (see [`route`]) —
|
||||
// they simply land in no window until a player registers them. A damage meter
|
||||
// reads them either way.
|
||||
//
|
||||
// Five names in that block have no producer yet and so no group here:
|
||||
// COMBAT_MISC_INFO, COMBAT_FRIENDLY_DEATH, COMBAT_HOSTILE_DEATH,
|
||||
// COMBAT_FACTION_CHANGE, SPELL_TRADESKILLS, SPELL_AURA_GONE_SELF,
|
||||
// SPELL_ITEM_ENCHANTMENTS and SPELL_BREAK_AURA — the leaves of the block whose
|
||||
// wire sources are still undecoded.
|
||||
vec![
|
||||
ChatGroup::Own(ChatEventKind::CombatSelfHits),
|
||||
ChatGroup::Own(ChatEventKind::CombatSelfMisses),
|
||||
ChatGroup::Own(ChatEventKind::CombatPetHits),
|
||||
ChatGroup::Own(ChatEventKind::CombatPetMisses),
|
||||
ChatGroup::Own(ChatEventKind::CombatHostilePlayerHits),
|
||||
ChatGroup::Own(ChatEventKind::CombatHostilePlayerMisses),
|
||||
ChatGroup::Own(ChatEventKind::CombatCreatureVsSelfHits),
|
||||
ChatGroup::Own(ChatEventKind::CombatCreatureVsSelfMisses),
|
||||
// XP joined with the ding arc (0304) and honour with the honor arc (1512);
|
||||
// the reference registers the two one line apart (ChatFrame.lua l.2428-2429).
|
||||
ChatGroup::Own(ChatEventKind::CombatXpGain),
|
||||
ChatGroup::Own(ChatEventKind::CombatHonorGain),
|
||||
ChatGroup::Own(ChatEventKind::SpellSelfDamage),
|
||||
ChatGroup::Own(ChatEventKind::SpellSelfBuff),
|
||||
ChatGroup::Own(ChatEventKind::SpellPetDamage),
|
||||
ChatGroup::Own(ChatEventKind::SpellPetBuff),
|
||||
ChatGroup::Own(ChatEventKind::SpellHostilePlayerDamage),
|
||||
ChatGroup::Own(ChatEventKind::SpellHostilePlayerBuff),
|
||||
ChatGroup::Own(ChatEventKind::SpellCreatureVsSelfDamage),
|
||||
ChatGroup::Own(ChatEventKind::SpellCreatureVsSelfBuff),
|
||||
ChatGroup::Own(ChatEventKind::SpellDamageShieldsOnSelf),
|
||||
ChatGroup::Own(ChatEventKind::SpellPeriodicSelfDamage),
|
||||
ChatGroup::Own(ChatEventKind::SpellPeriodicSelfBuffs),
|
||||
ChatGroup::Own(ChatEventKind::SpellPeriodicHostilePlayerDamage),
|
||||
ChatGroup::Own(ChatEventKind::SpellPeriodicHostilePlayerBuffs),
|
||||
ChatGroup::Own(ChatEventKind::SpellPeriodicCreatureDamage),
|
||||
ChatGroup::Own(ChatEventKind::SpellPeriodicCreatureBuffs),
|
||||
ChatGroup::Money,
|
||||
ChatGroup::CombatXpGain,
|
||||
ChatGroup::CombatHonorGain,
|
||||
],
|
||||
],
|
||||
tell_alert_left: 0.0,
|
||||
@@ -79,13 +116,11 @@ impl ChatWindows {
|
||||
/// Whether window `idx` (0-based) subscribes to `kind`. CHANNEL speech (the numbered
|
||||
/// channels) routes by the window's channel wiring — until 0288 P6 lands those lists, it
|
||||
/// rides window 1 (the chat-cache ZONECHANNELS mask wires exactly window 1 anyway).
|
||||
fn wants(&self, idx: usize, kind: ChatEventKind) -> bool {
|
||||
pub(super) fn wants(&self, idx: usize, kind: ChatEventKind) -> bool {
|
||||
if kind == ChatEventKind::Channel {
|
||||
return idx == 0;
|
||||
}
|
||||
self.groups[idx]
|
||||
.iter()
|
||||
.any(|&g| group_kinds(g).contains(&kind))
|
||||
self.groups[idx].iter().any(|&g| group_wants(g, kind))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +221,12 @@ pub(crate) fn compose(
|
||||
| K::BgSystemNeutral
|
||||
| K::BgSystemAlliance
|
||||
| K::BgSystemHorde => event.text.clone(),
|
||||
// The whole combat-log block is verbatim too, and the reference says so by PREFIX rather
|
||||
// than by name: l.1397-1400 is two arms, `strsub(type,1,7) == "COMBAT_"` and
|
||||
// `strsub(type,1,6) == "SPELL_"`, each doing nothing but `AddMessage(arg1, …)`. The
|
||||
// sentence was already built by the time it became an event — that is what
|
||||
// [`super::combat`] is — so there is nothing left for the composer to do.
|
||||
k if k.is_combat_log() => event.text.clone(),
|
||||
// "%s is ignoring you." (CHAT_IGNORED, arg2).
|
||||
K::Ignored => format!("{} is ignoring you.", event.sender),
|
||||
// "[%s] " .. the member list (CHAT_CHANNEL_LIST_GET, l.1409) — arg4 WHOLE, see
|
||||
|
||||
@@ -941,9 +941,176 @@ fn chattest_battery(log: &mut super::feed::ChatLog) {
|
||||
for e in battery {
|
||||
log.push_event(e);
|
||||
}
|
||||
combat_log_battery(log);
|
||||
info!("chattest: battery queued");
|
||||
}
|
||||
|
||||
/// The combat-log half of `/chattest` (B297): one synthetic line of every family, through the real
|
||||
/// [`super::combat`] composer and the real drain — so the whole `COMBAT_*`/`SPELL_*` block's
|
||||
/// wording, colours and window routing verify in one screen without a fight.
|
||||
///
|
||||
/// It exists because the alternative is a live combat probe, and unattended ones are out (the
|
||||
/// director's standing rule). The lines are driven with **guid 0 on both endpoints and literal
|
||||
/// names in the fills**, so the drain's name resolve is a no-op and nothing here touches the wire;
|
||||
/// everything downstream of that — the family's template lookup, the slot fill, the chat type, the
|
||||
/// route, the `CHAT_MSG_*` fire an addon sees — is the production path exactly.
|
||||
///
|
||||
/// The chat TYPES are picked to show the block's spread rather than one row: your own melee and
|
||||
/// spells, your pet, a hostile player, and a creature hitting you (which is the one that is red).
|
||||
fn combat_log_battery(log: &mut super::feed::ChatLog) {
|
||||
use super::combat::{self, Family, Fills, PendingCombat, Variant};
|
||||
use super::event::ChatEventKind as K;
|
||||
|
||||
let fills = |amount: i64, school: Option<u8>, power: Option<u32>| Fills {
|
||||
attacker: "Gnoll Brute".into(),
|
||||
victim: "Target Dummy".into(),
|
||||
spell: "Fireball".into(),
|
||||
school,
|
||||
power,
|
||||
amount,
|
||||
amount2: amount / 3,
|
||||
power2: power,
|
||||
};
|
||||
let line = |kind: K, family: Family, variant: Variant, f: Fills| PendingCombat {
|
||||
kind,
|
||||
family,
|
||||
variant,
|
||||
subject: 0,
|
||||
object: 0,
|
||||
fills: f,
|
||||
tries: 0,
|
||||
};
|
||||
let battery = [
|
||||
// Your own melee, plain and crit, and the school form.
|
||||
line(
|
||||
K::CombatSelfHits,
|
||||
combat::COMBATHIT,
|
||||
Variant::SelfOther,
|
||||
fills(120, None, None),
|
||||
),
|
||||
line(
|
||||
K::CombatSelfHits,
|
||||
combat::COMBATHITCRIT,
|
||||
Variant::SelfOther,
|
||||
fills(240, None, None),
|
||||
),
|
||||
line(
|
||||
K::CombatSelfHits,
|
||||
combat::COMBATHITSCHOOL,
|
||||
Variant::SelfOther,
|
||||
fills(35, Some(2), None),
|
||||
),
|
||||
line(
|
||||
K::CombatSelfMisses,
|
||||
combat::MISSED,
|
||||
Variant::SelfOther,
|
||||
fills(0, None, None),
|
||||
),
|
||||
// A creature hitting you — the red rows, and the ones a player notices first.
|
||||
line(
|
||||
K::CombatCreatureVsSelfHits,
|
||||
combat::COMBATHIT,
|
||||
Variant::OtherSelf,
|
||||
fills(87, None, None),
|
||||
),
|
||||
line(
|
||||
K::CombatCreatureVsSelfMisses,
|
||||
combat::VSDODGE,
|
||||
Variant::OtherSelf,
|
||||
fills(0, None, None),
|
||||
),
|
||||
line(
|
||||
K::CombatCreatureVsSelfMisses,
|
||||
combat::VSBLOCK,
|
||||
Variant::OtherSelf,
|
||||
fills(0, None, None),
|
||||
),
|
||||
line(
|
||||
K::CombatCreatureVsSelfMisses,
|
||||
combat::VSPARRY,
|
||||
Variant::OtherSelf,
|
||||
fills(0, None, None),
|
||||
),
|
||||
// Your own spells: the gold pair, and the outcomes that are not damage.
|
||||
line(
|
||||
K::SpellSelfDamage,
|
||||
combat::SPELLLOGSCHOOL,
|
||||
Variant::SelfOther,
|
||||
fills(412, Some(2), None),
|
||||
),
|
||||
line(
|
||||
K::SpellSelfDamage,
|
||||
combat::SPELLLOGCRITSCHOOL,
|
||||
Variant::SelfOther,
|
||||
fills(830, Some(2), None),
|
||||
),
|
||||
line(
|
||||
K::SpellSelfDamage,
|
||||
combat::SPELLMISS,
|
||||
Variant::SelfOther,
|
||||
fills(0, None, None),
|
||||
),
|
||||
line(
|
||||
K::SpellSelfDamage,
|
||||
combat::SPELLRESIST,
|
||||
Variant::SelfOther,
|
||||
fills(0, None, None),
|
||||
),
|
||||
line(
|
||||
K::SpellSelfBuff,
|
||||
combat::HEALED,
|
||||
Variant::SelfOther,
|
||||
fills(560, None, None),
|
||||
),
|
||||
line(
|
||||
K::SpellSelfBuff,
|
||||
combat::POWERGAIN,
|
||||
Variant::SelfSelf,
|
||||
fills(90, None, Some(0)),
|
||||
),
|
||||
// Your pet, a hostile player, and the periodic + shield rows.
|
||||
line(
|
||||
K::CombatPetHits,
|
||||
combat::COMBATHIT,
|
||||
Variant::OtherOther,
|
||||
fills(64, None, None),
|
||||
),
|
||||
line(
|
||||
K::SpellHostilePlayerDamage,
|
||||
combat::SPELLLOGSCHOOL,
|
||||
Variant::OtherSelf,
|
||||
fills(305, Some(5), None),
|
||||
),
|
||||
line(
|
||||
K::SpellPeriodicSelfDamage,
|
||||
combat::PERIODICAURADAMAGE,
|
||||
Variant::SelfOther,
|
||||
fills(48, Some(5), None),
|
||||
),
|
||||
line(
|
||||
K::SpellPeriodicSelfBuffs,
|
||||
combat::PERIODICAURAHEAL,
|
||||
Variant::SelfSelf,
|
||||
fills(75, None, None),
|
||||
),
|
||||
line(
|
||||
K::SpellDamageShieldsOnSelf,
|
||||
combat::DAMAGESHIELD,
|
||||
Variant::SelfOther,
|
||||
fills(22, Some(1), None),
|
||||
),
|
||||
line(
|
||||
K::SpellSelfBuff,
|
||||
combat::SPELLPOWERLEECH,
|
||||
Variant::SelfOther,
|
||||
fills(150, None, Some(0)),
|
||||
),
|
||||
];
|
||||
for l in battery {
|
||||
log.push_combat(l);
|
||||
}
|
||||
}
|
||||
|
||||
/// The send-side emote **posture-eligibility gate** (wow-re `object-layer/scratch/emote-posture-
|
||||
/// gate.md`, commit `f9584b45`, §0): the real client's `CheckEmoteEligible` (`0x47db40`), the *only*
|
||||
/// site that reads an `Emotes.dbc` `EmoteFlags` — called from `DoEmote` (`0x5ef560`) *before*
|
||||
|
||||
@@ -931,7 +931,7 @@ fn every_kind_is_in_all() {
|
||||
seen.sort_unstable();
|
||||
seen.dedup();
|
||||
assert_eq!(seen.len(), before, "a kind is listed twice in ALL");
|
||||
assert_eq!(before, 37, "37 kinds — update this when the kind set grows");
|
||||
assert_eq!(before, 81, "81 kinds — update this when the kind set grows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1763,3 +1763,94 @@ fn the_talk_gesture_reads_the_plaintext_not_the_garbled_line() {
|
||||
"the garbled form would NOT laugh — which is why the feed must pass the plaintext"
|
||||
);
|
||||
}
|
||||
|
||||
// ── B297: the combat log reaches addons ──────────────────────────────────────────────────────
|
||||
|
||||
/// **The bug, stated as a test.** B297 is "benilla emits no combat-log chat events at all", and the
|
||||
/// consumer named in the report is Quiver's TranqAnnouncer, whose *only* detector is
|
||||
/// `CHAT_MSG_SPELL_SELF_DAMAGE`. So the test is an addon registering exactly that event and
|
||||
/// receiving exactly that sentence.
|
||||
///
|
||||
/// `arg1` carrying the whole formatted line is the load-bearing half: every 1.12 damage meter and
|
||||
/// announcer parses `arg1` with a Lua pattern built from its own copy of the GlobalStrings, so a
|
||||
/// fire with an empty or differently-shaped arg1 would pass a "does it fire" check and still be
|
||||
/// useless. This asserts the text.
|
||||
#[test]
|
||||
fn an_addon_sees_the_combat_log_line_it_registers_for() {
|
||||
let mut s = chat_vm();
|
||||
let mut windows = super::frames::ChatWindows::default();
|
||||
s.run(SPY).unwrap();
|
||||
s.run(r#"BenillaChatSpy:RegisterEvent("CHAT_MSG_SPELL_SELF_DAMAGE")"#)
|
||||
.unwrap();
|
||||
|
||||
let line = "Your Fireball hits Kobold Vermin for 120 fire damage.";
|
||||
super::frames::route(
|
||||
&mut s,
|
||||
&mut windows,
|
||||
&ChatEvent::text_only(K::SpellSelfDamage, line.into()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
s.eval::<String>("return SpyEvent").unwrap(),
|
||||
"CHAT_MSG_SPELL_SELF_DAMAGE",
|
||||
"the event an addon registered is the event that fired"
|
||||
);
|
||||
assert_eq!(s.eval::<i64>("return SpyN").unwrap(), 1);
|
||||
let seen: String = s.eval("return SpyLine").unwrap();
|
||||
assert!(
|
||||
seen.starts_with(&format!("{line}|")),
|
||||
"arg1 must be the whole sentence, not a fragment — got {seen:?}"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The combat block is **verbatim**: the composer adds `arg1` and nothing else — no `[Name]` link,
|
||||
/// no `<AFK>` prefix, no language header. The reference says so by prefix
|
||||
/// (`ChatFrame_OnEvent` l.1397/1399, two arms that only `AddMessage(arg1, …)`), and the failure
|
||||
/// this guards against is the player/monster branch's decorations leaking onto a combat line.
|
||||
#[test]
|
||||
fn a_combat_log_line_renders_verbatim() {
|
||||
let default_language = String::from("Common");
|
||||
for kind in K::ALL.iter().copied().filter(|k| k.is_combat_log()) {
|
||||
let mut e = ChatEvent::text_only(kind, "You hit Kobold Vermin for 5.".into());
|
||||
// Deliberately populated: a combat line never carries these, and if the composer ever fell
|
||||
// through to the player branch it would splice them in.
|
||||
e.sender = "Somebody".into();
|
||||
e.flag = "AFK".into();
|
||||
e.language = "Orcish".into();
|
||||
assert_eq!(
|
||||
super::frames::compose(&e, kind, &default_language).as_deref(),
|
||||
Some("You hit Kobold Vermin for 5."),
|
||||
"{} must render verbatim",
|
||||
super::event::event_name(kind)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Window 2 "Combat Log" receives the self-relevant block and window 1 "General" receives none of
|
||||
/// it — the chat-cache default registration, which is what makes the two docked tabs mean
|
||||
/// something. The types the default leaves unregistered (PARTY, FRIENDLYPLAYER, the two other
|
||||
/// CREATURE_VS rows) still *fire*; they just land in no window, which is the reference's own
|
||||
/// behaviour and the reason a damage meter works without touching the windows.
|
||||
#[test]
|
||||
fn the_combat_log_lands_in_window_two_only() {
|
||||
let windows = super::frames::ChatWindows::default();
|
||||
for kind in [
|
||||
K::CombatSelfHits,
|
||||
K::CombatSelfMisses,
|
||||
K::CombatPetHits,
|
||||
K::CombatCreatureVsSelfHits,
|
||||
K::SpellSelfDamage,
|
||||
K::SpellSelfBuff,
|
||||
K::SpellPeriodicSelfDamage,
|
||||
K::SpellDamageShieldsOnSelf,
|
||||
] {
|
||||
assert!(!windows.wants(0, kind), "{kind:?} must not reach General");
|
||||
assert!(windows.wants(1, kind), "{kind:?} must reach the Combat Log");
|
||||
}
|
||||
// Registered by neither, deliberately — the default is self-relevant only.
|
||||
for kind in [K::CombatPartyHits, K::CombatCreatureVsCreatureHits] {
|
||||
assert!(!windows.wants(0, kind));
|
||||
assert!(!windows.wants(1, kind));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -876,6 +876,66 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The shapeshift divergence, pinned in numbers** (decision 1574). The reference derives the
|
||||
/// collision prism from `UNIT_FIELD_NATIVEDISPLAYID`, so a druid in a form keeps the druid's
|
||||
/// depth lines. This asserts the two readings really differ on shipped data, and by how much —
|
||||
/// a doc claiming "up to 0.72 yd" is worth nothing if the DBC rows drift under it.
|
||||
///
|
||||
/// `h = collisionHeight × max(SCALE_X, CreatureDisplayInfo.scale)` on the row named. Player
|
||||
/// `SCALE_X` starts at `modelScale × CDI.scale` and a shapeshift multiplies it by the form's
|
||||
/// own factor (vmangos `GetShapeshiftDisplayInfo`: 1.0 for bear/moonkin/tree, 0.80 for
|
||||
/// cat/travel/aquatic) — both live-confirmed by decision 0695's own probe (tauren bear
|
||||
/// `h = 2.083 × 1.35`, tauren cat `SCALE_X 1.35 → 1.08`).
|
||||
#[test]
|
||||
fn a_shapeshift_moves_the_collision_prism_and_the_native_row_is_what_stops_it() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_creature_catalog(&mut chain).expect("load creature catalog");
|
||||
|
||||
let h = |display: u32, scale_x: f32| {
|
||||
let col = cat.collision_height(display).expect("collision height");
|
||||
let s = cat.display_scale(display).expect("display scale");
|
||||
col * scale_x.max(s)
|
||||
};
|
||||
|
||||
// (label, native display, form display, native SCALE_X, the form's scale factor)
|
||||
let cases: &[(&str, u32, u32, f32, f32)] = &[
|
||||
("NElf M → cat", 55, 892, 1.0, 0.80),
|
||||
("NElf M → bear", 55, 2281, 1.0, 1.0),
|
||||
("Tauren M → moonkin", 59, 15375, 1.35, 1.0),
|
||||
("Tauren M → bear", 59, 2289, 1.35, 1.0),
|
||||
];
|
||||
let mut worst: f32 = 0.0;
|
||||
for &(label, native, form, native_scale_x, factor) in cases {
|
||||
let scale_x = native_scale_x * factor;
|
||||
let (reference, ours_before) = (h(native, scale_x), h(form, scale_x));
|
||||
let swim_delta = 0.75 * (ours_before - reference);
|
||||
assert!(
|
||||
swim_delta.abs() > 0.05,
|
||||
"{label}: the two readings must actually differ, else this test asserts nothing (reference {reference}, form-derived {ours_before})"
|
||||
);
|
||||
worst = worst.max(swim_delta.abs());
|
||||
}
|
||||
assert!(
|
||||
(worst - 0.72).abs() < 0.02,
|
||||
"worst swim-line divergence is {worst} yd, the doc says 0.72"
|
||||
);
|
||||
|
||||
// The direction flips with the form, which is why this can't be waved off as a constant
|
||||
// offset: a night elf cat swims too EARLY, a tauren moonkin far too LATE.
|
||||
assert!(h(892, 0.80) < h(55, 0.80), "NElf cat: form row is shorter");
|
||||
assert!(
|
||||
h(15375, 1.35) > h(59, 1.35),
|
||||
"Tauren moonkin: form row is taller"
|
||||
);
|
||||
|
||||
// 0695's own live probe, reproduced from the DBCs: tauren bear h = 2.083 × 1.35.
|
||||
assert!(
|
||||
(h(2289, 1.35) - 2.083 * 1.35).abs() < 5e-3,
|
||||
"tauren bear form-derived h should reproduce 0695's observed 2.812"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The Shore Strider, pinned** (B311, decision 1568). The reported giant's own chain and
|
||||
/// numbers, recorded so nobody re-suspects the height: display 4945 → `CreatureModelData` 35,
|
||||
/// `Creature\SeaGiant\SeaGiant.mdx`, column 2.083 over a display scale of 1.75 and a
|
||||
|
||||
@@ -44,6 +44,14 @@ pub struct AttackerState {
|
||||
pub resist: i32,
|
||||
/// `BlockedAmount` — the trailing blocked-damage word.
|
||||
pub blocked: u32,
|
||||
/// The **first** sub-damage block's school (`SpellSchools`: 0 physical … 6 arcane; vmangos
|
||||
/// writes `GetFirstSchoolInMask(subDamage->damageSchoolMask)`), `0` when the swing carried no
|
||||
/// sub-damage at all.
|
||||
///
|
||||
/// It exists for the combat log's wording: a non-physical swing takes the `…SCHOOL` template
|
||||
/// ("You hit X for 5 fire damage.") and physical takes the plain one. The floating text has
|
||||
/// never needed it, which is why the field was read and dropped until B297.
|
||||
pub school: u8,
|
||||
}
|
||||
|
||||
/// Read `SMSG_ATTACKERSTATEUPDATE` (byte-verified order, attacker **PackGUID first** — settled by
|
||||
@@ -59,9 +67,15 @@ pub(super) fn read_attacker_state(r: &mut impl Read) -> io::Result<AttackerState
|
||||
let subs = read_u8(r)?;
|
||||
let mut absorb = 0u32;
|
||||
let mut resist = 0i32;
|
||||
for _ in 0..subs {
|
||||
// school, damage f32, damage u32 — folded into TotalDamage above; absorb/resist summed.
|
||||
let _school = read_u32_le(r)?;
|
||||
let mut school = 0u8;
|
||||
for i in 0..subs {
|
||||
// damage f32 + damage u32 are folded into TotalDamage above; absorb/resist are summed. The
|
||||
// school is kept from the FIRST block only — the wording takes one word, and vmangos's
|
||||
// extra blocks are the off-school splits of the same swing.
|
||||
let block_school = read_u32_le(r)?;
|
||||
if i == 0 {
|
||||
school = u8::try_from(block_school).unwrap_or(0);
|
||||
}
|
||||
let _damage_f = read_f32_le(r)?;
|
||||
let _damage = read_u32_le(r)?;
|
||||
absorb += read_u32_le(r)?;
|
||||
@@ -80,6 +94,7 @@ pub(super) fn read_attacker_state(r: &mut impl Read) -> io::Result<AttackerState
|
||||
absorb,
|
||||
resist,
|
||||
blocked,
|
||||
school,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,18 @@ const FIELD_UNIT_BOUNDINGRADIUS: u16 = 129;
|
||||
const FIELD_UNIT_COMBATREACH: u16 = 130;
|
||||
const FIELD_UNIT_BASE_MANA: u16 = 162;
|
||||
const FIELD_UNIT_DISPLAYID: u16 = 131;
|
||||
/// `UNIT_FIELD_NATIVEDISPLAYID` (`OBJECT_END(6) + 0x7E` = 132, PUBLIC — vmangos
|
||||
/// `UpdateFields_1_12_1.h:77`; it is the index between DISPLAYID (131) and MOUNTDISPLAYID (133),
|
||||
/// both of which are independently pinned above). The unit's **unshifted** appearance: the server
|
||||
/// writes it once at spawn/login and leaves it alone through every transform — a druid form
|
||||
/// (`Aura::HandleAuraModShapeshift` sets DISPLAYID and restores it *from* this field), a GM
|
||||
/// `.modify morph`, a polymorph. `UNIT_FIELD_DISPLAYID` is what you see; this is what the unit is.
|
||||
///
|
||||
/// The client reads it for exactly one thing benilla cares about: the **mover collision prism**
|
||||
/// (`0x60b270` fetches its DBC row at `[unit+0x110]+0x1f8`, while its sibling `0x60ae10` uses
|
||||
/// `+0x1f4` = DISPLAYID against the same array) — so a shapeshift does *not* resize the collision
|
||||
/// box (wow-re `mover-collision-scalars.md`, `remote-swim-decision.md` §4; decision 1574).
|
||||
const FIELD_UNIT_NATIVEDISPLAYID: u16 = 132;
|
||||
/// `UNIT_FIELD_MOUNTDISPLAYID` (`OBJECT_END(6) + 0x7F` = 133, PUBLIC — vmangos
|
||||
/// `UpdateFields_1_12_1.h:78`; byte-verified client-side at `[unit+0x110]+0x1fc` = index 0x85,
|
||||
/// wow-re `rf39-field-index-map.md`/`d1.md`, HARD). A raw `CreatureDisplayInfo` id: the mount the
|
||||
|
||||
@@ -56,6 +56,13 @@ impl ObjectFields {
|
||||
pub fn unit_displayid(&self) -> Option<i32> {
|
||||
self.get_i32(FIELD_UNIT_DISPLAYID)
|
||||
}
|
||||
/// `UNIT_FIELD_NATIVEDISPLAYID` (units + players) — the unit's **unshifted** appearance, which
|
||||
/// no transform touches. The collision prism is derived from *this*, not from
|
||||
/// [`Self::unit_displayid`]: a druid in bear form keeps the druid's swim/splash/foam lines
|
||||
/// (decision 1574). `None` when absent — the caller falls back to the render display.
|
||||
pub fn unit_native_displayid(&self) -> Option<i32> {
|
||||
self.get_i32(FIELD_UNIT_NATIVEDISPLAYID)
|
||||
}
|
||||
/// `UNIT_FIELD_MOUNTDISPLAYID` — the `CreatureDisplayInfo` id of the mount this unit rides;
|
||||
/// `0` (or absent) = not mounted. The one and only mounted signal on the wire (decision 0441).
|
||||
pub fn unit_mount_display_id(&self) -> u32 {
|
||||
|
||||
@@ -264,6 +264,10 @@ fn spell_and_action_wire() {
|
||||
"absorb summed across both sub-damage blocks (5 + 7)"
|
||||
);
|
||||
assert_eq!(s.blocked, 15);
|
||||
assert_eq!(
|
||||
s.school, 0,
|
||||
"the wording's school is the FIRST block's (physical), not the second's frost"
|
||||
);
|
||||
}
|
||||
_ => panic!("attacker state"),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user