Compare commits

..
2 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
5 changed files with 142 additions and 27 deletions
+4 -1
View File
@@ -21617,8 +21617,11 @@ GetSpellBonusHealing() -- your +Healing
> Turtle customs) and exact — e.g. Spiritual Guidance 5/5 with 182 Spirit
> adds `int(182 × 25 / 100) = 45`.
>
> Set bonuses count: a bonus whose piece count your equipped gear meets
> (for example Lawbringer's 3-piece +31 Healing) is included.
>
> Residual: assumes talent ranks supersede (only the learned rank is known)
> and misses set-bonus healing granted via a set-completion spell.
> and ignores a set's profession-skill requirement.
### `GetCritChance()` / `GetRangedCritChance()`
+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();
+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
+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));