fix(Core/Spells): respect hostile self-immunity (#27482)

Co-authored-by: sogladev <[email protected]>
Co-authored-by: sogladev <[email protected]>
Co-authored-by: sudlud <[email protected]>
This commit is contained in:
Vinges541
2026-09-07 21:51:03 +02:00
committed by GitHub
co-authored by sogladev sogladev sudlud
parent d7ce67dc18
commit e89548f2f7
3 changed files with 352 additions and 2 deletions
@@ -0,0 +1,160 @@
//go:build e2e
package immunity_test
import (
"bytes"
"encoding/binary"
"io"
"testing"
)
const (
auraUpdateAll = uint16(0x495) // SMSG_AURA_UPDATE_ALL
auraUpdate = uint16(0x496) // SMSG_AURA_UPDATE
)
// decodeAuraSlots preserves duplicate spell IDs in separate slots. The pinned
// AzerothGhost client exposes only unique IDs and maximum stacks, so neither
// public accessor can observe Vashj's four separate Magic Barrier applications.
// Layout: AuraApplication::BuildUpdatePacket and Player::GetAurasForTarget.
func decodeAuraSlots(data []byte) (uint64, map[uint8]uint32, bool) {
r := bytes.NewReader(data)
readGUID := func() (uint64, error) {
mask, err := r.ReadByte()
if err != nil {
return 0, err
}
var guid uint64
for i := uint(0); i < 8; i++ {
if mask&(1<<i) != 0 {
b, err := r.ReadByte()
if err != nil {
return 0, err
}
guid |= uint64(b) << (i * 8)
}
}
return guid, nil
}
target, err := readGUID()
if err != nil {
return 0, nil, false
}
slots := make(map[uint8]uint32)
for r.Len() > 0 {
slot, _ := r.ReadByte()
var spell uint32
if binary.Read(r, binary.LittleEndian, &spell) != nil {
return 0, nil, false
}
slots[slot] = spell
if spell == 0 {
continue // removal has no flags, caster or duration
}
var header [3]byte // flags, caster level, stack count/charges
if _, err := io.ReadFull(r, header[:]); err != nil {
return 0, nil, false
}
flags := header[0]
if flags&0x08 == 0 { // AFLAG_CASTER: caster omitted for self-cast
if _, err := readGUID(); err != nil {
return 0, nil, false
}
}
skip := 0
if flags&0x20 != 0 { // AFLAG_DURATION
skip += 8
}
if flags&0x40 != 0 { // AFLAG_ANY_EFFECT_AMOUNT_SENT
for bit := byte(1); bit <= 4; bit <<= 1 {
if flags&bit != 0 {
skip += 4
}
}
}
if r.Len() < skip {
return 0, nil, false
}
r.Seek(int64(skip), io.SeekCurrent)
}
return target, slots, true
}
func applyAuraSlots(current, updates map[uint8]uint32, replace bool) {
if replace {
clear(current)
}
for slot, spell := range updates {
if spell == 0 {
delete(current, slot)
} else {
current[slot] = spell
}
}
}
func TestAuraSlotsPreserveSeparateApplications(t *testing.T) {
var packet bytes.Buffer
packet.Write([]byte{0x81, 0x34, 0x12}) // packed target 0x1200000000000034
for slot := byte(0); slot < 4; slot++ {
packet.WriteByte(slot)
binary.Write(&packet, binary.LittleEndian, uint32(38112))
packet.Write([]byte{0x20, 73, 0}) // duration, external caster, no stacks
packet.Write([]byte{0x01, slot + 1})
binary.Write(&packet, binary.LittleEndian, uint32(6000))
binary.Write(&packet, binary.LittleEndian, uint32(5000))
}
target, slots, ok := decodeAuraSlots(packet.Bytes())
if !ok || target != 0x1200000000000034 || len(slots) != 4 {
t.Fatalf("decoded target=%X slots=%v ok=%v", target, slots, ok)
}
for slot := uint8(0); slot < 4; slot++ {
if slots[slot] != 38112 {
t.Fatalf("slot %d: got %d, want Magic Barrier", slot, slots[slot])
}
}
// Updating an existing slot must not count it twice; removals and full
// snapshots must discard stale applications.
applyAuraSlots(slots, map[uint8]uint32{0: 38112}, false)
if len(slots) != 4 {
t.Fatal("repeated update changed the application count")
}
_, removal, ok := decodeAuraSlots([]byte{1, 1, 0, 0, 0, 0, 0})
if !ok {
t.Fatal("removal packet rejected")
}
applyAuraSlots(slots, removal, false)
if len(slots) != 3 {
t.Fatal("removal did not clear the slot")
}
applyAuraSlots(slots, map[uint8]uint32{7: 38112}, true)
if len(slots) != 1 || slots[7] != 38112 {
t.Fatal("full snapshot retained stale slots")
}
_, empty, ok := decodeAuraSlots([]byte{1, 1})
if !ok {
t.Fatal("empty snapshot rejected")
}
applyAuraSlots(slots, empty, true)
if len(slots) != 0 {
t.Fatal("empty snapshot did not clear slots")
}
// A partial nonempty record is rejected instead of partially updating state.
for _, length := range []int{0, 1, 2, 4, 7, 9, packet.Len() - 1} {
if _, _, ok := decodeAuraSlots(packet.Bytes()[:length]); ok {
t.Fatalf("accepted truncated packet of length %d", length)
}
}
}
func TestAuraSlotsSkipSelfCastEffectAmounts(t *testing.T) {
// Self-cast aura with effect amounts 0 and 2, followed by a removal.
packet := []byte{1, 1, 3, 0xe0, 0x94, 0, 0, 0x4d, 73, 1}
packet = append(packet, make([]byte, 8)...)
packet = append(packet, 4, 0, 0, 0, 0)
_, slots, ok := decodeAuraSlots(packet)
if !ok || len(slots) != 2 || slots[3] != 38112 || slots[4] != 0 {
t.Fatalf("decoded slots=%v ok=%v", slots, ok)
}
}
@@ -0,0 +1,189 @@
//go:build e2e
package immunity_test
import (
"sync"
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
)
// Issue: https://github.com/azerothcore/azerothcore-wotlk/issues/27355
// Cyclone makes its target immune to another Cyclone while active, so the Druid
// cannot refresh it on the same target (only re-cast after it ends).
func TestAC_27355_CycloneCannotReapplyWhileActive(t *testing.T) {
meta.Begin(t, meta.TestMeta{
Tags: []string{"med", "spells", "combat", "issue", "serial"},
Runtime: "med",
Issue: 27355,
Category: "spells/immunity",
})
const (
spellCyclone = uint32(33786) // Druid Cyclone (WotLK)
dummy = uint32(32171) // hostile dummy; no CC immunity
cycloneDur = 6 * time.Second
recastDelay = 4500 * time.Millisecond // leave time to observe an immune instant recast
judgeSlack = 500 * time.Millisecond
)
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "Cyc27", Race: e2eharness.RaceTauren,
Class: e2eharness.ClassDruid, Level: 80, LearnAllClass: true,
})
bot.TeleportPad(t, e2eharness.PackagePad(t))
// Northshire strip (map 0): open and spawn-safe.
const (
cx, cy, cz float32 = -8904.0, -128.0, 81.0
cm uint32 = 0
)
bot.Teleport(t, cx, cy, cz, cm)
bot.CombatReadyFull(t)
bot.CombatStop(t)
bot.FlushWorld(t)
bot.Learn(t, spellCyclone)
// Remove cast time so the second cast cannot land after the first aura expires.
bot.GM(t, ".cheat casttime on")
guid := bot.Spawn(t, dummy, 30*time.Second)
if guid == 0 {
e2eharness.Preconditionf(t, "failed to spawn Cyclone target")
}
_ = bot.World.SetTarget(guid)
bot.Face(t, guid)
// First Cyclone must land and hold the target.
bot.CastMust(t, spellCyclone, guid, 15*time.Second)
bot.WaitUnitAura(t, guid, spellCyclone, 3*time.Second)
firstAppliedAt := time.Now()
// Recast while the first Cyclone is about to expire. A refresh (the bug) would
// replace it with a fresh DR'd aura that outlasts the ~6s window; an immune recast
// leaves the original to end on its own.
recastAt := firstAppliedAt.Add(recastDelay)
for time.Now().Before(recastAt) && bot.UnitHasAura(guid, spellCyclone) {
time.Sleep(50 * time.Millisecond)
}
if !bot.UnitHasAura(guid, spellCyclone) {
e2eharness.Preconditionf(t, "Cyclone faded before the recast window")
}
bot.Face(t, guid)
if result, err := bot.TryCast(t, spellCyclone, guid, 10*time.Second); err != nil {
e2eharness.HarnessFailf(t, "second Cyclone produced no cast result: %v", err)
} else if !result.Success {
e2eharness.Preconditionf(t, "second Cyclone failed to cast: %s", e2eharness.SpellFailReasonName(result.FailReason))
}
// After the recast the target must drop out within the time left of the first
// Cyclone; still being cycloned past that means it was refreshed.
deadline := firstAppliedAt.Add(cycloneDur + judgeSlack)
if !time.Now().Before(deadline) {
e2eharness.Preconditionf(t, "cast response arrived after the observation window")
}
for time.Now().Before(deadline) {
if !bot.UnitHasAura(guid, spellCyclone) {
break
}
time.Sleep(50 * time.Millisecond)
}
if bot.UnitHasAura(guid, spellCyclone) {
e2eharness.ConfirmedBugf(t, 27355,
"second Cyclone refreshed the active Cyclone; recast should be immune")
}
// The spell must become usable again once its immunity aura has ended.
bot.Face(t, guid)
bot.CastMust(t, spellCyclone, guid, 10*time.Second)
bot.WaitUnitAura(t, guid, spellCyclone, 3*time.Second)
bot.AssertWorldAlive(t)
t.Logf("PASS second Cyclone did not refresh active Cyclone on dummy 0x%X", guid)
}
// Issue: https://github.com/azerothcore/azerothcore-wotlk/issues/25481
// PR: https://github.com/azerothcore/azerothcore-wotlk/pull/25984
// Lady Vashj (21212) is guarded by four shield generators; entering phase 2 must
// summon all four beams and apply one Magic Barrier (38112) per generator.
func TestAC_25481_LadyVashjFourMagicBarriers(t *testing.T) {
meta.Begin(t, meta.TestMeta{
Tags: []string{"long", "spells", "instances", "issue", "serial"},
Runtime: "long",
Issue: 25481,
Category: "spells/immunity",
})
const (
npcLadyVashj = uint32(21212)
spellMagicBarrier = uint32(38112)
)
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "Vashj", Level: 80, LearnAllClass: true,
})
// Enter the raid instance while still GM (a solo, non-GM player cannot be placed
// on a raid map). CombatReady below drops GM mode and enables god so Vashj
// actually engages us instead of ignoring an invisible GM.
bot.GoCreatureID(t, npcLadyVashj)
vashj := bot.WaitUnit(t, npcLadyVashj, 30*time.Second)
if vashj == 0 {
e2eharness.Preconditionf(t, "Lady Vashj (21212) not found after .go creature id 21212")
}
// Observe slots before phase 2. GetActiveAuras deduplicates spell IDs, and
// AuraStacks returns the maximum stack count, not the number of applications.
var auraMu sync.Mutex
auraSlots := make(map[uint8]uint32)
cancel := bot.World.AddPacketHook(func(opcode uint16, data []byte) {
if opcode != auraUpdate && opcode != auraUpdateAll {
return
}
target, slots, ok := decodeAuraSlots(data)
if !ok || target != vashj {
return
}
auraMu.Lock()
defer auraMu.Unlock()
applyAuraSlots(auraSlots, slots, opcode == auraUpdateAll)
})
defer cancel()
bot.CombatReadyFull(t)
bot.Engage(t, vashj, 20*time.Second)
// Phase 2 starts at 70% HP; drop her below that without killing her.
bot.DamageToFraction(t, vashj, 0.5, 30*time.Second)
// The four phase-2 beams each apply their own Magic Barrier (one aura slot per
// generator/caster), so count aura applications rather than stack count.
barriers := func() int {
auraMu.Lock()
defer auraMu.Unlock()
n := 0
for _, id := range auraSlots {
if id == spellMagicBarrier {
n++
}
}
return n
}
deadline := time.Now().Add(8 * time.Second)
seen := 0
for time.Now().Before(deadline) {
seen = barriers()
if seen >= 4 {
break
}
time.Sleep(100 * time.Millisecond)
}
t.Logf("Lady Vashj Magic Barrier applications=%d (want 4)", seen)
if seen == 0 {
e2eharness.Preconditionf(t, "no Magic Barrier applied in phase 2 (encounter/reach not driven)")
}
if seen < 4 {
e2eharness.ConfirmedBugf(t, 25481,
"Lady Vashj has %d Magic Barrier application(s) in phase 2, expected 4 (positive school-immunity stacking broke)",
seen)
}
bot.AssertWorldAlive(t)
t.Logf("PASS Lady Vashj has 4 Magic Barriers in phase 2")
}