Compare commits

...
4 Commits
Author SHA1 Message Date
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
7 changed files with 908 additions and 45 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
+5
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
@@ -132,6 +133,10 @@ when launching with `-console`), not as Lua functions. See the
| `QUEST_DATA_LOAD_RESULT` | `questID, success` |
| `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
+141 -4
View File
@@ -402,6 +402,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
@@ -8268,10 +8276,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 +9773,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` |
+22
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,
+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);
+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