Voice: relay mode for RakVoice
SendFrame has always transmitted peer-to-peer, which a dedicated-server game cannot use — clients connect only to the server, never to each other. Relay mode lets clients send frames to a host that forwards them without decoding, so the server stays authoritative over who hears whom without paying for a codec: a hacked client cannot hear players it isn't allowed to, because it never receives their bytes.
Relay frames carry the talker's GUID, since the sender is now the relay rather than the speaker, plus a format-version byte:
[id][format version][origin guid][channel id][sequence][opus payload]
The version byte is a deliberate escape hatch — a future layout change is rejected by today's build instead of misparsed. Every offset derives from the one before it, so the writer and both readers cannot drift apart.
Alongside it:
- Origin-keyed channels — frames are looked up by origin rather than
packet->guid; otherwise every speaker arrives under the relay's GUID and collapses into one decoder. - Per-speaker output —
SetPerSpeakerOutput/ReceiveFrameFrompull one speaker's decoded PCM instead of the pre-mix, which is what makes 3D positioning possible above this layer. - Bounded relay state — concurrent speakers are capped (origins are attacker-influenced and each costs a decoder plus two rings), and idle relay channels are reaped, since
OnClosedConnectionnever fires for peers of the host rather than of us. RelayFramevalidates centrally — origin against the transport-authenticated sender, frame size, packet id, recipient list — rather than trusting every host to remember the impersonation check.
Peer-to-peer behaviour is unchanged when relay mode is off.
Security and robustness
Five pre-existing remote-input bugs, found while auditing RakVoice's packet entry points. All are reachable by any connected peer and none require relay mode — they affect anyone running an earlier release with RakVoice attached:
OnVoiceDataread out of bounds on a 1–2 byteID_RAKVOICE_DATApacket: the headermemcpyran past the buffer andpacket->length - headerSizeunderflowed to a huge unsigned value passed toopus_decodeas the payload length.OpenChannelcalledRakAsserton a remotely supplied sample rate.RakAssertis a realassert()in debug builds, so one malformed channel-open packet aborted a debug server.OpenChannelused that sample rate without checking the read succeeded; a packet too short to carry it left the value indeterminate.OnReceivedispatched ondata[0]with no length check.OnOpenChannelReplylacked the initialisation guardOnOpenChannelRequesthas, so an unsolicited reply on an uninitialised instance opened a channel withbufferSizeBytesof 0 and allocated empty rings.
Two further latent bugs: the constructor never initialised zeroBufferedOutput or bufferedOutputCount, so Update() read indeterminate values on any attached-but-uninitialised instance; and CloseVoiceChannel sent ID_RAKVOICE_CLOSE_CHANNEL unconditionally, so a peer that never opened a channel still got one on disconnect.
Testing
23 new unit cases (Tests/Unit/RakVoiceRelayTests.cpp) over the wire layout, hostile relay input, the speaker cap and the channel-open paths. 149/149 pass.
⚠️ Breaking change
ID_RAKVOICE_RELAY_DATA is inserted after ID_RAKVOICE_DATA and shifts every subsequent message id — including ID_READY_EVENT_SET, the RPC4 and two-way-authentication ids, and ID_USER_PACKET_ENUM. Peers must be rebuilt together; a peer built against the old header misparses everything past that point.
Full changelog: v0.12.0...v0.13.0