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:
Sam
2026-08-25 18:35:32 +07:00
co-authored by Claude Fable 5
parent b3a156f090
commit ac2ed34735
20 changed files with 3068 additions and 181 deletions
@@ -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;
+23 -15
View File
@@ -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);
}
}
+114 -60
View File
@@ -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);
}
}
+80 -36
View File
@@ -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"
);
}
+1 -1
View File
@@ -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
+48 -38
View File
@@ -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
+3
View File
@@ -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);
}
+268 -6
View File
@@ -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],
}
}
+46
View File
@@ -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(
+53 -12
View File
@@ -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
+167
View File
@@ -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*
+92 -1
View File
@@ -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));
}
}
+60
View File
@@ -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
+18 -3
View File
@@ -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 {
+4
View File
@@ -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"),
}