Compare commits

...
6 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
Brues 49865e9b9e Fix cast-then-channel spells showing the channel bar at cast start
Mind Control is a 3 s cast on a spell flagged CHANNELED with a 60 s
duration. HandleSpellStart branched on the channel attribute alone, so
at cast start it stamped g_channel with the 60 s duration and killed
the client hook's correct 3 s cast stamp — the bar showed a premature
60 s countdown (pfUI #9). Same story remotely: an observed MC stored a
60 s channel entry instead of the 3 s cast.

- Treat a channeled spell with castTime > 0 as its CAST phase in
  HandleSpellStart (player and remote paths); instant channels keep
  the immediate stamp.
- Co-hook the MSG_CHANNEL_START handler (0x006E7550, layout from
  nampower's SpellChannelStartHandlerHook: spellId u32, duration u32).
  The server sends it to the caster the moment the channel begins —
  after MC's cast completes — with the server-authoritative duration.
  Stamp g_channel there, before the original fires
  SPELLCAST_CHANNEL_START, so pollers see fresh data. For instant
  channels it re-stamps what the SpellStart path just wrote.

Verified: MC shows 3 s cast then 60 s channel; Mind Flay-style instant
channels unaffected.
2026-07-03 21:36:42 -05:00
Brues 98d4504b6f CBOR encode: keep encoding through OutOfMemory so the resize tally is complete
tinycbor's grow-and-retry contract: on buffer overflow the encoder
switches to counting mode and encoding must CONTINUE for
cbor_encoder_get_extra_bytes_needed() to be accurate — a nested
container's tally only reaches the parent via close_container. The old
code returned false on the first CborErrorOutOfMemory, so a mid-
container overflow could report extra == 0 and be misread as a genuine
"value not representable" failure (returning nil), or undercount and
churn through multiple resize passes.

Add EncOk() (NoError or OutOfMemory = keep going) around every encode
call; the retry loop now distinguishes real failures (return nil) from
overflow (single complete-tally resize, retry).
2026-07-03 20:48:39 -05:00
Brues 597530d558 Evict interrupted casts via SMSG_SPELL_FAILED_OTHER (TODO #98)
Remote casts cached from SMSG_SPELL_START were expired only by their
computed end time — an interrupted remote cast lingered as a ghost bar
for every UnitCastingInfo consumer (pfUI #11). The interrupt signal
does exist at the packet layer: the server broadcasts
SMSG_SPELL_FAILED_OTHER to observers whenever a started cast aborts
(interrupt, death, movement, fizzle).

Co-hook its handler (0x006E8E40, same __stdcall shape as
FUN_SPELL_DELAYED; address + layout mirrored from nampower's
SpellFailedOtherHandlerHook — body is plain casterGuid(u64),
spellId(u32)) and:

- evict the matching g_remoteCasts entry (guid + spellID), so remote
  cast/channel bars drop the same tick the cast dies;
- clear the player's own g_cast on a guid match — covers the
  server-stamped chained recast that the WorldTick VAR==0 poll can't
  clear (self-receipt is server-dependent; the endMs self-expiry
  backstop remains if it never fires).

Update the stale 'no per-unit interrupt signal in 1.12' notes in
Cast.cpp and docs/API.md.
2026-07-03 20:24:14 -05:00
Brues 2165a8e986 Restore TTS with VanillaTTS coexistence
Re-add the C_VoiceChat / C_TTSSettings SAPI module removed in 6f543ec,
gated so it coexists with the standalone VanillaTTS DLL: when
VanillaTTS.dll is loaded in the process, the module stands down
entirely — no Lua functions, no ttsVoice/ttsSpeed/ttsVolume cvars, no
VOICE_CHAT_TTS_* event reservations, no SAPI init. Without the gate,
each DLL would claim its own event-table slot for the same event names
(listeners bind to one slot while the other DLL fires the other) and
the cvar registrar would replace the first DLL's clamp callbacks.

The check (GetModuleHandleA at RegisterLuaFunctions time) is
load-order-independent — VanillaFixes loads every DLL at process
attach, long before FUN_LOAD_SCRIPT_FUNCTIONS fires. The event
AutoReserves move from file scope into the gated registration as
function-local statics so a stand-down leaves no reservations behind.

Restore the ole32 link, the README VoiceChat/API rows + event table,
and the docs/API.md VoiceChat section (with a coexistence note).
2026-07-03 19:00:17 -05:00
9 changed files with 1239 additions and 76 deletions
+4 -1
View File
@@ -99,7 +99,10 @@ target_compile_definitions(
${PROJECT_NAME}
PRIVATE CLASSICAPI_VERSION_VALUE=${CLASSICAPI_VERSION_VALUE})
target_link_libraries(${PROJECT_NAME} PRIVATE minhook advapi32)
# ole32: COM (CoInitializeEx / CoCreateInstance / CoTaskMemFree) for the
# SAPI text-to-speech backend in src/voicechat/Tts.cpp. The SAPI GUIDs are
# instantiated in that TU via <initguid.h>, so no sapi.lib is needed.
target_link_libraries(${PROJECT_NAME} PRIVATE minhook advapi32 ole32)
target_include_directories(
${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src
+6
View File
@@ -59,6 +59,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitGUID`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras` |
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
### GlueXML calls
@@ -130,8 +131,13 @@ 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` |
| `VOICE_CHAT_TTS_PLAYBACK_FINISHED` | `numConsumers, utteranceID, destination` |
| `VOICE_CHAT_TTS_PLAYBACK_FAILED` | `status, utteranceID, destination` |
| `VOICE_CHAT_TTS_VOICES_UPDATE` | *(none)* |
### Globals
+177 -4
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)
@@ -402,6 +403,14 @@ build instructions.
- [`C_UnitAuras.GetUnitAuras(unit [, filter])`](#c_unitaurasgetunitaurasunit--filter)
- [`C_UnitAuras.GetAuraDispelTypeColor(dispelName)`](#c_unitaurasgetauradispeltypecolordispelname)
- [VoiceChat](#voicechat)
- [`C_VoiceChat.GetTtsVoices()` / `C_VoiceChat.GetRemoteTtsVoices()`](#c_voicechatgetttsvoices--c_voicechatgetremotettsvoices)
- [`C_VoiceChat.SpeakText(voiceID, text [, destination, rate, volume])`](#c_voicechatspeaktextvoiceid-text--destination-rate-volume)
- [`C_VoiceChat.StopSpeakingText()`](#c_voicechatstopspeakingtext)
- [`C_TTSSettings` — getters & setters](#c_ttssettings--getters--setters)
- [TTS events](#tts-events)
- [TTS CVars](#tts-cvars)
## Account
### `SaveAccount(name, password)` / `DeleteAccount(name)` / `GetSavedAccounts()` / `LoginWithSavedAccount(name)` — GlueXML only
@@ -2379,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
@@ -8268,10 +8312,12 @@ so progress is `(GetTime()*1000 - startTimeMs) / (endTimeMs - startTimeMs)`.
> another unit's cast with a server-authoritative cast time, cached per
> caster GUID. So `C_Spell.UnitCastingInfo("target")` now backs
> enemy/target/focus/nameplate cast bars. Instant casts return `nil` (no
> cast bar). Best-effort for remote units, in three ways: only casts that
> began while the unit was in range are seen; since 1.12 has no per-unit
> interrupt packet to hook, an *interrupted* remote cast lingers until its
> computed `endTimeMs` rather than clearing instantly; and the remote
> cast bar). Interrupted remote casts clear the same tick — a co-hook on
> `SMSG_SPELL_FAILED_OTHER` (the packet the server broadcasts to observers
> when a started cast aborts: interrupt, death, movement, fizzle) evicts
> the cached cast immediately, so no ghost bar runs to completion.
> Best-effort for remote units, in two ways: only casts that began while
> the unit was in range are seen; and the remote
> `startTimeMs`/`endTimeMs` are shifted later by roughly your latency.
> (1.12's `SMSG_SPELL_START` carries only a single `castTime`, so we stamp
> `start = now, end = now + castTime` on receipt. 3.3.5+ avoids the skew
@@ -9763,3 +9809,130 @@ builds. So the returned value carries the mixin methods
`.r/.g/.b/.a` fields. Falls back to a plain `{r,g,b,a}` table if
`CreateColor` isn't loaded yet (shouldn't happen — `!!!ClassicAPI`
loads first thanks to the triple-`!` prefix).
## VoiceChat
Backport of modern WoW's text-to-speech surface — the `C_VoiceChat` and
`C_TTSSettings` namespaces — backed by **Windows SAPI** (the OS speech
engine). All playback is **local** (rendered on your own machine);
vanilla 1.12 has no voice-chat transport, so there is nothing "remote" to
transmit synthesized speech into.
> **Coexistence with VanillaTTS:** the same surface also ships as the
> standalone [VanillaTTS](https://github.com/brues-code/VanillaTTS) DLL,
> which adds a software-synth backend for Wine. If `VanillaTTS.dll` is
> loaded in the same client, this built-in module stands down automatically
> (registers none of the functions, events, or cvars below) and VanillaTTS
> provides them instead — the Lua-visible API is identical either way, so
> addons don't need to care which one is active.
SAPI is created lazily on first use. If SAPI is unavailable (e.g. running
the client under Wine, or a stripped Windows), every call degrades
gracefully: `GetTtsVoices` returns an empty table, `SpeakText` fires
`VOICE_CHAT_TTS_PLAYBACK_FAILED` and does nothing, and the client stays
stable — there is no crash. Install more voices under **Windows Settings →
Time & Language → Speech**, then call `C_TTSSettings.RefreshVoices()`.
### `C_VoiceChat.GetTtsVoices()` / `C_VoiceChat.GetRemoteTtsVoices()`
Return a numerically-indexed array of voice tables — one per SAPI voice
installed on the machine:
```lua
for _, v in ipairs(C_VoiceChat.GetTtsVoices()) do
print(v.voiceID, v.name) -- e.g. 0 "Microsoft David Desktop - English (United States)"
end
```
Each entry is `{ voiceID = <0-based index>, name = <display name> }`. The
`voiceID` is the index you pass to `SpeakText` and store via
`C_TTSSettings.SetVoiceOption`. `GetRemoteTtsVoices` is an alias returning
the same local list (kept for API parity — vanilla has no voice chat).
Calling either refreshes the cached voice list and fires
`VOICE_CHAT_TTS_VOICES_UPDATE` if it changed.
### `C_VoiceChat.SpeakText(voiceID, text [, destination, rate, volume])`
Speaks `text` aloud using the SAPI voice at index `voiceID`.
```lua
C_VoiceChat.SpeakText(
C_TTSSettings.GetSpeechVoiceID(), -- use the configured voice
UnitName("target") or "no target")
```
- **`voiceID`** (number, required) — index into `GetTtsVoices()`. Note this
is an **explicit argument** — `SpeakText` does *not* read the `ttsVoice`
cvar itself, matching Blizzard's contract. Pass
`C_TTSSettings.GetSpeechVoiceID()` to honor the configured default.
- **`text`** (string, required) — UTF-8; spoken as-is.
- **`destination`** (number, optional, default `1`) — `1` =
`LOCAL_PLAYBACK`, `4` = `QUEUED_LOCAL_PLAYBACK`. Accepted for parity;
both queue FIFO through SAPI (no app-side queue).
- **`rate`** (number, optional, default `0`) — speech rate, clamped to
`[-10, 10]`.
- **`volume`** (number, optional, default `100`) — clamped to `[0, 100]`.
Speech is asynchronous. The call returns immediately and fires
`VOICE_CHAT_TTS_PLAYBACK_STARTED` when SAPI begins the utterance and
`VOICE_CHAT_TTS_PLAYBACK_FINISHED` when it completes (or
`..._PLAYBACK_FAILED` if the voice/engine is unavailable). Raises a Lua
error if `voiceID` isn't a number or `text` isn't a string.
### `C_VoiceChat.StopSpeakingText()`
Stops and purges everything currently playing or queued.
### `C_TTSSettings` — getters & setters
The settings store (separate from `SpeakText`, which takes its parameters
explicitly). All three values are backed by CVars (see below), so they
**persist across sessions** and are clamped to valid ranges on write.
| Function | Returns / effect |
|---|---|
| `C_TTSSettings.GetSpeechRate()` | configured rate, `[-10, 10]` (default `0`) |
| `C_TTSSettings.GetSpeechVolume()` | configured volume, `[0, 100]` (default `100`) |
| `C_TTSSettings.GetSpeechVoiceID()` | configured voice index (default `0`) |
| `C_TTSSettings.GetVoiceOptionName()` | display name of the configured voice, or `""` |
| `C_TTSSettings.SetSpeechRate(rate)` | set rate (clamped) |
| `C_TTSSettings.SetSpeechVolume(volume)` | set volume (clamped) |
| `C_TTSSettings.SetVoiceOption(voiceID)` | set the voice by index (clamped to installed count) |
| `C_TTSSettings.SetVoiceOptionByName(name)` | set the voice by display name (case-insensitive exact match) |
| `C_TTSSettings.SetDefaultSettings()` | reset to voice `1` (if present, else `0`), rate `0`, volume `100` |
| `C_TTSSettings.RefreshVoices()` | re-enumerate SAPI voices; fires `VOICE_CHAT_TTS_VOICES_UPDATE` if the list changed |
```lua
C_TTSSettings.SetVoiceOptionByName("Microsoft Zira Desktop - English (United States)")
C_TTSSettings.SetSpeechVolume(150) -- clamps to 100
print(C_TTSSettings.GetVoiceOptionName()) -- "Microsoft Zira Desktop - ..."
```
### TTS events
Registered like any engine event (`frame:RegisterEvent("VOICE_CHAT_TTS_PLAYBACK_STARTED")`).
| Event | Args |
|---|---|
| `VOICE_CHAT_TTS_PLAYBACK_STARTED` | `numConsumers` (number), `utteranceID` (number), `durationMS` (number, always `0`), `destination` (number) |
| `VOICE_CHAT_TTS_PLAYBACK_FINISHED` | `numConsumers` (number), `utteranceID` (number), `destination` (number) |
| `VOICE_CHAT_TTS_PLAYBACK_FAILED` | `status` (string — e.g. `"EngineAllocationFailed"`, `"InternalError"`), `utteranceID` (number), `destination` (number) |
| `VOICE_CHAT_TTS_VOICES_UPDATE` | *(none)* — the installed-voice list changed |
| `VOICE_CHAT_TTS_SPEAK_TEXT_UPDATE` | reserved for parity; not currently fired |
`utteranceID` correlates the START/FINISHED/FAILED events for a given
`SpeakText` call. `PLAYBACK_FINISHED` fires only when playback actually
completes, so it's the signal to start the next utterance in a queue.
### TTS CVars
The `C_TTSSettings` values are stored as engine CVars, persisted to
`WTF\Config.wtf`. You can also read/write them with the standard
`GetCVar`/`SetCVar` (or `/console set`); writes are clamped by the same
validation:
| CVar | Default | Range | Backing setting |
|---|---|---|---|
| `ttsVoice` | `0` | `[0, voiceCount-1]` | `GetSpeechVoiceID` / `SetVoiceOption` |
| `ttsSpeed` | `0` | `[-10, 10]` | `GetSpeechRate` / `SetSpeechRate` |
| `ttsVolume` | `100` | `[0, 100]` | `GetSpeechVolume` / `SetSpeechVolume` |
+100 -4
View File
@@ -876,6 +876,28 @@ enum Offsets {
// reflects pushback. Mirrored from nampower's SpellDelayedHook.
FUN_SPELL_DELAYED = 0x006E74F0,
// `MSG_CHANNEL_START` handler — `int __stdcall(uint32_t *opCode,
// CDataStore *packet)`. Body: `spellId(u32), durationMs(u32)`. Sent
// only to the caster when their channel begins, with the server-
// authoritative duration. `Spell::Cast` co-hooks it to stamp the
// player's channel — required for cast-then-channel spells (Mind
// Control: 3 s cast then 60 s channel), whose SMSG_SPELL_START only
// covers the cast phase. Address + layout mirrored from nampower's
// SpellChannelStartHandlerHook (they hook it too — accepted co-hook
// collision, per-channel-start frequency).
FUN_SPELL_CHANNEL_START = 0x006E7550,
// `SMSG_SPELL_FAILED_OTHER` handler — `int __stdcall(uint32_t *opCode,
// CDataStore *packet)`, same shape as FUN_SPELL_DELAYED. Body:
// `casterGuid(u64, plain), spellId(u32)`. Broadcast to observers when a
// started cast aborts (interrupt, death, movement, fizzle) — the ONLY
// per-unit interrupt signal 1.12 has at the packet layer. `Spell::Cast`
// co-hooks it to invalidate the matching remote-cast cache entry (and
// the player's own server-stamped chained recast) so an interrupted
// cast doesn't linger as a ghost bar until its computed end. Address +
// packet layout mirrored from nampower's SpellFailedOtherHandlerHook.
FUN_SPELL_FAILED_OTHER = 0x006E8E40,
// `CGUnit_C::OnAuraAdded` — `__thiscall void(CGUnit *unit, uint32_t
// slot, uint32_t spellId)` (i.e. `__fastcall(unit /*ecx*/, edx_unused,
// slot, spellId)`). Fires for EVERY aura that lands on a tracked unit,
@@ -2418,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.
@@ -4337,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
+29 -26
View File
@@ -30,6 +30,16 @@ constexpr int kMaxDepth = 64;
// ---------------- encode ----------------
// tinycbor's grow-and-retry contract: on buffer overflow the encoder
// switches to counting mode (end = NULL, accumulates bytes_needed) and
// encoding must CONTINUE so the final tally is complete — a nested
// container's tally only reaches the parent via close_container. So
// CborErrorOutOfMemory is not a failure here; the caller reads
// cbor_encoder_get_extra_bytes_needed() afterwards, resizes, retries.
bool EncOk(CborError err) {
return err == CborNoError || err == CborErrorOutOfMemory;
}
bool EncodeValue(void *L, int idx, CborEncoder *enc, int depth);
bool IsLuaArray(void *L, int idx, int &outLen) {
@@ -73,8 +83,8 @@ bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
int arrayLen = 0;
if (IsLuaArray(L, idx, arrayLen)) {
CborEncoder arr;
if (cbor_encoder_create_array(enc, &arr,
static_cast<size_t>(arrayLen)) != CborNoError)
if (!EncOk(cbor_encoder_create_array(enc, &arr,
static_cast<size_t>(arrayLen))))
return false;
for (int i = 1; i <= arrayLen; ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i));
@@ -85,12 +95,12 @@ bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
}
Game::Lua::SetTop(L, -2);
}
return cbor_encoder_close_container(enc, &arr) == CborNoError;
return EncOk(cbor_encoder_close_container(enc, &arr));
}
const int pairs = CountTablePairs(L, idx);
CborEncoder map;
if (cbor_encoder_create_map(enc, &map, static_cast<size_t>(pairs)) != CborNoError)
if (!EncOk(cbor_encoder_create_map(enc, &map, static_cast<size_t>(pairs))))
return false;
Game::Lua::PushNil(L);
while (Game::Lua::Next(L, idx) != 0) {
@@ -101,16 +111,16 @@ bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
}
Game::Lua::SetTop(L, -2);
}
return cbor_encoder_close_container(enc, &map) == CborNoError;
return EncOk(cbor_encoder_close_container(enc, &map));
}
bool EncodeValue(void *L, int idx, CborEncoder *enc, int depth) {
switch (Game::Lua::Type(L, idx)) {
case Game::Lua::TYPE_NIL:
return cbor_encode_null(enc) == CborNoError;
return EncOk(cbor_encode_null(enc));
case Game::Lua::TYPE_BOOLEAN:
return cbor_encode_boolean(enc,
Game::Lua::ToBoolean(L, idx) != 0) == CborNoError;
return EncOk(cbor_encode_boolean(enc,
Game::Lua::ToBoolean(L, idx) != 0));
case Game::Lua::TYPE_NUMBER: {
const double n = Game::Lua::ToNumber(L, idx);
// Whole numbers in [-2^53, 2^53] go out as integers so
@@ -118,17 +128,17 @@ bool EncodeValue(void *L, int idx, CborEncoder *enc, int depth) {
if (std::isfinite(n) && n == std::floor(n) &&
n >= -9007199254740992.0 && n <= 9007199254740992.0) {
if (n >= 0.0)
return cbor_encode_uint(enc,
static_cast<uint64_t>(n)) == CborNoError;
return cbor_encode_negative_int(enc,
static_cast<uint64_t>(-n)) == CborNoError;
return EncOk(cbor_encode_uint(enc,
static_cast<uint64_t>(n)));
return EncOk(cbor_encode_negative_int(enc,
static_cast<uint64_t>(-n)));
}
return cbor_encode_double(enc, n) == CborNoError;
return EncOk(cbor_encode_double(enc, n));
}
case Game::Lua::TYPE_STRING: {
const char *s = Game::Lua::ToString(L, idx);
const unsigned int n = Game::Lua::StrLen(L, idx);
return cbor_encode_text_string(enc, s, n) == CborNoError;
return EncOk(cbor_encode_text_string(enc, s, n));
}
case Game::Lua::TYPE_TABLE:
return EncodeTable(L, idx, enc, depth);
@@ -143,22 +153,15 @@ int __fastcall Script_SerializeCBOR(void *L) {
return 0;
}
// tinycbor encodes into a fixed-size buffer; if it overflows, it
// tracks the extra bytes needed so we can resize and retry.
// tinycbor encodes into a fixed-size buffer; on overflow it keeps
// encoding in counting mode (EncOk treats OutOfMemory as success) so
// the extra-bytes tally is complete, then we resize and retry once.
std::vector<uint8_t> buf(256);
for (;;) {
CborEncoder enc;
cbor_encoder_init(&enc, buf.data(), buf.size(), 0);
if (!EncodeValue(L, 1, &enc, 0)) {
// If it was a buffer-size issue we'll catch it via the
// extra-bytes-needed check below; otherwise this is a
// genuine "value not representable" failure.
const size_t extra = cbor_encoder_get_extra_bytes_needed(&enc);
if (extra == 0)
return 0;
buf.resize(buf.size() + extra);
continue;
}
if (!EncodeValue(L, 1, &enc, 0))
return 0; // genuine "value not representable" failure
const size_t extra = cbor_encoder_get_extra_bytes_needed(&enc);
if (extra > 0) {
buf.resize(buf.size() + extra);
+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
+113 -14
View File
@@ -32,11 +32,15 @@
// time), dedup'd against a just-made client stamp so a normal cast's
// confirming packet doesn't restart its bar. `g_castFromServer = true`;
// since the global was never set for these, the WorldTick VAR==0 clear
// must skip them — they expire on their computed endMs (and pfUI clears
// on nampower's SPELL_FAILED_SELF for interrupts).
// - channel: the player's broadcast `UNIT_FIELD_CHANNEL_SPELL`
// (descriptor +0x228), polled on `WorldTick`. endMs = now + effective
// channel duration.
// must skip them — they expire on their computed endMs, or earlier via
// the SMSG_SPELL_FAILED_OTHER co-hook when the server tells us the cast
// aborted.
// - channel: stamped from `SMSG_SPELL_START` at packet receipt (instant
// channels; engine-computed duration) and from `MSG_CHANNEL_START`
// (server duration — and the ONLY start signal for cast-then-channel
// spells like Mind Control, whose SpellStart covers just the cast
// phase). The broadcast +0x228 field is NOT polled for the player —
// it lags the channel start by ~1 tick.
// `now` is the engine ms clock (`FUN_OS_TICKCOUNT_MS`), the same source
// Lua's `GetTime()` scales by 0.001 — so startMs/endMs are directly
// comparable to `GetTime()*1000` in addon progress math.
@@ -47,8 +51,10 @@
// cache — see the SpellStart co-hook below. Scope:
// - Player casts/channels: full timing (self-tracked, exact engine math).
// - Other units' regular CASTS: full timing while within the cast window,
// from the SpellStart cache. No clean per-unit interrupt signal exists
// in 1.12, so an interrupted remote cast lingers until its computed end.
// from the SpellStart cache. Interrupts are handled by the
// SMSG_SPELL_FAILED_OTHER co-hook, which invalidates the cache entry
// the moment the server reports the cast aborted (interrupt, death,
// movement, fizzle) — no lingering ghost bar.
// - Other units' CHANNELS: the broadcast +0x228 field gates "channeling
// now"; the SpellStart cache adds real start/end times when we observed
// the channel begin (else spellID/name/texture with nil times).
@@ -332,7 +338,7 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
const bool channel = IsChannelSpell(rec);
if (caster == Unit::Identity::PlayerGuid()) {
if (channel) {
if (channel && castTime == 0) {
// Player channel start. The broadcast +0x228 field that signals
// "channeling" is server-pushed and lands ~1 tick (~1s) after the
// channel actually begins, so a +0x228 poll shows the bar far too
@@ -341,7 +347,8 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
// now with the engine-computed duration. We run before the
// original handler that fires SPELLCAST_CHANNEL_START, so the data
// is fresh when addons poll. Cleared by self-expiry (PushChannel-
// Info) at endMs.
// Info) at endMs. (MSG_CHANNEL_START re-stamps moments later with
// the server's duration — see ChannelStart_h.)
const int dur = ChannelDurationMs(spellID);
if (dur > 0) {
g_channel = TrackedSpell{spellID, now, now + dur, 0};
@@ -349,6 +356,12 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
}
return;
}
// channel && castTime > 0 → a CAST-THEN-CHANNEL spell (Mind Control:
// 3 s cast, then a 60 s channel). This packet is the cast phase —
// the channel begins only when the cast completes, signaled by
// MSG_CHANNEL_START (ChannelStart_h). Fall through to regular cast
// handling so the bar shows the 3 s cast, not a premature 60 s
// channel (pfUI #9).
if (castTime == 0)
return; // instant — no cast bar
// Backstop for chained same-spell recasts: the engine short-circuits
@@ -367,9 +380,14 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
g_channel.spellID = 0; // a cast supersedes any channel
return;
}
const int endMs = channel ? now + RemoteChannelDurationMs(rec)
: now + static_cast<int>(castTime);
StoreRemoteCast(caster, spellID, now, endMs, channel);
// A channeled spell with a cast time (Mind Control) is in its CAST phase
// here — store it as a regular cast so observers see the 3 s bar. The
// remote channel phase has no packet (MSG_CHANNEL_START is caster-only);
// it surfaces through the unit's broadcast +0x228 field as usual.
const bool instantChannel = channel && castTime == 0;
const int endMs = instantChannel ? now + RemoteChannelDurationMs(rec)
: now + static_cast<int>(castTime);
StoreRemoteCast(caster, spellID, now, endMs, instantChannel);
}
// Co-hook on the SMSG_SPELL_START handler. Gated on opCode 0x131 so every
@@ -427,6 +445,87 @@ const Game::HookAutoRegister _spellDelayedHook{
Offsets::FUN_SPELL_DELAYED, reinterpret_cast<void *>(&SpellDelayed_h),
reinterpret_cast<void **>(&g_origSpellDelayed)};
// Co-hook on the MSG_CHANNEL_START handler — the server tells the caster a
// channel has begun, with the server-authoritative (modifier-applied)
// duration. Sent only to the caster, so it's always the local player. This
// is what makes CAST-THEN-CHANNEL spells (Mind Control: 3 s cast, then a
// 60 s channel) show both phases: their SMSG_SPELL_START carries the cast
// (castTime > 0, handled as a regular cast in HandleSpellStart) and only
// this packet marks the channel actually starting. For instant channels it
// re-stamps what the SpellStart path just wrote (same moment, server
// duration) — harmless. We stamp before the original runs so the data is
// fresh when the engine fires SPELLCAST_CHANNEL_START. Packet layout
// mirrored from nampower's SpellChannelStartHandlerHook:
// spellId(u32), durationMs(u32).
using ChannelStart_t = int(__stdcall *)(uint32_t *opCode,
Net::CDataStore *packet);
ChannelStart_t g_origChannelStart = nullptr;
int __stdcall ChannelStart_h(uint32_t *opCode, Net::CDataStore *packet) {
if (packet != nullptr) {
const uint32_t saved = packet->m_read;
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
const uint32_t duration = Net::Read<uint32_t>(packet);
packet->m_read = saved;
if (spellID != 0 && duration > 0) {
const int now = NowMs();
g_channel =
TrackedSpell{spellID, now, now + static_cast<int>(duration), 0};
g_cast.spellID = 0; // the channel supersedes the completed cast
}
}
return g_origChannelStart(opCode, packet);
}
const Game::HookAutoRegister _channelStartHook{
Offsets::FUN_SPELL_CHANNEL_START,
reinterpret_cast<void *>(&ChannelStart_h),
reinterpret_cast<void **>(&g_origChannelStart)};
// Co-hook on the SMSG_SPELL_FAILED_OTHER handler — the server broadcasts it
// to observers whenever a started cast aborts (interrupt, death, movement,
// fizzle). It's the only per-unit interrupt signal 1.12 has, so it's what
// lets us drop a remote cast the moment it dies instead of letting the
// ghost bar run to its computed end (the documented remote-unit limitation
// above). Packet body is plain (not packed): casterGuid(u64), spellId(u32).
using SpellFailedOther_t = int(__stdcall *)(uint32_t *opCode,
Net::CDataStore *packet);
SpellFailedOther_t g_origSpellFailedOther = nullptr;
int __stdcall SpellFailedOther_h(uint32_t *opCode, Net::CDataStore *packet) {
if (packet != nullptr) {
const uint32_t saved = packet->m_read;
const uint64_t guid = Net::Read<uint64_t>(packet);
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
packet->m_read = saved;
if (guid != 0 && spellID != 0) {
// Remote units: invalidate the cached cast/channel so
// UnitCastingInfo/UnitChannelInfo stop reporting it this tick.
for (auto &e : g_remoteCasts) {
if (e.used && e.casterGuid == guid && e.spellID == spellID) {
e.used = false;
break;
}
}
// Local player: a server-stamped chained recast
// (g_castFromServer) never set VAR_CURRENT_CAST_SPELL, so the
// WorldTick VAR==0 poll can't clear it on interrupt — this
// packet is the only signal. Whether the caster receives their
// own broadcast is server-dependent; if it never fires, the
// endMs self-expiry backstop still applies.
if (guid == Unit::Identity::PlayerGuid() &&
g_cast.spellID == spellID)
g_cast.spellID = 0;
}
}
return g_origSpellFailedOther(opCode, packet);
}
const Game::HookAutoRegister _spellFailedOtherHook{
Offsets::FUN_SPELL_FAILED_OTHER,
reinterpret_cast<void *>(&SpellFailedOther_h),
reinterpret_cast<void **>(&g_origSpellFailedOther)};
} // namespace
// Per-frame upkeep for the player. The regular cast is stamped by the
@@ -458,8 +557,8 @@ static int __fastcall Script_CastingInfo(void *L) { return PushCastInfo(L, g_cas
// `UnitCastingInfo(unit)` — local player from self-tracking; other units
// from the SMSG_SPELL_START cache (regular casts still within their cast
// window). An interrupted remote cast lingers until its computed end time
// (no clean per-unit interrupt signal in 1.12) — acceptable best-effort.
// window; interrupted casts are evicted by the SMSG_SPELL_FAILED_OTHER
// co-hook).
static int __fastcall Script_UnitCastingInfo(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: C_Spell.UnitCastingInfo(\"unit\")");
+594
View File
@@ -0,0 +1,594 @@
// 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/>.
// Text-to-speech — a Windows SAPI backend behind modern WoW's C_VoiceChat /
// C_TTSSettings Lua surface. Ported from awesome_wotlk's VoiceChat.cpp (the
// 3.3.5 implementation); the SAPI core is client-version-agnostic, so only
// the three integration seams are ClassicAPI-native:
// - namespaces → Game::Lua::RegisterTableFunction
// - events → Event::Custom (AutoReserve + Fire)
// - settings → CVar::Factory (ttsVoice / ttsSpeed / ttsVolume, persisted
// to Config.wtf, clamped via change callbacks)
//
// Lua surface:
// C_VoiceChat
// GetTtsVoices() -> { {voiceID=, name=}, ... }
// GetRemoteTtsVoices() -> same (vanilla has no real voice chat)
// SpeakText(voiceID, text[, destination[, rate[, volume]]])
// StopSpeakingText()
// C_TTSSettings
// GetSpeechRate() / GetSpeechVolume() / GetSpeechVoiceID()
// GetVoiceOptionName()
// SetDefaultSettings()
// SetSpeechRate(v) / SetSpeechVolume(v)
// SetVoiceOption(id) / SetVoiceOptionByName(name)
// RefreshVoices()
//
// Events: VOICE_CHAT_TTS_PLAYBACK_STARTED/FINISHED/FAILED,
// VOICE_CHAT_TTS_SPEAK_TEXT_UPDATE (reserved, unused),
// VOICE_CHAT_TTS_VOICES_UPDATE.
//
// Threading: SAPI is created on the main (game) thread under an STA, and
// SetNotifyCallbackFunction marshals the notify back to that thread's message
// pump (WoW pumps messages every frame), so OnSpeechNotify runs on the main
// thread and may touch the Lua event dispatcher safely.
//
// Coexistence: the same TTS surface also ships as the standalone VanillaTTS
// DLL (which adds a pluggable backend — software synth under Wine). When
// that DLL is injected alongside us, this module stands down entirely — see
// StandaloneTtsPresent below.
#include "Game.h"
#include "cvar/Factory.h"
#include "event/Custom.h"
#include <initguid.h> // instantiate the SAPI GUIDs in this TU (no sapi.lib)
#include <sapi.h>
#include <windows.h>
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cwctype>
#include <mutex>
#include <string>
#include <unordered_map>
#include <vector>
namespace VoiceChat::Tts {
namespace {
// ---------------------------------------------------------------------------
// Standalone-DLL coexistence
// ---------------------------------------------------------------------------
// The dedicated VanillaTTS DLL provides this exact Lua surface (same
// C_VoiceChat / C_TTSSettings functions, same VOICE_CHAT_TTS_* events, same
// ttsVoice/ttsSpeed/ttsVolume cvars) with a more capable backend. If both
// registered, the damage wouldn't be limited to "last one wins":
// - Each DLL claims its OWN event-table slot for the same event name, so
// the table ends up with duplicate-name entries. frame:RegisterEvent
// binds listeners to whichever slot the engine's scan finds first, while
// the other DLL fires the other slot — events silently lost depending on
// load order.
// - The cvar registrar dedups by name but REPLACES the change callback on
// re-register, so one DLL's clamp callbacks (and userData) go dead.
// - Both would init COM/SAPI and answer SpeakText → double playback risk.
// So when VanillaTTS.dll is in the process, this module registers nothing —
// no Lua functions, no cvars, no event reservations, no SAPI init.
//
// The check runs at RegisterLuaFunctions time (first login), which is
// load-order-independent: VanillaFixes loads every DLL at process attach,
// long before FUN_LOAD_SCRIPT_FUNCTIONS first fires. Latched once — modules
// can't appear or vanish mid-session.
bool StandaloneTtsPresent() {
static const bool present = GetModuleHandleA("VanillaTTS.dll") != nullptr;
return present;
}
// ---------------------------------------------------------------------------
// Event names — reserved lazily in RegisterLuaFunctions (NOT via file-scope
// AutoReserve statics, which would run unconditionally at DLL load and claim
// slots even when we're standing down for VanillaTTS).
// ---------------------------------------------------------------------------
constexpr const char *kEvtPlaybackFailed = "VOICE_CHAT_TTS_PLAYBACK_FAILED";
constexpr const char *kEvtPlaybackFinished = "VOICE_CHAT_TTS_PLAYBACK_FINISHED";
constexpr const char *kEvtPlaybackStarted = "VOICE_CHAT_TTS_PLAYBACK_STARTED";
constexpr const char *kEvtSpeakTextUpdate = "VOICE_CHAT_TTS_SPEAK_TEXT_UPDATE";
constexpr const char *kEvtVoicesUpdate = "VOICE_CHAT_TTS_VOICES_UPDATE";
// ---------------------------------------------------------------------------
// CVar names + handles (registered in RegisterLuaFunctions)
// ---------------------------------------------------------------------------
constexpr const char *kCVarVoice = "ttsVoice";
constexpr const char *kCVarSpeed = "ttsSpeed";
constexpr const char *kCVarVolume = "ttsVolume";
CVar::Factory::Handle g_cvarVoice = nullptr;
CVar::Factory::Handle g_cvarSpeed = nullptr;
CVar::Factory::Handle g_cvarVolume = nullptr;
// Destination constants (kept for API parity; no special handling — SAPI
// queues FIFO either way).
enum : int {
DEST_LOCAL_PLAYBACK = 1,
DEST_QUEUED_LOCAL_PLAYBACK = 4,
};
int ClampDestination(int d) {
return d == DEST_QUEUED_LOCAL_PLAYBACK ? DEST_QUEUED_LOCAL_PLAYBACK
: DEST_LOCAL_PLAYBACK;
}
// ---------------------------------------------------------------------------
// SAPI state
// ---------------------------------------------------------------------------
ISpVoice *g_pVoice = nullptr;
bool g_comInit = false;
int g_nextUtteranceID = 1;
int NextUtteranceID() {
if (g_nextUtteranceID >= 0x7fffffff)
g_nextUtteranceID = 1;
return g_nextUtteranceID++;
}
struct UtteranceMeta {
int id;
int destination;
bool startedEmitted;
};
std::mutex g_streamMx;
std::unordered_map<ULONG, UtteranceMeta> g_streamMap;
struct VoiceEntry {
int voiceID;
std::wstring name;
};
std::vector<VoiceEntry> g_cachedVoices;
// ---------------------------------------------------------------------------
// UTF-8 / wide conversion
// ---------------------------------------------------------------------------
std::string WideToUtf8(const std::wstring &w) {
if (w.empty())
return {};
int need = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), -1, nullptr, 0, nullptr, nullptr);
if (need <= 1)
return {};
std::string out(static_cast<size_t>(need - 1), '\0');
WideCharToMultiByte(CP_UTF8, 0, w.c_str(), -1, &out[0], need - 1, nullptr, nullptr);
return out;
}
std::wstring Utf8ToWide(const char *s) {
if (s == nullptr || *s == '\0')
return {};
int need = MultiByteToWideChar(CP_UTF8, 0, s, -1, nullptr, 0);
if (need <= 1)
return {};
std::wstring out(static_cast<size_t>(need - 1), L'\0');
MultiByteToWideChar(CP_UTF8, 0, s, -1, &out[0], need);
return out;
}
// ---------------------------------------------------------------------------
// Event fire helpers (engine dispatcher via Event::Custom)
// ---------------------------------------------------------------------------
void FireVoicesUpdate() {
Event::Custom::Fire(Event::Custom::Lookup(kEvtVoicesUpdate), "");
}
void FirePlaybackStarted(int numConsumers, int utteranceID, int durationMS, int dest) {
Event::Custom::Fire(Event::Custom::Lookup(kEvtPlaybackStarted), "%d%d%d%d",
numConsumers, utteranceID, durationMS, dest);
}
void FirePlaybackFinished(int numConsumers, int utteranceID, int dest) {
Event::Custom::Fire(Event::Custom::Lookup(kEvtPlaybackFinished), "%d%d%d",
numConsumers, utteranceID, dest);
}
void FirePlaybackFailed(const char *status, int utteranceID, int dest) {
Event::Custom::Fire(Event::Custom::Lookup(kEvtPlaybackFailed), "%s%d%d",
status, utteranceID, dest);
}
// ---------------------------------------------------------------------------
// COM / SAPI lifecycle
// ---------------------------------------------------------------------------
void InitCom() {
if (g_comInit)
return;
HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
// S_FALSE = already initialized on this thread; treat as success.
if (SUCCEEDED(hr) || hr == RPC_E_CHANGED_MODE)
g_comInit = true;
}
void __stdcall OnSpeechNotify(WPARAM, LPARAM) {
if (g_pVoice == nullptr)
return;
SPEVENT ev = {};
ULONG fetched = 0;
while (SUCCEEDED(g_pVoice->GetEvents(1, &ev, &fetched)) && fetched == 1) {
if (ev.eEventId == SPEI_START_INPUT_STREAM) {
std::lock_guard<std::mutex> lock(g_streamMx);
auto it = g_streamMap.find(ev.ulStreamNum);
if (it != g_streamMap.end() && !it->second.startedEmitted) {
FirePlaybackStarted(1, it->second.id, /*durationMS*/ 0,
it->second.destination);
it->second.startedEmitted = true;
}
} else if (ev.eEventId == SPEI_END_INPUT_STREAM) {
UtteranceMeta meta{0, DEST_LOCAL_PLAYBACK, false};
{
std::lock_guard<std::mutex> lock(g_streamMx);
auto it = g_streamMap.find(ev.ulStreamNum);
if (it != g_streamMap.end()) {
meta = it->second;
g_streamMap.erase(it);
}
}
FirePlaybackFinished(1, meta.id, meta.destination);
}
// Only START/END stream events are requested; neither carries an
// allocated lParam, so no SpClearEvent (sphelper) is needed.
ev = SPEVENT{};
}
}
void InitVoice() {
if (g_pVoice != nullptr)
return;
InitCom();
HRESULT hr = CoCreateInstance(CLSID_SpVoice, nullptr, CLSCTX_ALL,
IID_ISpVoice, reinterpret_cast<void **>(&g_pVoice));
if (FAILED(hr) || g_pVoice == nullptr) {
g_pVoice = nullptr;
return;
}
g_pVoice->SetNotifyCallbackFunction(&OnSpeechNotify, 0, 0);
const ULONGLONG interest =
SPFEI(SPEI_START_INPUT_STREAM) | SPFEI(SPEI_END_INPUT_STREAM);
g_pVoice->SetInterest(interest, interest);
}
// ---------------------------------------------------------------------------
// Voice enumeration (CLSID_SpObjectTokenCategory; no sphelper)
// ---------------------------------------------------------------------------
std::vector<VoiceEntry> EnumerateVoices() {
InitCom();
std::vector<VoiceEntry> voices;
ISpObjectTokenCategory *cat = nullptr;
if (FAILED(CoCreateInstance(CLSID_SpObjectTokenCategory, nullptr, CLSCTX_ALL,
IID_ISpObjectTokenCategory,
reinterpret_cast<void **>(&cat))) ||
cat == nullptr)
return voices;
if (SUCCEEDED(cat->SetId(SPCAT_VOICES, FALSE))) {
IEnumSpObjectTokens *en = nullptr;
if (SUCCEEDED(cat->EnumTokens(nullptr, nullptr, &en)) && en != nullptr) {
ISpObjectToken *tok = nullptr;
ULONG fetched = 0;
int index = 0;
while (en->Next(1, &tok, &fetched) == S_OK && fetched == 1) {
LPWSTR desc = nullptr;
if (SUCCEEDED(tok->GetStringValue(nullptr, &desc)) && desc != nullptr) {
voices.push_back({index, desc});
CoTaskMemFree(desc);
}
tok->Release();
++index;
}
en->Release();
}
}
cat->Release();
return voices;
}
int VoiceCount() {
return static_cast<int>(EnumerateVoices().size());
}
void RefreshVoices() {
auto fresh = EnumerateVoices();
bool changed = fresh.size() != g_cachedVoices.size();
if (!changed) {
for (size_t i = 0; i < fresh.size(); ++i) {
if (fresh[i].name != g_cachedVoices[i].name) {
changed = true;
break;
}
}
}
g_cachedVoices = std::move(fresh);
if (changed)
FireVoicesUpdate();
}
// Select the voice at enumeration index `voiceID` on g_pVoice.
bool SelectVoice(int voiceID) {
ISpObjectTokenCategory *cat = nullptr;
if (FAILED(CoCreateInstance(CLSID_SpObjectTokenCategory, nullptr, CLSCTX_ALL,
IID_ISpObjectTokenCategory,
reinterpret_cast<void **>(&cat))) ||
cat == nullptr)
return false;
bool set = false;
if (SUCCEEDED(cat->SetId(SPCAT_VOICES, FALSE))) {
IEnumSpObjectTokens *en = nullptr;
if (SUCCEEDED(cat->EnumTokens(nullptr, nullptr, &en)) && en != nullptr) {
ISpObjectToken *tok = nullptr;
ULONG fetched = 0;
int index = 0;
while (en->Next(1, &tok, &fetched) == S_OK && fetched == 1) {
if (index == voiceID) {
set = SUCCEEDED(g_pVoice->SetVoice(tok));
tok->Release();
break;
}
tok->Release();
++index;
}
en->Release();
}
}
cat->Release();
return set;
}
// ---------------------------------------------------------------------------
// Speak / stop
// ---------------------------------------------------------------------------
void SpeakText(int voiceID, const std::wstring &text, int destination, int rate,
int volume) {
const int utteranceID = NextUtteranceID();
const int dest = ClampDestination(destination);
InitVoice();
if (g_pVoice == nullptr) {
FirePlaybackFailed("EngineAllocationFailed", utteranceID, dest);
return;
}
if (!SelectVoice(voiceID)) {
FirePlaybackFailed("InternalError", utteranceID, dest);
return;
}
g_pVoice->SetRate(std::clamp(rate, -10, 10));
g_pVoice->SetVolume(static_cast<USHORT>(std::clamp(volume, 0, 100)));
ULONG streamNum = 0;
if (FAILED(g_pVoice->Speak(text.c_str(), SPF_ASYNC, &streamNum))) {
FirePlaybackFailed("InternalError", utteranceID, dest);
return;
}
std::lock_guard<std::mutex> lock(g_streamMx);
g_streamMap[streamNum] = UtteranceMeta{utteranceID, dest, false};
}
void StopAll() {
InitVoice();
if (g_pVoice == nullptr)
return;
g_pVoice->Speak(nullptr, SPF_PURGEBEFORESPEAK, nullptr);
std::lock_guard<std::mutex> lock(g_streamMx);
g_streamMap.clear();
}
// ---------------------------------------------------------------------------
// Lua helpers
// ---------------------------------------------------------------------------
// Push a { {voiceID=, name=}, ... } array built from g_cachedVoices.
void PushVoicesTable(void *L) {
Game::Lua::NewTable(L); // outer array at top
for (size_t i = 0; i < g_cachedVoices.size(); ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1)); // 1-based array key
Game::Lua::NewTable(L); // sub-table
Game::Lua::SetFieldNumber(L, "voiceID", g_cachedVoices[i].voiceID);
Game::Lua::SetFieldString(L, "name",
WideToUtf8(g_cachedVoices[i].name).c_str());
// stack: [outer, key, sub] → outer[key] = sub
Game::Lua::RawSet(L, -3);
}
}
int OptInt(void *L, int idx, int fallback) {
return Game::Lua::IsNumber(L, idx)
? static_cast<int>(Game::Lua::ToNumber(L, idx))
: fallback;
}
// ---------------------------------------------------------------------------
// C_VoiceChat
// ---------------------------------------------------------------------------
int __fastcall Script_GetTtsVoices(void *L) {
RefreshVoices();
PushVoicesTable(L);
return 1;
}
int __fastcall Script_GetRemoteTtsVoices(void *L) {
RefreshVoices();
PushVoicesTable(L);
return 1;
}
int __fastcall Script_SpeakText(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: C_VoiceChat.SpeakText(voiceID, text "
"[, destination, rate, volume])");
return 0;
}
const int voiceID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const std::wstring text = Utf8ToWide(Game::Lua::ToString(L, 2));
const int dest = OptInt(L, 3, DEST_LOCAL_PLAYBACK);
const int rate = OptInt(L, 4, 0);
const int volume = OptInt(L, 5, 100);
SpeakText(voiceID, text, dest, rate, volume);
return 0;
}
int __fastcall Script_StopSpeakingText(void *) {
StopAll();
return 0;
}
// ---------------------------------------------------------------------------
// C_TTSSettings
// ---------------------------------------------------------------------------
int __fastcall Script_GetSpeechRate(void *L) {
Game::Lua::PushNumber(L, CVar::Factory::GetInt(g_cvarSpeed, 0));
return 1;
}
int __fastcall Script_GetSpeechVolume(void *L) {
Game::Lua::PushNumber(L, CVar::Factory::GetInt(g_cvarVolume, 100));
return 1;
}
int __fastcall Script_GetSpeechVoiceID(void *L) {
Game::Lua::PushNumber(L, CVar::Factory::GetInt(g_cvarVoice, 0));
return 1;
}
int __fastcall Script_GetVoiceOptionName(void *L) {
const int id = CVar::Factory::GetInt(g_cvarVoice, 0);
auto voices = EnumerateVoices();
if (id >= 0 && id < static_cast<int>(voices.size()))
Game::Lua::PushString(L, WideToUtf8(voices[id].name).c_str());
else
Game::Lua::PushString(L, "");
return 1;
}
int __fastcall Script_SetSpeechRate(void *L) {
if (Game::Lua::IsNumber(L, 1))
CVar::Factory::SetInt(g_cvarSpeed, static_cast<int>(Game::Lua::ToNumber(L, 1)));
return 0;
}
int __fastcall Script_SetSpeechVolume(void *L) {
if (Game::Lua::IsNumber(L, 1))
CVar::Factory::SetInt(g_cvarVolume, static_cast<int>(Game::Lua::ToNumber(L, 1)));
return 0;
}
int __fastcall Script_SetVoiceOption(void *L) {
if (Game::Lua::IsNumber(L, 1))
CVar::Factory::SetInt(g_cvarVoice, static_cast<int>(Game::Lua::ToNumber(L, 1)));
return 0;
}
int __fastcall Script_SetVoiceOptionByName(void *L) {
if (!Game::Lua::IsString(L, 1))
return 0;
const std::wstring want = Utf8ToWide(Game::Lua::ToString(L, 1));
auto voices = EnumerateVoices();
for (const auto &v : voices) {
if (v.name.size() == want.size()) {
bool eq = true;
for (size_t i = 0; i < want.size(); ++i) {
if (towlower(v.name[i]) != towlower(want[i])) {
eq = false;
break;
}
}
if (eq) {
CVar::Factory::SetInt(g_cvarVoice, v.voiceID);
break;
}
}
}
return 0;
}
int __fastcall Script_SetDefaultSettings(void *) {
const int maxVoice = VoiceCount();
CVar::Factory::SetInt(g_cvarVoice, maxVoice > 1 ? 1 : 0);
CVar::Factory::SetInt(g_cvarSpeed, 0);
CVar::Factory::SetInt(g_cvarVolume, 100);
FireVoicesUpdate();
return 0;
}
int __fastcall Script_RefreshVoices(void *) {
RefreshVoices();
return 0;
}
// ---------------------------------------------------------------------------
// CVar clamp callbacks (re-entrant SetString to clamp; reject out-of-range)
// ---------------------------------------------------------------------------
int __fastcall ClampRange(CVar::Factory::Handle cvar, const char *, const char *next,
int lo, int hi) {
if (next == nullptr)
return 1;
const int v = std::atoi(next);
const int c = std::clamp(v, lo, hi);
if (c != v) {
char buf[16];
std::snprintf(buf, sizeof buf, "%d", c);
CVar::Factory::SetString(cvar, buf);
return 0;
}
return 1;
}
int __fastcall OnSpeedChanged(CVar::Factory::Handle c, const char *p, const char *n, void *) {
return ClampRange(c, p, n, -10, 10);
}
int __fastcall OnVolumeChanged(CVar::Factory::Handle c, const char *p, const char *n, void *) {
return ClampRange(c, p, n, 0, 100);
}
int __fastcall OnVoiceChanged(CVar::Factory::Handle c, const char *p, const char *n, void *) {
const int maxIdx = VoiceCount() - 1;
return ClampRange(c, p, n, 0, maxIdx < 0 ? 0 : maxIdx);
}
// ---------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------
void RegisterLuaFunctions() {
if (StandaloneTtsPresent())
return; // VanillaTTS owns the TTS surface this session — see above.
// Reserve the events on first (real) registration. AutoReserve only
// appends to Event::Custom's reservation list — the actual slot claims
// happen later via the usual RetryClaims walk — so reserving here instead
// of at static-init time changes nothing but the conditionality.
static const Event::Custom::AutoReserve _r1{kEvtPlaybackFailed};
static const Event::Custom::AutoReserve _r2{kEvtPlaybackFinished};
static const Event::Custom::AutoReserve _r3{kEvtPlaybackStarted};
static const Event::Custom::AutoReserve _r4{kEvtSpeakTextUpdate};
static const Event::Custom::AutoReserve _r5{kEvtVoicesUpdate};
g_cvarVoice = CVar::Factory::Register(kCVarVoice, "0", 0, &OnVoiceChanged);
g_cvarSpeed = CVar::Factory::Register(kCVarSpeed, "0", 0, &OnSpeedChanged);
g_cvarVolume = CVar::Factory::Register(kCVarVolume, "100", 0, &OnVolumeChanged);
Game::Lua::RegisterTableFunction("C_VoiceChat", "GetTtsVoices", &Script_GetTtsVoices);
Game::Lua::RegisterTableFunction("C_VoiceChat", "GetRemoteTtsVoices", &Script_GetRemoteTtsVoices);
Game::Lua::RegisterTableFunction("C_VoiceChat", "SpeakText", &Script_SpeakText);
Game::Lua::RegisterTableFunction("C_VoiceChat", "StopSpeakingText", &Script_StopSpeakingText);
Game::Lua::RegisterTableFunction("C_TTSSettings", "GetSpeechRate", &Script_GetSpeechRate);
Game::Lua::RegisterTableFunction("C_TTSSettings", "GetSpeechVolume", &Script_GetSpeechVolume);
Game::Lua::RegisterTableFunction("C_TTSSettings", "GetSpeechVoiceID", &Script_GetSpeechVoiceID);
Game::Lua::RegisterTableFunction("C_TTSSettings", "GetVoiceOptionName", &Script_GetVoiceOptionName);
Game::Lua::RegisterTableFunction("C_TTSSettings", "SetDefaultSettings", &Script_SetDefaultSettings);
Game::Lua::RegisterTableFunction("C_TTSSettings", "SetSpeechRate", &Script_SetSpeechRate);
Game::Lua::RegisterTableFunction("C_TTSSettings", "SetSpeechVolume", &Script_SetSpeechVolume);
Game::Lua::RegisterTableFunction("C_TTSSettings", "SetVoiceOption", &Script_SetVoiceOption);
Game::Lua::RegisterTableFunction("C_TTSSettings", "SetVoiceOptionByName", &Script_SetVoiceOptionByName);
Game::Lua::RegisterTableFunction("C_TTSSettings", "RefreshVoices", &Script_RefreshVoices);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace VoiceChat::Tts