plans.md aktualisiert

This commit is contained in:
2026-08-17 18:25:41 +02:00
parent acbd07486b
commit 121a63697f
2 changed files with 113 additions and 470 deletions
+113
View File
@@ -0,0 +1,113 @@
---
name: Plugin Server Data Plan
overview: "Concrete work plan for the current V8 plugin (Plugin API 26): improve what we send via retail TeamSpeak (plugin commands + hub policy). No ServerLib app. No hybrid authority in v1. Inventory already in vergleichs.md."
todos:
- id: inventory-wire
content: "DONE — vergleichs.md inventories CEPOS, hub keys, used/unused API 26"
status: completed
- id: protocol-design
content: "Write doku design: versioned CE* plugin-command map + hub schema version (doc only, freeze wire rules)"
status: pending
- id: hub-schema-vnext
content: "Specify additive hub keys (feature flags, schema version) — clients read only"
status: pending
- id: implement-cemode
content: "First code WP: CEMODE (or equivalent) edge send on voice-mode change — one function, keep CEPOS"
status: pending
- id: wire-filter-intensity
content: "Optional follow-up WP: actually wire hubAudioFilterIntensity / FilterIntensity into PCM if still unwired"
status: pending
- id: whisper-experiment
content: "Later experiment only: requestClientSetWhisperList for admin overlay — not proximity"
status: pending
- id: no-serverlib
content: "Never revive ClientLib/ServerLib custom app in this workstream"
status: pending
isProject: false
---
# Current-plugin plan: better use of retail TeamSpeak (API 26)
**Locked product:** existing V8 DLL on retail TeamSpeak (`PLUGIN_API_VERSION 26`).
**Locked approach:** **A-only** — send/store more via TeamSpeak relay + hub description.
**Deferred:** hybrid voice authority, radio product, ServerLib custom app.
**Baseline inventory:** [`vergleichs.md`](../../vergleichs.md).
```text
Keep: Pos.txt -> CEPOS -> client proximity -> PCM/3D
Improve: cleaner peer messages + richer hub policy on TS server
Never: expect ts3server to compute who hears whom
```
---
## 0. Constraints (from live plugin)
| Can | Cannot |
|---|---|
| `sendPluginCommand` relay (CEPOS today) | Proximity math inside retail `ts3server` |
| Hub policy in root channel description | True packet drop for inaudible speakers |
| Client PCM + 3D as hearing | Trust client position as anti-cheat truth |
| Optional later: `requestClientSetWhisperList` overlays | Replace PCM with ServerLib routing |
---
## 1. Work packages (ordered)
### WP0 — Inventory (done)
- CEPOS 56-byte layout, throttle/keepalive
- Hub `[GLOBAL]` / zones / races / defaults keys
- Used vs unused `TS3Functions`
- Documented in `vergleichs.md`
### WP1 — Protocol + hub design (doc only)
**Plugin commands (keep CEPOS wire-compatible):**
| Prefix | Purpose | When to send |
|---|---|---|
| `CEPOS:` | Position + voiceDistance (unchanged 56 B) | Throttled move + 1 Hz keepalive |
| `CEDRAIN:` | Wakeup (unchanged) | Internal |
| `CEMODE:` **(first new)** | Voice mode id + distance | On mode change only (edge) |
| `CEPING:` (later) | Seq / stale hint | Low rate |
| `CEAUTH:` (later) | Soft identity hint | On connect — still spoofable |
Rules: `sendPluginCommand` only; bounds-check; throttle; no secrets; unknown prefixes ignored.
**Hub schema (additive):** `SchemaVersion` + flags; missing keys = old defaults; clients read only.
### WP2 — First code change (Golden Rule: one function)
**Implement `CEMODE` only:**
1. On voice-mode change, send small plugin command (mode + distance).
2. Peers update without waiting for CEPOS keepalive.
3. Do not break 56-byte CEPOS.
Likely files: `ts3_cepos.c` / new cmd dispatch, `voice_modes.c`, `ts3_entry.c`, `doku/aenderungen/…`.
**Off-limits in WP2:** hub keys, whisper lists, filter wiring, radio, hybrid backend.
### WP3 — Optional: wire FilterIntensity if still unwired (ask first)
### WP4 — Optional: whisper-list admin overlay experiment (not proximity)
---
## 2. Out of scope
- ServerLib custom app / `ts3-Testing` product
- Proximity inside retail `ts3server`
- Hybrid authority (unless reopened)
- Breaking CEPOS without compatibility plan
---
## 3. Success / test
- Two clients: mode hotkey → peer sees update via `CEMODE`; CEPOS distance still works; old clients ignore `CEMODE` and keep CEPOS.
## 4. Default on “go”
Implement **CEMODE** first.
-470
View File
@@ -1,470 +0,0 @@
# Live Plugin (V8) vs Planned SDK/ServerLib Architecture
A **development-ready** side-by-side comparison of:
| Document side | What it is |
|---|---|
| **Live V8** | Shipping Conan Exiles proximity plugin (`tsmain`): a DLL loaded by the **retail** TeamSpeak 3 client |
| **Plan (Option B)** | New product on branch `ts3-Testing`: custom **ClientLib** app + custom **ServerLib** voice server |
This file answers: *which API does each use, in what order, who sends data to whom, and what actually changes.*
---
## Table of contents
1. [One-sentence difference](#1-one-sentence-difference)
2. [At-a-glance table](#2-at-a-glance-table)
3. [The two SDKs explained](#3-the-two-sdks-explained)
4. [Architecture diagrams](#4-architecture-diagrams)
5. [Lifecycle: boot order (Live V8)](#5-lifecycle-boot-order-live-v8)
6. [Lifecycle: boot order (Plan)](#6-lifecycle-boot-order-plan)
7. [API map: every callback](#7-api-map-every-callback)
8. [API map: every active call we make](#8-api-map-every-active-call-we-make)
9. [Data flow: position](#9-data-flow-position)
10. [Data flow: who hears whom](#10-data-flow-who-hears-whom)
11. [Data flow: 3D and audio effects](#11-data-flow-3d-and-audio-effects)
12. [Threading models](#12-threading-models)
13. [Module map](#13-module-map)
14. [Operations, license, scale](#14-operations-license-scale)
15. [What is reusable vs rewritten](#15-what-is-reusable-vs-rewritten)
16. [Conclusion](#16-conclusion)
---
## 1. One-sentence difference
> **Live V8** is a guest DLL inside retail TeamSpeak that edits voice **locally**.
> **The plan** is our own TeamSpeak-compatible voice stack where a **server** decides who receives whose packets.
Same English word "SDK". Two different TeamSpeak products. Not an upgrade — an architecture switch.
---
## 2. At-a-glance table
| Topic | Live V8 (today) | Plan (Option B) |
|---|---|---|
| Product shape | One DLL (`conan_exiles.dll`) | Client app + server process |
| SDK product | **Client Plugin SDK** (free) | **TeamSpeak 3 SDK** ClientLib + ServerLib (paid) |
| Headers | `sdk/include/ts3_functions.h` | `clientlib.h`, `serverlib.h` |
| Function prefixes | `ts3plugin_*`, `TS3Functions.*` | `ts3client_*`, `ts3server_*` |
| Voice host | Foreign retail `ts3server` | Our `conan-voice-server` |
| User runs | TeamSpeak 3 Client + our plugin | Our Conan Voice Client app |
| Who hears whom | Client PCM mute/gain | Server `ts3server_setClientWhisperList` |
| Position path | Peer-to-peer `sendPluginCommand("CEPOS:…")` | Client → server `CLIENT_META_DATA` |
| Packet bandwidth | All channel members always receive voice | Only audible targets receive voice |
| License | Free | Paid (eval free for development) |
| EOL risk | Tied to TS3 client | TS3 SDK EOL **2027-12-31** |
| Branch | `tsmain` | `ts3-Testing` (isolated) |
---
## 3. The two SDKs explained
### 3.1 Live V8 — Client Plugin SDK
Evidence in this repo:
```1:3:sdk/README.md
# TeamSpeak 3 Client Plugin SDK
Welcome to the official helper repository for creating native plugins for the TeamSpeak 3 Client.
```
- TeamSpeak loads our DLL and calls exported `ts3plugin_*` functions.
- We call back into TeamSpeak through `struct TS3Functions` filled by `ts3plugin_setFunctionPointers`.
- We **cannot** create a virtual server, set server-side whisper routing, or replace TeamSpeak.
- Proximity = edit PCM samples in `ts3plugin_onEditPlaybackVoiceDataEvent`.
There are **zero** `ts3client_*` / `ts3server_*` symbols in this repository.
### 3.2 Plan — TeamSpeak 3 SDK (ClientLib + ServerLib)
- Separate commercial download (not the Plugin SDK folder in this repo).
- We link **ServerLib** into `conan-voice-server` and **ClientLib** into our custom client.
- Retail TeamSpeak **cannot** connect to an SDK ServerLib server (community-confirmed error `0x0705`).
- Users install **our** client app (similar to how Mumble works), not TeamSpeak + a plugin.
```text
Plugin SDK (today) TeamSpeak 3 SDK (plan)
───────────────── ──────────────────────
ts3plugin_* ts3client_* (client app)
TS3Functions.* ts3server_* (our server)
retail TS loads DLL we host ServerLib ourselves
local PCM mix server whisper-list routing
```
---
## 4. Architecture diagrams
### 4.1 Live V8
```mermaid
sequenceDiagram
participant Game as Conan Mod
participant Plugin as Plugin DLL
participant Peer as Other Plugin
participant PCM as Playback PCM
participant Retail as Retail ts3server
Game->>Plugin: Pos.txt (x,y,z,yaw)
Plugin->>Retail: sendPluginCommand("CEPOS:...")
Retail->>Peer: relay plugin command
Peer->>Peer: player_table_put + proximity_math
Retail->>Plugin: voice packets from Peer
Plugin->>PCM: onEditPlaybackVoiceDataEvent gain/pan/filter
```
### 4.2 Plan (Option B)
```mermaid
sequenceDiagram
participant Game as Conan
participant Client as Conan Voice Client
participant Server as Conan Voice Server
participant Router as VoiceRouter
Game->>Client: IGameDataProvider position
Client->>Server: CLIENT_META_DATA + flushClientSelfUpdates
Server->>Server: PlayerManager.updatePos
Router->>Router: spatial hash -> audible set
Router->>Server: ts3server_setClientWhisperList(A,{B,C})
Note over Client,Server: A speaks - only B and C receive packets
Client->>Client: 3D attributes + optional local effects
```
---
## 5. Lifecycle: boot order (Live V8)
Retail TeamSpeak owns the process. Our plugin only hooks in.
```text
1. TeamSpeak loads conan_exiles.dll
2. ts3plugin_name / version / apiVersion / author / description
3. ts3plugin_setFunctionPointers(TS3Functions) -> ts3_adapter_set_functions
4. ts3plugin_registerPluginID(id) -> needed for sendPluginCommand
5. ts3plugin_init()
6. onConnectStatusChangeEvent(STATUS_CONNECTION_ESTABLISHED)
7. Runtime loop (callback thread + CEDRAIN + PCM):
- pos watcher reads Pos.txt
- CEPOS send/receive via onPluginCommandEvent
- proximity recompute on callback thread
- onEditPlaybackVoiceDataEvent applies mix
- systemset3DListenerAttributes / channelset3DAttributes
8. ts3plugin_shutdown() -> ts3_adapter_shutdown
```
Key files:
| Step | File |
|---|---|
| Exports | `src/ts/entry/ts3_exports.h`, `ts3_entry.c` |
| TS API wrapper | `src/ts/adapter/ts3_adapter.c` |
| CEPOS | `src/ts/proximity/ts3_cepos.c` |
| PCM mix | `src/ts/proximity/ts3_proximity_audio.c` |
| 3D | `src/ts/proximity/ts3_3d.c` |
| Position file | `src/core/mod_file/pos_file.c` |
---
## 6. Lifecycle: boot order (Plan)
### 6.1 Server process (`conan-voice-server`)
```text
1. Load config + start logger
2. memset(&ServerLibFunctions, 0, sizeof(...))
3. Assign callbacks (onClientConnected, onClientDisconnected, ...)
4. ts3server_initServerLib(&funcs, LogType_FILE|LogType_CONSOLE, logDir)
5. Create/load server keypair [VERIFY exact name]
6. ts3server_createVirtualServer(port, ip, name, keyPair, maxClients, &serverID)
7. Init PlayerManager, ProximityEngine, VoiceRouter
8. Event loop + routing tick (5-10 Hz)
9. Shutdown:
ts3server_stopVirtualServer(serverID)
ts3server_destroyServerLib()
```
Confirmed server APIs:
| Function | Role |
|---|---|
| `ts3server_initServerLib` | Start ServerLib with callback table |
| `ts3server_createVirtualServer` | Create the virtual server |
| `ts3server_stopVirtualServer` | Stop one virtual server |
| `ts3server_destroyServerLib` | Tear down ServerLib |
| `ts3server_setClientWhisperList` | **Core routing API** |
| `ts3server_getClientList` | Enumerate clients |
| `ts3server_getChannelList` | Enumerate channels |
| `ts3server_freeMemory` | Free SDK-allocated arrays |
### 6.2 Client app (`conan-voice-client`)
```text
1. Fill ClientUIFunctions callbacks
2. ts3client_initClientLib(...)
3. ts3client_spawnNewServerConnectionHandler(0, &scHandlerID)
4. ts3client_createIdentity (or load stored identity)
5. ts3client_openPlaybackDevice / openCaptureDevice
6. ts3client_startConnection(scHandlerID, identity, host, port, nick, ...)
7. Wait onConnectStatusChangeEvent == STATUS_CONNECTION_ESTABLISHED
8. Loop:
- read game position
- setClientSelfVariableAsString(CLIENT_META_DATA, packed)
- flushClientSelfUpdates
- apply 3D listener/speaker attributes
9. Shutdown:
ts3client_stopConnection
ts3client_destroyServerConnectionHandler
ts3client_destroyClientLib
```
---
## 7. API map: every callback
### Live V8 — `ts3plugin_*` (exported)
From `src/ts/entry/ts3_exports.h`:
| Callback | What V8 does with it |
|---|---|
| `ts3plugin_init` / `shutdown` | Start/stop plugin subsystems |
| `ts3plugin_setFunctionPointers` | Store `TS3Functions` into adapter |
| `ts3plugin_registerPluginID` | Required for `sendPluginCommand` |
| `onConnectStatusChangeEvent` | Adopt active connection / reset caches |
| `onPluginCommandEvent` | Receive CEPOS / CEDRAIN / plugin traffic |
| `onEditPlaybackVoiceDataEvent` | **Primary proximity mix** (gain/pan/filter) |
| `onCustom3dRolloffCalculationClientEvent` | Custom distance curve for 3D |
| `onClientMoveEvent` / `onClientMoveMovedEvent` | Hub ↔ ingame channel tracking |
| `onChannelDescriptionUpdateEvent` | Hub settings from root channel description |
| `onUpdateChannelEvent` / `onUpdateChannelEditedEvent` | Channel metadata refresh |
| `onTalkStatusChangeEvent` | Talk indicator / HUD hints |
| `onHotkeyEvent` | Voice-mode hotkeys |
### Plan — ClientLib + ServerLib callbacks
| Side | Callback | Role |
|---|---|---|
| Client | `onConnectStatusChangeEvent` | Connection lifecycle |
| Client | `onEditPlaybackVoiceDataEvent` | Local effects only (reverb/lowpass) |
| Client | `onCustom3dRolloffCalculationClientEvent` | Distance curve |
| Client | `onClientMoveEvent` | Channel visibility |
| Client | `onTalkStatusChangeEvent` | HUD |
| Server | `onClientConnected` | `PlayerManager.add` |
| Server | `onClientDisconnected` | remove + clear whisper list |
| Server | `onClientMoved` | channel tracking |
| Server | `onClientStartTalkingEvent` / `Stop` | diagnostics / HUD hints |
| Server | `onAccountingErrorEvent` | license/slot errors |
Names marked **VERIFY** in the plan must be confirmed against `serverlib.h` / `clientlib.h` in Phase 0.
---
## 8. API map: every active call we make
### Live V8 — through `TS3Functions` (`g_ts3.*` in `ts3_adapter.c`)
| Call | Wrapper | Why |
|---|---|---|
| `sendPluginCommand` | `ts3_send_plugin_command_server` | CEPOS positions + CEDRAIN wakeup |
| `requestUnmuteClientsTemporary` | `ts3_unmute_clients_for_pcm` | PCM hook only receives unmuted clients |
| `setClientVolumeModifier` | (inside unmute path) | Reset volume modifiers |
| `requestClientMove` | `ts3_request_client_move` | Hub ↔ ingame |
| `requestChannelDescription` | `ts3_request_channel_description` | Hub config |
| `getChannelDescription` | `ts3_get_channel_description` | Parse hub settings |
| `systemset3DSettings` | `ts3_set_3d_settings` | 3D global scale |
| `systemset3DListenerAttributes` | `ts3_set_3d_listener` | Listener pose |
| `channelset3DAttributes` | `ts3_set_3d_client` | Per-speaker 3D position |
| get channel/client lists, nicknames, printMessage, logMessage | various | channel manage / UI / logging |
**Missing in Live V8:** any server API that stops packets to non-audible clients.
### Plan — SDK calls
| Call | Side | Why |
|---|---|---|
| `ts3server_initServerLib` / `createVirtualServer` / `stop` / `destroy` | Server | Host voice |
| `ts3server_setClientWhisperList` | Server | **Who hears whom** |
| `ts3server_getClientList` / `getChannelList` / `freeMemory` | Server | Enumerate + free |
| `ts3client_initClientLib` / `spawnNewServerConnectionHandler` | Client | Host client |
| `ts3client_createIdentity` / `startConnection` / `stopConnection` | Client | Connect |
| `ts3client_setClientSelfVariableAsString(CLIENT_META_DATA)` | Client | Send position/state |
| `ts3client_flushClientSelfUpdates` | Client | Push metadata to server |
| `ts3client_systemset3DListenerAttributes` | Client | Listener 3D |
| `ts3client_channelset3DAttributes` | Client | Speaker 3D |
| `ts3client_openPlaybackDevice` / `openCaptureDevice` | Client | Audio devices |
---
## 9. Data flow: position
| Step | Live V8 | Plan |
|---|---|---|
| 1. Game source | Mod writes `Pos.txt` | Same idea via `IGameDataProvider` |
| 2. Read | `pos_file.c` watcher (~30 ms) | Game provider thread (~20–30 Hz) |
| 3. Pack | CEPOS 56-byte base64 | Compact binary/base64 in `CLIENT_META_DATA` (≤4096 B) |
| 4. Send | `sendPluginCommand("CEPOS:…")` to **all clients** | `setClientSelfVariableAsString` + `flushClientSelfUpdates` to **server** |
| 5. Receive | `onPluginCommandEvent` → `player_table_put` | Server reads metadata → `PlayerManager.updatePos` |
| 6. Distance math | **Each client** (`proximity_math`) | **Server** (`ProximityEngine`) |
| 7. Trust | Client-reported (spoofable) | Client-reported (spoofable); server still owns routing/permissions |
```text
Live V8: A ──CEPOS──▶ B,C,D (everyone learns A's position)
Plan: A ──META───▶ Server ──whisper lists──▶ only who should hear A
```
---
## 10. Data flow: who hears whom
### Live V8 (local presentation only)
```text
Retail server delivers everyone's voice in the channel
│
▼
onEditPlaybackVoiceDataEvent(clientID, samples[])
│
├─ distance far → multiply samples by ~0 (mute)
├─ distance near → gain + pan + lowpass/reverb
└─ must unmute via requestUnmuteClientsTemporary
otherwise PCM hook gets nothing
```
Result: **bandwidth still paid for every speaker**. Hearing is a local volume illusion.
### Plan (real network routing)
```text
VoiceRouter tick (5-10 Hz)
for each speaker S:
audible = proximity ∪ radio ∪ whisper ∪ global ∩ permissions
if audible changed:
ts3server_setClientWhisperList(serverID, S, NULL, audibleClientIds)
# or clear with (NULL, NULL)
```
Result: non-audible clients **never receive** S's voice packets.
> [!WARNING]
> With an active whisper list, normal channel talk for that client is disabled.
> Therefore **all** proximity/radio/whisper/global must be expressed as whisper lists.
---
## 11. Data flow: 3D and audio effects
| Concern | Live V8 | Plan |
|---|---|---|
| Listener pose | `systemset3DListenerAttributes` | `ts3client_systemset3DListenerAttributes` |
| Speaker pose | `channelset3DAttributes` | `ts3client_channelset3DAttributes` |
| Global 3D | `systemset3DSettings` | `ts3client_systemset3DSettings` |
| Custom curve | `onCustom3dRolloffCalculationClientEvent` | Same idea on ClientLib |
| Cave reverb / lowpass | PCM path in `ts3_proximity_audio.c` | Still client PCM; server may send env state (CAVE/NORMAL) |
**Portable:** 3D call pattern and effect DSP.
**Not portable:** using PCM mute as the *primary* "who hears whom" mechanism.
---
## 12. Threading models
### Live V8 (strict)
| Thread | May do | Must not |
|---|---|---|
| TS callback | All `TS3Functions` / CEDRAIN drain | Block |
| Wakeup | Only `sendPluginCommand("CEDRAIN:1")` | Other TS API |
| PCM audio | Read snapshots; apply gain/pan | TS API, locks |
| Pos watcher / UI | Set flags + wakeup | TS API |
### Plan
| Thread | May do | Must not |
|---|---|---|
| SDK server callbacks | Short handoff into our queues | Heavy proximity math inline |
| Routing tick (5–10 Hz) | Compute audible sets; call `setClientWhisperList` | Game file I/O |
| SDK client callbacks | Connection/PCM/3D | Block |
| Game provider (~20 Hz) | Read Conan; publish snapshot | Direct SDK calls without adapter |
Same discipline as V8: **one adapter owns all SDK calls**.
---
## 13. Module map
| Concern | Live V8 files | Plan modules |
|---|---|---|
| Entry / lifecycle | `ts3_entry.c` | `client/main.c`, `server/main.c` |
| TS API wrapper | `ts3_adapter.c` | `TeamSpeakClientAdapter`, `TeamSpeakServerAdapter` |
| Positions | `pos_file.c`, `ts3_cepos.c` | `IGameDataProvider`, `CLIENT_META_DATA` |
| Player table | `player_table.c` | `PlayerManager` (server) |
| Proximity math | `proximity_math.c`, `zone_resolve.c` | `ProximityEngine` (server) |
| Hearability | PCM + volume modifier | `VoiceRouter` + whisper lists |
| 3D | `ts3_3d.c` | ClientLib 3D calls |
| Hub config | `hub_parser.c`, `ts3_server_profile.c` | root-channel / server config |
| Voice modes | `voice_modes.c` | client state + server ranges |
| UI / HUD | `ui_main.c`, overlay | client HUD |
---
## 14. Operations, license, scale
| Topic | Live V8 | Plan |
|---|---|---|
| User install | DLL → `%AppData%\TS3Client\plugins\` | Install custom client |
| Server ops | None (use any retail TS server) | Own Linux process, systemd, monitoring |
| Build | MSVC + MinGW cross + gcc tests | CMake: client + server + shared |
| License | Free Plugin SDK | Paid SDK; evaluation for PoC |
| Scale target | ~200 players (512 table cap) | License slot cap + routing cost |
| EOL | TS3 client lifetime | **SDK EOL 2027-12-31** |
---
## 15. What is reusable vs rewritten
### Reuse (rewrite into new adapters — do not blind-copy)
- Proximity math / zones (`proximity_math`, `zone_resolve`)
- PCM effects (reverb/lowpass patterns from `ts3_proximity_audio.c`)
- 3D call sequencing ideas from `ts3_3d.c`
- Position acquisition ideas from `pos_file.c` / Mumble reference
- HUD / voice-mode UX concepts
### Must be new
- Entire ServerLib host process
- Entire ClientLib application shell
- Whisper-list router as the hearability authority
- `CLIENT_META_DATA` (or equivalent) transport replacing CEPOS P2P
- License/accounting handling
- Deployment (systemd, crash recovery)
### Explicitly abandoned for Option B
- Depending on retail TeamSpeak client
- CEPOS peer-to-peer as the primary position bus
- Local PCM mute as the primary routing mechanism
---
## 16. Conclusion
| | Live V8 | Plan |
|---|---|---|
| Strength | Ships today; free; users already have TeamSpeak | True server routing; central authority; bandwidth win |
| Weakness | Everyone still receives packets; client can spoof position; no server game logic | New client+server product; paid license; SDK EOL 2027; retail TS gone |
| Right next step | Continue maintaining on `tsmain` | On `ts3-Testing`: prove ServerLib boot + whisper routing **before** Conan code |
> [!IMPORTANT]
> Do not write Conan integration for Option B until Phase 0–3 succeed:
> SDK examples build → server starts/stops → two clients talk →
> `ts3server_setClientWhisperList` proves **A hears B, not C**.
---
*Related living plan:* [`.cursor/plans/voice_architecture_plan_ed3d8830.plan.md`](.cursor/plans/voice_architecture_plan_ed3d8830.plan.md)