From 0e6a5d322ca19137801e704ba32748e71b81305b Mon Sep 17 00:00:00 2001 From: Andrew <47818697+Nyeriah@users.noreply.github.com> Date: Thu, 17 Sep 2026 06:14:54 -0300 Subject: [PATCH] fix(Scripts/Ulduar): do not let Thorim's evade despawn trigger his defeat (#27672) --- e2e/README.md | 2 +- .../northrend/ulduar/thorim_evade_e2e_test.go | 281 ++++++++++++++++++ .../Northrend/Ulduar/Ulduar/boss_thorim.cpp | 4 +- 3 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 e2e/suites/instances/northrend/ulduar/thorim_evade_e2e_test.go diff --git a/e2e/README.md b/e2e/README.md index c5ca9c7fcb..fee8194f2a 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -164,7 +164,7 @@ go test -tags=e2e ./local/... -count=1 -v -timeout 30m -parallel 1 | guild/charter_bank | charter buy+turn-in | P2 | covered | — | | instances/bind_reset | party tele; ritual summon | P2 | covered; post-reset summon `blocked-harness` (AcceptSummon after reset) | #10708 | | instances/classic/stratholme | Timmy remains hidden while a relevant Square Scarlet lives, then emerges after the area is clear | P2 | covered (`TestAC_26363_TimmyEmergesAfterSquareCleared`) | #26363 | -| instances/ulduar | named tele; Freya wave interval; a Laughing Skull's Lunatic Gaze stops at the brain room's geometry instead of draining sanity through it; Elder Brightleaf's Unstable Sun Beams despawn on their own timer instead of outliving him, one lands under the player rather than all on the elder, and a wave stays capped however many players stand in range; Psychosis and Malady of the Mind stop picking a player once they are at 40 Sanity or less; Algalon holds still for 3s once his Big Bang cast lands instead of resuming melee and Quantum Strike on the next tick; a Writhing Lasher stays up past 62947's own 10s summon duration, because the despawn type its AI sets in `IsSummonedBy` is no longer overwritten on the way out of the summon call | P2 | covered (`TestAC_27095_*`, `TestAC_27602_*`, `TestAC_27590_*`, `TestAC_27539_*`, `TestUlduar_BrightleafSunBeams*`, `TestUlduar_FreyaWardLasherOutlivesSummonDuration`); Kologarn Charge `blocked-harness` (bridge Z after Charge) | #26266 #27095 #27539 #27590 #27602 | +| instances/ulduar | named tele; Freya wave interval; a Laughing Skull's Lunatic Gaze stops at the brain room's geometry instead of draining sanity through it; Elder Brightleaf's Unstable Sun Beams despawn on their own timer instead of outliving him, one lands under the player rather than all on the elder, and a wave stays capped however many players stand in range; Psychosis and Malady of the Mind stop picking a player once they are at 40 Sanity or less; Algalon holds still for 3s once his Big Bang cast lands instead of resuming melee and Quantum Strike on the next tick; a Writhing Lasher stays up past 62947's own 10s summon duration, because the despawn type its AI sets in `IsSummonedBy` is no longer overwritten on the way out of the summon call; a wipe to Thorim's melee with a death-persistent damage shield on the last victim spawns no Cache of Storms while his hard-reset despawn runs, and a lethal hit on the respawned Thorim still yields | P2 | covered (`TestAC_27095_*`, `TestAC_27602_*`, `TestAC_27590_*`, `TestAC_27539_*`, `TestUlduar_BrightleafSunBeams*`, `TestUlduar_FreyaWardLasherOutlivesSummonDuration`, `TestUlduar_ThorimEvadeDespawnDoesNotYield`); Kologarn Charge `blocked-harness` (bridge Z after Charge) | #26266 #27095 #27539 #27590 #27602 | | world/gameevents | Call to Arms banners at the Dalaran portals belong to the side they stand on, and the already-correct Warsong set is unchanged. **Wants an exclusive realm**: starting a holiday re-anchors its schedule in the running worldserver until restart; holidays already running are left alone | P2 | covered (`TestAC_24380_*`); Shattrath's 23 positions `gap` | #24380 | --- diff --git a/e2e/suites/instances/northrend/ulduar/thorim_evade_e2e_test.go b/e2e/suites/instances/northrend/ulduar/thorim_evade_e2e_test.go new file mode 100644 index 0000000000..ec7bc6178a --- /dev/null +++ b/e2e/suites/instances/northrend/ulduar/thorim_evade_e2e_test.go @@ -0,0 +1,281 @@ +//go:build e2e + +package ulduar_test + +import ( + "sync/atomic" + "testing" + "time" + + _ "github.com/go-sql-driver/mysql" + + "github.com/azerothcore/AzerothGhost/client" + "github.com/azerothcore/AzerothGhost/e2e/e2eharness" + "github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta" +) + +// Thorim despawns on evade (HARD_RESET, PR #27059). When his own melee kills the last player, +// the evade runs inside that damage call, and the damage-shield pass that follows still calls +// DealDamage on the now-dead Thorim with 0 damage because Retribution Aura persists through +// death. His defeat check (damage >= health) then held on 0 health: the Cache of Storms spawned, +// the encounter credit fired and DONE was written on an object already queued for removal, while +// the scheduled respawn brought back a fresh Thorim whose Reset cleared the state again. The +// encounter reopened and a second defeat produced a second chest. +// +// Drive: a paladin bait with Retribution Aura stands in the arena, a second bot on the balcony +// unlocks the ring phase and pulls Thorim down onto the bait, whose death is the wipe. +// Oracle: no Cache of Storms after the evade, Thorim respawns hostile, and a real lethal hit on the +// respawned Thorim still yields (chest spawns, he turns friendly and unattackable). +func TestUlduar_ThorimEvadeDespawnDoesNotYield(t *testing.T) { + meta.Begin(t, meta.TestMeta{ + Tags: []string{"long", "instances", "multi_bot"}, + Runtime: "long", + Category: "instances/northrend/ulduar", + }) + + const ( + npcThorim = uint32(32865) + npcRunicColossus = uint32(32872) + npcAncientRuneGiant = uint32(32873) + + goCacheOfStorms10 = uint32(194312) + goCacheOfStorms10Hard = uint32(194313) + + spellRetributionAura = uint32(54043) + + // SMSG_SPELLNONMELEEDAMAGELOG (3.3.5a); the harness has no constant for it. + opSpellNonMeleeDamageLog = uint16(0x0250) + + unitFlagNonAttackable = uint32(0x00000002) + factionFriendly = uint32(35) + + // Thorim's DB spawn on the balcony; the jump-down needs the hitter above z 430. + balconyX, balconyY, balconyZ = float32(2131.02), float32(-297.65), float32(438.331) + // Arena floor (game_tele BossThorim), inside GetArenaPlayer()'s box. + arenaX, arenaY, arenaZ = float32(2135.35), float32(-251.086), float32(419.743) + // In front of the Ancient Rune Giant; the Runic Colossus is ~100y from here, in cache range. + gauntletX, gauntletY, gauntletZ = float32(2134.57), float32(-430.0), float32(438.331) + + // A chest spawned by the bug is created in the same tick as the death. + chestWindow = 6 * time.Second + // DespawnOnEvade's default respawn delay is 20s. + respawnWindow = 45 * time.Second + lethalHit = uint32(50_000_000) + ) + + bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{ + Prefix: "ThEvd", + Bots: []e2eharness.BotSpec{ + {Role: "driver", Race: e2eharness.RaceHuman, Class: e2eharness.ClassWarrior, Level: 80, LearnAllClass: true}, + {Role: "bait", Race: e2eharness.RaceHuman, Class: e2eharness.ClassPaladin, Level: 80, LearnAllClass: true}, + }, + }) + driver := e2eharness.ByRole(t, bots, "driver") + bait := e2eharness.ByRole(t, bots, "bait") + + // A plain tele would hand each bot its own instance copy; .summon pulls the bait into the + // driver's. Stay GM through the raid enter. + e2eharness.FormParty(t, driver, bait) + // Reset any Ulduar the leader is bound to so a re-run does not enter a still-loaded instance + // left mid-fight by a previous run (harmless no-op on a fresh account). + driver.LeaderResetInstances(t, 5*time.Second) + driver.Teleport(t, balconyX, balconyY, balconyZ, e2eharness.MapUlduar) + if _, _, _, m := driver.Pos(); m != e2eharness.MapUlduar { + e2eharness.Preconditionf(t, "driver not in Ulduar after balcony tele map=%d", m) + } + driverGUID := driver.World.CharGUID() + before := bait.World.TeleportSeq() + driver.GM(t, ".summon "+bait.Name) + if err := bait.World.WaitForTeleportAfter(before, 15*time.Second); err != nil { + e2eharness.Preconditionf(t, "bait .summon into Ulduar: %v", err) + } + colocated := time.Now().Add(15 * time.Second) + for bait.World.GetObject(driverGUID) == nil { + if !time.Now().Before(colocated) { + e2eharness.Preconditionf(t, "bait never saw the driver after .summon: separate instance copies") + } + time.Sleep(100 * time.Millisecond) + } + + // The jump-down is gated on the Ancient Rune Giant's death (ACTION_ALLOW_HIT). Both gauntlet + // golems are Thorim's summons, up since his Reset; nothing is engaged yet, so GM damage is fine. + driver.Teleport(t, gauntletX, gauntletY, gauntletZ, e2eharness.MapUlduar) + giant := driver.WaitUnit(t, npcAncientRuneGiant, 15*time.Second) + colossus := driver.WaitUnit(t, npcRunicColossus, 15*time.Second) + driver.DamageKill(t, []uint64{giant, colossus}, 10_000_000, 20*time.Second) + + driver.Teleport(t, balconyX, balconyY, balconyZ, e2eharness.MapUlduar) + bait.Teleport(t, arenaX, arenaY, arenaZ, e2eharness.MapUlduar) + // Co-location is already proven (the bait saw the driver's own GUID); the bait never needs + // Thorim in its cache, since the packet hook matches him by GUID from raw packets. Creature + // GUIDs are shared across one instance, so the driver's Thorim GUID is the bait's too. + thorim := driver.WaitUnit(t, npcThorim, 15*time.Second) + driver.CombatReady(t) + + // The bait must stay killable by Thorim's melee, so it is left out of god mode (CombatReady + // would enable it). Retribution Aura is the death-persistent damage shield that lets the shield + // pass reach Thorim after the bait is already dead. + bait.GM(t, ".gm off") + bait.GM(t, ".cheat god off") + bait.FlushWorld(t) + bait.CastMust(t, spellRetributionAura, bait.World.CharGUID(), 10*time.Second) + if !bait.HasAura(spellRetributionAura) { + e2eharness.Preconditionf(t, "bait has no Retribution Aura after the cast") + } + // The bait keeps its (small) full health; Thorim's melee kills it in a swing or two, and every + // swing reflects Retribution Aura back at him. + + // Only Thorim's own hits on the bait matter: his melee (SMSG_ATTACKERSTATEUPDATE, attacker then + // target) runs the death-persistent shield pass that reproduces the bug, a spell + // (SMSG_SPELLNONMELEEDAMAGELOG, target then caster) does not. Both opcodes are broadcast for + // nearby combat too, so filter by GUID or the driver's own hits would misclassify the death. + baitGUID := bait.World.CharGUID() + var lastMelee, lastSpell atomic.Int64 + cancelHook := bait.World.AddPacketHook(func(opcode uint16, data []byte) { + switch opcode { + case client.SmsgAttackerStateUpdate: + attacker, off := readPackedGUID(data, 4) // after uint32 HitInfo + if target, _ := readPackedGUID(data, off); attacker == thorim && target == baitGUID { + lastMelee.Store(time.Now().UnixNano()) + } + case opSpellNonMeleeDamageLog: + target, off := readPackedGUID(data, 0) + if caster, _ := readPackedGUID(data, off); caster == thorim && target == baitGUID { + lastSpell.Store(time.Now().UnixNano()) + } + } + }) + defer cancelHook() + + // Killing the Ancient Rune Giant already set ACTION_ALLOW_HIT, so a player hit from the balcony + // (z > 430) drops Thorim into the ring phase. He resets threat onto the arena player (the bait) + // and chases her. + dx, dy, dz, _ := driver.Pos() + t.Logf("driver on balcony at (%.1f,%.1f,%.1f)", dx, dy, dz) + if dz <= 430 { + e2eharness.Preconditionf(t, "driver stands at z=%.1f, below the balcony threshold the jump needs", dz) + } + jumpDeadline := time.Now().Add(15 * time.Second) + jumped := false + for time.Now().Before(jumpDeadline) { + driver.Damage(t, thorim, 1) + obj := driver.World.GetObject(thorim) + if obj != nil && obj.PosZ < 430 { + jumped = true + break + } + time.Sleep(500 * time.Millisecond) + } + if !jumped { + e2eharness.Preconditionf(t, "Thorim did not jump into the arena after balcony hits") + } + // The driver leaves combat so the bait's death is the wipe that ends Thorim's combat. + driver.CombatStop(t) + + bait.WaitDead(t, 25*time.Second) + diedAt := time.Now() + if lastMelee.Load() == 0 || lastSpell.Load() > lastMelee.Load() { + e2eharness.Preconditionf(t, "bait died to a spell, not to Thorim's melee: no damage-shield pass to judge") + } + t.Logf("bait died to melee at %s", diedAt.Format(time.StampMilli)) + + // A chest here is the defeat block running on the despawning Thorim. This drive never summons + // Sif, so Thorim lacks the Touch of Dominion trigger (62565) and the ring phase flags hard mode; + // the defeat block would then spawn the hard-mode Cache (194313), so accept either entry. + if g := anyCacheOfStorms(t, driver.World, goCacheOfStorms10, goCacheOfStorms10Hard, chestWindow); g != 0 { + e2eharness.Assertf(t, "Cache of Storms 0x%X spawned from Thorim's evade despawn", g) + } + + // The hard reset brings back a fresh, hostile Thorim on the balcony. On the buggy core the same + // evade also produced the chest above; here he is simply attackable again. + respawned := waitHostileThorim(t, driver, npcThorim, unitFlagNonAttackable, factionFriendly, respawnWindow) + if respawned == thorim { + e2eharness.Preconditionf(t, "Thorim 0x%X was never despawned by the evade (HARD_RESET missing?)", thorim) + } + t.Logf("after evade: respawned hostile Thorim 0x%X (was 0x%X)", respawned, thorim) + + // A real lethal hit on the respawned Thorim must still yield: chest spawns and he turns friendly + // and unattackable. This proves the guard did not break the normal defeat. + t.Run("LethalHitStillYields", func(t *testing.T) { + driver.Damage(t, respawned, lethalHit) + chest := anyCacheOfStorms(t, driver.World, goCacheOfStorms10, goCacheOfStorms10Hard, 10*time.Second) + if chest == 0 { + e2eharness.Assertf(t, "no Cache of Storms after a lethal hit on Thorim") + } + yieldDeadline := time.Now().Add(5 * time.Second) + yielded := false + for time.Now().Before(yieldDeadline) { + obj := driver.World.GetObject(respawned) + if obj != nil && obj.Value(client.UnitFieldFlags)&unitFlagNonAttackable != 0 && obj.Value(client.UnitFieldFaction) == factionFriendly { + yielded = true + break + } + time.Sleep(50 * time.Millisecond) + } + if !yielded { + e2eharness.Assertf(t, "Thorim not friendly and unattackable after his defeat") + } + t.Logf("PASS lethal hit yields: chest=0x%X", chest) + }) + + t.Logf("PASS Thorim evade despawn does not yield: old=0x%X respawned=0x%X", thorim, respawned) +} + +// waitHostileThorim waits for a living Thorim that is attackable and not friendly. +func waitHostileThorim(t *testing.T, b *e2eharness.ScenarioBot, entry, nonAttackable, friendly uint32, timeout time.Duration) uint64 { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + for _, g := range e2eharness.LivingByEntries(b.World, 120, entry) { + obj := b.World.GetObject(g) + if obj == nil { + continue + } + if obj.Value(client.UnitFieldFlags)&nonAttackable == 0 && obj.Value(client.UnitFieldFaction) != friendly { + return g + } + } + time.Sleep(100 * time.Millisecond) + } + e2eharness.Assertf(t, "no hostile Thorim within %s of the evade", timeout) + return 0 +} + +// anyCacheOfStorms returns the guid of the first Cache of Storms of either entry found in range, +// or 0 if none appears within timeout. +func anyCacheOfStorms(t *testing.T, w *client.WorldClient, normal, hard uint32, timeout time.Duration) uint64 { + t.Helper() + deadline := time.Now().Add(timeout) + for { + for _, entry := range []uint32{normal, hard} { + if g := w.FindGameObjectByEntry(entry, 60); g != 0 { + return g + } + } + if !time.Now().Before(deadline) { + return 0 + } + time.Sleep(100 * time.Millisecond) + } +} + +// readPackedGUID reads a WoW packed GUID starting at off and returns the guid and the next offset. +// The first byte is a bitmask; each set bit i contributes byte i of the guid, low bit first. +func readPackedGUID(b []byte, off int) (uint64, int) { + if off >= len(b) { + return 0, off + } + mask := b[off] + off++ + var guid uint64 + for i := 0; i < 8; i++ { + if mask&(1<= len(b) { + break + } + guid |= uint64(b[off]) << (8 * uint(i)) + off++ + } + } + return guid, off +} diff --git a/src/server/scripts/Northrend/Ulduar/Ulduar/boss_thorim.cpp b/src/server/scripts/Northrend/Ulduar/Ulduar/boss_thorim.cpp index 6990f5d799..3cf2c5a52f 100644 --- a/src/server/scripts/Northrend/Ulduar/Ulduar/boss_thorim.cpp +++ b/src/server/scripts/Northrend/Ulduar/Ulduar/boss_thorim.cpp @@ -552,7 +552,9 @@ struct boss_thorim : public BossAI me->AddThreat(player, 1000.0f); } - if (damage >= me->GetHealth()|| me->GetHealth()<2) + // The hook also fires with zero damage on an already despawned Thorim (damage shields after + // his own lethal melee during the HARD_RESET evade), where 0 >= 0 would count as a defeat + if (me->IsAlive() && damage >= me->GetHealth()) { damage = 0; if (!_encounterFinished)