From b4e5f4f2bb13742c58b4cb2956d8aaf514b4d010 Mon Sep 17 00:00:00 2001 From: Brues <5278969+brues-code@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:21:16 -0500 Subject: [PATCH] feat(spell): fill notInterruptible in UnitCastingInfo / UnitChannelInfo Mirror the 3.3.5 client's own predicate on 1.12 data instead of pushing a placeholder false. The value is relative to the local player: the spellbook is folded into school masks of INTERRUPT_CAST spells and MOD_SILENCE spells (cached on Player::StatSignal), a cast is not interruptible when its PreventionType is not SILENCE or when the caster's immunity auras (effect / state / school / mechanic immunity) strip every mask, and the flag is never reported while the player knows no interrupt or silence at all. 1.12 differences, each documented in Spell::Interruptible: the vanilla server's interrupt effect also needs InterruptFlags 0x02 (channels: ChannelInterruptFlags 0x04), which silence ignores; SMSG_SPELL_START carries no immunity block, so creature_template interrupt immunity stays invisible; Feral Charge's interrupt lives on its triggered spell, so the capability walk follows one TRIGGER_SPELL hop. Verified in-game: a paladin's Holy Light reads false, flips to true while Divine Shield is up, and back to false when it drops. Also centralize SPELL_EFFECT_APPLY_AURA in Offsets.h (was local to Level.cpp). --- docs/API.md | 19 +++- src/Offsets.h | 41 +++++++ src/spell/Cast.cpp | 51 +++++---- src/spell/Interruptible.cpp | 210 ++++++++++++++++++++++++++++++++++++ src/spell/Interruptible.h | 32 ++++++ src/spell/Level.cpp | 3 +- 6 files changed, 331 insertions(+), 25 deletions(-) create mode 100644 src/spell/Interruptible.cpp create mode 100644 src/spell/Interruptible.h diff --git a/docs/API.md b/docs/API.md index e44624ed..c06fd917 100644 --- a/docs/API.md +++ b/docs/API.md @@ -15286,9 +15286,18 @@ so progress is `(GetTime()*1000 - startTimeMs) / (endTimeMs - startTimeMs)`. > the cast's **castGUID** — the exact string the > [`UNIT_SPELLCAST_*`](#unit_spellcast_-events) events carry for this cast, > so you can correlate the polled info with the events (works for the player -> and other units). Fields that can't be filled are structurally-correct -> placeholders: `castBarID` = `nil`, `notInterruptible` = `false`, -> `delayTimeMs` = `0`. +> and other units). `delayTimeMs` is the pushback the cast has taken so far, +> in milliseconds. `castBarID` has no source here and is always `nil`. +> +> `notInterruptible` is `true` when no interrupt or silence that **you** +> know can stop the cast. The value is relative to your own abilities, so a +> rogue and a mage can read different values for the same cast. It is `true` +> in two cases: the spell can never be interrupted, or the caster has an aura +> that blocks every interrupt and silence you know (for example Divine Shield, +> Ice Block, or Banish). If you know no interrupt and no silence, the value is +> always `false`. Limits: a creature's built-in interrupt immunity is +> server-side data that the client never receives, so a boss with that +> immunity reads `false` while your interrupt still fails on it. ### `C_Spell.UnitChannelInfo(unit)` / `C_Spell.ChannelInfo()` @@ -15316,8 +15325,8 @@ as casts: when we observed the channel begin, the remote unit returns full **start/end times** (validated against the live `UNIT_FIELD_CHANNEL_SPELL` so a stale cache entry never applies to a different/ended channel); otherwise it falls back to `name`/`displayName`/`textureID`/`spellID` with -**`nil` times**. The player path is unchanged (full timing). Same -placeholder fields as `C_Spell.UnitCastingInfo`. +**`nil` times**. The player path is unchanged (full timing). +`notInterruptible` follows the same rules as in `C_Spell.UnitCastingInfo`. ### `C_Spell.GetSpellLevelInfo(spellID)` diff --git a/src/Offsets.h b/src/Offsets.h index 49a17c53..ccc035f1 100644 --- a/src/Offsets.h +++ b/src/Offsets.h @@ -7817,6 +7817,15 @@ enum Offsets { // recipes, e.g. "Steel Plate Boots" = effect 36). OFF_SPELL_RECORD_EFFECT = 0xF4, // int32[3] SPELL_EFFECT_SCHOOL_DAMAGE = 2, // direct damage (verified vs server SpellDefines.h) + SPELL_EFFECT_APPLY_AURA = 6, // EffectApplyAuraName[i] then names the aura type + SPELL_EFFECT_TRIGGER_SPELL = 64, // casts EffectTriggerSpell[i] (Feral Charge 16979 → 19675) + // Kick / Pummel / Shield Bash / Counterspell / Earth Shock — 57 Spell.dbc + // rows carry it. The server's Spell::EffectInterruptCast is its handler. + SPELL_EFFECT_INTERRUPT_CAST = 68, + // EffectTriggerSpell[3] (columns 109-111): the spell an effect 64 casts. + // Verified in Spell.dbc: Feral Charge 16979 → [1] = 19675 "Feral Charge + // Effect" (the row that carries the druid's INTERRUPT_CAST). + OFF_SPELL_RECORD_EFFECT_TRIGGER_SPELL = 0x1B4, // int32[3] SPELL_EFFECT_CREATE_ITEM = 24, SPELL_EFFECT_LEARN_SPELL = 36, SPELL_EFFECT_ENCHANT_ITEM = 53, @@ -7918,10 +7927,27 @@ enum Offsets { // failure on this bit) use it; thrown items (grenades — e.g. spell 4068) // lack it, so movement must not end their cast. See Spell::Cast (issue #23). SPELL_INTERRUPT_FLAG_MOVEMENT = 0x1, + // Bit 1: the bit the server's interrupt EFFECT tests on a cast before Kick / + // Pummel / Counterspell may stop it — tortoise `Spell::EffectInterruptCast` + // (vmangos names the same bit DAMAGE_PUSHBACK). Player casts carry 0x0F; + // creature casts with 0x00 / 0x08 / 0x09 / 0x0D (Shadow Flame, Frost + // Breath, …) cannot be interrupted by those abilities. Silence auras ignore + // the flags (PreventionType only). Read by Spell::Interruptible. + SPELL_INTERRUPT_FLAG_DAMAGE = 0x2, + // ChannelInterruptFlags (column 23). Bit 2 is the channel analog of the + // gate above (CHANNEL_FLAG_INTERRUPT in both cores). Verified in Spell.dbc: + // Blizzard / Arcane Missiles 0x7C0C, non-channels 0. + OFF_SPELL_RECORD_CHANNEL_INTERRUPT_FLAGS = 0x5C, // u32 (column 23) + CHANNEL_FLAG_INTERRUPT = 0x4, // Remaining Spell.dbc record fields — the single source of truth for the // record layout (byte = column * 4). Modules must use these rather than // redefining local copies. + // Single school id (column 1) — 0 physical … 6 arcane; a spell's school + // MASK is `1 << School` (the server's GetSpellSchoolMask). Verified in + // Spell.dbc: Kick 0, Fireball 2, Earth Shock 3, Shadow Bolt 5, + // Counterspell 6. Read by Spell::Interruptible. + OFF_SPELL_RECORD_SCHOOL = 0x04, // u32 (column 1) OFF_SPELL_RECORD_CASTING_TIME_INDEX = 0x48, // u32 → SpellCastTimes.dbc OFF_SPELL_RECORD_MAX_LEVEL = 0x6C, // u32 (scaling cap level) OFF_SPELL_RECORD_SPELL_LEVEL = 0x74, // u32 (this rank's effective level) @@ -7946,6 +7972,12 @@ enum Offsets { // reads `spellRec[+0x14]` as the spell's mechanic when the per- // effect EffectMiscValue is 0. Read by C_Spell.GetSpellMechanicByID. OFF_SPELL_RECORD_MECHANIC = 0x14, + // SpellMechanic.dbc ids (server SpellDefines.h `Mechanics`). Every + // interrupt ability carries 26 — per effect (Kick / Pummel / Shield Bash / + // Earth Shock EffectMechanic[1]) or spell-level (Counterspell Mechanic) — + // so MECHANIC_IMMUNITY of 26 is what makes a caster unkickable. + MECHANIC_SILENCE = 9, + MECHANIC_INTERRUPT = 26, SPELL_AURA_MOD_STEALTH = 16, SPELL_AURA_MOD_SHAPESHIFT = 36, SPELL_AURA_MOUNTED = 78, @@ -7967,6 +7999,15 @@ enum Offsets { SPELL_AURA_MOD_SILENCE = 27, SPELL_AURA_MOD_PACIFY_SILENCE = 60, SPELL_AURA_MOD_DISARM = 67, + // Immunity aura types (server SpellAuraDefines.h). EffectMiscValue: 37 = a + // SPELL_EFFECT id, 38 = a SPELL_AURA id, 39 = a school MASK (Divine Shield + // rows carry 39 with 1 and 126; verified in Spell.dbc), 77 = a SpellMechanic + // id. The 3.3.5 client's not-interruptible walk tests exactly these four; + // read by Spell::Interruptible. + SPELL_AURA_EFFECT_IMMUNITY = 37, + SPELL_AURA_STATE_IMMUNITY = 38, + SPELL_AURA_SCHOOL_IMMUNITY = 39, + SPELL_AURA_MECHANIC_IMMUNITY = 77, // SMSG_SPELL_COOLDOWN (0x134) handler, registered by the spell-opcode // boot registrar FUN_006e7150 exactly like the failure/channel handlers diff --git a/src/spell/Cast.cpp b/src/spell/Cast.cpp index 34da78ca..24598013 100644 --- a/src/spell/Cast.cpp +++ b/src/spell/Cast.cpp @@ -64,9 +64,11 @@ // test only the victim's client received it), so a remote bar can't // stretch. Inherent 1.12 protocol gap, not recoverable client-side. // -// Modern-signature fields vanilla can't fill are structurally-correct -// placeholders: castID / castBarID = nil, notInterruptible = false, -// isTradeskill = false (no readable flag in 1.12), delayTimeMs = 0. +// The other modern-signature fields: `castID` is the cast's castGUID +// (Spell::CastEvents), `isTradeskill` is SPELL_ATTR_TRADESPELL, `delayTimeMs` +// the accumulated pushback, `notInterruptible` the 3.3.5 client's own +// player-relative predicate on 1.12 data (Spell::Interruptible). `castBarID` +// has no 1.12 source and stays nil. #include "Game.h" #include "Offsets.h" @@ -75,6 +77,7 @@ #include "net/PacketDispatch.h" #include "net/PacketReader.h" #include "player/Info.h" +#include "spell/Interruptible.h" #include "spell/Lookup.h" #include "spell/CastEvents.h" #include "tick/WorldTick.h" @@ -293,8 +296,11 @@ const char *SpellIconPath(const uint8_t *rec) { // Pushes UnitCastingInfo's 11-tuple from a tracked cast, or nothing (nil) // if there's no active cast. `casterGuid` identifies whose cast this is (the // player's or a remote unit's) so `castID` can be pulled from Spell::CastEvents -// — the same castGUID the cast's UNIT_SPELLCAST_* events carry. -int PushCastInfo(void *L, const TrackedSpell &c, uint64_t casterGuid) { +// — the same castGUID the cast's UNIT_SPELLCAST_* events carry. `casterUnit` +// is the caster's CGUnit (null when unresolvable) for the immunity-aura half +// of `notInterruptible`. +int PushCastInfo(void *L, const TrackedSpell &c, uint64_t casterGuid, + const uint8_t *casterUnit) { if (c.spellID == 0) return 0; // Self-expire: once the cast window has elapsed, report nothing even if @@ -319,7 +325,8 @@ int PushCastInfo(void *L, const TrackedSpell &c, uint64_t casterGuid) { Game::Lua::PushString(L, castGuid); else Game::Lua::PushNil(L); - Game::Lua::PushBool(L, false); // 8 notInterruptible + Game::Lua::PushBool(L, Spell::Interruptible::NotInterruptible( + casterUnit, rec, /*isChannel*/ false)); // 8 notInterruptible Game::Lua::PushNumber(L, static_cast(c.spellID)); // 9 castingSpellID Game::Lua::PushNil(L); // 10 castBarID Game::Lua::PushNumber(L, static_cast(c.delayMs)); // 11 delayTimeMs @@ -328,7 +335,10 @@ int PushCastInfo(void *L, const TrackedSpell &c, uint64_t casterGuid) { // Pushes UnitChannelInfo's 8-tuple. `haveTimes` is false for remote units // (we only track the local player's channel start), pushing nil times. -int PushChannelInfo(void *L, int spellID, int startMs, int endMs, bool haveTimes) { +// `casterUnit` is the channeling CGUnit (null when unresolvable), for the +// immunity-aura half of `notInterruptible`. +int PushChannelInfo(void *L, int spellID, int startMs, int endMs, bool haveTimes, + const uint8_t *casterUnit) { if (spellID == 0) return 0; // Self-expire a timed channel once its window elapses (mirrors @@ -352,7 +362,8 @@ int PushChannelInfo(void *L, int spellID, int startMs, int endMs, bool haveTimes Game::Lua::PushNil(L); } Game::Lua::PushBool(L, IsTradeskill(rec)); // 6 isTradeskill - Game::Lua::PushBool(L, false); // 7 notInterruptible + Game::Lua::PushBool(L, Spell::Interruptible::NotInterruptible( + casterUnit, rec, /*isChannel*/ true)); // 7 notInterruptible Game::Lua::PushNumber(L, static_cast(spellID)); // 8 spellID return 8; } @@ -857,7 +868,8 @@ static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick}; // `CastingInfo()` — local player's cast, no token lookup. static int __fastcall Script_CastingInfo(void *L) { - return PushCastInfo(L, g_cast, Unit::Identity::PlayerGuid()); + return PushCastInfo(L, g_cast, Unit::Identity::PlayerGuid(), + Unit::Identity::PlayerObject()); } // `UnitCastingInfo(unit)` — local player from self-tracking; other units @@ -874,19 +886,21 @@ static int __fastcall Script_UnitCastingInfo(void *L) { if (u == nullptr) return 0; if (u == Resolve("player")) - return PushCastInfo(L, g_cast, Unit::Identity::PlayerGuid()); + return PushCastInfo(L, g_cast, Unit::Identity::PlayerGuid(), + Unit::Identity::PlayerObject()); const uint64_t guid = Unit::Identity::GuidForObject(u); const RemoteCast *rc = FindRemoteCast(guid); if (rc != nullptr && !rc->isChannel && NowMs() < rc->endMs) return PushCastInfo(L, TrackedSpell{rc->spellID, rc->startMs, rc->endMs}, - guid); + guid, static_cast(u)); return 0; } // `ChannelInfo()` — local player's channel, no token lookup. static int __fastcall Script_ChannelInfo(void *L) { - return PushChannelInfo(L, g_channel.spellID, g_channel.startMs, g_channel.endMs, true); + return PushChannelInfo(L, g_channel.spellID, g_channel.startMs, g_channel.endMs, true, + Unit::Identity::PlayerObject()); } // `UnitChannelInfo(unit)` — full timing for the player; spellID/name/ @@ -901,10 +915,11 @@ static int __fastcall Script_UnitChannelInfo(void *L) { if (u == nullptr) return 0; if (u == Resolve("player")) - return PushChannelInfo(L, g_channel.spellID, g_channel.startMs, g_channel.endMs, true); + return PushChannelInfo(L, g_channel.spellID, g_channel.startMs, g_channel.endMs, true, + Unit::Identity::PlayerObject()); - auto *desc = Game::Read( - u, Offsets::OFF_UNIT_DESCRIPTOR); + auto *unit = static_cast(u); + auto *desc = Game::Read(unit, Offsets::OFF_UNIT_DESCRIPTOR); if (desc == nullptr) return 0; // The live +0x228 field is authoritative for "is this unit channeling @@ -913,10 +928,10 @@ static int __fastcall Script_UnitChannelInfo(void *L) { const int spellID = Game::Read(desc, Offsets::OFF_UNIT_FIELD_CHANNEL_SPELL); if (spellID == 0) return 0; - const RemoteCast *rc = FindRemoteCast(Unit::Identity::GuidForObject(u)); + const RemoteCast *rc = FindRemoteCast(Unit::Identity::GuidForObject(unit)); if (rc != nullptr && rc->isChannel && rc->spellID == spellID && NowMs() < rc->endMs) - return PushChannelInfo(L, spellID, rc->startMs, rc->endMs, /*haveTimes*/ true); - return PushChannelInfo(L, spellID, 0, 0, /*haveTimes*/ false); + return PushChannelInfo(L, spellID, rc->startMs, rc->endMs, /*haveTimes*/ true, unit); + return PushChannelInfo(L, spellID, 0, 0, /*haveTimes*/ false, unit); } // The unit GUID `caster` is currently casting / channeling AT, or 0 when it diff --git a/src/spell/Interruptible.cpp b/src/spell/Interruptible.cpp new file mode 100644 index 00000000..eaf0edd8 --- /dev/null +++ b/src/spell/Interruptible.cpp @@ -0,0 +1,210 @@ +// This file is part of ClassicAPI. +// +// ClassicAPI is free software: you can redistribute it and/or modify it under the terms +// of the GNU General Public License as published by the Free Software Foundation, either +// version 3 of the License, or (at your option) any later version. +// +// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR +// PURPOSE. See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with +// ClassicAPI. If not, see . + +// `Spell::Interruptible` — `notInterruptible` for UnitCastingInfo / +// UnitChannelInfo. +// +// The value is RELATIVE TO THE LOCAL PLAYER: "can none of MY interrupts or +// silences stop this cast?" — a rogue and a mage read different answers for +// the same boss cast. That is the 3.3.5 client's design, decompiled on the +// Frostmourne binary and mirrored here piece by piece: +// +// 1. Capability (CGSpellBook rebuild `FUN_0053ca70`, the `param_3` block): +// walk the player's spellbook and OR each spell's school mask into two +// globals — spells with an INTERRUPT_CAST effect, and spells applying a +// MOD_SILENCE aura. Both zero ⇒ the reader below never returns true. +// (3.3.5 also keeps NPC-only variants from AttributesEx7 bits 0x800 / +// 0x1000; 1.12 has no AttributesEx7, so those masks are simply 0.) +// 2. Per-cast verdict (`FUN_007262e0`, run from the cast / channel setters +// for the target, focus, player and pet, and again on their aura updates): +// bail "interruptible" on AttributesEx7 CAN_ALWAYS_BE_INTERRUPTED (absent +// in 1.12); flag "not interruptible" when PreventionType != SILENCE; else +// strip from the two masks everything the caster is immune to — the +// SMSG_SPELL_START immunity block (CAST_FLAG_IMMUNITY 0x04000000: a school +// mask and a mechanic mask, stored at CGUnit +0xa64 / +0xa68 by the 0x131 +// body `FUN_00806700`) and the caster's immunity AURAS: EFFECT_IMMUNITY of +// INTERRUPT_CAST, STATE_IMMUNITY of MOD_SILENCE, SCHOOL_IMMUNITY (its misc +// is a school mask), MECHANIC_IMMUNITY of interrupt / silence. Not +// interruptible when nothing survives. +// 3. Reader (`FUN_0071ab20`, what Script_UnitCastingInfo / UnitChannelInfo +// push): the per-cast flag AND the player has any capability. +// +// What 1.12 changes: +// - No immunity block in SMSG_SPELL_START (tortoise SendSpellStart writes +// only 0x02 | AMMO), so a creature's `creature_template` interrupt immunity +// is invisible — 295 of 12,701 Turtle templates, 64 of 229 rank-3 bosses +// carry the MECHANIC_INTERRUPT bit. Documented gap in API.md; the aura part +// of step 2 is fully available. +// - Spell.dbc carries a single `School` column, so a spell's mask is +// `1 << School` (the server's GetSpellSchoolMask does the same). +// - The vanilla server's interrupt EFFECT has an extra static gate the 3.3.5 +// client never checks: tortoise and vmangos `Spell::EffectInterruptCast` +// only stop a cast with InterruptFlags 0x02 (a channel: ChannelInterrupt- +// Flags 0x04). Blizzard data keeps those consistent with PreventionType; +// vanilla creature spells do not (225 timed casts have PreventionType +// SILENCE and no 0x02 — Nefarian's Shadow Flame 0x08, Frost Breath 0). A +// SILENCE aura ignores the flags (`Aura::HandleAuraModSilence` tests +// PreventionType only), so the gate applies to the interrupt mask alone. +// - A druid's interrupt lives on the TRIGGERED "Feral Charge Effect" 19675, +// not on Feral Charge 16979 (effect TRIGGER_SPELL). 3.3.5 covered that case +// with an AttributesEx7 bit on 16979; here the capability walk follows one +// TRIGGER_SPELL hop instead. +// +// Evaluation is lazy — at query time, from the caster's live aura slots — rather +// than stamped at cast start and re-evaluated on aura updates like 3.3.5. Same +// answer, no extra hooks; the 3.3.5 UNIT_SPELLCAST_INTERRUPTIBLE / +// _NOT_INTERRUPTIBLE change events are not fired (yet). + +#include "Game.h" +#include "Offsets.h" +#include "aura/Data.h" +#include "player/StatSignal.h" +#include "spell/Interruptible.h" +#include "spell/Lookup.h" + +#include + +namespace Spell::Interruptible { + +namespace { + +// School masks of what the local player can stop a cast with. +struct Capability { + uint32_t interrupt = 0; // schools of the player's INTERRUPT_CAST spells + uint32_t silence = 0; // schools of the player's MOD_SILENCE spells + bool Any() const { return (interrupt | silence) != 0; } +}; + +uint32_t SchoolMask(const uint8_t *rec) { + const uint32_t school = Game::Read(rec, Offsets::OFF_SPELL_RECORD_SCHOOL); + return school < 32 ? (1u << school) : 0; +} + +// ORs `rec`'s school into the capability for each stopping effect it carries. +// `followTrigger` takes one TRIGGER_SPELL hop (Feral Charge — see the header); +// the triggered spell contributes ITS school, since that is the spell that lands. +void Fold(const uint8_t *rec, Capability &cap, bool followTrigger) { + const uint32_t mask = SchoolMask(rec); + auto *effect = Game::Ptr(rec, Offsets::OFF_SPELL_RECORD_EFFECT); + auto *aura = Game::Ptr(rec, Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME); + auto *trigger = Game::Ptr(rec, Offsets::OFF_SPELL_RECORD_EFFECT_TRIGGER_SPELL); + for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) { + if (effect[i] == Offsets::SPELL_EFFECT_INTERRUPT_CAST) { + cap.interrupt |= mask; + } else if (effect[i] == Offsets::SPELL_EFFECT_APPLY_AURA && + aura[i] == Offsets::SPELL_AURA_MOD_SILENCE) { + cap.silence |= mask; + } else if (followTrigger && effect[i] == Offsets::SPELL_EFFECT_TRIGGER_SPELL && + trigger[i] > 0) { + if (const uint8_t *t = Spell::Lookup::RecordForID(trigger[i])) + Fold(t, cap, false); + } + } +} + +// The player's capability, recomputed only when the spellbook may have changed: +// keyed on Player::StatSignal, which Spell::Learn bumps from the engine's learn / +// unlearn writers (the same invalidation the spell-knowledge caches use). +uint32_t g_epoch = 0; +bool g_valid = false; +Capability g_cap; + +const Capability &PlayerCapability() { + const uint32_t epoch = Player::StatSignal::Epoch(); + if (g_valid && epoch == g_epoch) + return g_cap; + Capability cap; + const int count = Game::Read(Offsets::VAR_PLAYER_SPELLBOOK_COUNT); + for (int slot = 1; slot <= count && slot <= Offsets::SPELLBOOK_MAX_SLOTS; ++slot) { + const int id = Spell::Lookup::SpellbookSlotToID(slot, /*player*/ 0); + if (id <= 0) + continue; + if (const uint8_t *rec = Spell::Lookup::RecordForID(id)) + Fold(rec, cap, /*followTrigger*/ true); + } + g_cap = cap; + g_epoch = epoch; + g_valid = true; + return g_cap; +} + +// Strips from `cap` everything `caster`'s auras make it immune to — the 3.3.5 +// aura walk of `FUN_007262e0`, over the raw descriptor slots (hidden passive +// immunities count too, so no visibility gate). Stops once nothing is left. +void StripImmunities(const uint8_t *caster, Capability &cap) { + for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL && cap.Any(); ++slot) { + const uint32_t id = Aura::Data::ReadSpellID(caster, slot); + if (id == 0) + continue; + const uint8_t *rec = Spell::Lookup::RecordForID(static_cast(id)); + if (rec == nullptr) + continue; + auto *aura = Game::Ptr(rec, Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME); + auto *misc = Game::Ptr(rec, Offsets::OFF_SPELL_RECORD_EFFECT_MISC_VALUE); + for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) { + switch (aura[i]) { + case Offsets::SPELL_AURA_EFFECT_IMMUNITY: + if (misc[i] == Offsets::SPELL_EFFECT_INTERRUPT_CAST) + cap.interrupt = 0; + break; + case Offsets::SPELL_AURA_STATE_IMMUNITY: + if (misc[i] == Offsets::SPELL_AURA_MOD_SILENCE) + cap.silence = 0; + break; + case Offsets::SPELL_AURA_SCHOOL_IMMUNITY: + cap.interrupt &= ~static_cast(misc[i]); + cap.silence &= ~static_cast(misc[i]); + break; + case Offsets::SPELL_AURA_MECHANIC_IMMUNITY: + if (misc[i] == Offsets::MECHANIC_INTERRUPT) + cap.interrupt = 0; + else if (misc[i] == Offsets::MECHANIC_SILENCE) + cap.silence = 0; + break; + default: + break; + } + } + } +} + +} // namespace + +bool NotInterruptible(const uint8_t *caster, const uint8_t *spellRecord, bool isChannel) { + if (spellRecord == nullptr) + return false; + // Reader rule first: with no interrupt or silence known, the flag is never + // reported — whatever the cast is. + Capability cap = PlayerCapability(); + if (!cap.Any()) + return false; + // Static: PreventionType is the one gate shared by the server's interrupt + // effect, the server's silence aura and the 3.3.5 client. + if (Game::Read(spellRecord, Offsets::OFF_SPELL_RECORD_PREVENTION_TYPE) != + Offsets::SPELL_PREVENTION_TYPE_SILENCE) + return true; + if (caster != nullptr) + StripImmunities(caster, cap); + // The interrupt EFFECT additionally needs the cast's flag bit (server gate); + // a silence does not. + const uint32_t flags = Game::Read( + spellRecord, isChannel ? Offsets::OFF_SPELL_RECORD_CHANNEL_INTERRUPT_FLAGS + : Offsets::OFF_SPELL_RECORD_INTERRUPT_FLAGS); + const uint32_t needed = isChannel ? Offsets::CHANNEL_FLAG_INTERRUPT + : Offsets::SPELL_INTERRUPT_FLAG_DAMAGE; + const bool interruptWorks = cap.interrupt != 0 && (flags & needed) != 0; + const bool silenceWorks = cap.silence != 0; + return !(interruptWorks || silenceWorks); +} + +} // namespace Spell::Interruptible diff --git a/src/spell/Interruptible.h b/src/spell/Interruptible.h new file mode 100644 index 00000000..248a8447 --- /dev/null +++ b/src/spell/Interruptible.h @@ -0,0 +1,32 @@ +// This file is part of ClassicAPI. +// +// ClassicAPI is free software: you can redistribute it and/or modify it under the terms +// of the GNU General Public License as published by the Free Software Foundation, either +// version 3 of the License, or (at your option) any later version. +// +// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR +// PURPOSE. See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with +// ClassicAPI. If not, see . + +#pragma once + +#include + +// `Spell::Interruptible` — the `notInterruptible` field of `UnitCastingInfo` / +// `UnitChannelInfo`: can nothing the LOCAL PLAYER knows stop this cast? The +// 3.3.5 client's own predicate, run on 1.12 data — see Interruptible.cpp. +namespace Spell::Interruptible { + +// True when no interrupt or silence the local player knows can stop +// `spellRecord`, cast (or channeled, per `isChannel`) by `caster` — a CGUnit +// pointer, or null when the caster's auras aren't readable (the static and +// capability parts still apply). False for a null record, and ALWAYS false +// while the player knows no interrupt or silence at all (the 3.3.5 reader's +// rule: the flag only exists for someone who could act on it). +bool NotInterruptible(const uint8_t *caster, const uint8_t *spellRecord, + bool isChannel); + +} // namespace Spell::Interruptible diff --git a/src/spell/Level.cpp b/src/spell/Level.cpp index 02f1a49c..dfceb450 100644 --- a/src/spell/Level.cpp +++ b/src/spell/Level.cpp @@ -91,7 +91,6 @@ bool SpellHiddenFromSpellbook(int spellID) { // checks are NOT in the 1.12 client's Spell.dbc — server-only columns — so // the spellLevel rule is the only client-readable target-level mechanism.) -constexpr uint32_t SPELL_EFFECT_APPLY_AURA = 6; constexpr uint32_t SPELL_EFFECT_APPLY_AREA_AURA_PARTY = 35; int LocaleIndex() { @@ -154,7 +153,7 @@ int RequiredTargetLevel(const uint8_t *record) { Game::Ptr(record, Offsets::OFF_SPELL_RECORD_EFFECT_IMPLICIT_TARGET_A); bool positiveAura = false; for (int i = 0; i < 3; ++i) { - if ((effect[i] == SPELL_EFFECT_APPLY_AURA && + if ((effect[i] == Offsets::SPELL_EFFECT_APPLY_AURA && IsPositiveRankTarget(targetA[i])) || effect[i] == SPELL_EFFECT_APPLY_AREA_AURA_PARTY) { positiveAura = true;