Compare commits

...
8 Commits
Author SHA1 Message Date
Brues d7103de564 feat(spell): count set bonuses in GetSpellBonusHealing
Set-bonus spells are applied server-side as auras the client never sees
as buffs or known spells, so the derived healing sum missed them —
Lawbringer (3) is "Increase Healing 31" (9317), Judgement (3) is 57
(18037). Add a pass that counts the equipped members of each set from
the item record's set id (OFF_ITEMSTATS_ITEM_SET), reads ItemSet.dbc's
bonus spells and member thresholds, and runs every met bonus through
the same aura decoder — the rule the item tooltip's set-bonus lines use
at 0x52DFB9.

The Ironclad (aura 199) armor conversion stays on
UNIT_FIELD_RESISTANCES[0]: the server's SpellBaseHealingBonusDone uses
GetArmor(), the full total, even though the talent text says "Armor
from items".

Refs #135
2026-11-09 11:28:51 -06:00
Brues c00eb24a8d fix(event): refuse glue's event table and re-validate cached claim slots
CGGameUI::Initialize (0x0048FE00) calls RegisterScriptFunctions — where
our LoadScriptFunctions hook enables claim writes — and only then
SetScriptEventTable(0xBE1198, 0x225), which builds the in-game table.
Between the two the live table at [0xCEEF68] is still glue's 26-entry
one. A third-party DLL that registers a frame from its own
LoadScriptFunctions hook fires our Frame::RegisterEvent (0x00702140)
hook in that window: Grow enlarged the glue table (26 -> 142), the
reservations claimed slots 26..125 in it, the engine freed that buffer
and built the real table, and every cached slot stayed non-negative —
never retried, invisible to IsEventValid, and a Fire would have
signalled whatever engine event sat at that id.

Two guards, both read off the engine's own build:

- GameTableInstalled: the in-game build always passes 0x225 names and
  the other DLLs' SetEventCount clamps only enlarge, so a table smaller
  than EVENT_NAME_TABLE_STATIC_COUNT is glue's. Claim and Grow refuse it.
- Stale: each reservation keeps the SStrDup'd pointer it wrote; when the
  live entry at its slot no longer holds that pointer (or name), the
  table was rebuilt behind us and every slot is forgotten and re-claimed
  before the original RegisterEvent populates any chain.

Fixes #134
2026-11-09 11:05:32 -06:00
Brues b65c9e0319 fix(spell): never auto-unshift out of Spirit of Redemption
3.3.5's and 4.4.2's SpellShapeshiftForm.dbc give Spirit of Redemption
(form 32) flags 0x4100, which include SHAPESHIFT_FORM_FLAG_NO_AUTO_UNSHIFT
(0x100). So the later clients' CGUnit_C::CanAutoUnshift refuses it, and no
cast, interaction or item use cancels it. 1.12's row has flags 0, so with
autoUnshift on, our CanAutoUnshift allowed it. A priest in Spirit of
Redemption who cast a NOT_SHAPESHIFT spell, right-clicked an NPC or object,
used an item or opened the flight map sent CMSG_CANCEL_AURA for it.

The later-client flags that 1.12's rows lack now sit in one table:
CAN_INTERACT on the druid forms and Ghost Wolf (read only while
autoUnshift is on, as before), and NO_AUTO_UNSHIFT on form 32, which
CanAutoUnshift reads unconditionally, as 4.3.4 does. With it, every
autoUnshift site leaves Spirit of Redemption on and keeps 1.12's own
refusal.

The comment on FormCanInteract also records why a locked object still
unshifts. It opens by a spell (Opening 6478 for chests and barrels), and
the server's CheckCast form test refuses that spell on 1.12's spell data,
which keeps SPELL_ATTR_NOT_SHAPESHIFT (3.3.5 dropped it).
2026-11-08 11:29:09 -06:00
Brues de033896b1 feat(spell): let autoUnshift talk and use objects in the druid forms
1.12's SpellShapeshiftForm.dbc gives SHAPESHIFT_FORM_FLAG_CAN_INTERACT
(0x8) only to Shadowform. 3.3.5 and 4.4.2 also give it to Cat, Travel,
Aquatic, Bear, Dire Bear and Ghost Wolf, and Turtle's id 11 (Swift
Travel Form) has it there too. So in the later clients, these forms pass
the NPC right-click gate (CGPlayer_C::HandleOnRightClick, 0x005F0130)
and the game-object use check (0x005F3130) as they are. The form cancel
that comes before the range check almost never runs.

In 1.12, autoUnshift cancelled every druid form at that gate, even when
the NPC was out of interact range. With autoUnshift on, the redirected
IsShapeShifted calls at both gates and at CanInteractNow (0x00606880)
now read a form in that set as able to interact. They report "not
shifted", so the engine's own 0x8 test that follows does not refuse it,
and the cursor shows the NPC's service icon. The cancel stays for a form
that cannot interact in either client.

Neither vmangos nor tortoise-wow tests the form on an NPC or object
interaction. CanInteractWithNPC and the use handlers have no form check;
the only one is item use. So the client gates are the only refusal.
2026-11-07 20:46:48 -06:00
MrbcatandBrues dc8d4caf12 fix(loot): skip corpses that stay lootable after LootAllCorpses (#133)
* fix(loot): skip corpses that stay lootable after LootAllCorpses

A corpse whose remaining loot this player can never take keeps
UNIT_DYNFLAG_LOOTABLE set: quest items for quests they don't have,
condition-gated loot, and the like. The per-corpse re-check still
passes, so a caller that runs C_Loot.LootAllCorpses from a timer
re-opens the same corpse on every walk and re-sends the refused
CMSG_LOOT_ITEM, which never clears.

Loot mode now remembers every corpse it opened and released. Later
walks skip a remembered corpse while it is still visible and
lootable; the entry is dropped once the corpse stops being lootable or
leaves the object manager. A corpse that merely left render range is
dropped too, so it gets one retry when the player comes back.
ScanNearbyLoot stays read-only and keeps reporting the corpse.

1.12 has no per-slot "cannot loot" flag (GetLootSlotInfo has no
`locked` return), so one refused request per corpse is unavoidable;
this only stops it from repeating on every walk.

* docs(loot): describe the LootAllCorpses corpse skip

Explain why a corpse that stays lootable after the walk is skipped by
later walks, and when the skip entry is dropped.

* fix(loot): skip only corpses that open empty in LootAllCorpses

The previous change remembered every corpse the loot walk opened. The
most common reason a corpse stays UNIT_DYNFLAG_LOOTABLE is full bags,
so those corpses were never looted again by the walk, even after the
player freed bag space. Rolls and master-loot assignments in progress
had the same problem.

The server never sends a player a loot slot that player cannot take:
another member's quest item and condition-gated loot are left out of
SMSG_LOOT_RESPONSE, while the corpse stays lootable for as long as any
loot remains. So the stuck corpse opens to an EMPTY window, with no
coin and no populated VAR_LOOT_SLOTS entry. Loot mode now remembers a
corpse only when the scraped window is empty. A window with anything
in it (full bags, a roll, a master-loot slot) is retried.

An entry is forgotten:
- on the first world tick where its unit is gone or no longer
  lootable. This replaces the prune at walk start, which missed a
  corpse that left view and came back, and a creature that respawned
  with the same GUID and died again, between two walks;
- all at once on QUEST_LOG_UPDATE (event 0x134, observed through the
  shared FUN_FIRE_EVENT_NO_ARGS interceptor and never swallowed),
  because quest items are per-player loot and a quest change can put
  an item into a window that was empty.

The memory moves out of the per-walk ScanCtx into its own vector. The
API reference now describes the skip as a contract only.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-11-06 23:25:23 -06:00
Brues 20069f3815 docs(api): sort the reference's sections alphabetically
Seven sections were out of place in the body: ChatBubbles (after Mount),
Bindings (after Focus), APIDocumentation, CVar, MerchantFrame, NamePlate
and PvP. The contents list shared the last four. Both are now in the same
case-insensitive order, with one blank line between contents groups
(DeathInfo and Sound had none). The ClassicAPI namespace section had no
contents entry; it has one now.

The PVP_TIMER_UPDATE entry moves from the PvP section to Events, between
PLAYER_SWING_RANGE_UPDATE and QUEST_ACCEPTED. It now names the PvP timer
and links the two timer functions, and the PvP section links to it.
2026-11-06 17:35:16 -06:00
Brues f2fa112f0a feat(mount): add C_MountJournal.GetMountFromSpell and GetMountInfoExtraByID
1.12.1 has no mount table, so a mount's ID is the ID of the spell that
summons it: GetMountFromSpell(458) returns 458. A spell is a mount when
one of its effects applies SPELL_AURA_MOUNTED (78); Spell.dbc is read
directly, so mounts the player has not learned resolve too. On Turtle
every mount item carries spell 46499 "Add Mount to Collection" instead of
its mount spell (the server maps item to spell from its own table), so
only the mount spell gives a mount ID.

GetMountInfoExtraByID returns the nine retail values. The display ID is
the one the server mounts the player on: the mount aura's misc value is a
creature entry, and the client's creature cache holds that creature's
display (SMSG_CREATURE_QUERY_RESPONSE carries the template's first
display, and every mount creature has exactly one). An uncached creature
returns nil and starts the cache load through the new
Creature::Info::RequestLoad, which C_CreatureInfo.RequestLoadCreatureByID
now uses too; CREATURE_DATA_LOAD_RESULT fires when the data lands.
Verified in game: 23219 (Swift Mistsaber) returns 14332.

Mount type and model scene default to 230 and 4. Seven mounts take
retail's values instead (the six Qiraji battle tanks use scene 44, the
Riding Turtle type 231), each checked by name against Turtle's
Spell.dbc. The seat animation is 91 (Mount), the animation 1.12.1's
world mount plays on the rider.
2026-11-06 17:29:11 -06:00
Brues fca967c827 refactor(spell): test a spell's auras through one HasAuraEffect
Six places asked the same question of a Spell.dbc record: does one of its
three effects apply aura N (EffectApplyAuraName, +0x16C, int32[3])? Each
had its own loop: the combat log's damage-shield lookup, crowd-control
classification and its snare test, tracking-spell detection,
C_SpellBook.GetPlayerSpellsByAura, and the aura-source periodic-damage
gate (which also hard-coded 3 for the effect count).

Spell::Lookup now has FindAuraEffect (the index of the first effect that
applies the aura, or -1, for callers that read that effect's other
columns) and HasAuraEffect on top of it, next to the existing HasEffect.
All six call it. Code that pairs an aura with the same effect's misc
value, amount or effect code (Cancel, Interruptible, BonusDamage,
StatAccum, EffectInfo) keeps its own per-effect reads.
2026-11-06 17:29:02 -06:00
24 changed files with 1516 additions and 1011 deletions
+1 -1
View File
@@ -221,7 +221,7 @@ open the game menu and click AddOn Options, then ClassicAPI.
| [Combat log](docs/API.md#kept-entries) | `combatLogRetentionTime` | `300` | The number of seconds that the combat log keeps an entry. |
| [Stand, dismount and form](docs/API.md#stand-dismount-and-form-settings-autostand-autodismount-autounshift) | `autoStand` | `0` | `1` makes you stand up when you cast, loot or open the flight map while you sit. |
| | `autoDismount` | `0` | `1` makes you dismount when you cast, use an object, loot, attack or open the flight map while you are mounted. |
| | `autoUnshift` | `0` | `1` makes you leave your form when you cast a spell that cannot be cast in it, talk to an NPC, use an object or an item, or open the flight map. |
| | `autoUnshift` | `0` | `1` makes you leave your form when you cast a spell that cannot be cast in it, use an item, or open the flight map. It also lets you talk to NPCs and use objects in druid forms and Ghost Wolf. |
| [Bag sorting](docs/API.md#c_containergetsortbagsrighttoleft--c_containersetsortbagsrighttoleftenable) | `sortBagsRightToLeft` | `0` | `1` makes a sort fill your bags from the last slot of the last bag. |
| | `backpackAutosortDisabled` | `0` | `1` keeps the items in your backpack in place when you sort your bags. |
| | `bankAutosortDisabled` | `0` | `1` keeps the items in the main bank slots in place when you sort your bank. |
+1001 -905
View File
File diff suppressed because it is too large Load Diff
+7 -4
View File
@@ -80,10 +80,13 @@ static void __stdcall LoadGlueScriptFunctions_h() {
}
// Every Lua-side `frame:RegisterEvent(...)` is a chance to claim a slot
// for any custom event still waiting. By this point the engine's table
// is fully populated and SuperWoWhook / other DLLs have done their
// post-rebuild writes, so the table state is settled and our walk from the
// low end finds genuinely free slots.
// for any custom event still waiting. By the first FrameXML registration
// the engine's table is fully populated and SuperWoWhook / other DLLs have
// done their post-rebuild writes, so the walk from the low end finds
// genuinely free slots. A registration that arrives EARLIER (another DLL's
// frame, between LoadScriptFunctions and the table build — issue #134)
// still sees glue's 26-entry table; `RetryClaims` refuses that table and
// re-validates its cached slots against the live one on every call.
static void __fastcall FrameRegisterEvent_h(void *frame, void *edx,
const char *eventName) {
Event::Custom::RetryClaims();
+2 -1
View File
@@ -12071,7 +12071,8 @@ enum Offsets {
// column 11: the parsed 1.12 DBC gives 0x7 for the warrior stances, 0x1
// Stealth, 0x9 Shadowform, matching 3.3.5's column. 0x100 is the form
// flag 3.3.5 / 4.3.4 `CGUnit_C::CanAutoUnshift` refuses on (set there
// only on Spirit of Redemption and the Ghoul forms); no 1.12 row has it.
// only on Spirit of Redemption and the Ghoul forms); no 1.12 row has it,
// so Spell::AutoPrepare adds it to Spirit of Redemption (form 32).
VAR_SPELL_SHAPESHIFT_FORM_RECORDS = 0x00C0D774,
VAR_SPELL_SHAPESHIFT_FORM_COUNT = 0x00C0D778,
OFF_SPELL_SHAPESHIFT_FORM_FLAGS = 0x2C,
+2 -9
View File
@@ -1023,16 +1023,9 @@ CompressState *FindOrClaimCompressState(uint64_t targetGuid,
// would compress it, this gate excludes it exactly as the server's does.
// (Drain Soul passes via its effect-2 periodic damage: aura=[86,3,0].)
bool HasPeriodicDamageOrLeech(const uint8_t *rec) {
if (rec == nullptr)
return false;
constexpr int32_t kPeriodicDamage = 3, kPeriodicLeech = 53;
for (int i = 0; i < 3; ++i) {
const int32_t aura = *reinterpret_cast<const int32_t *>(
rec + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME + i * 4);
if (aura == kPeriodicDamage || aura == kPeriodicLeech)
return true;
}
return false;
return Spell::Lookup::HasAuraEffect(rec, kPeriodicDamage) ||
Spell::Lookup::HasAuraEffect(rec, kPeriodicLeech);
}
// Compression pairs by CASTER, mirroring the server (each warlock's Dark
-11
View File
@@ -618,17 +618,6 @@ uint32_t SpellSchoolMask(const uint8_t *spell) {
: 0;
}
bool HasAuraEffect(const uint8_t *spell, int auraName) {
if (spell == nullptr)
return false;
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
if (Game::Read<int>(spell, Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME + i * 4) ==
auraName)
return true;
}
return false;
}
Entry Begin(uint64_t source, uint64_t dest, int event, const uint8_t *spell) {
Entry e;
e.event = event;
-4
View File
@@ -167,10 +167,6 @@ uint32_t SchoolMask(uint32_t school);
// The spell's school as a mask (4.3.4's SpellRec m_schoolMask), 0 for nullptr.
uint32_t SpellSchoolMask(const uint8_t *spell);
// Whether one of the spell's three effects applies the aura `auraName`
// (EffectApplyAuraName).
bool HasAuraEffect(const uint8_t *spell, int auraName);
// GetCombatLogEntry: an entry for `event` from `source` to `dest` (either may
// be 0), with the spell prefix when `spell` is a record.
Entry Begin(uint64_t source, uint64_t dest, int event, const uint8_t *spell);
+2 -1
View File
@@ -55,6 +55,7 @@
#include "aura/Data.h"
#include "net/PacketDispatch.h"
#include "object/Resolve.h"
#include "spell/Lookup.h"
#include "time/Clock.h"
#include <cstdint>
@@ -501,7 +502,7 @@ const uint8_t *ShieldSpell(uint64_t owner, uint32_t school) {
const uint8_t *spell = SpellRecord(static_cast<int>(spellId));
if (spell == nullptr ||
Game::Read<uint32_t>(spell, Offsets::OFF_SPELL_RECORD_SCHOOL) != school ||
!HasAuraEffect(spell, AURA_DAMAGE_SHIELD))
!Spell::Lookup::HasAuraEffect(spell, AURA_DAMAGE_SHIELD))
continue;
if (found != nullptr && found != spell)
return nullptr;
+6 -4
View File
@@ -131,10 +131,7 @@ int __fastcall Script_RequestLoadCreatureByID(void *L) {
Game::Lua::PushBool(L, false);
return 1;
}
auto *cache = reinterpret_cast<void *>(Offsets::VAR_CREATURE_CACHE);
const bool accepted =
Cache::QueryLoad::RequestLoad(cache, static_cast<uint32_t>(creatureID));
Game::Lua::PushBool(L, accepted);
Game::Lua::PushBool(L, RequestLoad(static_cast<uint32_t>(creatureID)));
return 1;
}
@@ -381,4 +378,9 @@ const uint8_t *CacheRecord(uint32_t creatureID) {
return creatureID != 0 ? PeekCreature(creatureID) : nullptr;
}
bool RequestLoad(uint32_t creatureID) {
return Cache::QueryLoad::RequestLoad(reinterpret_cast<void *>(Offsets::VAR_CREATURE_CACHE),
creatureID);
}
} // namespace Creature::Info
+6
View File
@@ -24,6 +24,12 @@ namespace Creature::Info {
// engine's SetDisplay takes, and `PlayerModel:SetCreature` passes it there.
const uint8_t *CacheRecord(uint32_t creatureID);
// Starts loading `creatureID` into the creature cache; fires
// `CREATURE_DATA_LOAD_RESULT(creatureID, success)` when it lands (at once
// when already cached). False only when the load could not be tracked.
// Backs `C_CreatureInfo.RequestLoadCreatureByID`.
bool RequestLoad(uint32_t creatureID);
// The faction group a player race belongs to, resolved the way
// `Script_UnitFactionGroup` does it for a player whose object isn't in the
// world (its name-cache branch, which reads the race): ChrRaces -> FactionTemplate
@@ -17,7 +17,7 @@ CLASSICAPI_AUTO_DISMOUNT_TOOLTIP = "Dismount when you cast a spell, use an objec
CLASSICAPI_AUTO_STAND = "Auto Stand";
CLASSICAPI_AUTO_STAND_TOOLTIP = "Stand up when you cast a spell, loot, or open the flight map while you sit.";
CLASSICAPI_AUTO_UNSHIFT = "Auto Cancel Form";
CLASSICAPI_AUTO_UNSHIFT_TOOLTIP = "Leave your form when you cast a spell that you cannot cast in it, talk to an NPC, use an object or an item, or open the flight map.";
CLASSICAPI_AUTO_UNSHIFT_TOOLTIP = "Leave your form when you cast a spell that you cannot cast in it, use an item, or open the flight map. Also lets you talk to NPCs and use objects in druid forms and Ghost Wolf.";
CLASSICAPI_BAGS_AND_ITEMS = "Bags and Items";
CLASSICAPI_CASTING_AND_ATTACKING = "Casting and Attacking";
CLASSICAPI_COMBAT_LOG_RETENTION = "Combat Log Retention";
@@ -19,7 +19,7 @@ CLASSICAPI_AUTO_DISMOUNT_TOOLTIP = "Спешиваться при примене
CLASSICAPI_AUTO_STAND = "Автоматически вставать";
CLASSICAPI_AUTO_STAND_TOOLTIP = "Вставать при применении заклинания, сборе добычи или открытии карты полетов, если вы сидите.";
CLASSICAPI_AUTO_UNSHIFT = "Автоматически снимать облик";
CLASSICAPI_AUTO_UNSHIFT_TOOLTIP = "Покидать свой облик при применении заклинания, которое в нем недоступно, разговоре с NPC, использовании объекта или предмета, а также при открытии карты полетов.";
CLASSICAPI_AUTO_UNSHIFT_TOOLTIP = "Покидать свой облик при применении заклинания, которое в нем недоступно, использовании предмета, а также при открытии карты полетов. Также позволяет говорить с NPC и использовать объекты в обликах друида и призрачного волка.";
CLASSICAPI_BAGS_AND_ITEMS = "Сумки и предметы";
CLASSICAPI_CASTING_AND_ATTACKING = "Заклинания и атаки";
CLASSICAPI_COMBAT_LOG_RETENTION = "Хранение журнала боя";
@@ -19,7 +19,7 @@ CLASSICAPI_AUTO_DISMOUNT_TOOLTIP = "骑乘状态下施放法术、使用物体
CLASSICAPI_AUTO_STAND = "自动站起";
CLASSICAPI_AUTO_STAND_TOOLTIP = "坐下状态下施放法术、拾取或打开飞行地图时自动站起。";
CLASSICAPI_AUTO_UNSHIFT = "自动取消形态";
CLASSICAPI_AUTO_UNSHIFT_TOOLTIP = "施放当前形态无法施放的法术、与NPC对话、使用物体或物品、或打开飞行地图时自动取消该形态。";
CLASSICAPI_AUTO_UNSHIFT_TOOLTIP = "施放当前形态无法施放的法术、使用物品或打开飞行地图时自动取消该形态。同时允许在德鲁伊形态和幽魂之狼形态下与NPC对话和使用物体。";
CLASSICAPI_BAGS_AND_ITEMS = "背包与物品";
CLASSICAPI_CASTING_AND_ATTACKING = "施法与攻击";
CLASSICAPI_COMBAT_LOG_RETENTION = "战斗记录保留时间";
+81 -19
View File
@@ -55,6 +55,10 @@ struct Reservation {
int slot; // -1 until claimed
const Game::Doc::Event *doc; // API documentation; nullptr = undocumented
int fixedId; // an `EngineName`'s engine id (its slot for good); -1 = claimed
// The SStrDup'd name `Claim` wrote into `slot`'s entry; nullptr while
// unclaimed. `RetryClaims` compares it against the live entry to notice
// that the engine rebuilt the table underneath a cached slot.
const char *claimed;
};
Reservation g_reserved[MAX_RESERVED];
int g_reservedCount = 0;
@@ -63,7 +67,7 @@ bool g_writesEnabled = false;
// claim) is logged once per session instead of silently dropping the
// event — the failure mode that would otherwise rot unnoticed. Re-armed on
// `/reload` (the table is rebuilt fresh). In practice this never triggers:
// the engine leaves ~115 claimable gaps below 549 (see `TryClaim`) against
// the engine leaves ~115 claimable gaps below 549 (see `Claim`) against
// our ~77 reservations, and `Grow` adds room past that. The log is
// purely a backstop if some future install somehow drains the gap pool.
bool g_warnedTableFull = false;
@@ -147,23 +151,39 @@ int ClaimDemand() {
// tail. Below 549 only nampower writes, at 369 and 540, both before any Lua
// runs, so skip-occupied steps over them. Claiming low keeps us out of
// everyone's way and independent of their clamp / hook order.
int TryClaim(const char *eventName) {
//
// Claims only into the IN-GAME table. `CGGameUI::Initialize` (0x0048FE00)
// calls `RegisterScriptFunctions` (our `EnableWrites` hook) and only THEN
// `SetScriptEventTable(0xBE1198, 0x225)`, so between the two the live table
// is still glue's 26-entry one. A `Frame::RegisterEvent` in that window
// (a third-party DLL registering a frame from its own LoadScriptFunctions
// hook — issue #134) used to grow the glue table and claim into it; the
// engine then freed that buffer and our cached ids silently aliased engine
// events in the real table. The in-game build always passes 0x225 names
// (clamps by other DLLs only enlarge it), so a smaller count is not it.
bool GameTableInstalled(int count) {
return count >= Offsets::EVENT_NAME_TABLE_STATIC_COUNT;
}
void Claim(Reservation &r) {
if (!g_writesEnabled)
return -1;
return;
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
const int count = Game::Read<int>(Offsets::VAR_EVENT_TABLE_COUNT);
if (base == nullptr || count <= 0)
return -1;
if (base == nullptr || !GameTableInstalled(count))
return;
for (int i = 0; i < count; ++i) {
if (Claimable(base, i)) {
auto **namePtr = Game::Ptr<const char *>(
base + i * Offsets::EVENT_ENTRY_STRIDE,
Offsets::OFF_EVENT_ENTRY_NAME);
char *copy = SStrDup(eventName);
char *copy = SStrDup(r.name);
if (copy == nullptr)
return -1;
return;
*namePtr = copy;
return i;
r.slot = i;
r.claimed = copy;
return;
}
}
// Writes are enabled and the table is valid, but every slot is taken —
@@ -173,9 +193,39 @@ int TryClaim(const char *eventName) {
Debug::Log::Printf(
"[event] table full (%d slots, no NULL left) — cannot claim '%s'"
" and any further custom events; even Grow couldn't make room.",
count, eventName ? eventName : "?");
count, r.name);
}
return -1;
}
// True when a claimed reservation's slot no longer holds its name: the
// engine rebuilt the table (every entry re-`SStrDup`'d at a fresh buffer)
// without our `PrepareForReload` seeing it. The pointer test is the cheap
// common case; the strcmp covers an allocator handing the engine our freed
// block for a different name.
bool Stale(const Reservation &r) {
if (r.claimed == nullptr)
return false;
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
const int count = Game::Read<int>(Offsets::VAR_EVENT_TABLE_COUNT);
if (base == nullptr || r.slot >= count)
return true;
const char *live = Game::Read<const char *>(
base + r.slot * Offsets::EVENT_ENTRY_STRIDE, Offsets::OFF_EVENT_ENTRY_NAME);
if (live == r.claimed)
return false;
return live == nullptr || std::strcmp(live, r.name) != 0;
}
// Forget every claimed slot: the table they were claimed in is gone. The
// engine freed our name copies with its per-entry teardown, so nothing to
// release here. Re-arms the per-build one-shots.
void ForgetClaims() {
for (int i = 0; i < g_reservedCount; ++i) {
g_reserved[i].slot = g_reserved[i].fixedId;
g_reserved[i].claimed = nullptr;
}
g_warnedTableFull = false;
g_growLatched = false;
}
// On-demand event-table grow. Correctly TIMED: called once per in-game
@@ -205,12 +255,14 @@ int TryClaim(const char *eventName) {
void Grow() {
if (g_growLatched || !g_writesEnabled)
return;
g_growLatched = true; // one attempt per build, whether or not it grows
const int count = Game::Read<int>(Offsets::VAR_EVENT_TABLE_COUNT);
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
if (base == nullptr || !GameTableInstalled(count))
return; // still glue's table — the in-game build hasn't run yet
g_growLatched = true; // one attempt per build, whether or not it grows
const int demand = ClaimDemand();
if (base == nullptr || count <= 0 || demand <= 0)
if (demand <= 0)
return;
int nulls = 0;
@@ -320,6 +372,7 @@ AutoReserve::AutoReserve(const char *name, const Game::Doc::Event *doc) {
g_reserved[g_reservedCount].slot = -1;
g_reserved[g_reservedCount].doc = doc;
g_reserved[g_reservedCount].fixedId = -1;
g_reserved[g_reservedCount].claimed = nullptr;
index_ = g_reservedCount;
++g_reservedCount;
}
@@ -339,7 +392,7 @@ EngineName::EngineName(int id, const char *name, const Game::Doc::Event *doc) {
if (*slot != nullptr)
return; // the engine names it; never take an engine event's slot
*slot = name;
g_reserved[g_reservedCount] = {name, id, doc, id};
g_reserved[g_reservedCount] = {name, id, doc, id, nullptr};
++g_reservedCount;
}
@@ -428,6 +481,18 @@ void FireValues(int slot, void *L, int firstArgIdx, int nargs) {
}
void RetryClaims() {
// A table rebuilt behind our back (no FrameScript_Initialize in between)
// leaves every cached slot pointing at whatever engine event now sits at
// that id. Check the claims against the live table and start over if any
// moved — we run before the original registers, so no frame has joined a
// chain in the new table yet.
for (int i = 0; i < g_reservedCount; ++i) {
if (Stale(g_reserved[i])) {
Debug::Log::Printf("[event] table rebuilt under cached slots — re-claiming");
ForgetClaims();
break;
}
}
// Grow the table first if the low gap pool is short (dormant valve).
// Runs at most once per table build; on the first call every chain is
// still empty (this is the first registration), which is the only safe
@@ -435,18 +500,15 @@ void RetryClaims() {
Grow();
for (int i = 0; i < g_reservedCount; ++i) {
if (g_reserved[i].slot < 0)
g_reserved[i].slot = TryClaim(g_reserved[i].name);
Claim(g_reserved[i]);
}
}
void EnableWrites() { g_writesEnabled = true; }
void PrepareForReload() {
for (int i = 0; i < g_reservedCount; ++i)
g_reserved[i].slot = g_reserved[i].fixedId;
ForgetClaims();
g_writesEnabled = false;
g_warnedTableFull = false; // fresh table after the rebuild — re-arm
g_growLatched = false; // re-arm the one-shot grow for the new table
}
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
+1 -1
View File
@@ -201,7 +201,7 @@ inline void FireIdSuccess(int eventID, int id, bool success) {
// catch any reservations that couldn't claim earlier.
void RetryClaims();
// Internal: permit `TryClaim` to write to the event table. Held
// Internal: permit `Claim` to write to the event table. Held
// closed until `LoadScriptFunctions_h` returns, so the boot phase
// (during which the engine and SuperWoWhook fire many internal
// `RegisterEvent` calls) can't trigger our writes. Writing during
+61 -4
View File
@@ -34,6 +34,15 @@
// state machine — we no longer need `WaitingOpen` / `WaitingClose` /
// per-tick `VAR_LOOT_GUID` reads, because the controller hook *is* the
// state transition.
//
// Loot mode remembers corpses whose window opened EMPTY but which stay
// LOOTABLE: the server keeps the flag while any loot is left (another
// member's quest item, condition-gated loot) yet never sends this player a
// slot it can't take, so an empty window is the server's own "nothing here
// for you". Later walks skip such a corpse. A corpse whose window held
// anything (bags full, a roll or master-loot assignment in progress) is not
// remembered and is retried, because those refusals clear on their own.
// See `s_emptyCorpses` for when an entry is forgotten.
#include "../Game.h"
#include "../Offsets.h"
@@ -45,6 +54,7 @@
#include "../time/Clock.h"
#include "Request.h"
#include <algorithm>
#include <cstdint>
#include <string>
#include <vector>
@@ -68,6 +78,8 @@ constexpr uint32_t kStepTimeoutMs = 3000;
// fire unmodified.
constexpr int EVENT_LOOT_OPENED = 0x10B;
constexpr int EVENT_LOOT_CLOSED = 0x10D;
// Observed, never swallowed (see `s_emptyCorpses`).
constexpr int EVENT_QUEST_LOG_UPDATE = 0x134;
enum class State {
Idle,
@@ -100,6 +112,21 @@ struct ScanCtx {
ScanCtx s_scan;
// Loot-mode corpses whose window opened empty while the unit stayed
// LOOTABLE (see the file comment). Outlives a walk. An entry is forgotten:
// - on the first world tick where its unit is gone (despawned, out of
// view) or no longer LOOTABLE (alive again after a respawn
// with the same GUID, or emptied by someone else), so a corpse back in
// view or a fresh corpse is opened again;
// - all at once on QUEST_LOG_UPDATE: quest items are per-player loot, so
// a quest change can turn an empty window into one with an item in it.
std::vector<uint64_t> s_emptyCorpses;
bool IsKnownEmpty(uint64_t guid) {
return std::find(s_emptyCorpses.begin(), s_emptyCorpses.end(), guid) !=
s_emptyCorpses.end();
}
// === Typedefs for engine helpers ===
using ClntObjMgrEnumVisibleObjectsCallback_t = int(__fastcall *)(void *ctx,
@@ -130,6 +157,19 @@ using LootController_t = void(__fastcall *)(int lootStructPtr,
int coin,
int unk);
// Run every world tick, so no gone-and-back or respawn-and-die-again
// can happen between two checks.
void PruneEmptyCorpses() {
s_emptyCorpses.erase(
std::remove_if(s_emptyCorpses.begin(), s_emptyCorpses.end(),
[](uint64_t guid) {
void *unit = Object::ByGuid(Offsets::TYPEMASK_UNIT,
guid, nullptr, 0);
return unit == nullptr || !Request::IsLootable(unit);
}),
s_emptyCorpses.end());
}
// === Scrape ===
// Reads the post-population `VAR_LOOT_SLOTS` table into a LootEntry.
@@ -325,6 +365,10 @@ void __fastcall LootController_h(int lootStructPtr, int coin, int unk) {
LootEntry entry;
entry.guid = s_scan.currentGuid;
ScrapeCurrentLoot(&entry);
// Each corpse is queued once per walk and known-empty ones are not
// queued in loot mode, so this never adds a duplicate.
if (s_scan.lootMode && entry.coin == 0 && entry.items.empty())
s_emptyCorpses.push_back(entry.guid);
s_scan.results.push_back(std::move(entry));
// Loot mode: actually take everything (coin + all slots) before the
@@ -355,9 +399,19 @@ const Game::HookAutoRegister _hookController{
const Event::SignalHook::AutoSubscribe _lootIntercept{&SuppressLootEvents};
// === Tick (refusals and timeouts) ===
// Observes only: the event still fires.
bool ForgetEmptyCorpsesOnQuestChange(int eventID) {
if (eventID == EVENT_QUEST_LOG_UPDATE)
s_emptyCorpses.clear();
return false;
}
const Event::SignalHook::AutoSubscribe _questIntercept{&ForgetEmptyCorpsesOnQuestChange};
// === Tick (memory upkeep, refusals and timeouts) ===
void OnTick() {
PruneEmptyCorpses();
if (s_scan.state == State::Idle)
return;
const bool timedOut = Time::Clock::Elapsed(s_scan.stepStartMs) >= kStepTimeoutMs;
@@ -396,6 +450,7 @@ const Tick::WorldTick::AutoSubscribe _tick{&OnTick};
struct EnumCtx {
void *player;
std::vector<uint64_t> *out;
bool skipKnownEmpty;
};
int __fastcall EnumCallback(EnumCtx *ctx, void * /*unusedEdx*/, uint64_t guid) {
@@ -404,17 +459,19 @@ int __fastcall EnumCallback(EnumCtx *ctx, void * /*unusedEdx*/, uint64_t guid) {
return 1;
if (!Request::IsLootable(obj))
return 1;
if (ctx->skipKnownEmpty && IsKnownEmpty(guid))
return 1;
if (!Request::InReach(ctx->player, obj))
return 1;
ctx->out->push_back(guid);
return 1;
}
void BuildQueue(void *player, size_t maxCount) {
void BuildQueue(void *player, size_t maxCount, bool skipKnownEmpty) {
s_scan.queue.clear();
auto Enum = reinterpret_cast<ClntObjMgrEnumVisibleObjects_t>(
Offsets::FUN_CLNT_OBJ_MGR_ENUM_VISIBLE_OBJECTS);
EnumCtx ctx{player, &s_scan.queue};
EnumCtx ctx{player, &s_scan.queue, skipKnownEmpty};
Enum(reinterpret_cast<ClntObjMgrEnumVisibleObjectsCallback_t>(&EnumCallback),
&ctx);
// Cap to `maxCount` corpses (0 = unlimited). Order is engine
@@ -440,7 +497,7 @@ bool BeginWalk(bool lootMode, size_t maxCount) {
s_scan.results.clear();
s_scan.player = player;
s_scan.lootMode = lootMode;
BuildQueue(player, maxCount);
BuildQueue(player, maxCount, /*skipKnownEmpty=*/lootMode);
if (s_scan.queue.empty()) {
Complete(); // nothing nearby — fire LOOT_SCAN_COMPLETED immediately
+195
View File
@@ -0,0 +1,195 @@
// 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 <https://www.gnu.org/licenses/>.
// `C_MountJournal.GetMountFromSpell(spellID)` -> mountID | nil
// `C_MountJournal.GetMountInfoExtraByID(mountID)` -> creatureDisplayInfoID,
// description, source, isSelfMount, mountTypeID, uiModelSceneID, animID,
// spellVisualKitID, disablePlayerMountPreview
//
// Retail's mount ID indexes its Mount table. 1.12 has no such table, so here
// a mount's ID IS its spell ID: GetMountFromSpell(458) returns 458. That
// keeps every mount the client knows addressable (Turtle's own mounts are
// in no retail table), and retail's numbering means nothing to a 1.12 addon.
//
// A spell is a mount when one of its effects applies SPELL_AURA_MOUNTED
// (78). Spell.dbc is read directly, so a mount not yet learned resolves too.
// On Turtle a mount ITEM does not carry its mount spell: every one carries
// 46499 "Add Mount to Collection", which teaches a spell from a server-only
// item table. So only the mount spell (spellbook, spell link) resolves; 46499
// is not a mount. See docs/notes/model-scene.md.
//
// creatureDisplayInfoID is the display the server mounts the caster on: the
// aura's misc value is a creature entry, and the server takes a display from
// that creature's template (tortoise-wow Aura::HandleAuraMounted ->
// Creature::ChooseDisplayId). The client's creature cache holds the
// template's first display (SMSG_CREATURE_QUERY_RESPONSE sends display_id[0])
// and every mount creature has exactly one, so the cached display is the
// mounted one (23219 Swift Mistsaber -> creature 14555 -> 14332). An
// uncached creature returns nil and starts the cache load;
// CREATURE_DATA_LOAD_RESULT fires when it lands, as for
// C_CreatureInfo.RequestLoadCreatureByID.
//
// The rest follows the retail Mount table where a 1.12 mount spell has a
// row (kRetailRows), else retail's common values: mount type 230 (ground),
// model scene 4, seat animation 91 (Mount — what a retail probe returned for
// every mount, and what 1.12's world mount seats the rider with), no visual
// kit. 1.12 has no self-mounts and no shop source text. The description is
// the spell's own, tokens filled in.
#include "Game.h"
#include "Offsets.h"
#include "creature/Info.h"
#include "spell/Arg.h"
#include "spell/Description.h"
#include "spell/Lookup.h"
#include <cstdint>
namespace Mount::Journal {
namespace {
// Retail Mount rows (12.1.7.70740 export) for 1.12 mount spells whose scene
// or mount type differs from the defaults below. Only rows whose spell has
// the same name in Turtle's Spell.dbc: retail's 3363 (Nether-Swept Drake) is
// Turtle's "Summon Riding Gryphon", so it is left out. The other 92 of the
// 100 matching spells are scene 4 / type 230.
struct RetailRow {
uint32_t spellID;
uint32_t mountTypeID;
uint32_t uiModelSceneID;
};
constexpr RetailRow kRetailRows[] = {
{25863, 230, 44}, // Black Qiraji Battle Tank
{25953, 241, 44}, // Blue Qiraji Battle Tank
{26054, 241, 44}, // Red Qiraji Battle Tank
{26055, 241, 44}, // Yellow Qiraji Battle Tank
{26056, 241, 44}, // Green Qiraji Battle Tank
{26655, 230, 44}, // Black Qiraji Battle Tank
{30174, 231, 4}, // Riding Turtle
};
constexpr uint32_t kDefaultMountTypeID = 230;
constexpr uint32_t kDefaultUiModelSceneID = 4;
constexpr uint32_t kSeatAnimation = 91; // Mount
// The mount spell's record for `mountID`, or null when it is not a mount.
const uint8_t *MountRecord(int mountID) {
const uint8_t *record = Spell::Lookup::RecordForID(mountID);
return Spell::Lookup::HasAuraEffect(record, Offsets::SPELL_AURA_MOUNTED) ? record : nullptr;
}
// The display the mount aura's creature is cached with, 0 when the creature
// is not cached yet (and then its load is started).
uint32_t MountDisplayID(const uint8_t *record) {
const int effect = Spell::Lookup::FindAuraEffect(record, Offsets::SPELL_AURA_MOUNTED);
const int creatureID =
Game::Read<int>(record, Offsets::OFF_SPELL_RECORD_EFFECT_MISC_VALUE + effect * 4);
if (creatureID <= 0)
return 0;
const uint8_t *creature = Creature::Info::CacheRecord(static_cast<uint32_t>(creatureID));
if (creature == nullptr) {
Creature::Info::RequestLoad(static_cast<uint32_t>(creatureID));
return 0;
}
return Game::Read<uint32_t>(creature, Offsets::OFF_CREATURE_DISPLAYID);
}
int __fastcall Script_GetMountFromSpell(void *L) {
const int spellID = Spell::Arg::ResolveSpellID(L, 1);
if (MountRecord(spellID) == nullptr)
return 0;
Game::Lua::PushNumber(L, static_cast<double>(spellID));
return 1;
}
int __fastcall Script_GetMountInfoExtraByID(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0;
const int mountID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const uint8_t *record = MountRecord(mountID);
if (record == nullptr)
return 0;
uint32_t mountTypeID = kDefaultMountTypeID;
uint32_t uiModelSceneID = kDefaultUiModelSceneID;
for (const RetailRow &row : kRetailRows) {
if (row.spellID == static_cast<uint32_t>(mountID)) {
mountTypeID = row.mountTypeID;
uiModelSceneID = row.uiModelSceneID;
break;
}
}
if (const uint32_t displayID = MountDisplayID(record))
Game::Lua::PushNumber(L, static_cast<double>(displayID));
else
Game::Lua::PushNil(L);
char description[1024];
Spell::Description::Expand(record, description, sizeof description);
Game::Lua::PushString(L, description);
Game::Lua::PushString(L, ""); // source
Game::Lua::PushBool(L, false); // isSelfMount
Game::Lua::PushNumber(L, static_cast<double>(mountTypeID));
Game::Lua::PushNumber(L, static_cast<double>(uiModelSceneID));
Game::Lua::PushNumber(L, static_cast<double>(kSeatAnimation));
Game::Lua::PushNumber(L, 0); // spellVisualKitID
Game::Lua::PushBool(L, false); // disablePlayerMountPreview
return 9;
}
// --- Documentation ----------------------------------------------------------
const Game::Doc::Field kGetMountFromSpellArgs[] = {
Game::Doc::Req("spellID", "SpellIdentifier", "A spell ID, spell link, or spell name."),
};
const Game::Doc::Field kGetMountFromSpellRets[] = {
Game::Doc::Opt("mountID", "number", nullptr,
"The mount's ID, which is its spell ID. Nil when the spell is not a mount."),
};
const Game::Doc::Function kGetMountFromSpell{
"The mount a spell summons. A mount's ID is the ID of the spell that summons it.",
kGetMountFromSpellArgs, kGetMountFromSpellRets};
const Game::Doc::Field kGetMountInfoExtraByIDArgs[] = {
Game::Doc::Req("mountID", "number", "A mount ID, which is the mount's spell ID."),
};
const Game::Doc::Field kGetMountInfoExtraByIDRets[] = {
Game::Doc::Opt("creatureDisplayInfoID", "number", nullptr,
"The display the player is mounted on. Nil until the mount's creature "
"is cached; the call starts loading it, and CREATURE_DATA_LOAD_RESULT "
"fires when it arrives."),
Game::Doc::Req("description", "cstring", "The mount spell's description."),
Game::Doc::Req("source", "cstring", "Always empty."),
Game::Doc::Req("isSelfMount", "bool", "Always false."),
Game::Doc::Req("mountTypeID", "number", "The mount type; 230 for most mounts."),
Game::Doc::Req("uiModelSceneID", "number", "The model scene that shows the mount."),
Game::Doc::Req("animID", "number", "The animation the rider plays while mounted."),
Game::Doc::Req("spellVisualKitID", "number", "Always 0."),
Game::Doc::Req("disablePlayerMountPreview", "bool", "Always false."),
};
const Game::Doc::Function kGetMountInfoExtraByID{
"Display and preview details of a mount. Returns nothing when the ID is not a mount.",
kGetMountInfoExtraByIDArgs, kGetMountInfoExtraByIDRets};
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_MountJournal", "GetMountFromSpell",
&Script_GetMountFromSpell, &kGetMountFromSpell);
Game::Lua::RegisterTableFunction("C_MountJournal", "GetMountInfoExtraByID",
&Script_GetMountInfoExtraByID, &kGetMountInfoExtraByID);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Mount::Journal
+65 -9
View File
@@ -40,6 +40,13 @@
// cancel / stand before the cast packet that follows it.
//
// autoUnshift and autoDismount also cover interaction, as in 4.3.4:
// - the interact flag: the later clients' SpellShapeshiftForm.dbc gives
// SHAPESHIFT_FORM_FLAG_CAN_INTERACT to the druid forms and Ghost Wolf,
// so 4.3.4 talks and uses objects in them without leaving the form.
// 1.12's rows lack it; with autoUnshift on, the gates read it as the
// later clients give it, so the cancels below are only for a form that
// cannot interact in either client. Spirit of Redemption likewise
// reads the later clients' NO_AUTO_UNSHIFT, so it is never cancelled.
// - NPC right-click: 4.3.4's CGPlayer_C::HandleOnRightClick cancels a form
// without SHAPESHIFT_FORM_FLAG_CAN_INTERACT in place of
// ERR_CANT_INTERACT_SHAPESHIFTED, before its range check, so we do the
@@ -200,10 +207,43 @@ bool CanAutoDismount(const void *unit) {
return IsActivePlayer(unit) && !OnTaxi(unit) && Enabled(g_autoDismount);
}
// 4.3.4 CGUnit_C::CanAutoUnshift.
// SpellShapeshiftForm flags that 3.3.5 and 4.4.2 give (both DBCs parsed)
// but 1.12's rows lack, for the forms a 1.12 player can take:
// - CAN_INTERACT on Cat (1), Travel (3), Aquatic (4), Bear (5), Dire Bear
// (8), Ghost Wolf (16), and 11, which Turtle uses for Swift Travel Form;
// - NO_AUTO_UNSHIFT on Spirit of Redemption (32, 0x4100 there, 0 in 1.12).
// Every other 1.12 player form either has the same flags or has flag bit 0
// (Moonkin, Tree of Life, the stances, Stealth), so IsShapeShifted does not
// count it and no gate or form rung tests it.
struct LaterFormFlags {
int form;
uint32_t flags;
};
constexpr LaterFormFlags kLaterFormFlags[] = {
{1, Offsets::SHAPESHIFT_FORM_FLAG_CAN_INTERACT},
{3, Offsets::SHAPESHIFT_FORM_FLAG_CAN_INTERACT},
{4, Offsets::SHAPESHIFT_FORM_FLAG_CAN_INTERACT},
{5, Offsets::SHAPESHIFT_FORM_FLAG_CAN_INTERACT},
{8, Offsets::SHAPESHIFT_FORM_FLAG_CAN_INTERACT},
{11, Offsets::SHAPESHIFT_FORM_FLAG_CAN_INTERACT},
{16, Offsets::SHAPESHIFT_FORM_FLAG_CAN_INTERACT},
{32, Offsets::SHAPESHIFT_FORM_FLAG_NO_AUTO_UNSHIFT},
};
uint32_t AddedFormFlags(const void *unit) {
const int form = Form(unit);
for (const LaterFormFlags &entry : kLaterFormFlags) {
if (entry.form == form)
return entry.flags;
}
return 0;
}
// 4.3.4 CGUnit_C::CanAutoUnshift, on the later clients' NO_AUTO_UNSHIFT.
bool CanAutoUnshift(const void *unit) {
return IsActivePlayer(unit) && !OnTaxi(unit) && Enabled(g_autoUnshift) &&
(FormFlags(unit) & Offsets::SHAPESHIFT_FORM_FLAG_NO_AUTO_UNSHIFT) == 0;
((FormFlags(unit) | AddedFormFlags(unit)) &
Offsets::SHAPESHIFT_FORM_FLAG_NO_AUTO_UNSHIFT) == 0;
}
int StandState(const void *unit) {
@@ -238,15 +278,31 @@ char IsShapeShifted(const void *unit) {
return reinterpret_cast<IsShapeShifted_t>(Offsets::FUN_UNIT_IS_SHAPESHIFTED)(unit);
}
// The gates' test after IsShapeShifted: a form with no row, or one
// without the interact flag.
bool FormBlocksInteraction(const void *unit) {
return (FormFlags(unit) & Offsets::SHAPESHIFT_FORM_FLAG_CAN_INTERACT) == 0;
// The gates' test after IsShapeShifted: the form row's interact flag. With
// autoUnshift on, a form reads the flag that the later clients give it, so
// the form stays on for the forms that can interact there. No 1.12 server
// tests the form on an NPC or a plain object use (vmangos / tortoise-wow
// CanInteractWithNPC, the use handlers, and no form spell's AuraInterrupt-
// Flags has the use bit 0x800), so the client gates are the only refusal.
// A locked object opens by a spell instead, which the server's CheckCast
// form test refuses on 1.12's spell data (Opening 6478 keeps NOT_SHAPESHIFT,
// which 3.3.5 dropped), so the cast's form rung must still unshift there.
bool FormCanInteract(const void *unit) {
if ((FormFlags(unit) & Offsets::SHAPESHIFT_FORM_FLAG_CAN_INTERACT) != 0)
return true;
return Enabled(g_autoUnshift) &&
(AddedFormFlags(unit) & Offsets::SHAPESHIFT_FORM_FLAG_CAN_INTERACT) != 0;
}
// True when the gate would refuse and autoUnshift takes the form off instead.
bool UnshiftsForInteraction(const void *player) {
return IsShapeShifted(player) && FormBlocksInteraction(player) && CanAutoUnshift(player);
return IsShapeShifted(player) && !FormCanInteract(player) && CanAutoUnshift(player);
}
// The gates' IsShapeShifted: "not shifted" when the form can interact, so
// the engine's own flag test that follows does not refuse it.
char ShiftedForGate(const void *player) {
return IsShapeShifted(player) && !FormCanInteract(player);
}
// The NPC right-click's IsShapeShifted: leaves the form and reports "not
@@ -256,14 +312,14 @@ char __fastcall IsShapeShiftedForTalk(const void *player) {
CancelForm(player);
return 0;
}
return IsShapeShifted(player);
return ShiftedForGate(player);
}
// The game-object use check's and CanInteractNow's IsShapeShifted (4.3.4's
// CanUseNow / CanInteractNow): the same verdict, nothing done. Both are
// hover-cursor queries, so the cursor shows the service while shifted.
char __fastcall IsShapeShiftedForQuery(const void *player) {
return UnshiftsForInteraction(player) ? 0 : IsShapeShifted(player);
return UnshiftsForInteraction(player) ? 0 : ShiftedForGate(player);
}
// The mount reads of the gates 4.3.4 passes on CanAutoDismount (an object
+49 -2
View File
@@ -53,8 +53,12 @@
// +15%/rank SpellMod), so an 18-point gap leaked into healing when that pure
// +damage buff was gained. Reading aura 175 directly (above) sidesteps it.
//
// 3. Set bonuses — the bonus spells of every equipped set whose member
// count is met (ItemSet.dbc, the same rule the tooltip's set-bonus
// lines use), decoded through the same aura walk.
//
// Residual: assumes talent ranks supersede (only the learned rank is known —
// otherwise summed) and misses set-bonus healing granted via a set spell.
// otherwise summed) and ignores a set's profession-skill requirement.
#include "Game.h"
#include "Offsets.h"
@@ -185,11 +189,52 @@ void AddSpellHealingAuras(long &total, int spellID, int32_t spirit,
}
}
// Set bonuses the equipped gear has earned. The engine's own set-bonus lines
// (the item tooltip, 0x52DFB9..) count how many of the set's members the
// player has equipped and light a bonus whose member count (ItemSet.dbc
// `+0x8C`) is met; the server applies the same bonus spells as auras. Those
// auras are neither buffs nor known spells, so this is the only pass that
// sees them — Lawbringer (3) is "Increase Healing 31", Judgement (3) is 57.
// A set member's record names its set (`OFF_ITEMSTATS_ITEM_SET`), so the
// equipped count per set is the equipped members' count.
constexpr int kEquipSlots = 19;
void AddSetBonusHealing(long &total, int32_t spirit, int32_t armor) {
struct Worn { uint32_t setID; int count; };
Worn worn[kEquipSlots];
int sets = 0;
for (int slot = 1; slot <= kEquipSlots; ++slot) {
const int itemID = Item::ID::FromCGItem(Item::Location::ResolveEquipmentSlot(slot));
const uint8_t *record = itemID > 0 ? Item::PeekRecord(static_cast<uint32_t>(itemID)) : nullptr;
const uint32_t setID = record ? Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_ITEM_SET) : 0;
if (setID == 0)
continue;
int s = 0;
while (s < sets && worn[s].setID != setID)
++s;
if (s == sets)
worn[sets++] = {setID, 0};
++worn[s].count;
}
for (int s = 0; s < sets; ++s) {
const uint8_t *set = DBC::Record(Offsets::VAR_ITEMSET_RECORDS, Offsets::VAR_ITEMSET_COUNT,
worn[s].setID);
if (set == nullptr)
continue;
auto spells = Game::Ptr<const uint32_t>(set, Offsets::OFF_ITEMSET_SPELL_IDS);
auto needed = Game::Ptr<const uint32_t>(set, Offsets::OFF_ITEMSET_THRESHOLDS);
for (int i = 0; i < Offsets::ITEMSET_MAX_BONUSES; ++i)
if (spells[i] != 0 && needed[i] <= static_cast<uint32_t>(worn[s].count))
AddSpellHealingAuras(total, static_cast<int>(spells[i]), spirit, armor,
/*requirePassive=*/false);
}
}
long ComputeHealing() {
long total = 0;
// Items — flat +healing via the shared StatAccum engine.
for (int slot = 1; slot <= 19; ++slot)
for (int slot = 1; slot <= kEquipSlots; ++slot)
total += ItemFlatHealing(Item::Location::ResolveEquipmentSlot(slot));
// Player Spirit / Armor for the aura-175 / aura-199 conversions.
@@ -199,6 +244,8 @@ long ComputeHealing() {
const int32_t armor = desc ? Game::Read<int32_t>(
desc, Offsets::OFF_UNIT_FIELD_RESISTANCE_ARMOR) : 0;
AddSetBonusHealing(total, spirit, armor);
// Active buffs — flat healing + Spirit/Armor conversions, one pass.
auto *table = reinterpret_cast<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_BUFF_TABLE));
+2 -14
View File
@@ -21,14 +21,7 @@ namespace Spell::CrowdControl {
const char *Classify(const uint8_t *rec) {
if (rec == nullptr)
return nullptr;
const int32_t *aura = reinterpret_cast<const int32_t *>(
rec + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
auto has = [&](int type) {
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i)
if (aura[i] == type)
return true;
return false;
};
auto has = [rec](int type) { return Spell::Lookup::HasAuraEffect(rec, type); };
if (has(Offsets::SPELL_AURA_MOD_POSSESS)) return "POSSESS";
if (has(Offsets::SPELL_AURA_MOD_CHARM)) return "CHARM";
if (has(Offsets::SPELL_AURA_MOD_STUN)) return "STUN";
@@ -50,12 +43,7 @@ bool IsCrowdControl(uint32_t spellID) {
// slows / snares, which `Classify` (the LoC set) deliberately excludes.
if (Classify(rec) != nullptr)
return true;
const int32_t *aura = reinterpret_cast<const int32_t *>(
rec + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i)
if (aura[i] == Offsets::SPELL_AURA_MOD_DECREASE_SPEED)
return true;
return false;
return Spell::Lookup::HasAuraEffect(rec, Offsets::SPELL_AURA_MOD_DECREASE_SPEED);
}
} // namespace Spell::CrowdControl
+5 -9
View File
@@ -534,15 +534,11 @@ static int __fastcall Script_GetPlayerSpellsByAura(void *L) {
int n = 0;
ForEachKnownSpell([&](int spellID, const uint8_t *record) {
auto *auras = reinterpret_cast<const int32_t *>(
record + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
for (int e = 0; e < Offsets::SPELL_RECORD_EFFECT_COUNT; ++e) {
if (auras[e] == auraName) {
Game::Lua::PushNumber(L, static_cast<double>(++n));
Game::Lua::PushNumber(L, static_cast<double>(spellID));
Game::Lua::RawSet(L, -3);
break; // one entry per spell, however many effects match
}
// One entry per spell, however many effects match.
if (Spell::Lookup::HasAuraEffect(record, auraName)) {
Game::Lua::PushNumber(L, static_cast<double>(++n));
Game::Lua::PushNumber(L, static_cast<double>(spellID));
Game::Lua::RawSet(L, -3);
}
return true;
});
+15
View File
@@ -62,6 +62,21 @@ bool HasEffect(const uint8_t *spellRecord, int effect) {
return false;
}
int FindAuraEffect(const uint8_t *spellRecord, int aura) {
if (spellRecord == nullptr)
return -1;
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
if (Game::Read<int>(spellRecord, Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME + i * 4) ==
aura)
return i;
}
return -1;
}
bool HasAuraEffect(const uint8_t *spellRecord, int aura) {
return FindAuraEffect(spellRecord, aura) >= 0;
}
int SpellbookSlotToID(int slot1Based, int bookType) {
const int slot = slot1Based - 1;
if (slot < 0 || slot >= Offsets::SPELLBOOK_MAX_SLOTS)
+9
View File
@@ -35,6 +35,15 @@ bool IsFitToFamily(const uint8_t *spellRecord, uint32_t family,
// SpellHasEffect. False for a null record.
bool HasEffect(const uint8_t *spellRecord, int effect);
// The index (0..2) of the first of the spell's three effects that applies
// the aura `aura` (EffectApplyAuraName), or -1 when none does or the record
// is null. For reading that effect's other columns (its misc value, ...).
int FindAuraEffect(const uint8_t *spellRecord, int aura);
// Whether one of the spell's three effects applies the aura `aura` —
// 4.3.4's SpellHasAuraEffect. False for a null record.
bool HasAuraEffect(const uint8_t *spellRecord, int aura);
// Resolves a 1-based Lua-facing spellbook slot to the spellID stored
// there. `bookType` is `0` for the player spellbook, `1` for the pet
// spellbook (matches the engine's encoding from `Script_GetSpellName`).
+3 -10
View File
@@ -46,16 +46,9 @@ static constexpr int32_t AURA_TRACK_STEALTHED = 151;
// True iff `spellID`'s record has any of the three Track* effects.
static bool IsTrackingSpell(int spellID) {
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
if (record == nullptr)
return false;
auto *aura = Game::Ptr<const int32_t>(
record, Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
if (aura[i] == AURA_TRACK_CREATURES || aura[i] == AURA_TRACK_RESOURCES ||
aura[i] == AURA_TRACK_STEALTHED)
return true;
}
return false;
return Spell::Lookup::HasAuraEffect(record, AURA_TRACK_CREATURES) ||
Spell::Lookup::HasAuraEffect(record, AURA_TRACK_RESOURCES) ||
Spell::Lookup::HasAuraEffect(record, AURA_TRACK_STEALTHED);
}
// Walks the player spellbook in slot order counting tracking spells. If