mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-14 07:00:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0b7d03c28 | ||
|
|
15cae8a474 | ||
|
|
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
|
||||
|
||||
@@ -130,8 +131,13 @@ when launching with `-console`), not as Lua functions. See the
|
||||
| `PLAYER_STOPPED_TURNING` | *(none)* |
|
||||
| `QUEST_ACCEPTED` | `questLogIndex, questID` |
|
||||
| `QUEST_DATA_LOAD_RESULT` | `questID, success` |
|
||||
| `QUEST_REMOVED` | `questID` |
|
||||
| `QUEST_TURNED_IN` | `questID, xpReward, moneyReward` |
|
||||
| `UPDATE_SHAPESHIFT_FORM` | *(none)* |
|
||||
| `VOICE_CHAT_TTS_PLAYBACK_STARTED` | `numConsumers, utteranceID, durationMS, destination` |
|
||||
| `VOICE_CHAT_TTS_PLAYBACK_FINISHED` | `numConsumers, utteranceID, destination` |
|
||||
| `VOICE_CHAT_TTS_PLAYBACK_FAILED` | `status, utteranceID, destination` |
|
||||
| `VOICE_CHAT_TTS_VOICES_UPDATE` | *(none)* |
|
||||
|
||||
### Globals
|
||||
|
||||
|
||||
+177
-4
@@ -102,6 +102,7 @@ build instructions.
|
||||
- [`NAME_PLATE_CREATED` / `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` events](#name_plate_created--name_plate_unit_added--name_plate_unit_removed-events)
|
||||
- [`PLAYER_FOCUS_CHANGED` event](#player_focus_changed-event)
|
||||
- [`QUEST_ACCEPTED` event](#quest_accepted-event)
|
||||
- [`QUEST_REMOVED` event](#quest_removed-event)
|
||||
- [`QUEST_TURNED_IN` event](#quest_turned_in-event)
|
||||
- [`UNIT_FACTION` event (fire-coverage fix)](#unit_faction-event-fire-coverage-fix)
|
||||
- [`UPDATE_SHAPESHIFT_FORM` event](#update_shapeshift_form-event)
|
||||
@@ -402,6 +403,14 @@ build instructions.
|
||||
- [`C_UnitAuras.GetUnitAuras(unit [, filter])`](#c_unitaurasgetunitaurasunit--filter)
|
||||
- [`C_UnitAuras.GetAuraDispelTypeColor(dispelName)`](#c_unitaurasgetauradispeltypecolordispelname)
|
||||
|
||||
- [VoiceChat](#voicechat)
|
||||
- [`C_VoiceChat.GetTtsVoices()` / `C_VoiceChat.GetRemoteTtsVoices()`](#c_voicechatgetttsvoices--c_voicechatgetremotettsvoices)
|
||||
- [`C_VoiceChat.SpeakText(voiceID, text [, destination, rate, volume])`](#c_voicechatspeaktextvoiceid-text--destination-rate-volume)
|
||||
- [`C_VoiceChat.StopSpeakingText()`](#c_voicechatstopspeakingtext)
|
||||
- [`C_TTSSettings` — getters & setters](#c_ttssettings--getters--setters)
|
||||
- [TTS events](#tts-events)
|
||||
- [TTS CVars](#tts-cvars)
|
||||
|
||||
## Account
|
||||
|
||||
### `SaveAccount(name, password)` / `DeleteAccount(name)` / `GetSavedAccounts()` / `LoginWithSavedAccount(name)` — GlueXML only
|
||||
@@ -2379,6 +2388,41 @@ quests within the same engine tick, so single-add is always a real
|
||||
user accept. A brand-new character's very first quest accept
|
||||
(`0 → 1` entries) fires correctly.
|
||||
|
||||
### `QUEST_REMOVED` event
|
||||
|
||||
Fires once per quest leaving the local quest log, with the questID as
|
||||
the single payload arg. Covers **both turn-ins and abandons** — the
|
||||
two are distinguishable by whether a `QUEST_TURNED_IN` accompanies
|
||||
the removal. Polyfills modern WoW's event of the same name (added in
|
||||
8.0.1; the Classic-era signature is the bare questID, which is what
|
||||
we match).
|
||||
|
||||
```lua
|
||||
local f = CreateFrame("Frame")
|
||||
f:RegisterEvent("QUEST_REMOVED")
|
||||
f:SetScript("OnEvent", function()
|
||||
if event == "QUEST_REMOVED" then
|
||||
local questID = arg1
|
||||
-- ...
|
||||
end
|
||||
end)
|
||||
```
|
||||
|
||||
Synthesized from the same `FUN_QUEST_LOG_REBUILD` pre-/post-snapshot
|
||||
diff as `QUEST_ACCEPTED` — the removal side of the delta. For
|
||||
turn-ins, `QUEST_REMOVED` fires **after** `QUEST_TURNED_IN`, matching
|
||||
retail ordering: the SMSG_QUESTGIVER_QUEST_COMPLETE packet doesn't
|
||||
touch the log itself; the removal arrives in the follow-up quest-log
|
||||
update packets, whose rebuild triggers the diff. The observed turn-in
|
||||
sequence is `QUEST_TURNED_IN` → `UNIT_QUEST_LOG_CHANGED` →
|
||||
`QUEST_LOG_UPDATE` (fired inside the rebuild) → `QUEST_REMOVED`.
|
||||
|
||||
**Does not fire on login / character-switch resyncs.** Same
|
||||
suppression rule as `QUEST_ACCEPTED` (a user action removes at most
|
||||
one quest per rebuild), plus one extra gate: a rebuild that removes
|
||||
one quest while adding several is a cross-character resync shape, not
|
||||
gameplay, and stays silent.
|
||||
|
||||
### `QUEST_TURNED_IN` event
|
||||
|
||||
Fires when the server confirms a quest turn-in
|
||||
@@ -8268,10 +8312,12 @@ so progress is `(GetTime()*1000 - startTimeMs) / (endTimeMs - startTimeMs)`.
|
||||
> another unit's cast with a server-authoritative cast time, cached per
|
||||
> caster GUID. So `C_Spell.UnitCastingInfo("target")` now backs
|
||||
> enemy/target/focus/nameplate cast bars. Instant casts return `nil` (no
|
||||
> cast bar). Best-effort for remote units, in three ways: only casts that
|
||||
> began while the unit was in range are seen; since 1.12 has no per-unit
|
||||
> interrupt packet to hook, an *interrupted* remote cast lingers until its
|
||||
> computed `endTimeMs` rather than clearing instantly; and the remote
|
||||
> cast bar). Interrupted remote casts clear the same tick — a co-hook on
|
||||
> `SMSG_SPELL_FAILED_OTHER` (the packet the server broadcasts to observers
|
||||
> when a started cast aborts: interrupt, death, movement, fizzle) evicts
|
||||
> the cached cast immediately, so no ghost bar runs to completion.
|
||||
> Best-effort for remote units, in two ways: only casts that began while
|
||||
> the unit was in range are seen; and the remote
|
||||
> `startTimeMs`/`endTimeMs` are shifted later by roughly your latency.
|
||||
> (1.12's `SMSG_SPELL_START` carries only a single `castTime`, so we stamp
|
||||
> `start = now, end = now + castTime` on receipt. 3.3.5+ avoids the skew
|
||||
@@ -9763,3 +9809,130 @@ builds. So the returned value carries the mixin methods
|
||||
`.r/.g/.b/.a` fields. Falls back to a plain `{r,g,b,a}` table if
|
||||
`CreateColor` isn't loaded yet (shouldn't happen — `!!!ClassicAPI`
|
||||
loads first thanks to the triple-`!` prefix).
|
||||
|
||||
## VoiceChat
|
||||
|
||||
Backport of modern WoW's text-to-speech surface — the `C_VoiceChat` and
|
||||
`C_TTSSettings` namespaces — backed by **Windows SAPI** (the OS speech
|
||||
engine). All playback is **local** (rendered on your own machine);
|
||||
vanilla 1.12 has no voice-chat transport, so there is nothing "remote" to
|
||||
transmit synthesized speech into.
|
||||
|
||||
> **Coexistence with VanillaTTS:** the same surface also ships as the
|
||||
> standalone [VanillaTTS](https://github.com/brues-code/VanillaTTS) DLL,
|
||||
> which adds a software-synth backend for Wine. If `VanillaTTS.dll` is
|
||||
> loaded in the same client, this built-in module stands down automatically
|
||||
> (registers none of the functions, events, or cvars below) and VanillaTTS
|
||||
> provides them instead — the Lua-visible API is identical either way, so
|
||||
> addons don't need to care which one is active.
|
||||
|
||||
SAPI is created lazily on first use. If SAPI is unavailable (e.g. running
|
||||
the client under Wine, or a stripped Windows), every call degrades
|
||||
gracefully: `GetTtsVoices` returns an empty table, `SpeakText` fires
|
||||
`VOICE_CHAT_TTS_PLAYBACK_FAILED` and does nothing, and the client stays
|
||||
stable — there is no crash. Install more voices under **Windows Settings →
|
||||
Time & Language → Speech**, then call `C_TTSSettings.RefreshVoices()`.
|
||||
|
||||
### `C_VoiceChat.GetTtsVoices()` / `C_VoiceChat.GetRemoteTtsVoices()`
|
||||
|
||||
Return a numerically-indexed array of voice tables — one per SAPI voice
|
||||
installed on the machine:
|
||||
|
||||
```lua
|
||||
for _, v in ipairs(C_VoiceChat.GetTtsVoices()) do
|
||||
print(v.voiceID, v.name) -- e.g. 0 "Microsoft David Desktop - English (United States)"
|
||||
end
|
||||
```
|
||||
|
||||
Each entry is `{ voiceID = <0-based index>, name = <display name> }`. The
|
||||
`voiceID` is the index you pass to `SpeakText` and store via
|
||||
`C_TTSSettings.SetVoiceOption`. `GetRemoteTtsVoices` is an alias returning
|
||||
the same local list (kept for API parity — vanilla has no voice chat).
|
||||
Calling either refreshes the cached voice list and fires
|
||||
`VOICE_CHAT_TTS_VOICES_UPDATE` if it changed.
|
||||
|
||||
### `C_VoiceChat.SpeakText(voiceID, text [, destination, rate, volume])`
|
||||
|
||||
Speaks `text` aloud using the SAPI voice at index `voiceID`.
|
||||
|
||||
```lua
|
||||
C_VoiceChat.SpeakText(
|
||||
C_TTSSettings.GetSpeechVoiceID(), -- use the configured voice
|
||||
UnitName("target") or "no target")
|
||||
```
|
||||
|
||||
- **`voiceID`** (number, required) — index into `GetTtsVoices()`. Note this
|
||||
is an **explicit argument** — `SpeakText` does *not* read the `ttsVoice`
|
||||
cvar itself, matching Blizzard's contract. Pass
|
||||
`C_TTSSettings.GetSpeechVoiceID()` to honor the configured default.
|
||||
- **`text`** (string, required) — UTF-8; spoken as-is.
|
||||
- **`destination`** (number, optional, default `1`) — `1` =
|
||||
`LOCAL_PLAYBACK`, `4` = `QUEUED_LOCAL_PLAYBACK`. Accepted for parity;
|
||||
both queue FIFO through SAPI (no app-side queue).
|
||||
- **`rate`** (number, optional, default `0`) — speech rate, clamped to
|
||||
`[-10, 10]`.
|
||||
- **`volume`** (number, optional, default `100`) — clamped to `[0, 100]`.
|
||||
|
||||
Speech is asynchronous. The call returns immediately and fires
|
||||
`VOICE_CHAT_TTS_PLAYBACK_STARTED` when SAPI begins the utterance and
|
||||
`VOICE_CHAT_TTS_PLAYBACK_FINISHED` when it completes (or
|
||||
`..._PLAYBACK_FAILED` if the voice/engine is unavailable). Raises a Lua
|
||||
error if `voiceID` isn't a number or `text` isn't a string.
|
||||
|
||||
### `C_VoiceChat.StopSpeakingText()`
|
||||
|
||||
Stops and purges everything currently playing or queued.
|
||||
|
||||
### `C_TTSSettings` — getters & setters
|
||||
|
||||
The settings store (separate from `SpeakText`, which takes its parameters
|
||||
explicitly). All three values are backed by CVars (see below), so they
|
||||
**persist across sessions** and are clamped to valid ranges on write.
|
||||
|
||||
| Function | Returns / effect |
|
||||
|---|---|
|
||||
| `C_TTSSettings.GetSpeechRate()` | configured rate, `[-10, 10]` (default `0`) |
|
||||
| `C_TTSSettings.GetSpeechVolume()` | configured volume, `[0, 100]` (default `100`) |
|
||||
| `C_TTSSettings.GetSpeechVoiceID()` | configured voice index (default `0`) |
|
||||
| `C_TTSSettings.GetVoiceOptionName()` | display name of the configured voice, or `""` |
|
||||
| `C_TTSSettings.SetSpeechRate(rate)` | set rate (clamped) |
|
||||
| `C_TTSSettings.SetSpeechVolume(volume)` | set volume (clamped) |
|
||||
| `C_TTSSettings.SetVoiceOption(voiceID)` | set the voice by index (clamped to installed count) |
|
||||
| `C_TTSSettings.SetVoiceOptionByName(name)` | set the voice by display name (case-insensitive exact match) |
|
||||
| `C_TTSSettings.SetDefaultSettings()` | reset to voice `1` (if present, else `0`), rate `0`, volume `100` |
|
||||
| `C_TTSSettings.RefreshVoices()` | re-enumerate SAPI voices; fires `VOICE_CHAT_TTS_VOICES_UPDATE` if the list changed |
|
||||
|
||||
```lua
|
||||
C_TTSSettings.SetVoiceOptionByName("Microsoft Zira Desktop - English (United States)")
|
||||
C_TTSSettings.SetSpeechVolume(150) -- clamps to 100
|
||||
print(C_TTSSettings.GetVoiceOptionName()) -- "Microsoft Zira Desktop - ..."
|
||||
```
|
||||
|
||||
### TTS events
|
||||
|
||||
Registered like any engine event (`frame:RegisterEvent("VOICE_CHAT_TTS_PLAYBACK_STARTED")`).
|
||||
|
||||
| Event | Args |
|
||||
|---|---|
|
||||
| `VOICE_CHAT_TTS_PLAYBACK_STARTED` | `numConsumers` (number), `utteranceID` (number), `durationMS` (number, always `0`), `destination` (number) |
|
||||
| `VOICE_CHAT_TTS_PLAYBACK_FINISHED` | `numConsumers` (number), `utteranceID` (number), `destination` (number) |
|
||||
| `VOICE_CHAT_TTS_PLAYBACK_FAILED` | `status` (string — e.g. `"EngineAllocationFailed"`, `"InternalError"`), `utteranceID` (number), `destination` (number) |
|
||||
| `VOICE_CHAT_TTS_VOICES_UPDATE` | *(none)* — the installed-voice list changed |
|
||||
| `VOICE_CHAT_TTS_SPEAK_TEXT_UPDATE` | reserved for parity; not currently fired |
|
||||
|
||||
`utteranceID` correlates the START/FINISHED/FAILED events for a given
|
||||
`SpeakText` call. `PLAYBACK_FINISHED` fires only when playback actually
|
||||
completes, so it's the signal to start the next utterance in a queue.
|
||||
|
||||
### TTS CVars
|
||||
|
||||
The `C_TTSSettings` values are stored as engine CVars, persisted to
|
||||
`WTF\Config.wtf`. You can also read/write them with the standard
|
||||
`GetCVar`/`SetCVar` (or `/console set`); writes are clamped by the same
|
||||
validation:
|
||||
|
||||
| CVar | Default | Range | Backing setting |
|
||||
|---|---|---|---|
|
||||
| `ttsVoice` | `0` | `[0, voiceCount-1]` | `GetSpeechVoiceID` / `SetVoiceOption` |
|
||||
| `ttsSpeed` | `0` | `[-10, 10]` | `GetSpeechRate` / `SetSpeechRate` |
|
||||
| `ttsVolume` | `100` | `[0, 100]` | `GetSpeechVolume` / `SetSpeechVolume` |
|
||||
|
||||
+100
-4
@@ -876,6 +876,28 @@ enum Offsets {
|
||||
// reflects pushback. Mirrored from nampower's SpellDelayedHook.
|
||||
FUN_SPELL_DELAYED = 0x006E74F0,
|
||||
|
||||
// `MSG_CHANNEL_START` handler — `int __stdcall(uint32_t *opCode,
|
||||
// CDataStore *packet)`. Body: `spellId(u32), durationMs(u32)`. Sent
|
||||
// only to the caster when their channel begins, with the server-
|
||||
// authoritative duration. `Spell::Cast` co-hooks it to stamp the
|
||||
// player's channel — required for cast-then-channel spells (Mind
|
||||
// Control: 3 s cast then 60 s channel), whose SMSG_SPELL_START only
|
||||
// covers the cast phase. Address + layout mirrored from nampower's
|
||||
// SpellChannelStartHandlerHook (they hook it too — accepted co-hook
|
||||
// collision, per-channel-start frequency).
|
||||
FUN_SPELL_CHANNEL_START = 0x006E7550,
|
||||
|
||||
// `SMSG_SPELL_FAILED_OTHER` handler — `int __stdcall(uint32_t *opCode,
|
||||
// CDataStore *packet)`, same shape as FUN_SPELL_DELAYED. Body:
|
||||
// `casterGuid(u64, plain), spellId(u32)`. Broadcast to observers when a
|
||||
// started cast aborts (interrupt, death, movement, fizzle) — the ONLY
|
||||
// per-unit interrupt signal 1.12 has at the packet layer. `Spell::Cast`
|
||||
// co-hooks it to invalidate the matching remote-cast cache entry (and
|
||||
// the player's own server-stamped chained recast) so an interrupted
|
||||
// cast doesn't linger as a ghost bar until its computed end. Address +
|
||||
// packet layout mirrored from nampower's SpellFailedOtherHandlerHook.
|
||||
FUN_SPELL_FAILED_OTHER = 0x006E8E40,
|
||||
|
||||
// `CGUnit_C::OnAuraAdded` — `__thiscall void(CGUnit *unit, uint32_t
|
||||
// slot, uint32_t spellId)` (i.e. `__fastcall(unit /*ecx*/, edx_unused,
|
||||
// slot, spellId)`). Fires for EVERY aura that lands on a tracked unit,
|
||||
@@ -2418,10 +2440,10 @@ enum Offsets {
|
||||
// walks the player's slot data, repopulates `VAR_QUEST_LOG_ENTRIES`
|
||||
// / `VAR_QUEST_LOG_ENTRY_COUNT`, then fires QUEST_LOG_UPDATE.
|
||||
//
|
||||
// Hooked by `Quest::Accepted` to snapshot the log pre-/post-rebuild
|
||||
// and fire `QUEST_ACCEPTED(logIndex, questID)` for the diff. Single
|
||||
// quiet target in the `0x004DExxx` quest region — no known DLL
|
||||
// collisions.
|
||||
// Hooked by `Quest::LogEvents` to snapshot the log pre-/post-rebuild
|
||||
// and fire `QUEST_ACCEPTED(logIndex, questID)` / `QUEST_REMOVED(questID)`
|
||||
// for the diff. Single quiet target in the `0x004DExxx` quest region —
|
||||
// no known DLL collisions.
|
||||
FUN_QUEST_LOG_REBUILD = 0x004DE510,
|
||||
|
||||
// Player and pet spellbooks — flat int32 arrays indexed by 0-based slot.
|
||||
@@ -4337,6 +4359,80 @@ enum Offsets {
|
||||
FUN_GOSSIP_SELECT_ACTIVE_QUEST = 0x004E2600,
|
||||
FUN_SCRIPT_CLOSE_GOSSIP = 0x004E2B20,
|
||||
|
||||
// Current gossip NPC GUID (u64). Set by the SMSG_GOSSIP_MESSAGE
|
||||
// handler, cleared by the gossip-close worker at 0x004E1FA0 (which
|
||||
// then fires GOSSIP_CLOSED, event 0x12B). Non-zero = a gossip
|
||||
// session is live — the gate that keeps `C_GossipInfo` from serving
|
||||
// the previous NPC's stale arrays/text after close.
|
||||
VAR_GOSSIP_NPC_GUID = 0x00BC3F58,
|
||||
|
||||
// --- Quest-greeting state (SMSG_QUESTGIVER_QUEST_LIST) -------------
|
||||
// Vanilla's SECOND questgiver path: NPCs without a gossip menu send
|
||||
// SMSG_QUESTGIVER_QUEST_LIST, whose handler (FUN_005dbb10) fills a
|
||||
// storage entirely separate from the gossip arrays and fires
|
||||
// QUEST_GREETING (event 300 = 0x12C) — the QuestFrameGreetingPanel
|
||||
// ("Current Quests" / "Available Quests"). Vanilla Lua reads it via
|
||||
// GetGreetingText / Get{Available,Active}Title / etc.; C_GossipInfo
|
||||
// merges it in so modern-addon ports work on greeting NPCs too.
|
||||
//
|
||||
// Shared quest-session state, written by the panel-begin helper
|
||||
// FUN_00500bd0(panelType, textID, guidLo, guidHi, ...):
|
||||
// VAR_QUESTGIVER_GUID — u64, the current questgiver. Cleared by
|
||||
// the session-close worker FUN_00501130 (fires QUEST_FINISHED,
|
||||
// event 0x130). Non-zero = quest session live.
|
||||
// VAR_QUEST_PANEL_TYPE — i32 panel: 0 greeting, 1 detail,
|
||||
// 2 progress, 3 reward. begin(0) also zeroes both greeting
|
||||
// arrays + counts + the text buffers.
|
||||
VAR_QUESTGIVER_GUID = 0x00BE0810,
|
||||
VAR_QUEST_PANEL_TYPE = 0x00BE0818,
|
||||
|
||||
// GUID the greeting arrays were filled for — written ONLY by the
|
||||
// SMSG_QUESTGIVER_QUEST_LIST handler. The greeting arrays are valid
|
||||
// iff this equals VAR_QUESTGIVER_GUID (and that is non-zero);
|
||||
// otherwise they're leftovers from an older NPC (e.g. the current
|
||||
// quest session came from a gossip-menu quest click, which begins
|
||||
// at panel 1 without ever clearing the greeting arrays).
|
||||
VAR_GREETING_NPC_GUID = 0x00C4D740,
|
||||
|
||||
// Greeting text (0x200 inline, $N/$C-expanded) — what vanilla's
|
||||
// Script_GetGreetingText pushes.
|
||||
VAR_QUEST_GREETING_TEXT = 0x00BDE068,
|
||||
|
||||
// The two greeting quest arrays: 32 entries, stride 0x4C =
|
||||
// { u32 questID @ +0, i32 level @ +4, char title[0x40] @ +8,
|
||||
// u32 extra @ +0x48 (available: status==0 flag; active: always
|
||||
// 0 — the appender FUN_00500e90 never stores the status, see
|
||||
// the scratch arrays below for isComplete) }.
|
||||
// Appenders: FUN_00500e40 (available), FUN_00500e90 (active).
|
||||
// Readers: Script_Get{Available,Active}{Title,Level} @ 0x00501AC0/
|
||||
// 0x00501B30/0x00501BA0/0x00501C20.
|
||||
VAR_GREETING_AVAILABLE_ENTRIES = 0x00BDFE60,
|
||||
VAR_GREETING_ACTIVE_ENTRIES = 0x00BDE690,
|
||||
GREETING_QUESTS_STRIDE = 0x4C,
|
||||
GREETING_QUESTS_MAX = 32,
|
||||
OFF_GREETING_QUEST_ID = 0x000,
|
||||
OFF_GREETING_QUEST_LEVEL = 0x004,
|
||||
OFF_GREETING_QUEST_TITLE = 0x008,
|
||||
VAR_GREETING_AVAILABLE_COUNT = 0x00BE0834,
|
||||
VAR_GREETING_ACTIVE_COUNT = 0x00BE0838,
|
||||
|
||||
// Scratch arrays the SMSG_QUESTGIVER_QUEST_LIST handler fills in
|
||||
// packet order (32 × u32 each, zeroed per packet): questIDs and the
|
||||
// per-quest dialog status. Status 3|4 = active (same sentinel as
|
||||
// the gossip quest rows), 4 = complete/turn-in-ready. The canonical
|
||||
// greeting arrays drop the status, so `isComplete` for an active
|
||||
// greeting quest is recovered by questID lookup here.
|
||||
VAR_GREETING_SCRATCH_IDS = 0x00C4D2B0,
|
||||
VAR_GREETING_SCRATCH_STATUS = 0x00C4D340,
|
||||
|
||||
// Engine greeting-quest selectors — the workers vanilla's
|
||||
// Script_Select{Available,Active}Quest call: `__fastcall(int
|
||||
// idx0Based)`, index straight into the corresponding greeting
|
||||
// array. They validate VAR_QUESTGIVER_GUID internally and send
|
||||
// CMSG_QUESTGIVER_{QUERY_QUEST,COMPLETE_QUEST}.
|
||||
FUN_GREETING_SELECT_AVAILABLE_QUEST = 0x005012A0,
|
||||
FUN_GREETING_SELECT_ACTIVE_QUEST = 0x00501320,
|
||||
|
||||
// CDataStore primitive readers — `__thiscall void(this, void *out)`.
|
||||
// Read at the current cursor (`+0x14`) and advance it by the value
|
||||
// size. We call these directly from `Unit::MirrorTimer` to peek
|
||||
|
||||
+29
-26
@@ -30,6 +30,16 @@ constexpr int kMaxDepth = 64;
|
||||
|
||||
// ---------------- encode ----------------
|
||||
|
||||
// tinycbor's grow-and-retry contract: on buffer overflow the encoder
|
||||
// switches to counting mode (end = NULL, accumulates bytes_needed) and
|
||||
// encoding must CONTINUE so the final tally is complete — a nested
|
||||
// container's tally only reaches the parent via close_container. So
|
||||
// CborErrorOutOfMemory is not a failure here; the caller reads
|
||||
// cbor_encoder_get_extra_bytes_needed() afterwards, resizes, retries.
|
||||
bool EncOk(CborError err) {
|
||||
return err == CborNoError || err == CborErrorOutOfMemory;
|
||||
}
|
||||
|
||||
bool EncodeValue(void *L, int idx, CborEncoder *enc, int depth);
|
||||
|
||||
bool IsLuaArray(void *L, int idx, int &outLen) {
|
||||
@@ -73,8 +83,8 @@ bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
|
||||
int arrayLen = 0;
|
||||
if (IsLuaArray(L, idx, arrayLen)) {
|
||||
CborEncoder arr;
|
||||
if (cbor_encoder_create_array(enc, &arr,
|
||||
static_cast<size_t>(arrayLen)) != CborNoError)
|
||||
if (!EncOk(cbor_encoder_create_array(enc, &arr,
|
||||
static_cast<size_t>(arrayLen))))
|
||||
return false;
|
||||
for (int i = 1; i <= arrayLen; ++i) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(i));
|
||||
@@ -85,12 +95,12 @@ bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
|
||||
}
|
||||
Game::Lua::SetTop(L, -2);
|
||||
}
|
||||
return cbor_encoder_close_container(enc, &arr) == CborNoError;
|
||||
return EncOk(cbor_encoder_close_container(enc, &arr));
|
||||
}
|
||||
|
||||
const int pairs = CountTablePairs(L, idx);
|
||||
CborEncoder map;
|
||||
if (cbor_encoder_create_map(enc, &map, static_cast<size_t>(pairs)) != CborNoError)
|
||||
if (!EncOk(cbor_encoder_create_map(enc, &map, static_cast<size_t>(pairs))))
|
||||
return false;
|
||||
Game::Lua::PushNil(L);
|
||||
while (Game::Lua::Next(L, idx) != 0) {
|
||||
@@ -101,16 +111,16 @@ bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
|
||||
}
|
||||
Game::Lua::SetTop(L, -2);
|
||||
}
|
||||
return cbor_encoder_close_container(enc, &map) == CborNoError;
|
||||
return EncOk(cbor_encoder_close_container(enc, &map));
|
||||
}
|
||||
|
||||
bool EncodeValue(void *L, int idx, CborEncoder *enc, int depth) {
|
||||
switch (Game::Lua::Type(L, idx)) {
|
||||
case Game::Lua::TYPE_NIL:
|
||||
return cbor_encode_null(enc) == CborNoError;
|
||||
return EncOk(cbor_encode_null(enc));
|
||||
case Game::Lua::TYPE_BOOLEAN:
|
||||
return cbor_encode_boolean(enc,
|
||||
Game::Lua::ToBoolean(L, idx) != 0) == CborNoError;
|
||||
return EncOk(cbor_encode_boolean(enc,
|
||||
Game::Lua::ToBoolean(L, idx) != 0));
|
||||
case Game::Lua::TYPE_NUMBER: {
|
||||
const double n = Game::Lua::ToNumber(L, idx);
|
||||
// Whole numbers in [-2^53, 2^53] go out as integers so
|
||||
@@ -118,17 +128,17 @@ bool EncodeValue(void *L, int idx, CborEncoder *enc, int depth) {
|
||||
if (std::isfinite(n) && n == std::floor(n) &&
|
||||
n >= -9007199254740992.0 && n <= 9007199254740992.0) {
|
||||
if (n >= 0.0)
|
||||
return cbor_encode_uint(enc,
|
||||
static_cast<uint64_t>(n)) == CborNoError;
|
||||
return cbor_encode_negative_int(enc,
|
||||
static_cast<uint64_t>(-n)) == CborNoError;
|
||||
return EncOk(cbor_encode_uint(enc,
|
||||
static_cast<uint64_t>(n)));
|
||||
return EncOk(cbor_encode_negative_int(enc,
|
||||
static_cast<uint64_t>(-n)));
|
||||
}
|
||||
return cbor_encode_double(enc, n) == CborNoError;
|
||||
return EncOk(cbor_encode_double(enc, n));
|
||||
}
|
||||
case Game::Lua::TYPE_STRING: {
|
||||
const char *s = Game::Lua::ToString(L, idx);
|
||||
const unsigned int n = Game::Lua::StrLen(L, idx);
|
||||
return cbor_encode_text_string(enc, s, n) == CborNoError;
|
||||
return EncOk(cbor_encode_text_string(enc, s, n));
|
||||
}
|
||||
case Game::Lua::TYPE_TABLE:
|
||||
return EncodeTable(L, idx, enc, depth);
|
||||
@@ -143,22 +153,15 @@ int __fastcall Script_SerializeCBOR(void *L) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// tinycbor encodes into a fixed-size buffer; if it overflows, it
|
||||
// tracks the extra bytes needed so we can resize and retry.
|
||||
// tinycbor encodes into a fixed-size buffer; on overflow it keeps
|
||||
// encoding in counting mode (EncOk treats OutOfMemory as success) so
|
||||
// the extra-bytes tally is complete, then we resize and retry once.
|
||||
std::vector<uint8_t> buf(256);
|
||||
for (;;) {
|
||||
CborEncoder enc;
|
||||
cbor_encoder_init(&enc, buf.data(), buf.size(), 0);
|
||||
if (!EncodeValue(L, 1, &enc, 0)) {
|
||||
// If it was a buffer-size issue we'll catch it via the
|
||||
// extra-bytes-needed check below; otherwise this is a
|
||||
// genuine "value not representable" failure.
|
||||
const size_t extra = cbor_encoder_get_extra_bytes_needed(&enc);
|
||||
if (extra == 0)
|
||||
return 0;
|
||||
buf.resize(buf.size() + extra);
|
||||
continue;
|
||||
}
|
||||
if (!EncodeValue(L, 1, &enc, 0))
|
||||
return 0; // genuine "value not representable" failure
|
||||
const size_t extra = cbor_encoder_get_extra_bytes_needed(&enc);
|
||||
if (extra > 0) {
|
||||
buf.resize(buf.size() + extra);
|
||||
|
||||
+159
-11
@@ -22,6 +22,24 @@
|
||||
// SMSG_GOSSIP_MESSAGE handler at `0x004E26E0` and cleared each open.
|
||||
// We read the same shape the engine's own `Script_GetGossip*` reads.
|
||||
//
|
||||
// Vanilla has a SECOND questgiver path this namespace also covers:
|
||||
// NPCs without a gossip menu send SMSG_QUESTGIVER_QUEST_LIST instead —
|
||||
// the QUEST_GREETING event and the QuestFrameGreetingPanel ("Current
|
||||
// Quests" / "Available Quests") — whose data lands in a storage
|
||||
// entirely separate from the gossip arrays (see the VAR_GREETING_*
|
||||
// block in Offsets.h). Retail keeps the two APIs disjoint, but for
|
||||
// backporting it's far more useful for `C_GossipInfo` to serve
|
||||
// whichever questgiver session is actually live:
|
||||
// - gossip session live (VAR_GOSSIP_NPC_GUID != 0) → gossip arrays
|
||||
// - greeting session live (VAR_QUESTGIVER_GUID != 0
|
||||
// and == VAR_GREETING_NPC_GUID) → greeting arrays
|
||||
// - neither → empty / ""
|
||||
// The GUID gates double as staleness protection: both storages persist
|
||||
// after their frames close (the engine never clears them until the
|
||||
// next open), so ungated reads would report the previous NPC.
|
||||
// `GetOptions`/`GetNumOptions` stay gossip-only — greetings have no
|
||||
// options.
|
||||
//
|
||||
// Missing modern fields — vanilla 1.12's server simply never sends
|
||||
// them, so there's nothing to surface:
|
||||
// - `rewards`, `spellID` (post-vanilla; merged in with the
|
||||
@@ -46,6 +64,57 @@ namespace {
|
||||
|
||||
using EngineScriptFn = int(__fastcall *)(void *L);
|
||||
|
||||
uint64_t ReadU64(uintptr_t va) {
|
||||
return *reinterpret_cast<const uint64_t *>(va);
|
||||
}
|
||||
|
||||
// A gossip session is live from SMSG_GOSSIP_MESSAGE until the close
|
||||
// worker (0x004E1FA0) zeroes the GUID and fires GOSSIP_CLOSED.
|
||||
bool GossipSessionActive() {
|
||||
return ReadU64(Offsets::VAR_GOSSIP_NPC_GUID) != 0;
|
||||
}
|
||||
|
||||
// A quest-greeting session is live while the quest-session GUID is set
|
||||
// AND the greeting arrays were filled for that same NPC. The second
|
||||
// check matters because a gossip-menu quest click opens a quest session
|
||||
// at panel 1 without ever touching the greeting arrays — without it,
|
||||
// leftovers from an older greeting NPC would leak through.
|
||||
bool GreetingSessionActive() {
|
||||
const uint64_t giver = ReadU64(Offsets::VAR_QUESTGIVER_GUID);
|
||||
return giver != 0 && giver == ReadU64(Offsets::VAR_GREETING_NPC_GUID);
|
||||
}
|
||||
|
||||
const uint8_t *GreetingEntry(bool active, int slot) {
|
||||
const uintptr_t base = active ? Offsets::VAR_GREETING_ACTIVE_ENTRIES
|
||||
: Offsets::VAR_GREETING_AVAILABLE_ENTRIES;
|
||||
return reinterpret_cast<const uint8_t *>(
|
||||
base + static_cast<uintptr_t>(slot) * Offsets::GREETING_QUESTS_STRIDE);
|
||||
}
|
||||
|
||||
int GreetingCount(bool active) {
|
||||
const int count = *reinterpret_cast<const int *>(
|
||||
active ? Offsets::VAR_GREETING_ACTIVE_COUNT
|
||||
: Offsets::VAR_GREETING_AVAILABLE_COUNT);
|
||||
return (count < 0) ? 0
|
||||
: (count > Offsets::GREETING_QUESTS_MAX) ? Offsets::GREETING_QUESTS_MAX
|
||||
: count;
|
||||
}
|
||||
|
||||
// The greeting arrays drop the per-quest dialog status, but the packet
|
||||
// handler's scratch arrays (packet order, zeroed per packet) keep it —
|
||||
// status 4 = complete/turn-in-ready, same sentinel as the gossip rows.
|
||||
bool GreetingQuestIsComplete(uint32_t questID) {
|
||||
const auto *ids = reinterpret_cast<const uint32_t *>(
|
||||
Offsets::VAR_GREETING_SCRATCH_IDS);
|
||||
const auto *status = reinterpret_cast<const uint32_t *>(
|
||||
Offsets::VAR_GREETING_SCRATCH_STATUS);
|
||||
for (int i = 0; i < Offsets::GREETING_QUESTS_MAX; ++i) {
|
||||
if (ids[i] == questID)
|
||||
return status[i] == 4;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint8_t *OptionEntry(int slot) {
|
||||
return reinterpret_cast<const uint8_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_GOSSIP_OPTIONS) +
|
||||
@@ -79,14 +148,21 @@ bool IsActiveQuest(uint32_t status) {
|
||||
return status == 3 || status == 4;
|
||||
}
|
||||
|
||||
// `C_GossipInfo.GetText()` — pushes the greeting string the engine
|
||||
// resolved for the gossip-giver's NPC_TEXT.dbc entry. Mirrors what
|
||||
// `Script_GetGossipText` does (one inline `lua_pushstring` of the
|
||||
// fixed buffer at `VAR_GOSSIP_GREETING_TEXT`).
|
||||
// `C_GossipInfo.GetText()` — the greeting string of whichever
|
||||
// questgiver session is live: the gossip buffer (what
|
||||
// `Script_GetGossipText` pushes) for a gossip session, the quest
|
||||
// greeting buffer (what `Script_GetGreetingText` pushes) for a
|
||||
// greeting session, "" when neither is open. Both buffers persist
|
||||
// after close, so the gates keep stale text from the previous NPC
|
||||
// out.
|
||||
int __fastcall Script_C_GossipInfo_GetText(void *L) {
|
||||
const char *text = reinterpret_cast<const char *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_GOSSIP_GREETING_TEXT));
|
||||
Game::Lua::PushString(L, text);
|
||||
uintptr_t buf = 0;
|
||||
if (GossipSessionActive())
|
||||
buf = Offsets::VAR_GOSSIP_GREETING_TEXT;
|
||||
else if (GreetingSessionActive())
|
||||
buf = Offsets::VAR_QUEST_GREETING_TEXT;
|
||||
Game::Lua::PushString(
|
||||
L, (buf != 0) ? reinterpret_cast<const char *>(buf) : "");
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -108,6 +184,8 @@ int __fastcall Script_C_GossipInfo_GetText(void *L) {
|
||||
int __fastcall Script_C_GossipInfo_GetOptions(void *L) {
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
if (!GossipSessionActive())
|
||||
return 1; // greeting sessions have no options; stale data stays hidden
|
||||
|
||||
int outIdx = 0;
|
||||
for (int slot = 0; slot < Offsets::GOSSIP_OPTIONS_MAX; ++slot) {
|
||||
@@ -138,6 +216,36 @@ int __fastcall Script_C_GossipInfo_GetOptions(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Greeting-session variant: walks the pre-split greeting array for the
|
||||
// wanted side. Same table shape as the gossip rows (questID / title /
|
||||
// questLevel / isComplete) so consumers can't tell the sources apart.
|
||||
int PushGreetingQuestList(void *L, bool wantActive) {
|
||||
const int count = GreetingCount(wantActive);
|
||||
for (int slot = 0; slot < count; ++slot) {
|
||||
const uint8_t *entry = GreetingEntry(wantActive, slot);
|
||||
const uint32_t questID = *reinterpret_cast<const uint32_t *>(
|
||||
entry + Offsets::OFF_GREETING_QUEST_ID);
|
||||
|
||||
Game::Lua::PushNumber(L, static_cast<double>(slot + 1));
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
Game::Lua::SetFieldNumber(L, "questID",
|
||||
static_cast<double>(questID));
|
||||
Game::Lua::SetFieldString(L, "title", reinterpret_cast<const char *>(
|
||||
entry + Offsets::OFF_GREETING_QUEST_TITLE));
|
||||
const int32_t level = *reinterpret_cast<const int32_t *>(
|
||||
entry + Offsets::OFF_GREETING_QUEST_LEVEL);
|
||||
Game::Lua::SetFieldNumber(L, "questLevel",
|
||||
static_cast<double>(level));
|
||||
if (wantActive)
|
||||
Game::Lua::SetFieldBool(L, "isComplete",
|
||||
GreetingQuestIsComplete(questID));
|
||||
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Shared body for `GetAvailableQuests` / `GetActiveQuests`. Both walk
|
||||
// the same backing array and split by the `status` field — `wantActive`
|
||||
// inverts the filter.
|
||||
@@ -145,6 +253,12 @@ int PushQuestList(void *L, bool wantActive) {
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
if (!GossipSessionActive()) {
|
||||
if (GreetingSessionActive())
|
||||
return PushGreetingQuestList(L, wantActive);
|
||||
return 1; // no questgiver session — empty table, never stale data
|
||||
}
|
||||
|
||||
int outIdx = 0;
|
||||
for (int slot = 0; slot < Offsets::GOSSIP_QUESTS_MAX; ++slot) {
|
||||
const uint8_t *entry = QuestEntry(slot);
|
||||
@@ -189,15 +303,19 @@ int __fastcall Script_C_GossipInfo_GetActiveQuests(void *L) {
|
||||
|
||||
int __fastcall Script_C_GossipInfo_GetNumOptions(void *L) {
|
||||
int count = 0;
|
||||
for (int slot = 0; slot < Offsets::GOSSIP_OPTIONS_MAX; ++slot) {
|
||||
if (OptionIndex(OptionEntry(slot)) >= 0)
|
||||
count += 1;
|
||||
if (GossipSessionActive()) {
|
||||
for (int slot = 0; slot < Offsets::GOSSIP_OPTIONS_MAX; ++slot) {
|
||||
if (OptionIndex(OptionEntry(slot)) >= 0)
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Game::Lua::PushNumber(L, static_cast<double>(count));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int CountQuests(bool wantActive) {
|
||||
if (!GossipSessionActive())
|
||||
return GreetingSessionActive() ? GreetingCount(wantActive) : 0;
|
||||
int count = 0;
|
||||
for (int slot = 0; slot < Offsets::GOSSIP_QUESTS_MAX; ++slot) {
|
||||
const uint8_t *entry = QuestEntry(slot);
|
||||
@@ -247,6 +365,17 @@ void EngineSelectActiveQuest(int idx0Based) {
|
||||
fn(idx0Based);
|
||||
}
|
||||
|
||||
// Greeting-session selectors — the workers vanilla's
|
||||
// Script_Select{Available,Active}Quest call. Index straight into the
|
||||
// corresponding greeting array (they validate the questgiver GUID
|
||||
// internally before sending the CMSG).
|
||||
void EngineSelectGreetingQuest(bool wantActive, int idx0Based) {
|
||||
auto fn = reinterpret_cast<SelectQuest_t>(
|
||||
wantActive ? Offsets::FUN_GREETING_SELECT_ACTIVE_QUEST
|
||||
: Offsets::FUN_GREETING_SELECT_AVAILABLE_QUEST);
|
||||
fn(idx0Based);
|
||||
}
|
||||
|
||||
// `C_GossipInfo.SelectOption(gossipOptionID[, text])` — wraps vanilla's
|
||||
// `SelectGossipOption(slotIndex)`. `text` is the boxCoded password,
|
||||
// passed through to the engine helper which gates on the
|
||||
@@ -260,6 +389,8 @@ int __fastcall Script_C_GossipInfo_SelectOption(void *L) {
|
||||
const int target = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
const char *password =
|
||||
Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : nullptr;
|
||||
if (!GossipSessionActive())
|
||||
return 0; // stale option slots must not send a CMSG
|
||||
|
||||
for (int slot = 0; slot < Offsets::GOSSIP_OPTIONS_MAX; ++slot) {
|
||||
const uint8_t *entry = OptionEntry(slot);
|
||||
@@ -284,7 +415,8 @@ int __fastcall Script_C_GossipInfo_SelectOptionByIndex(void *L) {
|
||||
return 0;
|
||||
}
|
||||
const int slot0Based = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
|
||||
if (slot0Based < 0 || slot0Based >= Offsets::GOSSIP_OPTIONS_MAX)
|
||||
if (slot0Based < 0 || slot0Based >= Offsets::GOSSIP_OPTIONS_MAX ||
|
||||
!GossipSessionActive())
|
||||
return 0;
|
||||
EngineSelectOption(slot0Based, nullptr);
|
||||
return 0;
|
||||
@@ -301,6 +433,22 @@ int SelectQuestByID(void *L, bool wantActive, const char *errUsage) {
|
||||
}
|
||||
const uint32_t target = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
|
||||
|
||||
if (!GossipSessionActive()) {
|
||||
if (!GreetingSessionActive())
|
||||
return 0;
|
||||
const int count = GreetingCount(wantActive);
|
||||
for (int slot = 0; slot < count; ++slot) {
|
||||
const uint32_t questID = *reinterpret_cast<const uint32_t *>(
|
||||
GreetingEntry(wantActive, slot) +
|
||||
Offsets::OFF_GREETING_QUEST_ID);
|
||||
if (questID == target) {
|
||||
EngineSelectGreetingQuest(wantActive, slot);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int idx = 0;
|
||||
for (int slot = 0; slot < Offsets::GOSSIP_QUESTS_MAX; ++slot) {
|
||||
const uint8_t *entry = QuestEntry(slot);
|
||||
|
||||
@@ -11,18 +11,29 @@
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `QUEST_ACCEPTED(questLogIndex, questID)` event — fires once per
|
||||
// new quest entering the local quest log. Polyfills modern WoW's
|
||||
// event of the same name (added in 3.1.0). Matches the Cata/WotLK
|
||||
// signature: `(questLogIndex, questID)`, 1-based log index.
|
||||
// Quest log delta events — polyfills two modern WoW events from one
|
||||
// pre-/post-rebuild diff:
|
||||
//
|
||||
// `QUEST_ACCEPTED(questLogIndex, questID)` — fires once per new quest
|
||||
// entering the local quest log. Matches the Cata/WotLK signature,
|
||||
// 1-based log index (event added in 3.1.0).
|
||||
//
|
||||
// `QUEST_REMOVED(questID)` — fires once per quest leaving the local
|
||||
// quest log, for both turn-ins and abandons (event added in 8.0.1;
|
||||
// Classic-era signature is the bare questID). For turn-ins this fires
|
||||
// *after* `QUEST_TURNED_IN`, matching retail ordering: the 0x191
|
||||
// complete packet doesn't touch the log; the removal arrives in the
|
||||
// follow-up quest-log update packets, whose rebuild we diff here.
|
||||
// Observed turn-in sequence: QUEST_TURNED_IN → UNIT_QUEST_LOG_CHANGED
|
||||
// → QUEST_LOG_UPDATE (fired inside the rebuild) → QUEST_REMOVED.
|
||||
//
|
||||
// Hook target: `FUN_QUEST_LOG_REBUILD` (`0x004DE510`) — the single
|
||||
// chokepoint the engine uses to refresh the Lua-visible quest log
|
||||
// from the player's authoritative slot data at `[CGPlayer + 0xE68 +
|
||||
// 0x28]`. Every state change that adds, removes, or updates a quest
|
||||
// flows through here. By snapshotting the log before and after the
|
||||
// original runs, we can compute the delta and fire QUEST_ACCEPTED
|
||||
// only for genuine additions.
|
||||
// original runs, we can compute the delta and fire only for genuine
|
||||
// additions / removals.
|
||||
//
|
||||
// Login suppression: the same function handles the initial bulk-sync
|
||||
// at character entry, which appears as a single rebuild call adding
|
||||
@@ -30,7 +41,10 @@
|
||||
// added in a single rebuild (which can only happen on login / reload
|
||||
// resync — human input speed can't accept multiple quests in the
|
||||
// same engine tick). A brand-new character's first accept is
|
||||
// `0 → 1` and fires correctly.
|
||||
// `0 → 1` and fires correctly. Removals apply the same rule (a user
|
||||
// action removes at most one quest per rebuild) and are additionally
|
||||
// suppressed when the rebuild also added multiple quests — a
|
||||
// mixed-delta rebuild is a cross-character resync, not gameplay.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
@@ -39,13 +53,15 @@
|
||||
#include <cstdint>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace Quest::Accepted {
|
||||
namespace Quest::LogEvents {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kEventName = "QUEST_ACCEPTED";
|
||||
constexpr const char *kAcceptedEventName = "QUEST_ACCEPTED";
|
||||
constexpr const char *kRemovedEventName = "QUEST_REMOVED";
|
||||
|
||||
const Event::Custom::AutoReserve _reserve{kEventName};
|
||||
const Event::Custom::AutoReserve _reserveAccepted{kAcceptedEventName};
|
||||
const Event::Custom::AutoReserve _reserveRemoved{kRemovedEventName};
|
||||
|
||||
// Walk the live quest log and collect questIDs of real (non-header)
|
||||
// entries. Headers are 16-byte rows with a non-NULL pointer at +0x8;
|
||||
@@ -80,7 +96,7 @@ QuestLogRebuild_t QuestLogRebuild_o = nullptr;
|
||||
void __fastcall QuestLogRebuild_h(int param_1) {
|
||||
// `param_1 == 0` is the engine's "no-op rebuild" signal — just
|
||||
// refires QUEST_LOG_UPDATE without touching the entry array.
|
||||
// No diff possible, no accept happening, pass through.
|
||||
// No diff possible, no accept/remove happening, pass through.
|
||||
if (param_1 == 0) {
|
||||
QuestLogRebuild_o(param_1);
|
||||
return;
|
||||
@@ -93,14 +109,17 @@ void __fastcall QuestLogRebuild_h(int param_1) {
|
||||
|
||||
// Walk the post-state in index order so the fired event's
|
||||
// `questLogIndex` matches the 1-based slot the engine just
|
||||
// assigned. Skip questIDs already in `pre`.
|
||||
// assigned. Collect the full post-set on the way for the
|
||||
// removal diff.
|
||||
const int count = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_QUEST_LOG_ENTRY_COUNT));
|
||||
if (count <= 0)
|
||||
return;
|
||||
const auto *entries = reinterpret_cast<const uint8_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_QUEST_LOG_ENTRIES));
|
||||
|
||||
std::unordered_set<int> post;
|
||||
if (count > 0)
|
||||
post.reserve(static_cast<size_t>(count));
|
||||
|
||||
int newCount = 0;
|
||||
int firstNewIdx = 0;
|
||||
int firstNewID = 0;
|
||||
@@ -114,6 +133,7 @@ void __fastcall QuestLogRebuild_h(int param_1) {
|
||||
entry + Offsets::OFF_QUEST_LOG_ENTRY_QUEST_ID);
|
||||
if (questID <= 0)
|
||||
continue;
|
||||
post.insert(questID);
|
||||
if (pre.count(questID) != 0)
|
||||
continue;
|
||||
++newCount;
|
||||
@@ -123,14 +143,35 @@ void __fastcall QuestLogRebuild_h(int param_1) {
|
||||
}
|
||||
}
|
||||
|
||||
int removedCount = 0;
|
||||
int firstRemovedID = 0;
|
||||
for (const int questID : pre) {
|
||||
if (post.count(questID) != 0)
|
||||
continue;
|
||||
++removedCount;
|
||||
if (removedCount == 1)
|
||||
firstRemovedID = questID;
|
||||
}
|
||||
|
||||
// Single addition → user-driven accept. Multiple → bulk resync
|
||||
// (login or post-reload). Modern WoW's QUEST_ACCEPTED doesn't
|
||||
// fire on initial sync either; we match that.
|
||||
if (newCount == 1) {
|
||||
const int slot = Event::Custom::Lookup(kEventName);
|
||||
const int slot = Event::Custom::Lookup(kAcceptedEventName);
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "%d%d", firstNewIdx, firstNewID);
|
||||
}
|
||||
|
||||
// Single removal → user-driven turn-in or abandon. The extra
|
||||
// `newCount <= 1` gate rejects the one resync shape the removal
|
||||
// count alone can't: switching to a character whose log shares
|
||||
// all but one of the previous character's quests (1 removal, N
|
||||
// additions in the same rebuild).
|
||||
if (removedCount == 1 && newCount <= 1) {
|
||||
const int slot = Event::Custom::Lookup(kRemovedEventName);
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "%d", firstRemovedID);
|
||||
}
|
||||
}
|
||||
|
||||
static const Game::HookAutoRegister _hookreg{
|
||||
@@ -138,4 +179,4 @@ static const Game::HookAutoRegister _hookreg{
|
||||
reinterpret_cast<void *>(&QuestLogRebuild_h),
|
||||
reinterpret_cast<void **>(&QuestLogRebuild_o)};
|
||||
|
||||
} // namespace Quest::Accepted
|
||||
} // namespace Quest::LogEvents
|
||||
+113
-14
@@ -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