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
This commit is contained in:
Brues
2026-11-09 11:05:32 -06:00
parent b65c9e0319
commit c00eb24a8d
3 changed files with 89 additions and 24 deletions
+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