mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-13 14:47:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49865e9b9e | ||
|
|
98d4504b6f | ||
|
|
597530d558 | ||
|
|
2165a8e986 |
+4
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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` |
|
||||
|
||||
@@ -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
@@ -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
@@ -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\")");
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user