Compare commits

...
2 Commits
Author SHA1 Message Date
Brues a0b7d03c28 C_GossipInfo: session gating + quest-greeting support
Vanilla has two disjoint questgiver paths. Gossip NPCs send
SMSG_GOSSIP_MESSAGE into the gossip arrays this namespace reads; but
quest-only NPCs send SMSG_QUESTGIVER_QUEST_LIST (the QUEST_GREETING
event / QuestFrameGreetingPanel), whose data lands in entirely
separate storage C_GossipInfo never read — every getter returned
empty for those NPCs. GetText was also ungated: the gossip text
buffer persists after close, so it served the previous gossip NPC's
greeting.

- Gate all gossip reads on the live gossip NPC GUID (0x00BC3F58,
  cleared by the close worker at 0x004E1FA0 before GOSSIP_CLOSED
  fires). Stale text/options/quests are no longer visible, and
  selectors can't send a CMSG from stale slots.
- Serve the quest-greeting session when one is live instead: the
  questgiver GUID (0x00BE0810) must match the greeting-fill GUID
  (0x00C4D740) so leftovers can't leak when a quest session starts
  from a gossip-menu click. GetText returns the greeting text;
  Get{Available,Active}Quests / GetNum* read the greeting arrays
  (32 x 0x4C entries {questID, level, title}) with isComplete
  recovered from the packet handler's scratch status array
  (status 4 = turn-in-ready); Select{Available,Active}Quest route to
  the engine's greeting selectors (0x005012A0 / 0x00501320).
- Neither session live: empty tables, 0 counts, empty text.

GetOptions/GetNumOptions stay gossip-only — greetings have no
options. Retail keeps the two APIs disjoint; merging is deliberate so
modern-addon ports work on greeting-path NPCs (common on 1.12
servers).
2026-07-04 14:27:06 -05:00
Brues 15cae8a474 Add QUEST_REMOVED event; refactor quest log hook
Introduce QUEST_REMOVED (fires with questID) and document it in API.md and README. Rename Quest::Accepted → Quest::LogEvents and update the quest-log rebuild hook to compute pre/post deltas for both QUEST_ACCEPTED (questLogIndex, questID) and QUEST_REMOVED (questID). Add suppression rules to avoid firing on login/resync bulk changes. Update Offsets.h commentary to reflect the new LogEvents module and its responsibilities.
2026-07-04 12:04:27 -05:00
5 changed files with 331 additions and 31 deletions
+1
View File
@@ -131,6 +131,7 @@ when launching with `-console`), not as Lua functions. See the
| `PLAYER_STOPPED_TURNING` | *(none)* |
| `QUEST_ACCEPTED` | `questLogIndex, questID` |
| `QUEST_DATA_LOAD_RESULT` | `questID, success` |
| `QUEST_REMOVED` | `questID` |
| `QUEST_TURNED_IN` | `questID, xpReward, moneyReward` |
| `UPDATE_SHAPESHIFT_FORM` | *(none)* |
| `VOICE_CHAT_TTS_PLAYBACK_STARTED` | `numConsumers, utteranceID, durationMS, destination` |
+36
View File
@@ -102,6 +102,7 @@ build instructions.
- [`NAME_PLATE_CREATED` / `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` events](#name_plate_created--name_plate_unit_added--name_plate_unit_removed-events)
- [`PLAYER_FOCUS_CHANGED` event](#player_focus_changed-event)
- [`QUEST_ACCEPTED` event](#quest_accepted-event)
- [`QUEST_REMOVED` event](#quest_removed-event)
- [`QUEST_TURNED_IN` event](#quest_turned_in-event)
- [`UNIT_FACTION` event (fire-coverage fix)](#unit_faction-event-fire-coverage-fix)
- [`UPDATE_SHAPESHIFT_FORM` event](#update_shapeshift_form-event)
@@ -2387,6 +2388,41 @@ quests within the same engine tick, so single-add is always a real
user accept. A brand-new character's very first quest accept
(`0 → 1` entries) fires correctly.
### `QUEST_REMOVED` event
Fires once per quest leaving the local quest log, with the questID as
the single payload arg. Covers **both turn-ins and abandons** — the
two are distinguishable by whether a `QUEST_TURNED_IN` accompanies
the removal. Polyfills modern WoW's event of the same name (added in
8.0.1; the Classic-era signature is the bare questID, which is what
we match).
```lua
local f = CreateFrame("Frame")
f:RegisterEvent("QUEST_REMOVED")
f:SetScript("OnEvent", function()
if event == "QUEST_REMOVED" then
local questID = arg1
-- ...
end
end)
```
Synthesized from the same `FUN_QUEST_LOG_REBUILD` pre-/post-snapshot
diff as `QUEST_ACCEPTED` — the removal side of the delta. For
turn-ins, `QUEST_REMOVED` fires **after** `QUEST_TURNED_IN`, matching
retail ordering: the SMSG_QUESTGIVER_QUEST_COMPLETE packet doesn't
touch the log itself; the removal arrives in the follow-up quest-log
update packets, whose rebuild triggers the diff. The observed turn-in
sequence is `QUEST_TURNED_IN` → `UNIT_QUEST_LOG_CHANGED` →
`QUEST_LOG_UPDATE` (fired inside the rebuild) → `QUEST_REMOVED`.
**Does not fire on login / character-switch resyncs.** Same
suppression rule as `QUEST_ACCEPTED` (a user action removes at most
one quest per rebuild), plus one extra gate: a rebuild that removes
one quest while adding several is a cross-character resync shape, not
gameplay, and stays silent.
### `QUEST_TURNED_IN` event
Fires when the server confirms a quest turn-in
+78 -4
View File
@@ -2440,10 +2440,10 @@ enum Offsets {
// walks the player's slot data, repopulates `VAR_QUEST_LOG_ENTRIES`
// / `VAR_QUEST_LOG_ENTRY_COUNT`, then fires QUEST_LOG_UPDATE.
//
// Hooked by `Quest::Accepted` to snapshot the log pre-/post-rebuild
// and fire `QUEST_ACCEPTED(logIndex, questID)` for the diff. Single
// quiet target in the `0x004DExxx` quest region — no known DLL
// collisions.
// Hooked by `Quest::LogEvents` to snapshot the log pre-/post-rebuild
// and fire `QUEST_ACCEPTED(logIndex, questID)` / `QUEST_REMOVED(questID)`
// for the diff. Single quiet target in the `0x004DExxx` quest region —
// no known DLL collisions.
FUN_QUEST_LOG_REBUILD = 0x004DE510,
// Player and pet spellbooks — flat int32 arrays indexed by 0-based slot.
@@ -4359,6 +4359,80 @@ enum Offsets {
FUN_GOSSIP_SELECT_ACTIVE_QUEST = 0x004E2600,
FUN_SCRIPT_CLOSE_GOSSIP = 0x004E2B20,
// Current gossip NPC GUID (u64). Set by the SMSG_GOSSIP_MESSAGE
// handler, cleared by the gossip-close worker at 0x004E1FA0 (which
// then fires GOSSIP_CLOSED, event 0x12B). Non-zero = a gossip
// session is live — the gate that keeps `C_GossipInfo` from serving
// the previous NPC's stale arrays/text after close.
VAR_GOSSIP_NPC_GUID = 0x00BC3F58,
// --- Quest-greeting state (SMSG_QUESTGIVER_QUEST_LIST) -------------
// Vanilla's SECOND questgiver path: NPCs without a gossip menu send
// SMSG_QUESTGIVER_QUEST_LIST, whose handler (FUN_005dbb10) fills a
// storage entirely separate from the gossip arrays and fires
// QUEST_GREETING (event 300 = 0x12C) — the QuestFrameGreetingPanel
// ("Current Quests" / "Available Quests"). Vanilla Lua reads it via
// GetGreetingText / Get{Available,Active}Title / etc.; C_GossipInfo
// merges it in so modern-addon ports work on greeting NPCs too.
//
// Shared quest-session state, written by the panel-begin helper
// FUN_00500bd0(panelType, textID, guidLo, guidHi, ...):
// VAR_QUESTGIVER_GUID — u64, the current questgiver. Cleared by
// the session-close worker FUN_00501130 (fires QUEST_FINISHED,
// event 0x130). Non-zero = quest session live.
// VAR_QUEST_PANEL_TYPE — i32 panel: 0 greeting, 1 detail,
// 2 progress, 3 reward. begin(0) also zeroes both greeting
// arrays + counts + the text buffers.
VAR_QUESTGIVER_GUID = 0x00BE0810,
VAR_QUEST_PANEL_TYPE = 0x00BE0818,
// GUID the greeting arrays were filled for — written ONLY by the
// SMSG_QUESTGIVER_QUEST_LIST handler. The greeting arrays are valid
// iff this equals VAR_QUESTGIVER_GUID (and that is non-zero);
// otherwise they're leftovers from an older NPC (e.g. the current
// quest session came from a gossip-menu quest click, which begins
// at panel 1 without ever clearing the greeting arrays).
VAR_GREETING_NPC_GUID = 0x00C4D740,
// Greeting text (0x200 inline, $N/$C-expanded) — what vanilla's
// Script_GetGreetingText pushes.
VAR_QUEST_GREETING_TEXT = 0x00BDE068,
// The two greeting quest arrays: 32 entries, stride 0x4C =
// { u32 questID @ +0, i32 level @ +4, char title[0x40] @ +8,
// u32 extra @ +0x48 (available: status==0 flag; active: always
// 0 — the appender FUN_00500e90 never stores the status, see
// the scratch arrays below for isComplete) }.
// Appenders: FUN_00500e40 (available), FUN_00500e90 (active).
// Readers: Script_Get{Available,Active}{Title,Level} @ 0x00501AC0/
// 0x00501B30/0x00501BA0/0x00501C20.
VAR_GREETING_AVAILABLE_ENTRIES = 0x00BDFE60,
VAR_GREETING_ACTIVE_ENTRIES = 0x00BDE690,
GREETING_QUESTS_STRIDE = 0x4C,
GREETING_QUESTS_MAX = 32,
OFF_GREETING_QUEST_ID = 0x000,
OFF_GREETING_QUEST_LEVEL = 0x004,
OFF_GREETING_QUEST_TITLE = 0x008,
VAR_GREETING_AVAILABLE_COUNT = 0x00BE0834,
VAR_GREETING_ACTIVE_COUNT = 0x00BE0838,
// Scratch arrays the SMSG_QUESTGIVER_QUEST_LIST handler fills in
// packet order (32 × u32 each, zeroed per packet): questIDs and the
// per-quest dialog status. Status 3|4 = active (same sentinel as
// the gossip quest rows), 4 = complete/turn-in-ready. The canonical
// greeting arrays drop the status, so `isComplete` for an active
// greeting quest is recovered by questID lookup here.
VAR_GREETING_SCRATCH_IDS = 0x00C4D2B0,
VAR_GREETING_SCRATCH_STATUS = 0x00C4D340,
// Engine greeting-quest selectors — the workers vanilla's
// Script_Select{Available,Active}Quest call: `__fastcall(int
// idx0Based)`, index straight into the corresponding greeting
// array. They validate VAR_QUESTGIVER_GUID internally and send
// CMSG_QUESTGIVER_{QUERY_QUEST,COMPLETE_QUEST}.
FUN_GREETING_SELECT_AVAILABLE_QUEST = 0x005012A0,
FUN_GREETING_SELECT_ACTIVE_QUEST = 0x00501320,
// CDataStore primitive readers — `__thiscall void(this, void *out)`.
// Read at the current cursor (`+0x14`) and advance it by the value
// size. We call these directly from `Unit::MirrorTimer` to peek
+159 -11
View File
@@ -22,6 +22,24 @@
// SMSG_GOSSIP_MESSAGE handler at `0x004E26E0` and cleared each open.
// We read the same shape the engine's own `Script_GetGossip*` reads.
//
// Vanilla has a SECOND questgiver path this namespace also covers:
// NPCs without a gossip menu send SMSG_QUESTGIVER_QUEST_LIST instead —
// the QUEST_GREETING event and the QuestFrameGreetingPanel ("Current
// Quests" / "Available Quests") — whose data lands in a storage
// entirely separate from the gossip arrays (see the VAR_GREETING_*
// block in Offsets.h). Retail keeps the two APIs disjoint, but for
// backporting it's far more useful for `C_GossipInfo` to serve
// whichever questgiver session is actually live:
// - gossip session live (VAR_GOSSIP_NPC_GUID != 0) → gossip arrays
// - greeting session live (VAR_QUESTGIVER_GUID != 0
// and == VAR_GREETING_NPC_GUID) → greeting arrays
// - neither → empty / ""
// The GUID gates double as staleness protection: both storages persist
// after their frames close (the engine never clears them until the
// next open), so ungated reads would report the previous NPC.
// `GetOptions`/`GetNumOptions` stay gossip-only — greetings have no
// options.
//
// Missing modern fields — vanilla 1.12's server simply never sends
// them, so there's nothing to surface:
// - `rewards`, `spellID` (post-vanilla; merged in with the
@@ -46,6 +64,57 @@ namespace {
using EngineScriptFn = int(__fastcall *)(void *L);
uint64_t ReadU64(uintptr_t va) {
return *reinterpret_cast<const uint64_t *>(va);
}
// A gossip session is live from SMSG_GOSSIP_MESSAGE until the close
// worker (0x004E1FA0) zeroes the GUID and fires GOSSIP_CLOSED.
bool GossipSessionActive() {
return ReadU64(Offsets::VAR_GOSSIP_NPC_GUID) != 0;
}
// A quest-greeting session is live while the quest-session GUID is set
// AND the greeting arrays were filled for that same NPC. The second
// check matters because a gossip-menu quest click opens a quest session
// at panel 1 without ever touching the greeting arrays — without it,
// leftovers from an older greeting NPC would leak through.
bool GreetingSessionActive() {
const uint64_t giver = ReadU64(Offsets::VAR_QUESTGIVER_GUID);
return giver != 0 && giver == ReadU64(Offsets::VAR_GREETING_NPC_GUID);
}
const uint8_t *GreetingEntry(bool active, int slot) {
const uintptr_t base = active ? Offsets::VAR_GREETING_ACTIVE_ENTRIES
: Offsets::VAR_GREETING_AVAILABLE_ENTRIES;
return reinterpret_cast<const uint8_t *>(
base + static_cast<uintptr_t>(slot) * Offsets::GREETING_QUESTS_STRIDE);
}
int GreetingCount(bool active) {
const int count = *reinterpret_cast<const int *>(
active ? Offsets::VAR_GREETING_ACTIVE_COUNT
: Offsets::VAR_GREETING_AVAILABLE_COUNT);
return (count < 0) ? 0
: (count > Offsets::GREETING_QUESTS_MAX) ? Offsets::GREETING_QUESTS_MAX
: count;
}
// The greeting arrays drop the per-quest dialog status, but the packet
// handler's scratch arrays (packet order, zeroed per packet) keep it —
// status 4 = complete/turn-in-ready, same sentinel as the gossip rows.
bool GreetingQuestIsComplete(uint32_t questID) {
const auto *ids = reinterpret_cast<const uint32_t *>(
Offsets::VAR_GREETING_SCRATCH_IDS);
const auto *status = reinterpret_cast<const uint32_t *>(
Offsets::VAR_GREETING_SCRATCH_STATUS);
for (int i = 0; i < Offsets::GREETING_QUESTS_MAX; ++i) {
if (ids[i] == questID)
return status[i] == 4;
}
return false;
}
const uint8_t *OptionEntry(int slot) {
return reinterpret_cast<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_GOSSIP_OPTIONS) +
@@ -79,14 +148,21 @@ bool IsActiveQuest(uint32_t status) {
return status == 3 || status == 4;
}
// `C_GossipInfo.GetText()` — pushes the greeting string the engine
// resolved for the gossip-giver's NPC_TEXT.dbc entry. Mirrors what
// `Script_GetGossipText` does (one inline `lua_pushstring` of the
// fixed buffer at `VAR_GOSSIP_GREETING_TEXT`).
// `C_GossipInfo.GetText()` — the greeting string of whichever
// questgiver session is live: the gossip buffer (what
// `Script_GetGossipText` pushes) for a gossip session, the quest
// greeting buffer (what `Script_GetGreetingText` pushes) for a
// greeting session, "" when neither is open. Both buffers persist
// after close, so the gates keep stale text from the previous NPC
// out.
int __fastcall Script_C_GossipInfo_GetText(void *L) {
const char *text = reinterpret_cast<const char *>(
static_cast<uintptr_t>(Offsets::VAR_GOSSIP_GREETING_TEXT));
Game::Lua::PushString(L, text);
uintptr_t buf = 0;
if (GossipSessionActive())
buf = Offsets::VAR_GOSSIP_GREETING_TEXT;
else if (GreetingSessionActive())
buf = Offsets::VAR_QUEST_GREETING_TEXT;
Game::Lua::PushString(
L, (buf != 0) ? reinterpret_cast<const char *>(buf) : "");
return 1;
}
@@ -108,6 +184,8 @@ int __fastcall Script_C_GossipInfo_GetText(void *L) {
int __fastcall Script_C_GossipInfo_GetOptions(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
if (!GossipSessionActive())
return 1; // greeting sessions have no options; stale data stays hidden
int outIdx = 0;
for (int slot = 0; slot < Offsets::GOSSIP_OPTIONS_MAX; ++slot) {
@@ -138,6 +216,36 @@ int __fastcall Script_C_GossipInfo_GetOptions(void *L) {
return 1;
}
// Greeting-session variant: walks the pre-split greeting array for the
// wanted side. Same table shape as the gossip rows (questID / title /
// questLevel / isComplete) so consumers can't tell the sources apart.
int PushGreetingQuestList(void *L, bool wantActive) {
const int count = GreetingCount(wantActive);
for (int slot = 0; slot < count; ++slot) {
const uint8_t *entry = GreetingEntry(wantActive, slot);
const uint32_t questID = *reinterpret_cast<const uint32_t *>(
entry + Offsets::OFF_GREETING_QUEST_ID);
Game::Lua::PushNumber(L, static_cast<double>(slot + 1));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "questID",
static_cast<double>(questID));
Game::Lua::SetFieldString(L, "title", reinterpret_cast<const char *>(
entry + Offsets::OFF_GREETING_QUEST_TITLE));
const int32_t level = *reinterpret_cast<const int32_t *>(
entry + Offsets::OFF_GREETING_QUEST_LEVEL);
Game::Lua::SetFieldNumber(L, "questLevel",
static_cast<double>(level));
if (wantActive)
Game::Lua::SetFieldBool(L, "isComplete",
GreetingQuestIsComplete(questID));
Game::Lua::SetTable(L, -3);
}
return 1;
}
// Shared body for `GetAvailableQuests` / `GetActiveQuests`. Both walk
// the same backing array and split by the `status` field — `wantActive`
// inverts the filter.
@@ -145,6 +253,12 @@ int PushQuestList(void *L, bool wantActive) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
if (!GossipSessionActive()) {
if (GreetingSessionActive())
return PushGreetingQuestList(L, wantActive);
return 1; // no questgiver session — empty table, never stale data
}
int outIdx = 0;
for (int slot = 0; slot < Offsets::GOSSIP_QUESTS_MAX; ++slot) {
const uint8_t *entry = QuestEntry(slot);
@@ -189,15 +303,19 @@ int __fastcall Script_C_GossipInfo_GetActiveQuests(void *L) {
int __fastcall Script_C_GossipInfo_GetNumOptions(void *L) {
int count = 0;
for (int slot = 0; slot < Offsets::GOSSIP_OPTIONS_MAX; ++slot) {
if (OptionIndex(OptionEntry(slot)) >= 0)
count += 1;
if (GossipSessionActive()) {
for (int slot = 0; slot < Offsets::GOSSIP_OPTIONS_MAX; ++slot) {
if (OptionIndex(OptionEntry(slot)) >= 0)
count += 1;
}
}
Game::Lua::PushNumber(L, static_cast<double>(count));
return 1;
}
int CountQuests(bool wantActive) {
if (!GossipSessionActive())
return GreetingSessionActive() ? GreetingCount(wantActive) : 0;
int count = 0;
for (int slot = 0; slot < Offsets::GOSSIP_QUESTS_MAX; ++slot) {
const uint8_t *entry = QuestEntry(slot);
@@ -247,6 +365,17 @@ void EngineSelectActiveQuest(int idx0Based) {
fn(idx0Based);
}
// Greeting-session selectors — the workers vanilla's
// Script_Select{Available,Active}Quest call. Index straight into the
// corresponding greeting array (they validate the questgiver GUID
// internally before sending the CMSG).
void EngineSelectGreetingQuest(bool wantActive, int idx0Based) {
auto fn = reinterpret_cast<SelectQuest_t>(
wantActive ? Offsets::FUN_GREETING_SELECT_ACTIVE_QUEST
: Offsets::FUN_GREETING_SELECT_AVAILABLE_QUEST);
fn(idx0Based);
}
// `C_GossipInfo.SelectOption(gossipOptionID[, text])` — wraps vanilla's
// `SelectGossipOption(slotIndex)`. `text` is the boxCoded password,
// passed through to the engine helper which gates on the
@@ -260,6 +389,8 @@ int __fastcall Script_C_GossipInfo_SelectOption(void *L) {
const int target = static_cast<int>(Game::Lua::ToNumber(L, 1));
const char *password =
Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : nullptr;
if (!GossipSessionActive())
return 0; // stale option slots must not send a CMSG
for (int slot = 0; slot < Offsets::GOSSIP_OPTIONS_MAX; ++slot) {
const uint8_t *entry = OptionEntry(slot);
@@ -284,7 +415,8 @@ int __fastcall Script_C_GossipInfo_SelectOptionByIndex(void *L) {
return 0;
}
const int slot0Based = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
if (slot0Based < 0 || slot0Based >= Offsets::GOSSIP_OPTIONS_MAX)
if (slot0Based < 0 || slot0Based >= Offsets::GOSSIP_OPTIONS_MAX ||
!GossipSessionActive())
return 0;
EngineSelectOption(slot0Based, nullptr);
return 0;
@@ -301,6 +433,22 @@ int SelectQuestByID(void *L, bool wantActive, const char *errUsage) {
}
const uint32_t target = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
if (!GossipSessionActive()) {
if (!GreetingSessionActive())
return 0;
const int count = GreetingCount(wantActive);
for (int slot = 0; slot < count; ++slot) {
const uint32_t questID = *reinterpret_cast<const uint32_t *>(
GreetingEntry(wantActive, slot) +
Offsets::OFF_GREETING_QUEST_ID);
if (questID == target) {
EngineSelectGreetingQuest(wantActive, slot);
return 0;
}
}
return 0;
}
int idx = 0;
for (int slot = 0; slot < Offsets::GOSSIP_QUESTS_MAX; ++slot) {
const uint8_t *entry = QuestEntry(slot);
@@ -11,18 +11,29 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `QUEST_ACCEPTED(questLogIndex, questID)` event — fires once per
// new quest entering the local quest log. Polyfills modern WoW's
// event of the same name (added in 3.1.0). Matches the Cata/WotLK
// signature: `(questLogIndex, questID)`, 1-based log index.
// Quest log delta events — polyfills two modern WoW events from one
// pre-/post-rebuild diff:
//
// `QUEST_ACCEPTED(questLogIndex, questID)` — fires once per new quest
// entering the local quest log. Matches the Cata/WotLK signature,
// 1-based log index (event added in 3.1.0).
//
// `QUEST_REMOVED(questID)` — fires once per quest leaving the local
// quest log, for both turn-ins and abandons (event added in 8.0.1;
// Classic-era signature is the bare questID). For turn-ins this fires
// *after* `QUEST_TURNED_IN`, matching retail ordering: the 0x191
// complete packet doesn't touch the log; the removal arrives in the
// follow-up quest-log update packets, whose rebuild we diff here.
// Observed turn-in sequence: QUEST_TURNED_IN → UNIT_QUEST_LOG_CHANGED
// → QUEST_LOG_UPDATE (fired inside the rebuild) → QUEST_REMOVED.
//
// Hook target: `FUN_QUEST_LOG_REBUILD` (`0x004DE510`) — the single
// chokepoint the engine uses to refresh the Lua-visible quest log
// from the player's authoritative slot data at `[CGPlayer + 0xE68 +
// 0x28]`. Every state change that adds, removes, or updates a quest
// flows through here. By snapshotting the log before and after the
// original runs, we can compute the delta and fire QUEST_ACCEPTED
// only for genuine additions.
// original runs, we can compute the delta and fire only for genuine
// additions / removals.
//
// Login suppression: the same function handles the initial bulk-sync
// at character entry, which appears as a single rebuild call adding
@@ -30,7 +41,10 @@
// added in a single rebuild (which can only happen on login / reload
// resync — human input speed can't accept multiple quests in the
// same engine tick). A brand-new character's first accept is
// `0 → 1` and fires correctly.
// `0 → 1` and fires correctly. Removals apply the same rule (a user
// action removes at most one quest per rebuild) and are additionally
// suppressed when the rebuild also added multiple quests — a
// mixed-delta rebuild is a cross-character resync, not gameplay.
#include "Game.h"
#include "Offsets.h"
@@ -39,13 +53,15 @@
#include <cstdint>
#include <unordered_set>
namespace Quest::Accepted {
namespace Quest::LogEvents {
namespace {
constexpr const char *kEventName = "QUEST_ACCEPTED";
constexpr const char *kAcceptedEventName = "QUEST_ACCEPTED";
constexpr const char *kRemovedEventName = "QUEST_REMOVED";
const Event::Custom::AutoReserve _reserve{kEventName};
const Event::Custom::AutoReserve _reserveAccepted{kAcceptedEventName};
const Event::Custom::AutoReserve _reserveRemoved{kRemovedEventName};
// Walk the live quest log and collect questIDs of real (non-header)
// entries. Headers are 16-byte rows with a non-NULL pointer at +0x8;
@@ -80,7 +96,7 @@ QuestLogRebuild_t QuestLogRebuild_o = nullptr;
void __fastcall QuestLogRebuild_h(int param_1) {
// `param_1 == 0` is the engine's "no-op rebuild" signal — just
// refires QUEST_LOG_UPDATE without touching the entry array.
// No diff possible, no accept happening, pass through.
// No diff possible, no accept/remove happening, pass through.
if (param_1 == 0) {
QuestLogRebuild_o(param_1);
return;
@@ -93,14 +109,17 @@ void __fastcall QuestLogRebuild_h(int param_1) {
// Walk the post-state in index order so the fired event's
// `questLogIndex` matches the 1-based slot the engine just
// assigned. Skip questIDs already in `pre`.
// assigned. Collect the full post-set on the way for the
// removal diff.
const int count = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_QUEST_LOG_ENTRY_COUNT));
if (count <= 0)
return;
const auto *entries = reinterpret_cast<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_QUEST_LOG_ENTRIES));
std::unordered_set<int> post;
if (count > 0)
post.reserve(static_cast<size_t>(count));
int newCount = 0;
int firstNewIdx = 0;
int firstNewID = 0;
@@ -114,6 +133,7 @@ void __fastcall QuestLogRebuild_h(int param_1) {
entry + Offsets::OFF_QUEST_LOG_ENTRY_QUEST_ID);
if (questID <= 0)
continue;
post.insert(questID);
if (pre.count(questID) != 0)
continue;
++newCount;
@@ -123,14 +143,35 @@ void __fastcall QuestLogRebuild_h(int param_1) {
}
}
int removedCount = 0;
int firstRemovedID = 0;
for (const int questID : pre) {
if (post.count(questID) != 0)
continue;
++removedCount;
if (removedCount == 1)
firstRemovedID = questID;
}
// Single addition → user-driven accept. Multiple → bulk resync
// (login or post-reload). Modern WoW's QUEST_ACCEPTED doesn't
// fire on initial sync either; we match that.
if (newCount == 1) {
const int slot = Event::Custom::Lookup(kEventName);
const int slot = Event::Custom::Lookup(kAcceptedEventName);
if (slot >= 0)
Event::Custom::Fire(slot, "%d%d", firstNewIdx, firstNewID);
}
// Single removal → user-driven turn-in or abandon. The extra
// `newCount <= 1` gate rejects the one resync shape the removal
// count alone can't: switching to a character whose log shares
// all but one of the previous character's quests (1 removal, N
// additions in the same rebuild).
if (removedCount == 1 && newCount <= 1) {
const int slot = Event::Custom::Lookup(kRemovedEventName);
if (slot >= 0)
Event::Custom::Fire(slot, "%d", firstRemovedID);
}
}
static const Game::HookAutoRegister _hookreg{
@@ -138,4 +179,4 @@ static const Game::HookAutoRegister _hookreg{
reinterpret_cast<void *>(&QuestLogRebuild_h),
reinterpret_cast<void **>(&QuestLogRebuild_o)};
} // namespace Quest::Accepted
} // namespace Quest::LogEvents