- a refused auto-attack is no longer silent, and the display is a latch, not a toast: six opcodes, four arms — `0x145 SMSG_ATTACKSWING_NOTINRANGE` and `0x146 SMSG_ATTACKSWING_BADFACING` latch a code through `0x5ecdb0`, which writes `[0xc4d758]` and `[0xc4c270]` together; `0x148 DEADTARGET` and `0x149 CANT_ATTACK` share arm `0x625ab8` verbatim (StopAttack, no message), so the client cannot tell them apart and `AttackSwingError` collapses them into one `DeadOrUnattackable`; `0x147 NOTSTANDING` is never registered by the reference and `SendAttackSwingNotStanding` has zero callers in vmangos, so a test pins that it stays an unknown opcode; and the fourth arm hides: `0x14e SMSG_CANCEL_COMBAT` is registered at `0x5e3303` by the SPELL TU, not the combat one, so the eight-opcode census every note quotes did not contain it, and its handler `0x5e7dd0` is arm 4's body instruction for instruction; vmangos sends each refusal on the edge alone, so the repeat the player sees is the client's own: the tick `0x5ec950` — `CGPlayer_C` vtable `0x80af78` slot 14, dispatched once per object per rendered frame on the `CWorldFrame::OnPaint` chain, so a Bevy `Update` system is the exact cadence — prints `ERR_BADATTACKPOS`/`ERR_BADATTACKFACING` (catalog rows `0xd8`/`0xd7`, both red) at once and again every 4000 ms while an attack target is set, and a landed swing clears it: `SMSG_ATTACKERSTATEUPDATE`'s arm calls `0x5ea800`, whose first act is `0x5ecdb0(0)`, gated on the attacker being the active player AND the victim resolving (both gates kept — wow-re's round had glossed the second away, and sending that back corrected the note); the latch is one `Option<(Latched, Duration)>` because `[0xc4c270]` has no meaning while `[0xc4d758]` is 0, a second refusal packet defeats the cooldown and the re-arm precedes the print (both tested), the clear also runs once per world entry (`0x5e2510`), and both edges ride ONE `SwingRefusalEdge` stream so a refusal and a landing in the same frame stay in packet order; `SMSG_FEIGN_DEATH_RESISTED` (`0x2b4`) arrives beside them and is built differently because its handler `0x6e9800` is two instructions — a bare `DisplayError(421)` = `ERR_FEIGN_DEATH_RESISTED`, the single word "Resisted" — so it takes the plain `UiErrorKeys` route with no latch; the `--attack` world probe now reports the refusals it tallies; and two remainders are named, not built: `0x5ea800`'s post-swing leash — `R = 4.0 * max(5.0, reach(victim) + reach(self) + 1.3333333730697632)`, floor 20.0, sending a bare `CMSG_ATTACKSTOP` past it — a new packet-sending behaviour left for the director's call, and `StopAttack 0x5ecac0`'s two one-shot tutorials (10 low-health, 11 low-mana), which benilla's copy has never fired (2037)
- the trade arc stops being silent: eighteen of the twenty-three `SMSG_TRADE_STATUS` arms print where benilla printed none, all through `UiErrorKeys` so the text is the player's own `GlobalStrings.lua` and the surface is the catalog record's `+0x04`; the four-row start was the trade block's own emit-site census, which answered "which arms raise a TRADE-keyed message" — eleven of the ids come from elsewhere in the 465-row table (`ERR_PLAYER_BUSY_S` `0x3e`, `ERR_PLAYER_DEAD` `0x7e`, `ERR_GENERIC_NO_TARGET` `0xb8`, `ERR_PLAYER_WRONG_FACTION` `0xff`, `ERR_IGNORING_YOU_S` `0x13d`, the stunned pair `0x191`/`0x192`, the logout pair `0x19d`/`0x19e`, `ERR_RESTRICTED_ACCOUNT` `0x1be`, and `ERR_TRADE_WRONG_REALM` `0x1ca`, whose text is the ONLY_CONJURED sentence), and the surfaces do not follow the English — a successful trade's two lines are the yellow ones, `ERR_PLAYER_BUSY_S`/`ERR_IGNORING_YOU_S` are chat lines, the refusals red; `TRADE_REQUEST_CANCEL` fires from the top of the CANCELED arm (`0x4bf832`, the dispatcher's one `SignalEvent`) where nothing had fired it; `ERR_INITIATE_TRADE_S` prints — it is client-emitted from inside the `CMSG_INITIATE_TRADE` sender, at once on a name-cache hit (`0x5d4042`) and from the query callback on a miss (`0x5d40ca`), so clicking Trade had produced no text in either direction; the three arms that name a player (0/5/14) read the network handler's guid cell `[0xc4bed0]`/`[0xc4bed4]` behind the outbound-initiate latch `[0xc4bec8]` — `InitiateTrade 0x5d3fb0` writes the target's guid and then the latch, the `BEGIN_TRADE` arm writes the initiator's and no latch — which is what keeps a client that just ignored a trade from printing "X is ignoring you." to itself, and behind `0x468460(guid, TYPEMASK_PLAYER)`, a live-object test the initiate line does not share, so `NamedLine::needs_live_object` drops a status line the moment its player stops being streamed while an initiate line waits; a network-cell clear is not a window close: `0x4bf4e0` has four call sites in the dispatcher — one opens, `{3, 8, 12}` close — and the other fourteen run the handler's tail `0x5d490a`, which drops the latch and the pending guid and leaves the window standing, so `close()` splits into `close_window()` and `clear_pending()`; `REJECTED` drops only the partner's accept, `BACK_TO_TRADE` reaches `0x4bf230(0)` and sends `CMSG_UNACCEPT_TRADE` on a real change, `ONLY_CONJURED` bounces the offending slot out of our own offer; `CLOSE_WINDOW`'s message is deliberately not built (`0x4bfd70` maps `result` over a 61-id range with a flagged self/other polarity inconsistency, and vmangos never sends status 12); and `ERR_TRADE_BLOCKED_S`'s transcribed English literal is deleted along with `ChatLog::pending_texts` (2042)
- both halves of B373's lighting closed: `MeshTag` bits 6..=13 mean two things — the exterior payload's ground-shade byte and the low eight bits of the interior payload's 13-bit probe slot — and the overlap cannot be removed (the four mode-independent fields cost 19 bits, leaving 13 for a payload region the two modes want 21 of), so the payload question becomes a witness the accessors demand: `mesh_tag::ExteriorPayload` is a zero-sized proof only `exterior_payload(on_bake_law, own_probe)` mints, when both probe-slot populations answer no, and `with_shade`/`shade_of` take it, so no expression in the crate reaches those bits without having asked; `describe` alone reads the raw masks and prints both readings (2041); and a transport's deck cargo is lit by its own doodad def, not by the boat (B373): a transport's WMO is linked into the same global `TSExplicitList<CMapObjDef>` at `0xca7d98` the ordinary scene walk iterates, so its MODDs are ordinary doodad defs; `[def+0xa4]` has exactly four writers image-wide — the ctor `0x6a7d73` (0.0), the doodad-set commit `0x695bb3` (1.0), the ADT MDDF commit `0x6b01bb` (1.0) and the MCSH refresh `0x698cb4` (0.5) — and none is the light node, whose 2.5-producer and ramp are reachable only through `[obj+0xe0]`, a path a doodad def never takes; the MCSH sample is one-shot (`0x698c50` unlinks each entry) and its null-tile legs return lit; so we had the wrong source, the wrong cadence and the wrong mechanism: `entity_shade::DoodadDefLit` marks every WMO-gameobject prop batch from inside the placed-model assembler — the engine asserts it, not the caller; the world-API wall failed the first cut, and a following card is reachable only there — both shade write sites skip it on ownership, separately from 2041's payload question, and exterior props take one `doodad_ground_shade` at their own footprint at spawn, frozen, which answers lit for a non-resident tile so a boat at sea needs no special case and the unverified "a boat is never MCSH-shadowed" goes; 749 prop parts under a shade root carry the marker on a live run — the same 749 B373 measured reading a foreign probe — and the ADT doodad path is deliberately untouched, since the D3D capture's measured 2.5 gain outranks the closure (2047)
- 2043's unit-predicate law is confirmed byte-exact by the round it dispatched — all 29 `Unit*` predicates push the constant double 1.0 or nil, exactly one value at every live `ret`, and a BFS from all 83 bindings in `0x850438` reaches 460 functions without hitting `lua_pushboolean` — and three of its supporting citations are corrected in the comments, no behaviour changing: `lua_pushboolean 0x6f39f0` EXISTS with seven call sites, one of them the registered FrameScript binding `IsPetAttackActive 0x4be0e0`, which answers a real true/false and never nil (ours already did, and its doc already named itself the exception), so 2043's "no boolean on its binding surface at all" is withdrawn for the scoped claim 1830 actually made; `IsEnabled` is `0x7800b0`, not `0x780270` (that is `SetButtonState`), and its number-1-or-0-never-nil fact stands; and `UnitReaction 0x5167e0` pushes `0x6061e0(u1, u2)` PLUS ONE, so 0 is unreachable and its nil means only that a token did not resolve — our `reaction == 0` → nil stays, named now as a feed gap rather than a mechanism the binary does not have (2048)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
150 lines
7.6 KiB
Rust
150 lines
7.6 KiB
Rust
//! Melee auto-attack messages — the attack start/stop edges, the per-swing damage report, and the
|
|
//! creature-aggro notice. Split out of `messages/spells.rs` (decision 0640); mirrored by
|
|
//! `world::writer::attack`.
|
|
//!
|
|
//! `SMSG_ATTACKERSTATEUPDATE` is the melee twin of [`super::combat_log`]'s spell reports: a *swing*
|
|
//! reports itself here (fired exactly once per weapon-timer cycle, independently per hand — which is
|
|
//! what makes it the animation driver, decision 0073), a *spell* reports itself there.
|
|
//! `SMSG_AI_REACTION` rides along because it is the same moment from the creature's side: vmangos
|
|
//! broadcasts it from `Unit::Attack`, i.e. the instant a creature decides to swing (decision 0277).
|
|
|
|
use std::io::{self, Read};
|
|
|
|
use crate::wire::{read_f32_le, read_packed_guid, read_u32_le, read_u64_le, read_u8};
|
|
|
|
/// Read `SMSG_ATTACKSTART` (vmangos `AttackStart::AppendBodyTo`): two full `u64` guids.
|
|
pub(super) fn read_attack_start(r: &mut impl Read) -> io::Result<(u64, u64)> {
|
|
Ok((read_u64_le(r)?, read_u64_le(r)?))
|
|
}
|
|
|
|
/// One decoded `SMSG_ATTACKERSTATEUPDATE` — a completed melee swing (vmangos
|
|
/// `Unit::SendAttackStateUpdate`, `Unit.cpp:4572-4605`; fired **exactly once per weapon-timer
|
|
/// cycle**, independently per hand — the real client plays one attacker swing animation per packet,
|
|
/// wow-re `combat-swing-anim.md`, decision 0073). The per-school sub-damage split collapses to the
|
|
/// packet's own `TotalDamage` plus the summed `absorb` (decision 0137 phase 2's floating combat
|
|
/// text feed); `hit_info` bit `0x4` marks an **offhand** swing (the anim selector keys on it), bit
|
|
/// `0x10000` suppresses the swing anim.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct AttackerState {
|
|
pub attacker: u64,
|
|
pub victim: u64,
|
|
pub hit_info: u32,
|
|
/// `TotalDamage` — the swing's damage before the sub-damage split.
|
|
pub damage: u32,
|
|
/// `TargetState` (vmangos `VictimState`): 1 hit · 2 dodge · 3 parry · 4 interrupt · 5 blocks ….
|
|
/// A defended outcome (dodge/parry/block/deflect) plays a dedicated victim defense clip at
|
|
/// the swing's `$CPP` keyframe (decision 0279, correcting 0073's "never a body animation");
|
|
/// landed hits flinch/bleed; the rest is sound + floating text.
|
|
pub victim_state: u32,
|
|
/// Sum of the per-sub-damage `absorb` fields (vmangos writes exactly one sub-damage block in
|
|
/// practice; summed faithfully in case more than one ever rides the wire).
|
|
pub absorb: u32,
|
|
/// Sum of the per-sub-damage `resist` fields — the partial-resist trailer's amount
|
|
/// (decision 0580's center-text fold-back).
|
|
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
|
|
/// the wow-re §5 against the handler's downstream use, decision 0073): HitInfo · attacker PackGUID ·
|
|
/// victim PackGUID · TotalDamage · SubDamageCount + per-sub `{school u32, damage f32, damage u32,
|
|
/// absorb u32, resist i32}` · TargetState · two u32s (zero + "spell id, seen with heroic strike") ·
|
|
/// BlockedAmount.
|
|
pub(super) fn read_attacker_state(r: &mut impl Read) -> io::Result<AttackerState> {
|
|
let hit_info = read_u32_le(r)?;
|
|
let attacker = read_packed_guid(r)?;
|
|
let victim = read_packed_guid(r)?;
|
|
let damage = read_u32_le(r)?;
|
|
let subs = read_u8(r)?;
|
|
let mut absorb = 0u32;
|
|
let mut resist = 0i32;
|
|
let mut school = 0u8;
|
|
for i in 0..subs {
|
|
// damage f32 + damage u32 are folded into TotalDamage above; absorb/resist are summed. The
|
|
// school is kept from the FIRST block only — the wording takes one word, and vmangos's
|
|
// extra blocks are the off-school splits of the same swing.
|
|
let block_school = read_u32_le(r)?;
|
|
if i == 0 {
|
|
school = u8::try_from(block_school).unwrap_or(0);
|
|
}
|
|
let _damage_f = read_f32_le(r)?;
|
|
let _damage = read_u32_le(r)?;
|
|
absorb += read_u32_le(r)?;
|
|
resist += read_u32_le(r)? as i32;
|
|
}
|
|
let victim_state = read_u32_le(r)?;
|
|
let _zero = read_u32_le(r)?;
|
|
let _spell_id = read_u32_le(r)?;
|
|
let blocked = read_u32_le(r)?;
|
|
Ok(AttackerState {
|
|
attacker,
|
|
victim,
|
|
hit_info,
|
|
damage,
|
|
victim_state,
|
|
absorb,
|
|
resist,
|
|
blocked,
|
|
school,
|
|
})
|
|
}
|
|
|
|
/// The server's refusal of a `CMSG_ATTACKSWING` — the melee auto-attack answer that is not
|
|
/// `SMSG_ATTACKSTART`. **Three variants for four opcodes, because that is what the client can
|
|
/// distinguish**: the reference dispatches `0x145`/`0x146`/`0x148`/`0x149` through `0x6255b0`'s
|
|
/// jump table `0x625aec`, and `0x148` DEADTARGET and `0x149` CANT_ATTACK land on **arm 4
|
|
/// (`0x625ab8`) verbatim** — one shared body, no reason byte, nothing to tell them apart with.
|
|
/// Collapsing them here is the fidelity fact in the type: a consumer cannot key on a difference
|
|
/// the real client never had.
|
|
///
|
|
/// Every body is empty; the arms read nothing.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum AttackSwingError {
|
|
/// `SMSG_ATTACKSWING_NOTINRANGE` (`0x145`) — arm 2 `0x625a8a`: latch code **1**.
|
|
NotInRange,
|
|
/// `SMSG_ATTACKSWING_BADFACING` (`0x146`) — arm 3 `0x625aa1`: latch code **2**.
|
|
BadFacing,
|
|
/// `SMSG_ATTACKSWING_DEADTARGET` (`0x148`) **or** `SMSG_ATTACKSWING_CANT_ATTACK` (`0x149`) —
|
|
/// both arm 4 `0x625ab8`, which raises **no message at all**: it resolves the active player
|
|
/// and calls StopAttack (`0x5ecac0`) on it, full stop. The name is deliberately the union of
|
|
/// the two: naming one of them would claim a distinction the client does not have.
|
|
DeadOrUnattackable,
|
|
}
|
|
|
|
/// Read `SMSG_ATTACKSTOP` (vmangos `AttackStop::AppendBodyTo`): two **packed** guids + a `u32`
|
|
/// "victim is dead" word (dropped — death arrives through the descriptor seam).
|
|
pub(super) fn read_attack_stop(r: &mut impl Read) -> io::Result<(u64, u64)> {
|
|
let attacker = read_packed_guid(r)?;
|
|
let victim = read_packed_guid(r)?;
|
|
let _is_dead = read_u32_le(r)?;
|
|
Ok((attacker, victim))
|
|
}
|
|
|
|
/// Read `SMSG_AI_REACTION` → `(unit, reaction)` (vmangos `Creature::SendAIReaction`,
|
|
/// `Objects/Creature.cpp:2490-2498` → `WorldPackets::Misc::AiReaction::AppendBodyTo`,
|
|
/// `Server/Packets/Misc.cpp:445-449`): a raw (unpacked) `u64` guid + a `u32` reaction. Broadcast
|
|
/// with reaction 2 (HOSTILE) on every creature melee-attack start (`Unit::Attack`) and 0 (ALERT)
|
|
/// on stealth pre-aggro detection (`CreatureAI::TriggerAlertDirect`); 1/4 exist server-side but
|
|
/// are never sent (decision 0277).
|
|
pub(super) fn read_ai_reaction(r: &mut impl Read) -> io::Result<(u64, u32)> {
|
|
let unit = read_u64_le(r)?;
|
|
let reaction = read_u32_le(r)?;
|
|
Ok((unit, reaction))
|
|
}
|
|
|
|
/// Body of `CMSG_ATTACKSWING` (vmangos `AttackSwing::ReadFromWorldPacket`): one full `u64` victim
|
|
/// guid. Starts melee auto-attack; the server answers `SMSG_ATTACKSTART` (or an attack-swing error
|
|
/// packet). `CMSG_ATTACKSTOP`'s body is empty — no builder needed.
|
|
pub fn attack_swing(guid: u64) -> Vec<u8> {
|
|
guid.to_le_bytes().to_vec()
|
|
}
|