diff --git a/crates/benilla-app/src/entities/collision_height.rs b/crates/benilla-app/src/entities/collision_height.rs index d0845047..c789761c 100644 --- a/crates/benilla-app/src/entities/collision_height.rs +++ b/crates/benilla-app/src/entities/collision_height.rs @@ -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) -> Option { + 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 { /// 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>, - units: Query<(Entity, &NetEntity), Without>, + units: Query<(Entity, &NetEntity, Option<&ObjectStore>), Without>, ) { - 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; diff --git a/crates/benilla-app/src/entities/live_display.rs b/crates/benilla-app/src/entities/live_display.rs index 2acaeea9..58b6a9b0 100644 --- a/crates/benilla-app/src/entities/live_display.rs +++ b/crates/benilla-app/src/entities/live_display.rs @@ -107,8 +107,6 @@ pub(super) fn refresh_live_display( mut swapped: MessageWriter, ) { 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); } } diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs index 855dcffb..d56cf373 100644 --- a/crates/benilla-app/src/net/apply.rs +++ b/crates/benilla-app/src/net/apply.rs @@ -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, - // 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, ResMut, + Option>, ), ResMut, ResMut, @@ -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) } diff --git a/crates/benilla-app/src/net/apply/combat_chat.rs b/crates/benilla-app/src/net/apply/combat_chat.rs new file mode 100644 index 00000000..6fcbc4d4 --- /dev/null +++ b/crates/benilla-app/src/net/apply/combat_chat.rs @@ -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 { + 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_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); + } +} diff --git a/crates/benilla-app/src/net/motion/spline.rs b/crates/benilla-app/src/net/motion/spline.rs index 946c38eb..8ab5f6c3 100644 --- a/crates/benilla-app/src/net/motion/spline.rs +++ b/crates/benilla-app/src/net/motion/spline.rs @@ -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" ); } diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs index 512807fa..ca4f28e5 100644 --- a/crates/benilla-app/src/player.rs +++ b/crates/benilla-app/src/player.rs @@ -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 diff --git a/crates/benilla-app/src/player/swim.rs b/crates/benilla-app/src/player/swim.rs index e1a886c2..5263ac98 100644 --- a/crates/benilla-app/src/player/swim.rs +++ b/crates/benilla-app/src/player/swim.rs @@ -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 diff --git a/crates/benilla-app/src/ui_chat.rs b/crates/benilla-app/src/ui_chat.rs index a4a9d0e4..296dae1c 100644 --- a/crates/benilla-app/src/ui_chat.rs +++ b/crates/benilla-app/src/ui_chat.rs @@ -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; diff --git a/crates/benilla-app/src/ui_chat/combat.rs b/crates/benilla-app/src/ui_chat/combat.rs new file mode 100644 index 00000000..a60390e9 --- /dev/null +++ b/crates/benilla-app/src/ui_chat/combat.rs @@ -0,0 +1,1022 @@ +//! The combat log's chat lines — the content pipeline decision 0288 §3 named out of its own scope +//! ("transcribing ATTACKERSTATEUPDATE/spell-log packets into COMBATHIT strings is the combat-log +//! arc"), and B297 is the bill for leaving it. The wire has been decoded for a long time; it fed +//! the floating numbers, the portrait indicator and the center text, and produced **no chat at +//! all**, which is why a damage meter or Quiver's TranqAnnouncer sees nothing to parse. +//! +//! This module is the reference's `UnitCombatLog_C` TU (`~0x625000-0x62e7xx`, wow-re +//! `object-layer/scratch/w2f2.md`), the presentation half: it owns **no** arithmetic, only the +//! composition — classify both endpoints, pick a chat type, pick a GlobalString *key*, fill it. +//! The reference's own chain is four hops and we take the same four: +//! +//! ```text +//! packet → classify(attacker), classify(victim) → 0..9 (the range table names them) +//! → msgType selector → the chat type (0x5e = drop) +//! → format-string selector → a GlobalString KEY + a variant code +//! → FrameScript_GetText(key) → vsnprintf → the line +//! ``` +//! +//! **We ship keys, never Blizzard's text.** The reference's selectors (`0x62a290`, `0x629f90`, the +//! other 43) return the *name* of a localized template; `0x703bf0` (`FrameScript_GetText`) resolves +//! it, and when it comes back empty `0x6269f0` prints "Warning: string %s not found" instead of a +//! line. benilla already runs the player's own `Interface\FrameXML\GlobalStrings.lua` into the VM +//! at boot ([`crate::ui_script`]'s `load_global_strings`), so [`global_string`] is that same hop: +//! the key is ours, the text is the install's. That is the licence-clean shape *and* the faithful +//! one — and it is why a non-enUS install gets its own language for free. +//! +//! **The slot orders are the one thing we author.** A family's format string is Blizzard's, but +//! which value lands in which `%s`/`%d` is a fact about that string, and we state it as an ordered +//! [`Slot`] list. Two properties make that safe rather than fragile: within a family the order is +//! **invariant across the four variants** (the variant only decides whether the `Attacker`/`Victim` +//! slots are *present* — "Your %s hits %s for %d." and "%s's %s hits %s for %d." are the same list +//! with one slot dropped), and [`tests`] fills every declared family against the *real* +//! `GlobalStrings.lua` and checks the `%s`/`%d` type signature it produces matches the shipped +//! template's. A transposition cannot survive that; a drift cannot either. + +use bevy::prelude::*; + +use crate::names::NameCache; +use crate::ui_chat::event::ChatEventKind; + +use crate::net::{GuidIndex, NetCommands, ObjectStore, Reputations, SelfGuid}; +use crate::target::ring::Factions; +use crate::ui_party::GroupState; + +/// A unit's standing relative to the active player — the `0..9` index every combat-log selector in +/// the reference takes, in both parameter positions. +/// +/// **The indices are VERIFIED by name, not inferred from behaviour.** `0x626810` (the combat-log +/// display-range getter) indexes a 10-entry table of `{cvarName, defaultValue}` pairs at +/// `DAT_008629e0`, and those cvar names *are* the class names — read out of `WoW.exe` at +/// `0x8629e0` (file offset `0x4629e0`): +/// +/// | idx | cvar | default | meaning | +/// |---|---|---|---| +/// | 0 | *(NULL)* | `100000.0` (`[0x80dcc4]`) | the active player | +/// | 1 | *(NULL)* | `100000.0` | the active player's pet | +/// | 2 | `CombatLogRangeParty` | `50` | a party member | +/// | 3 | `CombatLogRangePartyPet` | `50` | a party member's pet | +/// | 4 | `CombatLogRangeFriendlyPlayers` | `50` | any other friendly player | +/// | 5 | `CombatLogRangeFriendlyPlayersPets` | `50` | that player's pet | +/// | 6 | `CombatLogRangeHostilePlayers` | `50` | a hostile player | +/// | 7 | `CombatLogRangeHostilePlayersPets` | `50` | that player's pet | +/// | 8 | `CombatLogRangeCreature` | `30` | a creature | +/// | 9 | `""` (the empty static `0x882748`) | `0.0` (`[0x7ffd74]`) | anything else — never logged | +/// +/// Index 9's range of **zero** is why the msgType selectors can treat 8 and 9 alike: a class-9 +/// endpoint never passes the range gate, so it never reaches them. Index 0/1's `100000.0` is the +/// "no gate" sentinel — your own lines are never dropped for distance. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum UnitClass { + Me = 0, + MyPet = 1, + Party = 2, + PartyPet = 3, + FriendlyPlayer = 4, + FriendlyPet = 5, + HostilePlayer = 6, + HostilePet = 7, + Creature = 8, + /// Unresolvable — not streamed, or a guid family with no unit behind it. Range `0.0`: the + /// reference logs nothing for it, and neither do we. + Unknown = 9, +} + +impl UnitClass { + /// The CVar naming this class's display range, `None` for the two ungated classes (0/1). + /// + /// **Test-only, deliberately.** The gate itself runs on [`Self::default_range`] — the + /// compiled-in values — following the standing shape of [`crate::combat_text`]'s own cvar + /// gates. This function is the other half of that table, kept so + /// `tests::the_class_range_table_is_the_binarys` can pin what was read out of `WoW.exe`; it + /// becomes the production lookup the day the live CVar table is wired in, and until then a + /// name that drifted would otherwise have nothing checking it. + #[cfg(test)] + pub(crate) fn range_cvar(self) -> Option<&'static str> { + Some(match self { + Self::Me | Self::MyPet | Self::Unknown => return None, + Self::Party => "CombatLogRangeParty", + Self::PartyPet => "CombatLogRangePartyPet", + Self::FriendlyPlayer => "CombatLogRangeFriendlyPlayers", + Self::FriendlyPet => "CombatLogRangeFriendlyPlayersPets", + Self::HostilePlayer => "CombatLogRangeHostilePlayers", + Self::HostilePet => "CombatLogRangeHostilePlayersPets", + Self::Creature => "CombatLogRangeCreature", + }) + } + + /// The compiled-in default range in yards, before any CVar override. + pub(crate) fn default_range(self) -> f32 { + match self { + Self::Me | Self::MyPet => 100_000.0, + Self::Creature => 30.0, + Self::Unknown => 0.0, + _ => 50.0, + } + } + + /// `true` for the local player — the `SELF` half of every `…SELFOTHER`/`…OTHERSELF` key. + /// + /// **Only class 0.** Your own pet is class 1 and is an `OTHER` for string-selection purposes: + /// the reference says "Your pet hits X" through the same `…OTHEROTHER` template it uses for a + /// stranger, and routes it to `COMBAT_PET_HITS` by the *msgType* selector instead. The two + /// classifications are independent, which is the whole reason the selectors take the class + /// index and the string picker takes a pair of booleans. + fn is_me(self) -> bool { + self == Self::Me + } +} + +/// Classify one guid against the active player — the reference's `0x5efea0(ecx = GUID*)`. +/// +/// **The rule is now VERIFIED**, not inferred: the wow-re §5 this arc dispatched settled it +/// (`system/object-layer/scratch/combat-log-chat-law.md` §2), and it corrected 1571's first cut on +/// three points, each of which changed behaviour: +/// +/// - **The owner field is `UNIT_FIELD_CHARMEDBY`, then `UNIT_FIELD_CREATEDBY`.** We read +/// `SUMMONEDBY` first, copying the floating text's source classifier — a different field, and a +/// charmed unit was therefore judged on its own faction instead of its charmer's. +/// - **"Party" is the four party slots, NOT the raid roster.** A raid member outside your own +/// subgroup is class 4/5 (another friendly player), not class 2/3. We keyed off the whole +/// `GroupState` roster, so in a raid every one of 39 other players was "party". +/// - **Friend-or-foe is `CanAttack`, not a reaction rank.** A same-faction duel opponent reads +/// friendly by reaction and is unambiguously hostile here — which is the case the msgType +/// matrix's own duel arms exist for, so getting it from the reaction was self-defeating. +/// +/// One half of the verdict is **not** implemented and is deliberately named rather than quietly +/// dropped: the reference runs `0x606980 CanAttack` **mutually**, in both directions, and +/// [`crate::target::ring::can_attack_from_player`] is specialised to the local player as the +/// attacker (1530). We run the direction we have. It differs only for a unit that can attack you +/// while you cannot attack it, which needs the general two-unit form to answer. +#[allow(clippy::too_many_arguments)] +pub(crate) fn classify( + guid: u64, + self_guid: &SelfGuid, + group: Option<&GroupState>, + index: &GuidIndex, + stores: &Query<&mut ObjectStore>, + factions: Option<&Factions>, + reputations: &Reputations, +) -> UnitClass { + let Some(me) = self_guid.0 else { + return UnitClass::Unknown; + }; + if guid == me { + return UnitClass::Me; + } + let Some(entity) = index.0.get(&guid).copied() else { + return UnitClass::Unknown; + }; + let Ok(store) = stores.get(entity) else { + return UnitClass::Unknown; + }; + // The owner: CHARMEDBY first, then CREATEDBY (§2). A pet/guardian/totem/charmed unit is + // classified by WHOSE it is, one rung above its own faction. + let owner = store + .0 + .unit_charmed_by() + .or_else(|| store.0.unit_created_by()); + let owned_by = |g: u64| owner == Some(g); + + if owned_by(me) { + return UnitClass::MyPet; + } + // **Party, not raid.** The reference's party slots hold your own subgroup; a raid member in + // another subgroup is just another friendly player. `flags` bits 0-2 are the subgroup on both + // sides of this comparison (`GroupMemberEntry::flags` / `GroupState::own_flags`), so an + // ordinary 5-man party — where everyone is subgroup 0 — needs no special case. + let in_party = |g: u64| { + group.is_some_and(|s| { + s.in_group + && s.members + .iter() + .any(|m| m.guid == g && m.flags & 0x7 == s.own_flags & 0x7) + }) + }; + if in_party(guid) { + return UnitClass::Party; + } + if owner.is_some_and(in_party) { + return UnitClass::PartyPet; + } + + // Friend or foe — `CanAttack`, judged on **this unit**, never on its owner (§2). The owner + // hop was ours and it was wrong: the reference asks the pet itself. + let hostile = { + let me_store = index.0.get(&me).copied().and_then(|e| stores.get(e).ok()); + crate::target::ring::can_attack_from_player( + factions, + reputations, + Some(store), + me_store, + benilla_protocol::guid::is_player(guid), + ) + }; + + if benilla_protocol::guid::is_player(guid) { + return if hostile { + UnitClass::HostilePlayer + } else { + UnitClass::FriendlyPlayer + }; + } + // A creature with a PLAYER owner is that player's pet; a creature owned by nothing (or by + // another creature) is just a creature. + if owner.is_some_and(benilla_protocol::guid::is_player) { + return if hostile { + UnitClass::HostilePet + } else { + UnitClass::FriendlyPet + }; + } + UnitClass::Creature +} + +// ─────────────────────────────── the msgType selectors ──────────────────────────────── + +/// `0x62a0d0` / `0x62a2e0` — the melee family's `(attacker, victim) → chat type`, `None` where the +/// reference returns `0x5e` (94, one past the end of the 94-entry type table = "no type", checked +/// at the emit `0x626850`). +/// +/// Read off the decompiled selectors and cross-checked against wow-re's byte-verified 94-entry +/// default colour table (`system/ui/scratch/chat-color-table.md`, whose index column is the 1-based +/// `GetChatTypeIndex` value — so the `0x1b` the selector returns is that table's row 28, +/// `COMBAT_SELF_HITS`). `miss` picks the odd twin: every HITS type is immediately followed by its +/// MISSES type, which is why the reference's two selectors differ only by `+1`. +/// +/// **The two reclassifying arms are real and they are the duel/PvP case.** A *party* player (2) or +/// a *friendly* player (4) whose victim is you, your pet, or a party member is reported in the +/// HOSTILEPLAYER bucket — the client decides "attacking me makes you hostile" at the log, without +/// consulting faction. Their pets (3 and 5) get no such treatment. +pub(crate) fn combat_kind( + attacker: UnitClass, + victim: UnitClass, + miss: bool, +) -> Option { + use ChatEventKind as K; + use UnitClass as C; + let mine = matches!(victim, C::Me | C::MyPet | C::Party | C::PartyPet); + let hits = match attacker { + C::Me => K::CombatSelfHits, + C::MyPet => K::CombatPetHits, + C::Party if mine => K::CombatHostilePlayerHits, + C::Party | C::PartyPet => K::CombatPartyHits, + C::FriendlyPlayer if mine => K::CombatHostilePlayerHits, + C::FriendlyPlayer | C::FriendlyPet => K::CombatFriendlyPlayerHits, + C::HostilePlayer | C::HostilePet => K::CombatHostilePlayerHits, + C::Creature | C::Unknown => match victim { + C::Me | C::MyPet => K::CombatCreatureVsSelfHits, + C::Party | C::PartyPet => K::CombatCreatureVsPartyHits, + _ => K::CombatCreatureVsCreatureHits, + }, + }; + Some(if miss { miss_twin(hits) } else { hits }) +} + +/// `0x627820` and its damage sibling — the direct-spell family's `(attacker, victim) → chat type`. +/// +/// Byte-identical in shape to [`combat_kind`] (same ten source arms, same two reclassifying arms, +/// same victim split for a creature source); only the base row differs, and `buff` picks the odd +/// twin exactly as `miss` does there. A *heal*, a *power gain* and an *aura* are all "BUFF"; damage +/// and every failed-to-land outcome are "DAMAGE". +pub(crate) fn spell_kind( + attacker: UnitClass, + victim: UnitClass, + buff: bool, +) -> Option { + use ChatEventKind as K; + use UnitClass as C; + let mine = matches!(victim, C::Me | C::MyPet | C::Party | C::PartyPet); + let damage = match attacker { + C::Me => K::SpellSelfDamage, + C::MyPet => K::SpellPetDamage, + C::Party if mine => K::SpellHostilePlayerDamage, + C::Party | C::PartyPet => K::SpellPartyDamage, + C::FriendlyPlayer if mine => K::SpellHostilePlayerDamage, + C::FriendlyPlayer | C::FriendlyPet => K::SpellFriendlyPlayerDamage, + C::HostilePlayer | C::HostilePet => K::SpellHostilePlayerDamage, + C::Creature | C::Unknown => match victim { + C::Me | C::MyPet => K::SpellCreatureVsSelfDamage, + C::Party | C::PartyPet => K::SpellCreatureVsPartyDamage, + _ => K::SpellCreatureVsCreatureDamage, + }, + }; + Some(if buff { buff_twin(damage) } else { damage }) +} + +/// `0x627d80` (damage) / `0x6274a0` (buffs) — the periodic family, and it is **a different shape**. +/// +/// Ten rows, not sixteen: there is no PET bucket and no `CREATURE_VS_*` split, so a pet folds into +/// its owner's row and every creature source lands on one `SPELL_PERIODIC_CREATURE_*` row. And the +/// **victim is not consulted at all** — both selectors take a single argument. A DoT you put on a +/// hostile player and a DoT that player put on you are told apart by the *source* alone. +pub(crate) fn periodic_kind(attacker: UnitClass, buff: bool) -> Option { + use ChatEventKind as K; + use UnitClass as C; + let damage = match attacker { + C::Me | C::MyPet => K::SpellPeriodicSelfDamage, + C::Party | C::PartyPet => K::SpellPeriodicPartyDamage, + C::FriendlyPlayer | C::FriendlyPet => K::SpellPeriodicFriendlyPlayerDamage, + C::HostilePlayer | C::HostilePet => K::SpellPeriodicHostilePlayerDamage, + C::Creature | C::Unknown => K::SpellPeriodicCreatureDamage, + }; + Some(if buff { buff_twin(damage) } else { damage }) +} + +/// The HITS → MISSES step. Every combat pair is adjacent in the type table, so the reference's two +/// selectors are one table apart; this is that `+1`, spelled as the pairing it encodes. +fn miss_twin(hits: ChatEventKind) -> ChatEventKind { + use ChatEventKind as K; + match hits { + 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, + other => other, + } +} + +/// The DAMAGE → BUFF step — [`miss_twin`]'s spell-family twin, same `+1` in the type table. +fn buff_twin(damage: ChatEventKind) -> ChatEventKind { + use ChatEventKind as K; + match damage { + 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::SpellPeriodicSelfDamage => K::SpellPeriodicSelfBuffs, + K::SpellPeriodicPartyDamage => K::SpellPeriodicPartyBuffs, + K::SpellPeriodicFriendlyPlayerDamage => K::SpellPeriodicFriendlyPlayerBuffs, + K::SpellPeriodicHostilePlayerDamage => K::SpellPeriodicHostilePlayerBuffs, + K::SpellPeriodicCreatureDamage => K::SpellPeriodicCreatureBuffs, + other => other, + } +} + +// ──────────────────────────── the format-string selectors ───────────────────────────── + +/// Which of a family's four templates applies — the reference's 45 `char*(self, other, *out)` +/// selectors (wow-re `w2f2.md` §G7), whose two arguments are booleans ("the subject is not me", +/// "the object is not me") and whose `*out` variant code `0..3` is what tells the caller how many +/// names to push. +/// +/// `SelfSelf` is the code-`0` case for most families: the selector returns NULL and **no line is +/// produced**. Some families do define the key (`SPELLLOGSELFSELF`, `HEALEDSELFSELF`, +/// `PERIODICAURAHEALSELFSELF`); the ones that don't simply miss the lookup, which lands on the same +/// silence by the same route the reference's "string not found" arm takes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Variant { + /// I did it to someone else — `…SELFOTHER`, variant code 2. + SelfOther, + /// Someone else did it to me — `…OTHERSELF`, code 1. + OtherSelf, + /// Someone else, to someone else — `…OTHEROTHER`, code 3. + OtherOther, + /// Me, to me — `…SELFSELF`, code 0 for the families that have no such key. + SelfSelf, +} + +impl Variant { + fn of(subject: UnitClass, object: UnitClass) -> Self { + match (subject.is_me(), object.is_me()) { + (true, false) => Self::SelfOther, + (false, true) => Self::OtherSelf, + (false, false) => Self::OtherOther, + (true, true) => Self::SelfSelf, + } + } + + fn suffix(self) -> &'static str { + match self { + Self::SelfOther => "SELFOTHER", + Self::OtherSelf => "OTHERSELF", + Self::OtherOther => "OTHEROTHER", + Self::SelfSelf => "SELFSELF", + } + } + + /// Whether the subject is spelled by name in this variant (rather than as "You"/"Your"). + fn names_subject(self) -> bool { + matches!(self, Self::OtherSelf | Self::OtherOther) + } + + /// Whether the object is spelled by name in this variant. + fn names_object(self) -> bool { + matches!(self, Self::SelfOther | Self::OtherOther) + } +} + +/// One value slot in a family's format string, in the order the shipped template consumes it. +/// +/// `Attacker` and `Victim` are **conditional** — present only when [`Variant::names_subject`] / +/// [`Variant::names_object`] says the template spells that endpoint out. Everything else is +/// unconditional. That conditionality is exactly what makes a single ordered list describe all four +/// variants of a family: "Your %s hits %s for %d." and "%s's %s hits %s for %d." are the same +/// `[Attacker?, Spell, Victim?, Amount]`, once with the first slot dropped. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Slot { + /// `%s` — the subject's name, only when the variant spells it. + Attacker, + /// `%s` — the object's name, only when the variant spells it. + Victim, + /// `%s` — the spell's name. + Spell, + /// `%s` — the damage school's lowercase word (`SPELL_SCHOOL_NAME`). + School, + /// `%s` — a power's word (`MANA_POINTS`/`RAGE_POINTS`/`FOCUS_POINTS`/`ENERGY_POINTS`). + Power, + /// `%d` — the primary amount. + Amount, + /// `%d` — a second amount (the leech family's "You gain %d"). + Amount2, + /// `%s` — a second power word (the leech family's gained power). + Power2, +} + +/// A combat-log message family: the stem its four keys are built from, and the ordered slots its +/// templates consume. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Family { + pub stem: &'static str, + pub slots: &'static [Slot], +} + +/// The value bound to each [`Slot`] for one line. Built by the caller from the packet; the family's +/// slot list decides which of these are read and in what order. +/// +/// **The school and power slots hold INDICES, not words.** They are resolved to text inside +/// [`compose_line`], the same place the template itself is resolved and for the same reason: both +/// are localized strings off the install, and the packet arm that fills this struct has no VM in +/// hand. `None` in either where the family asks for it drops the line rather than printing a gap. +#[derive(Clone, Debug, Default)] +pub(crate) struct Fills { + pub attacker: String, + pub victim: String, + pub spell: String, + /// The damage school index (`SpellSchools`: 0 physical … 6 arcane) → `SPELL_SCHOOL_NAME`. + pub school: Option, + /// The vmangos `Powers` index (0 mana … 3 energy) → `MANA_POINTS` and kin. + pub power: Option, + pub amount: i64, + pub amount2: i64, + /// The leech family's *gained* power index — usually the same as [`Self::power`]. + pub power2: Option, +} + +/// Resolve a family + variant to a finished line, or `None` when the key is absent from the +/// install's `GlobalStrings.lua` (the reference's "Warning: string %s not found" arm — no line). +/// +/// The `%s`/`%d` walk is `vsnprintf` with a fixed argument list, which is what the reference does +/// at `0x626850`; 1.12's templates carry no positional (`%1$s`) specifiers, and could not — the +/// MSVC CRT the client links has never supported them. +pub(crate) fn compose_line( + lua: &benilla_ui::script::UiScript, + family: Family, + variant: Variant, + fills: &Fills, +) -> Option { + let key = format!("{}{}", family.stem, variant.suffix()); + let template = global_string(lua, &key)?; + // Resolved before the walk so the borrows outlive it. + let school = fills.school.and_then(|s| school_word(lua, s)); + let power = fills.power.and_then(|p| power_word(lua, p)); + let power2 = fills.power2.and_then(|p| power_word(lua, p)); + let mut args: Vec = Vec::with_capacity(family.slots.len()); + for slot in family.slots { + match slot { + Slot::Attacker if variant.names_subject() => args.push(Arg::Str(&fills.attacker)), + Slot::Victim if variant.names_object() => args.push(Arg::Str(&fills.victim)), + Slot::Attacker | Slot::Victim => {} + Slot::Spell => args.push(Arg::Str(&fills.spell)), + Slot::School => args.push(Arg::Str(school.as_deref()?)), + Slot::Power => args.push(Arg::Str(power.as_deref()?)), + Slot::Power2 => args.push(Arg::Str(power2.as_deref()?)), + Slot::Amount => args.push(Arg::Num(fills.amount)), + Slot::Amount2 => args.push(Arg::Num(fills.amount2)), + } + } + match fill(&template, &args) { + Some(line) => Some(line), + None => { + // A mismatch means our declared slot order disagrees with the shipped template — a + // real defect, not a data condition, so it is loud. `tests` proves it cannot happen for + // the enUS file; a locale that reshaped a string would land here rather than printing + // a mangled sentence. + warn!( + "combat log: {key} does not match our slot order ({:?})", + family.slots + ); + None + } + } +} + +/// One `vsnprintf` argument. +enum Arg<'a> { + Str(&'a str), + Num(i64), +} + +/// `vsnprintf` over the `%s`/`%d` subset the combat-log templates use. `None` on any mismatch — +/// too few arguments, or a `%d` where a string is queued (and vice versa). +fn fill(template: &str, args: &[Arg<'_>]) -> Option { + let mut out = String::with_capacity(template.len() + 32); + let mut next = args.iter(); + let mut chars = template.chars().peekable(); + while let Some(c) = chars.next() { + if c != '%' { + out.push(c); + continue; + } + match chars.next() { + Some('%') => out.push('%'), + Some('s') => match next.next()? { + Arg::Str(s) => out.push_str(s), + Arg::Num(_) => return None, + }, + Some('d') => match next.next()? { + Arg::Num(n) => out.push_str(&n.to_string()), + Arg::Str(_) => return None, + }, + // Any other conversion is outside the vocabulary these templates use; treating it as + // a mismatch is better than silently emitting a half-filled sentence. + _ => return None, + } + } + next.next().is_none().then_some(out) +} + +/// A GlobalString off the VM's globals, `None` when absent **or empty** — the same two tests +/// `0x703bf0`'s callers make (`GetPVPRankInfo` at `0x51aa1c`/`0x51aa20` is the pattern +/// [`crate::ui_script`] already follows for the pvp strings). +pub(crate) fn global_string(script: &benilla_ui::script::UiScript, key: &str) -> Option { + script + .lua() + .globals() + .get::>(key) + .ok() + .flatten() + .filter(|s| !s.is_empty()) +} + +/// The lowercase school word a `…SCHOOL…` template's `%s` takes — `SPELL_SCHOOL_NAME` +/// ("physical", "holy", "fire", …), resolved through the install's own strings like every other +/// template slot. +pub(crate) fn school_word(script: &benilla_ui::script::UiScript, school: u8) -> Option { + global_string(script, &format!("SPELL_SCHOOL{school}_NAME")) +} + +/// The power word a `POWERGAIN`/`SPELLPOWERLEECH`/`SPELLPOWERDRAIN` template takes, by the vmangos +/// `Powers` index the wire carries (0 mana · 1 rage · 2 focus · 3 energy). Happiness (4) has no +/// GlobalString and no combat-log line, so it answers `None` and the line is dropped. +pub(crate) fn power_word(script: &benilla_ui::script::UiScript, power: u32) -> Option { + let key = match power { + 0 => "MANA_POINTS", + 1 => "RAGE_POINTS", + 2 => "FOCUS_POINTS", + 3 => "ENERGY_POINTS", + _ => return None, + }; + global_string(script, key) +} + +/// Resolve one endpoint's display name — the reference's `GetObjectName` (`0x6264e0`), which is the +/// same ask-once name cache every other client-composed chat line waits on. `None` = not yet +/// answered; the caller re-tries next frame, exactly as the reference's deferred-name queue +/// (`DAT_00c4e208`, drained by the name-ready callback `0x6294b0`) replays its message. +pub(crate) fn object_name( + guid: u64, + names: &mut NameCache, + commands: &NetCommands, +) -> Option { + // Guid 0 = "the name is already in the fills" — no wire endpoint is ever guid 0, so the + // sentinel costs nothing and is what lets `/chattest` drive the real drain with literal names. + if guid == 0 { + return None; + } + names.resolve(guid, commands).map(str::to_owned) +} + +// ────────────────────────────────── the families ────────────────────────────────────── + +/// Declare a family: its key stem and the ordered slots the shipped templates consume. +/// +/// The order is read off the enUS `GlobalStrings.lua` and each declaration carries that line, so a +/// reader can check it without an install. `tests::every_family_matches_the_shipped_template` is +/// what checks it *with* one. +macro_rules! family { + ($name:ident, $stem:literal, [$($slot:ident),* $(,)?], $doc:literal) => { + #[doc = $doc] + pub(crate) const $name: Family = Family { + stem: $stem, + slots: &[$(Slot::$slot),*], + }; + }; +} + +// ── melee: the swing that landed ─────────────────────────────────────────────────────── +family!( + COMBATHIT, + "COMBATHIT", + [Attacker, Victim, Amount], + "`\"%s hits %s for %d.\"` (GlobalStrings.lua:777)" +); +family!( + COMBATHITCRIT, + "COMBATHITCRIT", + [Attacker, Victim, Amount], + "`\"%s crits %s for %d.\"` (:771)" +); +family!( + COMBATHITSCHOOL, + "COMBATHITSCHOOL", + [Attacker, Victim, Amount, School], + "`\"%s hits %s for %d %s damage.\"` (:779)" +); +family!( + COMBATHITCRITSCHOOL, + "COMBATHITCRITSCHOOL", + [Attacker, Victim, Amount, School], + "`\"%s crits %s for %d %s damage.\"` (:773)" +); + +// ── melee: the swing that did not ────────────────────────────────────────────────────── +family!( + MISSED, + "MISSED", + [Attacker, Victim], + "`\"%s misses %s.\"` (:2698)" +); +family!( + VSDODGE, + "VSDODGE", + [Attacker, Victim], + "`\"%s attacks. %s dodges.\"` (:5400)" +); +family!( + VSPARRY, + "VSPARRY", + [Attacker, Victim], + "`\"%s attacks. %s parries.\"` (:5421)" +); +family!( + VSBLOCK, + "VSBLOCK", + [Attacker, Victim], + "`\"%s attacks. %s blocks.\"` (:5394)" +); +family!( + VSABSORB, + "VSABSORB", + [Attacker, Victim], + "`\"%s attacks. %s absorbs all the damage.\"` (:5391)" +); +family!( + VSRESIST, + "VSRESIST", + [Attacker, Victim], + "`\"%s attacks. %s resists all the damage.\"` (:5424)" +); +family!( + VSIMMUNE, + "VSIMMUNE", + [Attacker, Victim], + "`\"%s attacks but %s is immune.\"` (:5418)" +); +family!( + VSEVADE, + "VSEVADE", + [Attacker, Victim], + "`\"%s attacks. %s evades.\"` (:5415)" +); +family!( + VSDEFLECT, + "VSDEFLECT", + [Attacker, Victim], + "`\"%s attacks. %s deflects.\"` (:5397)" +); + +// ── a spell's direct damage ──────────────────────────────────────────────────────────── +family!( + SPELLLOG, + "SPELLLOG", + [Attacker, Spell, Victim, Amount], + "`\"%s's %s hits %s for %d.\"` (:3878)" +); +family!( + SPELLLOGCRIT, + "SPELLLOGCRIT", + [Attacker, Spell, Victim, Amount], + "`\"%s's %s crits %s for %d.\"` (:3870)" +); +family!( + SPELLLOGSCHOOL, + "SPELLLOGSCHOOL", + [Attacker, Spell, Victim, Amount, School], + "`\"%s's %s hits %s for %d %s damage.\"` (:3880)" +); +family!( + SPELLLOGCRITSCHOOL, + "SPELLLOGCRITSCHOOL", + [Attacker, Spell, Victim, Amount, School], + "`\"%s's %s crits %s for %d %s damage.\"` (:3872)" +); + +// ── a spell that did not land ────────────────────────────────────────────────────────── +family!( + SPELLMISS, + "SPELLMISS", + [Attacker, Spell, Victim], + "`\"%s's %s missed %s.\"` (:3886)" +); +family!( + SPELLRESIST, + "SPELLRESIST", + [Attacker, Spell, Victim], + "`\"%s's %s was resisted by %s.\"` (:3911)" +); +family!( + SPELLDODGED, + "SPELLDODGED", + [Attacker, Spell, Victim], + "`\"%s's %s was dodged by %s.\"` (:3835)" +); +family!( + SPELLPARRIED, + "SPELLPARRIED", + [Attacker, Spell, Victim], + "`\"%s's %s was parried by %s.\"` (:3890)" +); +family!( + SPELLBLOCKED, + "SPELLBLOCKED", + [Attacker, Spell, Victim], + "`\"%s's %s was blocked by %s.\"` (:3817)" +); +family!( + SPELLEVADED, + "SPELLEVADED", + [Attacker, Spell, Victim], + "`\"%s's %s was evaded by %s.\"` (:3845)" +); +family!( + SPELLIMMUNE, + "SPELLIMMUNE", + [Attacker, Spell, Victim], + "`\"%s's %s fails. %s is immune.\"` (:3859)" +); +family!( + SPELLDEFLECTED, + "SPELLDEFLECTED", + [Attacker, Spell, Victim], + "`\"%s's %s was deflected by %s.\"` (:3829)" +); +family!( + SPELLLOGABSORB, + "SPELLLOGABSORB", + [Attacker, Spell, Victim], + "`\"%s's %s is absorbed by %s.\"` (:3866)" +); +family!( + SPELLREFLECT, + "SPELLREFLECT", + [Attacker, Spell, Victim], + "`\"%s's %s is reflected back by %s.\"` (:3907)" +); + +// ── heals and power ──────────────────────────────────────────────────────────────────── +family!( + HEALED, + "HEALED", + [Attacker, Spell, Victim, Amount], + "`\"%s's %s heals %s for %d.\"` (:2129)" +); +family!( + HEALEDCRIT, + "HEALEDCRIT", + [Attacker, Spell, Victim, Amount], + "`\"%s's %s critically heals %s for %d.\"` (:2125)" +); +family!( + POWERGAIN, + "POWERGAIN", + [Victim, Amount, Power, Attacker, Spell], + "`\"%s gains %d %s from %s's %s.\"` (:3091)" +); +family!(SPELLPOWERLEECH, "SPELLPOWERLEECH", + [Attacker, Spell, Amount, Power, Victim, Attacker, Amount2, Power2], + "`\"%s's %s drains %d %s from %s. %s gains %d %s.\"` (:3904) — the only family that spells the \ + subject TWICE, and the second one is the same name as the first (the drainer is the gainer)."); + +// ── periodic ticks ───────────────────────────────────────────────────────────────────── +family!( + PERIODICAURADAMAGE, + "PERIODICAURADAMAGE", + [Victim, Amount, School, Attacker, Spell], + "`\"%s suffers %d %s damage from %s's %s.\"` (:3001)" +); +family!( + PERIODICAURAHEAL, + "PERIODICAURAHEAL", + [Victim, Amount, Attacker, Spell], + "`\"%s gains %d health from %s's %s.\"` (:3005)" +); + +// ── a damage shield firing back ──────────────────────────────────────────────────────── +family!( + DAMAGESHIELD, + "DAMAGESHIELD", + [Attacker, Amount, School, Victim], + "`\"%s reflects %d %s damage to %s.\"` (:884) — the SUBJECT is the shield's BEARER and the \ + object is whoever struck them, which is the reverse of the packet's own field names." +); + +/// Every family this module can emit — the sweep [`tests`] uses to check each one against the +/// shipped `GlobalStrings.lua`. Adding a family without adding it here is the only way to get an +/// unchecked slot order, so the list is the gate. +#[cfg(test)] +pub(crate) const ALL_FAMILIES: &[Family] = &[ + COMBATHIT, + COMBATHITCRIT, + COMBATHITSCHOOL, + COMBATHITCRITSCHOOL, + MISSED, + VSDODGE, + VSPARRY, + VSBLOCK, + VSABSORB, + VSRESIST, + VSIMMUNE, + VSEVADE, + VSDEFLECT, + SPELLLOG, + SPELLLOGCRIT, + SPELLLOGSCHOOL, + SPELLLOGCRITSCHOOL, + SPELLMISS, + SPELLRESIST, + SPELLDODGED, + SPELLPARRIED, + SPELLBLOCKED, + SPELLEVADED, + SPELLIMMUNE, + SPELLDEFLECTED, + SPELLLOGABSORB, + SPELLREFLECT, + HEALED, + HEALEDCRIT, + POWERGAIN, + SPELLPOWERLEECH, + PERIODICAURADAMAGE, + PERIODICAURAHEAL, + DAMAGESHIELD, +]; + +/// The `SpellMissInfo` byte `SMSG_SPELLLOGMISS` (and `SpellGo`'s own list) carries → the family +/// that words it. vmangos `SpellDefines.h:160-174`; the reference reaches the same ten families +/// through its own miss switch. +/// +/// There is no `None` arm: the reference's jump table sends `0`, `1`, `10` and everything past the +/// enum to one default, `SPELLMISS*`. `0` is `SPELL_MISS_NONE` and should never appear in a miss +/// list, but if one arrives the reference words it as a miss rather than dropping it. +pub(crate) fn miss_family(miss_info: u8) -> Family { + match miss_info { + 2 => SPELLRESIST, + 3 => SPELLDODGED, + 4 => SPELLPARRIED, + 5 => SPELLBLOCKED, + 6 => SPELLEVADED, + // 7 IMMUNE and 8 IMMUNE2 are one outcome with two server-side spellings. + 7 | 8 => SPELLIMMUNE, + 9 => SPELLDEFLECTED, + 11 => SPELLREFLECT, + // **10 ABSORB lands on SPELLMISS, not SPELLLOGABSORB**, and so do 0, 1 and anything out of + // range: `0x62bb50`'s `lea eax,[edi-2]; cmp eax,9; ja default` puts all four on the + // default arm `0x62c0e0`. 1571 had 10 on `SPELLLOGABSORB` — a family this switch never + // reaches (it belongs to the direct-damage path) — and dropped 0/1 entirely. + _ => SPELLMISS, + } +} + +/// The melee family a swing's `HitInfo` + `VictimState` select — the reference's display dispatcher +/// `0x629b60`, arm for arm, and its order is load-bearing because the tests overlap. +/// +/// ```text +/// HitInfo & 0x10 (MISS) → MISSED +/// VictimState == 5 (BLOCKS) → VSBLOCK +/// damage == 0 && HitInfo & 0x20 → VSABSORB (ABSORB) +/// damage == 0 && HitInfo & 0x40 → VSRESIST (RESIST) +/// VictimState == 1 && damage > 0 → COMBATHIT[CRIT][SCHOOL] +/// otherwise → the VictimState word +/// ``` +/// +/// The bit values are vmangos's `HitInfo` under the `> 1.9.4` conditional that is compile-time true +/// for 5875 (`Objects/UnitDefines.h:250-268`) and its `VictimState` (`:237-248`) — the same pair +/// [`crate::sound::combat`] and [`crate::combat_text`] already read, here named once instead of a +/// fourth set of bare literals. +pub(crate) fn melee_family(hit_info: u32, victim_state: u32, damage: u32, school: u8) -> Family { + const MISS: u32 = 0x10; + const ABSORB: u32 = 0x20; + const RESIST: u32 = 0x40; + const CRIT: u32 = 0x80; + if hit_info & MISS != 0 { + return MISSED; + } + if victim_state == 5 { + return VSBLOCK; + } + if damage == 0 { + if hit_info & ABSORB != 0 { + return VSABSORB; + } + if hit_info & RESIST != 0 { + return VSRESIST; + } + } + if victim_state == 1 && damage > 0 { + return match (hit_info & CRIT != 0, school != 0) { + (false, false) => COMBATHIT, + (true, false) => COMBATHITCRIT, + (false, true) => COMBATHITSCHOOL, + (true, true) => COMBATHITCRITSCHOOL, + }; + } + match victim_state { + 2 => VSDODGE, + 3 => VSPARRY, + 6 => VSEVADE, + 7 => VSIMMUNE, + 8 => VSDEFLECT, + // VictimState 0 (UNAFFECTED, "seen in relation with HITINFO_MISS") and 4 (INTERRUPT) reach + // here only on a shape vmangos does not send; MISSED is the reference's own fall-through + // and is the least wrong thing to say about a swing that did nothing. + _ => MISSED, + } +} + +// ────────────────────────── the queued line, awaiting its names ─────────────────────── + +/// A combat-log line with every decision already made and only its **names** outstanding. +/// +/// This split is the reference's, not a convenience. `0x629b60` classifies and picks the family at +/// the packet; when a name the sentence needs is not in the cache yet, the message is pushed onto +/// the deferred queue at `DAT_00c4e208` and replayed by the name-ready callback `0x6294b0`. Doing +/// the same means the classification is made while both units are certainly streamed — a creature +/// that dies to the killing blow is gone from the object manager long before its name query +/// answers, and a classification deferred to that moment would read `Unknown` and drop the line. +#[derive(Clone, Debug)] +pub(crate) struct PendingCombat { + pub kind: ChatEventKind, + pub family: Family, + pub variant: Variant, + /// The guid whose name fills the `Attacker` slots — the sentence's SUBJECT, which for + /// [`DAMAGESHIELD`] is the shield's bearer rather than the packet's `attacker` field. + pub subject: u64, + /// The guid whose name fills the `Victim` slots. + pub object: u64, + /// Everything the template needs that is not a name; the two name fields are filled at drain. + pub fills: Fills, + pub tries: u16, +} + +/// Build a queued line from an already-classified pair, or `None` when the reference would emit +/// nothing: an unroutable type (`0x5e`), or an endpoint whose class is out of the range gate. +/// +/// `kind` is the caller's — the three msgType matrices differ per family, so the arm that knows +/// which packet it is decides, and this only assembles. +pub(crate) fn queue( + kind: ChatEventKind, + family: Family, + subject: (u64, UnitClass), + object: (u64, UnitClass), + fills: Fills, +) -> Option { + // **A class-9 endpoint alone does NOT drop the line**, and 1571 had this wrong. Its range of + // `0.0` means it can never satisfy *its own half* of the gate — but the gate is an OR over the + // two endpoints (§4/§5.2), so a resolvable one at the other end still carries the line. What + // drops here is only the case the reference's own resolve step fails on: neither endpoint + // resolvable at all. + if subject.1 == UnitClass::Unknown && object.1 == UnitClass::Unknown { + return None; + } + Some(PendingCombat { + kind, + family, + variant: Variant::of(subject.1, object.1), + subject: subject.0, + object: object.0, + fills, + tries: 0, + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/benilla-app/src/ui_chat/combat/tests.rs b/crates/benilla-app/src/ui_chat/combat/tests.rs new file mode 100644 index 00000000..201c4f0d --- /dev/null +++ b/crates/benilla-app/src/ui_chat/combat/tests.rs @@ -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 { + 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 { + 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); +} diff --git a/crates/benilla-app/src/ui_chat/event.rs b/crates/benilla-app/src/ui_chat/event.rs index aae996ab..f5e73813 100644 --- a/crates/benilla-app/src/ui_chat/event.rs +++ b/crates/benilla-app/src/ui_chat/event.rs @@ -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], } } diff --git a/crates/benilla-app/src/ui_chat/feed.rs b/crates/benilla-app/src/ui_chat/feed.rs index 4f9cddc1..2797f7ca 100644 --- a/crates/benilla-app/src/ui_chat/feed.rs +++ b/crates/benilla-app/src/ui_chat/feed.rs @@ -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), /// 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__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( diff --git a/crates/benilla-app/src/ui_chat/frames.rs b/crates/benilla-app/src/ui_chat/frames.rs index 992cf106..767915fa 100644 --- a/crates/benilla-app/src/ui_chat/frames.rs +++ b/crates/benilla-app/src/ui_chat/frames.rs @@ -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////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 diff --git a/crates/benilla-app/src/ui_chat/input/mod.rs b/crates/benilla-app/src/ui_chat/input/mod.rs index 8462538a..9a844bc7 100644 --- a/crates/benilla-app/src/ui_chat/input/mod.rs +++ b/crates/benilla-app/src/ui_chat/input/mod.rs @@ -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, power: Option| 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* diff --git a/crates/benilla-app/src/ui_chat/tests.rs b/crates/benilla-app/src/ui_chat/tests.rs index 916b6efb..90403d2f 100644 --- a/crates/benilla-app/src/ui_chat/tests.rs +++ b/crates/benilla-app/src/ui_chat/tests.rs @@ -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::("return SpyEvent").unwrap(), + "CHAT_MSG_SPELL_SELF_DAMAGE", + "the event an addon registered is the event that fired" + ); + assert_eq!(s.eval::("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 `` 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)); + } +} diff --git a/crates/benilla-formats/src/creatures.rs b/crates/benilla-formats/src/creatures.rs index 4d79cc3f..12b353b1 100644 --- a/crates/benilla-formats/src/creatures.rs +++ b/crates/benilla-formats/src/creatures.rs @@ -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 diff --git a/crates/benilla-protocol/src/messages/attack.rs b/crates/benilla-protocol/src/messages/attack.rs index 9c7d454a..58fd1953 100644 --- a/crates/benilla-protocol/src/messages/attack.rs +++ b/crates/benilla-protocol/src/messages/attack.rs @@ -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 io::Result Option { 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 { + 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 { diff --git a/crates/benilla-protocol/tests/spells.rs b/crates/benilla-protocol/tests/spells.rs index a39a2991..cbc1f3f8 100644 --- a/crates/benilla-protocol/tests/spells.rs +++ b/crates/benilla-protocol/tests/spells.rs @@ -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"), }